← Series hub ← PrevNext →

Bài 2: Động cơ Flash Sale - Giải quyết bài toán Overselling và Hot Key

Sự kiện Flash Sale là “lò lửa” kiểm chứng mọi hệ thống kiến trúc. Khi một chiếc iPhone được bán với giá 1.000 VNĐ, hàng triệu người sẽ click “Mua ngay” trong đúng một tích tắc. Nếu tất cả traffic này đánh thẳng vào MySQL, hệ thống sẽ sập ngay vì Row Lock (khóa dòng) và Deadlock.

1. Bài toán Hot Key và Caching 2 Tầng (Two-tier Cache)

Sản phẩm giảm giá đó được gọi là một Hot Key. Nhiều dev lầm tưởng rằng “Chỉ cần để tồn kho lên Redis là xong”. Tuy nhiên, một node Redis thông thường có giới hạn băng thông mạng (Network Bandwidth) và CPU khoảng 100k Ops/sec. Một triệu lượt click vào cùng 1 key sẽ làm tràn băng thông card mạng của node Redis đó.

Giải pháp của Shopee: Multi-level Caching

  • Tầng 1 (Local Cache): Tích hợp ngay trên bộ nhớ RAM của các Golang Server (dùng sync.Map hoặc thư viện như BigCache). Local Cache chỉ lưu trạng thái “Sản phẩm còn hàng hay không” trong vỏn vẹn 1-2 giây. Nó giúp chặn đứng 90% lượng traffic vô ích đọc sản phẩm khi sản phẩm đã hết hàng.
  • Tầng 2 (Distributed Cache - Redis): Chỉ khi Local Cache báo còn hàng, request mới được cho phép đi tiếp tới Redis cluster.

2. Vấn đề Overselling và Atomic Lua Scripts

Khi người dùng bấm Mua, hệ thống phải Trừ tồn kho. Nhưng nếu dùng luồng thao tác thông thường: Đọc tồn kho (GET) -> Kiểm tra nếu > 0 -> Ghi lại (SET), lỗi Race Condition sẽ xảy ra. Hai luồng cùng đọc thấy tồn kho là 1, và cùng trừ đi thành công, dẫn đến bán lố (Overselling).

Giải pháp: Shopee đưa thao tác Trừ tồn kho vào Lua Scripts chạy trực tiếp bên trong Redis. Redis là một hệ thống Single-threaded, khi nó thực thi một đoạn mã Lua, đoạn mã đó nghiễm nhiên trở thành một giao dịch Nguyên tử (Atomic) - không một request nào có thể chen ngang.

-- Đoạn mã Lua ví dụ xử lý trừ tồn kho
local stock_key = KEYS[1]
local stock = tonumber(redis.call('GET', stock_key))

if stock and stock > 0 then
    redis.call('DECR', stock_key)
    return 1 -- Mua thành công
else
    return 0 -- Hết hàng
end

Fail Fast (Từ chối siêu tốc): Nhờ cơ chế này, nếu Lua script trả về 0, request lập tức bị từ chối với thông báo “Rất tiếc sản phẩm đã hết”. Quá trình này tính bằng micro-seconds trên RAM.

3. Sharding Inventory (Băm nhỏ tồn kho)

Đối với siêu bão Flash Sale, 1 Hot Key trên 1 Node Redis vẫn rủi ro. Shopee sử dụng kỹ thuật Inventory Sharding. Giả sử có 1.000 chiếc iPhone, hệ thống không lưu số 1000 vào 1 key iphone_stock. Họ cắt làm 10 mảnh: iphone_stock_1 đến iphone_stock_10, mỗi key giữ 100 chiếc và đặt trên 10 Node Redis vật lý khác nhau.

Phía trước sẽ có một bộ định tuyến (Router) phân bổ ngẫu nhiên traffic của người dùng vào 1 trong 10 keys đó. Áp lực lên hệ thống ngay lập tức được chia 10.

sequenceDiagram
    participant User
    participant App as Golang Server<br/>(Local Cache)
    participant Redis as Redis Cluster<br/>(Sharded)
    participant Worker as Kafka Worker
    
    User->>App: Click "Buy Now"
    Note over App: Check Local Cache.<br/>Block if Out of Stock
    App->>Redis: Route to shard (e.g. stock_3)
    Note over Redis: Execute Atomic Lua Script
    Redis-->>App: If 0: Return Error
    Redis-->>Worker: If 1: Push Order Event to Queue
    Worker-->>User: Process Order Asynchronously

Bài học cho Dev: RAM và Cache là vũ khí mạnh nhất của hệ thống chịu tải. Tuy nhiên, đừng ỷ lại vào Distributed Cache. Phải biết kết hợp Local Cache (trên App Server) để giảm tải network, và dùng Lua Script để bảo đảm tính nhất quán (Consistency) khi thao tác với dữ liệu nhạy cảm như Tồn kho hoặc Số dư tiền.