Bài toán: Rạp chiếu phim chỉ còn 1 vé, 2 người cùng lúc vào mua vé và đều thành công và trừ tiền cả 2. Làm thế nào để giải quyết vấn đề này.
Đây là bài toán kinh điển về Race Condition (Cạnh tranh tài nguyên) trong hệ thống phân tán (Microservices). Khi hai yêu cầu đồng thời lao vào database, nếu không có cơ chế chặn, cả hai sẽ đọc thấy trạng thái "còn 1 ghế" và cùng trừ tiền khách hàng, dẫn đến lỗi bán trùng ghế (Overbooking).
Trong kiến trúc Microservices, bạn có 3 tầng để giải quyết bài toán này tùy thuộc vào mức độ chịu tải và yêu cầu trải nghiệm người dùng.
1. Phương án 1: Distributed Lock bằng Redis (Khuyên dùng cho Microservices)
Khi chạy Microservices, ứng dụng của bạn sẽ được deploy thành nhiều instance (pod) khác nhau. Cơ chế synchronized hay ReentrantLock của Java thuần sẽ bất khả thi vì nó chỉ có tác dụng trong nội bộ một JVM instance. Bạn cần một khóa tập trung ở tầng bộ nhớ tạm (Cache).
Sử dụng thư viện Redisson (Redis-based Java client) để hiện thực hóa Distributed Lock.
@Autowired
private RedissonClient redissonClient;
public BookingResult bookTicket(String showId, String seatId, String userId) {
String lockKey = "lock:seat:" + showId + ":" + seatId;
RLock lock = redissonClient.getLock(lockKey);
try {
// Thử thu giữ lock trong vòng 3 giây. Nếu giữ được, lock tự giải phóng sau 10 giây nếu crash.
boolean isLocked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (isLocked) {
// 1. Kiểm tra trạng thái ghế trong Database
Seat seat = seatRepository.findById(seatId);
if (seat.isAvailable()) {
// 2. Tiến hành giữ chỗ/Thanh toán
seat.setStatus(SeatStatus.BOOKED);
seat.setUserId(userId);
seatRepository.save(seat);
return BookingResult.SUCCESS;
} else {
return BookingResult.SEAT_ALREADY_SOLD;
}
} else {
return BookingResult.SYSTEM_BUSY; // Khách hàng thứ 2 không lấy được lock
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return BookingResult.ERROR;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock(); // Luôn giải phóng lock khi xong việc
}
}
}
Cơ chế: Khách hàng A chiếm được lockKey trước sẽ được vào kiểm tra DB và mua vé. Khách hàng B đến sau tích tắc sẽ phải xếp hàng đợi (hoặc nhận ngay thông báo hệ thống bận). Khi A mua xong và giải phóng lock, B vào kiểm tra sẽ thấy trạng thái ghế đã đổi sang BOOKED và nhận thông báo hết vé.
Khi nào thì cần dùng Redis, Khi lượng truy cập quá khủng kiếp đến mức làm sập Database.
Giải pháp dùng Redis Distributed Lock (Phương án 1) thường được ưu tiên lựa chọn cho hệ thống lớn vì nó giải quyết triệt để hai điểm yếu chí mạng của Microservices: Bảo vệ Database khỏi sập (Overload) và Khóa xuyên suốt nhiều server (Cross-instance Locking).
1.1. Cơ chế Single-Threaded của Redis biến "Nhiều" thành "Một"
Trong kiến trúc Microservices, bạn có thể deploy 5, 10 hoặc 20 instances của dịch vụ Đặt Vé (Booking Service) chạy song song sau Load Balancer. Khi 2 khách hàng click mua cùng lúc, Request A có thể rơi vào Instance 1, còn Request B rơi vào Instance 2.
-
Nếu bạn dùng từ khóa
synchronizedhoặcReentrantLockcủa Java, nó chỉ có tác dụng nội bộ trong từng Instance. Instance 1 không hề biết Instance 2 đang làm gì. Kết quả là cả 2 Instance đều cho qua, dẫn đến việc trùng ghế. -
Redis hoạt động đơn luồng (Single-threaded): Dù bạn có hàng nghìn request từ hàng chục server khác nhau đập vào Redis cùng một mili-giây, Redis vẫn xếp chúng vào một hàng đợi duy nhất và xử lý lần lượt từng lệnh một. Lệnh
SETNX(Set if Not Exists - cốt lõi của việc chiếm lock) của ai đến trước 1 phần triệu giây sẽ thành công, người đến sau bắt buộc phải thất bại. Điều này biến Redis thành một "trọng tài trung tâm" cực kỳ uy tín cho toàn bộ hệ thống phân tán.
1.2. Tốc độ In-Memory giải cứu Database
Hãy tưởng tượng một bộ phim bom tấn mở bán vé, hoặc một sự kiện Flash Sale. Hàng chục nghìn người cùng click vào một nút mua vé tại một thời điểm.
- Nếu bạn dùng Database Locking (Optimistic hay Pessimistic), hàng chục nghìn request đó sẽ tạo ra hàng chục nghìn kết nối (Connection) đập thẳng xuống Database. DB phải đọc đĩa, ghi log giao dịch (transaction log), tính toán khóa... Việc này tiêu tốn CPU và RAM khủng khiếp, dễ dẫn đến nghẽn cổ chai (Connection Pool bị cạn kiệt), làm sập toàn bộ hệ thống Database của doanh nghiệp.
- Redis lưu trữ hoàn toàn trên RAM: Nó có thể xử lý hàng trăm nghìn request trên một giây ($> 100,000$ QPS) với độ trễ chỉ dưới 1 mili-giây. Thay vì để 10,000 request đập xuống DB, bạn dùng Redis chặn lại ở tầng trên. Chỉ duy nhất 1 request chiếm được lock được phép đi tiếp xuống Database để ghi nhận đặt vé thành công. 9,999 request còn lại bị chặn đứng ngay tại tầng Redis và nhận thông báo lỗi/hết vé lập tức. Database của bạn được bảo vệ an toàn tuyệt đối.
1.3. Khả năng tự động giải phóng (Tắt máy không sợ chết Lock)
Một rủi ro cực lớn trong hệ thống phân tán là: Chuyện gì xảy ra nếu Instance 1 chiếm được lock, đang chuẩn bị ghi xuống DB thì server bị mất điện, crash hoặc bị kill đột ngột?
Nếu là một cơ chế khóa thủ công, cái ghế đó sẽ bị khóa vĩnh viễn vì server chiếm lock đã chết và không bao giờ gọi hàm unlock(). Không ai có thể mua được chiếc ghế đó nữa.
Redis giải quyết việc này bằng cơ chế TTL (Time-To-Live). Khi bạn chiếm lock, bạn bắt buộc phải đặt một thời hạn hết hạn (ví dụ: 10 giây).
- Nếu mọi chuyện suôn sẻ, hệ thống chạy xong sẽ tự giải phóng lock.
- ếu server chứa code Java bị crash giữa chừng, đúng 10 giây sau Redis sẽ tự động xóa cái lock đó đi. Ghế được giải phóng và khách hàng khác lại có thể tiếp tục vào mua, hệ thống tự phục hồi mà không cần kỹ sư can thiệp manual.
2. Phương án 2: Database Optimistic Locking (Khóa lạc quan)
Nếu lượng truy cập không quá khủng khiếp đến mức làm sập Database, bạn có thể tận dụng cơ chế Version Control của JPA/Hibernate ngay tại DB mà không cần cài thêm Redis.
Thêm một trường @Version vào Entity Seat:
@Entity
public class Seat {
@Id
private String id;
private String status;
@Version
private Long version; // Hibernate sẽ tự động quản lý trường này
}
Khi thực hiện update trạng thái, Hibernate sẽ thực thi câu lệnh SQL dạng:
UPDATE seat SET status = 'BOOKED', version = version + 1
WHERE id = 'GHE_CUOI' AND version = 5;
Cơ chế: Cả hai khách hàng cùng đọc ra ghế có version = 5. Khách hàng A nhanh hơn 1 mili-giây, câu lệnh UPDATE chạy thành công, version tăng lên 6. Khách hàng B chậm hơn, câu lệnh UPDATE chạy nhưng không tìm thấy dòng nào có version = 5 nữa (vì đã thành 6). Hibernate lập tức ném ra ngoại lệ OptimisticLockException. Bạn chỉ cần catch ngoại lệ này và báo cho khách hàng B là vé đã có người mua.
3. Phương án 3: Giữ chỗ ngắn hạn (Seat Holding Pattern)
Đối với rạp chiếu phim thực tế, người ta không bán đứt ghế ngay khi click, mà sẽ giữ ghế trong 5-10 phút để khách hàng nhập thông tin thẻ.
- Khi khách hàng click, hệ thống dùng Redis Distributed Lock (như Phương án 1) hoặc câu lệnh
UPDATE ... WHERE status = 'AVAILABLE'để chuyển trạng thái ghế từAVAILABLEthànhHOLDING, gắn vớiuserIdvà mộttimestamp. - Hệ thống bắn một Delay Message vào Message Broker (ví dụ RabbitMQ, Kafka) với thời gian hết hạn là 5 phút.
- Sau 5 phút, nếu khách hàng thanh toán thành công --> chuyển trạng thái sang
SOLD. - Nếu hết 5 phút mà chưa thanh toán $\rightarrow$ Consumer của Message Broker kích hoạt, kiểm tra nếu vẫn là
HOLDINGthì sẽ reset ghế về lạiAVAILABLEcho người khác mua.
Chọn Phương án 2 (Optimistic Lock) nếu hệ thống nhỏ, vừa phải, muốn code nhanh và lười cấu hình hạ tầng.