Thiết kế Hệ thống Flash Sale: Chống Overselling "Real-time" Không Làm Sập Database
Product Decode
•
Bài toán "Cổ chai" của Flash Sale
Hãy tưởng tượng chiến dịch Mega Sale 11/11. Bạn có 1.000 chiếc iPhone giá sốc và 2 triệu người dùng cùng lúc bấm nút "Mua ngay" tại giây số 0. Nếu hệ thống của bạn được thiết kế theo cách thông thường—nhận request, kiểm tra số lượng trong Database (RDBMS), giảm đi 1, và lưu lại—hệ thống của bạn sẽ sập ngay trong giây đầu tiên.
Tại sao Database truyền thống thất bại?
RDBMS (như MySQL hay PostgreSQL) được thiết kế cho sự toàn vẹn dữ liệu (ACID), không phải cho lượng truy cập khổng lồ đồng thời (high concurrency) vào cùng một bản ghi.
Khi hàng triệu request cố gắng cập nhật cùng một dòng dữ liệu (tồn kho của chiếc iPhone), Database sẽ phải sử dụng Row-level Lock (Khóa cấp độ hàng). Các request đến sau phải xếp hàng chờ request trước hoàn thành. Điều này dẫn đến cạn kiệt Connection Pool, thời gian phản hồi (latency) tăng vọt, và cuối cùng là hiệu ứng Domino làm sập toàn bộ hệ thống (Cascading Failure). Hơn nữa, nếu không xử lý tốt, bạn sẽ gặp lỗi (bán vượt quá số lượng thực tế có trong kho).
Nguyên tắc Thiết kế: Trong các sự kiện Flash Sale, Database quan hệ (RDBMS) chỉ đóng vai trò là "chân lý cuối cùng" (Source of Truth) về lâu dài, tuyệt đối không được dùng làm "chiến trường chính" để xử lý luồng giao dịch trực tiếp.
Kiến trúc Cache-First: Biến Redis thành "Chiến trường chính"
Để giải quyết bài toán trên, các hệ thống lớn như Shopee hay Amazon không đọc/ghi trực tiếp vào DB. Họ đẩy toàn bộ quá trình xử lý tồn kho lên In-Memory Cache, tiêu biểu nhất là Redis.
Lưu ý: Luồng dưới đây đã được lược bỏ một số edge cases để dễ theo dõi. Các vấn đề nâng cao như xử lý lỗi, idempotency, và failure recovery được bàn riêng ở phần cuối bài.
1. Pre-warming (Làm ấm Cache)
Trước khi sự kiện diễn ra (ví dụ: 23:55), số lượng tồn kho (1.000 chiếc) được tải sẵn từ Database lên Redis. Từ thời điểm này, mọi truy vấn "Còn bao nhiêu sản phẩm?" từ frontend sẽ chỉ đọc trực tiếp từ Redis. Do Redis lưu trữ trên RAM, tốc độ phản hồi chỉ tính bằng mili-giây (ms), đáp ứng dễ dàng hàng triệu lượt đọc.
2. Xử lý trừ kho bằng Atomic Operations
Khi người dùng bấm "Mua", làm sao để không bán lố? Chúng ta sử dụng tính năng Atomic Operation của Redis.
Lệnh DECR (Decrement) của Redis là đơn luồng (single-threaded) và nguyên tử. Nghĩa là tại một thời điểm, chỉ có 1 request được phép trừ đi 1 đơn vị.
Bằng cách này, tồn kho luôn chính xác một cách tuyệt đối trên Cache mà không cần bất kỳ Lock phức tạp nào từ Database.
Đồng bộ hóa qua Message Queue (Eventual Consistency)
Khi Redis đã trừ kho thành công (người dùng mua được hàng), làm thế nào để lưu kết quả này vào Database một cách an toàn để xử lý thanh toán và vận chuyển? Đây là lúc Message Queue (như Kafka hoặc RabbitMQ) xuất hiện.
Thay vì bắt Database ghi ngay lập tức, hệ thống sẽ đẩy một "tin nhắn" (Message: User A đã lấy được iPhone) vào Queue.
Queue hoạt động như một bộ giảm xóc (Shock Absorber):
Nhận hàng trăm ngàn tin nhắn từ Redis mỗi giây.
Các Worker ở đầu ra (Consumer) sẽ lấy tin nhắn từ Queue với một tốc độ ổn định mà Database có thể chịu đựng được (ví dụ: 1.000 ghi/giây).
Database từ từ cập nhật số liệu.
Tư duy Đánh đổi (Trade-off): Chúng ta đánh đổi Strict Consistency (Nhất quán tuyệt đối theo thời gian thực tại DB) để lấy High Availability (Tính khả dụng cao) và Eventual Consistency (Nhất quán cuối cùng). Trong suốt vài phút của Flash Sale, DB có thể báo còn 1.000 cái, nhưng Redis báo 0. Front-end luôn hiển thị số của Redis.
Trải nghiệm người dùng: Ảo ảnh của "Real-time"
Bản chất của bài toán không nằm ở việc frontend phải hiển thị đúng số lượng từng mili-giây, mà là backend phải kiểm tra và trừ kho real-time để chống overselling. Việc người dùng nhìn thấy số tồn kho bị trễ hoặc sai lệch một chút là một sự đánh đổi (Trade-off) hoàn toàn hợp lý. Việc cố gắng hiển thị con số "Chỉ còn X sản phẩm" chính xác tuyệt đối trên 2 triệu màn hình cùng lúc bằng WebSocket là vô cùng tốn kém tài nguyên với mức ROI âm. Trải nghiệm "xấu nhất" chỉ là user bấm mua và nhận thông báo "Đã hết hàng" – điều hoàn toàn bình thường, thậm chí còn kích thích FOMO trong văn hóa săn sale.
Giải pháp Product/Tech:
Rate Limiting & Traffic Shaping: Chặn traffic rác ngay từ API Gateway/CDN. Chỉ cho request thực sự lọt vào hệ thống lõi.
Client-side Throttling: Nút "Mua ngay" bị mờ (disabled) trong 2-3 giây sau mỗi lần bấm để ngăn user spam click.
Polling có kiểm soát: App trên điện thoại của user tự động gửi request hỏi số lượng tồn kho mỗi 3-5 giây thay vì giữ kết nối liên tục. Khi số lượng < 10, có thể chuyển sang WebSocket hoặc tăng tần suất polling để tạo cảm giác hồi hộp.
Việc thiết kế hệ thống Flash Sale không chỉ là tối ưu Code, mà là nghệ thuật quản lý kỳ vọng của người dùng và bảo vệ những điểm yếu nhất (Database) trong kiến trúc hạ tầng.
Nâng Cao: Những Edge Cases Bị Lược Bỏ
Phần trên trình bày luồng "happy path". Trong thực tế, có bốn vấn đề quan trọng mà interviewer sẽ hỏi đến.
1. DECR đơn thuần vẫn có thể gây Oversell ở tầng application
Nếu logic "check tồn kho → trừ kho" nằm trong application code (không phải chạy trực tiếp trên Redis), vẫn tồn tại race condition giữa các app instances. Giải pháp chuẩn là dùng Lua Script — gói toàn bộ logic check-and-decrement thành một atomic unit chạy trên Redis server, không thể bị interrupt. Ngoài ra, khi DECR trả về -1 mà không dùng Lua, cần INCR lại ngay để rollback — thêm một round-trip dễ sinh bug.
2. Rate Limiting theo IP làm hại user hợp lệ
Chặn theo IP sẽ block oan toàn bộ một văn phòng 500 người dùng chung NAT. Giải pháp tốt hơn là Virtual Waiting Room — thay vì reject thẳng, hệ thống đưa user vào hàng chờ có hiển thị vị trí và thời gian ước tính, nhỏ giọt user vào core system theo tốc độ backend có thể xử lý. Bảo vệ backend tốt hơn mà UX không bị phá vỡ.
3. Consumer Worker thất bại → cần Idempotency Key và Dead Letter Queue
Nếu Worker crash sau khi Redis đã trừ kho nhưng trước khi ghi vào DB, Queue sẽ retry. Nếu retry xử lý lại một message đã ghi thành công một phần, user bị trừ tiền hai lần. Idempotency Key — một UUID gắn cố định vào mỗi message — giúp Worker kiểm tra "đã xử lý chưa?" trước khi ghi, đảm bảo dù retry bao nhiêu lần cũng chỉ tạo một đơn hàng duy nhất. Với các message thất bại liên tục sau N lần retry (do lỗi cố hữu, không phải crash tạm thời), cần đẩy sang Dead Letter Queue để tránh chặn luồng chính và giữ lại để điều tra.
4. Tồn kho chưa được sync ngược khi có hủy đơn
Sau Flash Sale, nếu đơn hàng bị cancel, tồn kho cần được restore vào Redis. Nếu Redis key đã expire hoặc giá trị không được cập nhật đúng, các đơn hàng tiếp theo (re-stock, second chance sale) sẽ hiển thị số liệu sai. Cần có flow xử lý riêng cho cancel/refund đảm bảo Redis và DB đồng bộ trở lại.
Case StudyApr 11, 2026
Bài Toán "Chấm Xanh" Của Slack: Cơn Ác Mộng Kiến Trúc Thời Gian Thực
Việc hiển thị trạng thái online tưởng chừng cơ bản nhưng lại là bài toán hạ tầng khổng lồ ở quy mô lớn. Phân tích cách Slack cấu trúc lại mô hình Pub/Sub để cân bằng giữa độ chính xác realtime và chi phí máy chủ.
Product Decode
Thiết Kế Flash Sale: Chống Overselling Real-time | Product Decode