📖 Bản tiếng Anh (English Edition)
Bài trước: Bài 1 — Nền Tảng Microservices | Mục lục Series | Bài tiếp theo: Bài 3 — Tấm Khiên Lưu Lượng: WAF & Kafka Peak Shaving
Answer-first: Shopee triệt tiêu thảm họa bán quá kho và điểm nhiệt flash sale bằng token mua hàng, bộ đệm cục bộ và Redis Lua nguyên tử chia sub-key. Việc phân mảnh tồn kho SKU qua các sub-key ngẫu nhiên giúp tránh nghẽn node Redis, bảo đảm độ trễ dưới 2ms và cam kết toán học không bán vượt kho.
Điều kiện tiên quyết: Nắm vững cấu trúc dữ liệu Redis, cơ chế khóa phân tán, các thao tác nguyên tử (Lua script,
EVALSHA), kiểm soát tương tranh lạc quan trong cơ sở dữ liệu và kiến trúc hướng sự kiện với Apache Kafka.
1. Nguyên Tắc Bất Biến Chống Bán Quá Kho (Zero-Overselling Invariant)
Trong các chiến dịch siêu khuyến mãi hàng năm của Shopee (như 9.9, 11.11, và 12.12), những sản phẩm công nghệ chủ lực — chẳng hạn như dòng iPhone đời mới nhất được trợ giá tới 50% — thường chỉ có lượng hàng thực tế khoảng 1.000 sản phẩm nhưng thu hút hơn 1.500.000 lượt nhấn mua đồng thời chỉ trong vòng 500 mili-giây đầu tiên mở bán.
Trong các kiến trúc thương mại điện tử truyền thống, logic giữ chỗ tồn kho thường được thực hiện trực tiếp trên cơ sở dữ liệu quan hệ bằng khóa dòng bi quan (pessimistic locking):
-- Mô hình phản mẫu điển hình khi có 1.000.000 kết nối đồng thời
SELECT stock FROM product_inventory WHERE sku_id = 1001 FOR UPDATE;
UPDATE product_inventory SET stock = stock - 1 WHERE sku_id = 1001;
Khi hàng trăm ngàn luồng kết nối đồng loạt yêu cầu độc quyền khóa dòng có cùng sku_id trong MySQL InnoDB hoặc PostgreSQL, hệ thống cơ sở dữ liệu sẽ sụp đổ tức thì do nghẽn khóa (lock thrashing). Trong MySQL InnoDB, mỗi lệnh SELECT ... FOR UPDATE đều phải chiếm giữ một khóa độc quyền X trên bản ghi chỉ mục cụm (clustered index). Dưới tải giao dịch thông thường, khóa này được cấp phát, giữ trong vài mili-giây và giải phóng ngay khi commit. Tuy nhiên, khi chịu tải 1.500.000 yêu cầu mỗi giây:
- Tranh chấp Spinlock toàn cục (
lock_sys->mutex): Hệ thống quản lý khóa nội bộ của InnoDB trở thành điểm nghẽn chokepoint duy nhất. Mọi luồng worker muốn kiểm tra hoặc bổ sung giao dịch vào hàng đợi khóa đều phải tranh giành spinlock này trong nhân hệ điều hành, đẩy mức chiếm dụng CPU của toàn bộ máy chủ lên 100% chỉ để chạy các vòng lặp spinlock chờ đợi. - Bùng nổ đồ thị phát hiện Deadlock: Theo mặc định, bộ phát hiện deadlock của InnoDB sẽ duyệt qua toàn bộ đồ thị quan hệ chờ giữa các giao dịch (
innodb_deadlock_detect = ON) mỗi khi xuất hiện một lượt chờ khóa. Khi có 5.000 giao dịch cùng xếp hàng chờ một dòng tồn kho, độ phức tạp tính toán của thuật toán duyệt đồ thị suy thoái thành $O(N^2)$, làm đóng băng toàn bộ tiến trình cơ sở dữ liệu dù trong thực tế không hề xảy ra xung đột vòng tròn nào. - Nghẽn bộ đệm và chi phí Context Switch: Bể kết nối cơ sở dữ liệu bị cạn kiệt chỉ trong vài giây. Nhân hệ điều hành Linux dành tới 85% thời gian chỉ để hoán đổi ngữ cảnh luồng giữa các thanh ghi và bộ nhớ đệm CPU, khiến thông lượng xử lý giao dịch thực tế rơi tự do về gần như bằng 0.
Kể cả khi tắt bộ phát hiện deadlock (innodb_deadlock_detect = OFF), điểm nghẽn chỉ đơn thuần dịch chuyển sang cơ chế hết hạn thời gian chờ khóa (innodb_lock_wait_timeout), khiến hàng chục ngàn đơn hàng bị hủy bỏ hàng loạt sau 50 giây chờ đợi, để lại trải nghiệm tồi tệ cho người mua hàng.
flowchart TD
subgraph DotBungNoTraffic ["Đợt Bùng Nổ Lưu Lượng (1.500.000 Yêu Cầu / Giây)"]
Req["Yêu Cầu Đặt Hàng Từ Điện Thoại"] --> Gate["1. Cổng Kiểm Soát Token (Giới Hạn Hàng Đợi Bằng 2x Tồn Kho)"]
end
subgraph TangBoNhoDem ["Động Cơ Giữ Chỗ Tồn Kho Đa Tầng Trong Bộ Nhớ"]
Gate --> LocalCache{"2. Kiểm Tra FreeCache Cục Bộ (Đã Hết Hàng Chưa?)"}
LocalCache -->|Đã Hết Hàng| ShortCircuit["Phản Hồi Sớm HTTP 200 (Báo Hết Hàng Chỉ Mất 5µs)"]
LocalCache -->|Còn Hàng| SubKeyRoute["3. Định Tuyến Sub-Key Ngẫu Nhiên (CRC32 Shard 0..N-1)"]
SubKeyRoute --> RedisEngine["4. Kịch Bản Redis Lua Nguyên Tử (EVALSHA)"]
RedisEngine -->|"Lua Trả Về -1 (Hết Hàng)"| MarkSoldOut["Đánh Dấu Cờ Hết Hàng Trong Bộ Nhớ Cục Bộ"]
RedisEngine -->|"Lua Trả Về >= 0"| KafkaQueue["5. Phát Sự Kiện OrderPlaced Vào Kafka Topic"]
end
subgraph GhiSoBatDongBo ["Động Cơ Ghi Sổ Cái Cơ Sở Dữ Liệu Bất Đồng Bộ"]
KafkaQueue --> Worker["Bể Worker Tiêu Thụ Trong Go (Gom Batch 200 Đơn)"]
Worker --> DB["Hệ Quản Trị TiDB Phân Tán (Cập Nhật Lạc Quan CAS)"]
end
classDef burst fill:#ffebee,stroke:#c62828,stroke-width:2px;
classDef mem fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
classDef db fill:#e3f2fd,stroke:#1565c0,stroke-width:2px;
class DotBungNoTraffic burst;
class TangBoNhoDem mem;
class GhiSoBatDongBo db;
Hơn nữa, nếu thiết kế hệ thống theo cách ngây thơ “Đọc tồn kho từ Redis, nếu còn thì ghi nhận xuống Database”, race condition sẽ xảy ra ngay lập tức. Hai tiến trình worker đọc cùng lúc thấy tồn kho còn 1 và đều trừ xuống 0, dẫn tới thảm họa bán quá số lượng tồn kho (overselling). Trong ngành thương mại điện tử Đông Nam Á, việc bán vượt kho mang lại án phạt nặng nề từ các cơ quan bảo vệ người tiêu dùng, tiêu tốn hàng triệu USD chi phí hoàn tiền và hủy hoại uy tín thương hiệu của sàn.
Để bảo vệ nguyên tắc bất biến không bán vượt kho, thao tác trừ tồn kho bắt buộc phải đạt tính nguyên tử tuyệt đối về mặt toán học, vận hành hoàn toàn trong bộ nhớ RAM và tách biệt hoàn toàn thời gian ghi đĩa của cơ sở dữ liệu vật lý ra khỏi luồng xử lý đồng bộ của người dùng.
2. Điểm Nghẽn Nhiệt Độ Cao (Hotspot Key) Và Kiến Trúc Phân Mảnh Sub-Key
Một cụm Redis Cluster phân tán có thể dễ dàng phục vụ hơn 1.000.000 QPS tổng thể nếu các yêu cầu đọc ghi được phân bổ đều cho hàng triệu khóa sản phẩm khác nhau. Tuy nhiên, trong một đợt Flash Sale, toàn bộ lưu lượng khổng lồ đó lại hội tụ chính xác vào duy nhất một khóa: item:stock:1001.
Vì Redis xử lý các lệnh dữ liệu trên một lõi CPU đơn luồng cho mỗi shard, một máy chủ Redis Master chỉ có thể đạt tối đa từ 100.000 đến 120.000 lệnh mỗi giây trong điều kiện mạng lý tưởng. Khi 1.000.000 request/giây cùng lúc dội vào một node Redis Master duy nhất:
- Lõi CPU gánh luồng xử lý Redis shard bị nghẽn ở mức 100%.
- Hàng đợi nhận gói tin socket TCP (
client-output-buffer-limit/ net input buffer) bị tràn bộ đệm. - Độ trễ kết nối vọt lên hàng ngàn mili-giây, kéo theo sự sụp đổ liên hoàn của các dịch vụ microservice phía trước.
flowchart LR
subgraph DiemNghenDonNode ["Vấn Đề Nghẽn Điểm Nhiệt Cũ (Hotspot Key)"]
direction TB
HK_Req["1.000.000 Req/giây"] --> HK_Node["Duy Nhất Một Node Redis Master (Slot 8412)"]
HK_Node --> HK_CPU["Một Lõi CPU Đơn Luồng Bị Quá Tải 100%"]
HK_CPU --> HK_Drop["Hết Hạn Kết Nối & Sập Dây Chuyền"]
end
subgraph GiaiPhapSubKey ["Giải Pháp Phân Mảnh Sub-Key Của Shopee (Chuẩn 2027)"]
direction TB
SK_Req["1.000.000 Req/giây"] --> SK_Router["Bộ Định Tuyến Ngẫu Nhiên Phía Client"]
SK_Router --> SK_Shard0["Sub-Key {item:1001}:0 -> Node A (100k QPS)"]
SK_Router --> SK_Shard1["Sub-Key {item:1001}:1 -> Node B (100k QPS)"]
SK_Router --> SK_Shard2["Sub-Key {item:1001}:2 -> Node C (100k QPS)"]
SK_Router --> SK_ShardN["Sub-Key {item:1001}:9 -> Node J (100k QPS)"]
end
classDef danger fill:#ffebee,stroke:#c62828,stroke-width:2px;
classDef success fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
class DiemNghenDonNode danger;
class GiaiPhapSubKey success;
Thuật Toán Phân Mảnh Tồn Kho Thành Các Sub-Key
Shopee triệt tiêu điểm nghẽn nhiệt độ cao này bằng cách chia nhỏ tổng số lượng tồn kho của một mặt hàng siêu hot thành $N$ khóa con (sub-key) riêng biệt:
$$\text{Tổng Tồn Kho Thực Tế} = \sum_{i=0}^{N-1} \text{TồnKhoCon}_i$$
Ví dụ: 1.000 chiếc điện thoại trong kho được chia đều thành 10 sub-key, mỗi sub-key nắm giữ 100 chiếc:
item:stock:1001:shard_0$\to$ 100 sản phẩm (Nằm trên Redis Node A)item:stock:1001:shard_1$\to$ 100 sản phẩm (Nằm trên Redis Node B)- …
item:stock:1001:shard_9$\to$ 100 sản phẩm (Nằm trên Redis Node J)
Lưu ý rằng tên khóa hoàn toàn không sử dụng cặp ngoặc nhọn {...} (Hash Tag). Nhờ không có Hash Tag, thuật toán băm CRC16 của Redis Cluster sẽ phân tán ngẫu nhiên 10 khóa con này vào 10 Hash Slot nằm trên 10 máy chủ vật lý độc lập. Áp lực 1.000.000 QPS ban đầu được chia đều cho 10 node, mỗi node chỉ phải gánh 100.000 QPS — hoàn toàn nằm trong ngưỡng vận hành an toàn của Redis.
Cơ Chế Định Tuyến Phía Client Và Tràn Shard Tự Động
Khi một khách hàng tiến hành bấm nút mua:
- Thư viện client Go chọn ngẫu nhiên một phân mảnh $i \in [0, N-1]$ hoặc tính toán
MurmurHash3(user_id) % N. - Kịch bản Lua nguyên tử được gửi tới shard $i$ để trừ tồn kho.
- Nếu shard $i$ trả về mã lỗi
-1(tức shard $i$ đã hết 100 chiếc ban đầu, dù các shard khác vẫn còn hàng), client Go sẽ tự động tràn (spillover) sang shard kế tiếp $(i + 1) \pmod N$. - Chỉ khi toàn bộ $N$ sub-key đều trả về
-1, hệ thống mới xác nhận mặt hàng đã hoàn toàn cháy hàng trên toàn hệ thống.
3. Kịch Bản Redis Lua Nguyên Tử Trừ Tồn Kho Chuẩn Doanh Nghiệp
Dưới đây là mã nguồn kịch bản Lua chuẩn sản xuất của Shopee. Script thực hiện đồng thời việc kiểm tra số lượng tồn kho, kiểm tra hạn mức mua của từng tài khoản người dùng (ví dụ: mỗi người chỉ được mua tối đa 2 sản phẩm), ghi nhận mã giao dịch để đảm bảo tính bất biến (idempotency) và trả về mã lỗi chi tiết.
-- File: /scripts/lua/atomic_deduct_inventory.lua
-- KEYS[1]: Sub-key tồn kho của mặt hàng (Ví dụ: "item:stock:1001:shard_3")
-- KEYS[2]: Khóa lưu hạn mức người dùng (Ví dụ: "item:user_limit:1001")
-- ARGV[1]: Số lượng sản phẩm cần trừ (Ví dụ: "1")
-- ARGV[2]: ID người dùng đặt hàng (Ví dụ: "9823412")
-- ARGV[3]: Hạn mức mua tối đa của mỗi người (Ví dụ: "2")
-- ARGV[4]: Thời gian hết hạn TTL (Ví dụ: "900" giây cho 15 phút khóa hàng)
--
-- Các mã trả về:
-- >= 0 : Thành công. Trả về số lượng tồn kho còn lại của shard này.
-- -1 : Hết hàng trong shard hiện tại.
-- -2 : Vượt quá hạn mức mua của tài khoản.
-- -3 : Khóa tồn kho chưa được khởi tạo.
local stock_key = KEYS[1]
local user_key = KEYS[2]
local deduct_qty = tonumber(ARGV[1])
local user_id = ARGV[2]
local max_per_user = tonumber(ARGV[3])
local ttl_seconds = tonumber(ARGV[4])
-- 1. Kiểm tra khóa tồn kho có tồn tại hay không
local current_stock = redis.call('GET', stock_key)
if not current_stock then
return -3
end
current_stock = tonumber(current_stock)
if current_stock < deduct_qty then
return -1
end
-- 2. Kiểm tra hạn mức mua hàng của người dùng
local user_bought = redis.call('HGET', user_key, user_id)
if user_bought then
user_bought = tonumber(user_bought)
if (user_bought + deduct_qty) > max_per_user then
return -2
end
else
user_bought = 0
end
-- 3. Trừ tồn kho và cập nhật số lượng đã mua một cách nguyên tử
local remaining_stock = redis.call('DECRBY', stock_key, deduct_qty)
redis.call('HSET', user_key, user_id, user_bought + deduct_qty)
-- 4. Đặt thời gian sống TTL cho bảng hạn mức nếu mới tạo
if user_bought == 0 then
redis.call('EXPIRE', user_key, ttl_seconds)
end
return remaining_stock
Kịch Bản Hoàn Trả Tồn Kho Khi Hết Hạn Giữ Hàng
Nếu người dùng không thanh toán trong vòng 15 phút hoặc giao dịch ngân hàng thất bại, một tác vụ trễ (delay queue) sẽ kích hoạt kịch bản Lua hoàn trả tồn kho vào đúng shard ban đầu:
-- File: /scripts/lua/atomic_revert_inventory.lua
-- KEYS[1]: Sub-key tồn kho (Ví dụ: "item:stock:1001:shard_3")
-- KEYS[2]: Khóa hạn mức người dùng (Ví dụ: "item:user_limit:1001")
-- ARGV[1]: Số lượng hoàn lại (Ví dụ: "1")
-- ARGV[2]: ID người dùng (Ví dụ: "9823412")
local stock_key = KEYS[1]
local user_key = KEYS[2]
local revert_qty = tonumber(ARGV[1])
local user_id = ARGV[2]
-- Cộng lại số lượng vào tồn kho
local new_stock = redis.call('INCRBY', stock_key, revert_qty)
-- Giảm số lượng đã mua của người dùng
local user_bought = redis.call('HGET', user_key, user_id)
if user_bought then
local updated = tonumber(user_bought) - revert_qty
if updated <= 0 then
redis.call('HDEL', user_key, user_id)
else
redis.call('HSET', user_key, user_id, updated)
end
end
return new_stock
4. Cài Đặt Thực Tế Client Golang Với Cơ Chế Định Tuyến Sub-Key
Đoạn mã Golang dưới đây hiện thực hóa toàn bộ logic quản lý giữ chỗ tồn kho: định tuyến ngẫu nhiên giữa các shard, tràn shard tự động khi hết hàng cục bộ, cơ chế ngắt mạch sớm bằng bộ nhớ trong sync.Map và tải trước mã băm SHA1 của script (EVALSHA).
package flashsale
import (
"context"
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"math/rand"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
)
var (
ErrSoldOut = errors.New("flashsale: san pham da hoan toan het hang")
ErrQuotaExceeded = errors.New("flashsale: vuot qua han muc mua cua tai khoan")
ErrUninitialized = errors.New("flashsale: khoa ton kho chua khoi tao")
)
type InventoryManager struct {
rdb *redis.ClusterClient
deductSHA string
revertSHA string
shardCount int
soldOutFlags sync.Map // Cờ ngắt mạch sớm trong bộ nhớ RAM: map[int64]bool
activeRequests int64
}
func NewInventoryManager(rdb *redis.ClusterClient, deductLua, revertLua string, shards int) (*InventoryManager, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
dSHA, err := rdb.ScriptLoad(ctx, deductLua).Result()
if err != nil {
return nil, fmt.Errorf("khong the tai deduct lua vao redis: %w", err)
}
rSHA, err := rdb.ScriptLoad(ctx, revertLua).Result()
if err != nil {
return nil, fmt.Errorf("khong the tai revert lua vao redis: %w", err)
}
return &InventoryManager{
rdb: rdb,
deductSHA: dSHA,
revertSHA: rSHA,
shardCount: shards,
}, nil
}
// ReserveStock thực hiện trừ tồn kho phân mảnh sub-key với cơ chế tự tràn shard.
func (m *InventoryManager) ReserveStock(ctx context.Context, skuID int64, userID int64, qty int32, maxQuota int32) (string, error) {
// 1. Kiểm tra nhanh trong bộ nhớ RAM máy chủ
if val, ok := m.soldOutFlags.Load(skuID); ok && val.(bool) {
return "", ErrSoldOut
}
atomic.AddInt64(&m.activeRequests, 1)
defer atomic.AddInt64(&m.activeRequests, -1)
// 2. Chọn ngẫu nhiên shard bắt đầu để san đều tải
startShard := rand.Intn(m.shardCount)
userKey := fmt.Sprintf("item:user_limit:%d", skuID)
for attempt := 0; attempt < m.shardCount; attempt++ {
currentShard := (startShard + attempt) % m.shardCount
stockKey := fmt.Sprintf("item:stock:%d:shard_%d", skuID, currentShard)
res, err := m.rdb.EvalSha(ctx, m.deductSHA, []string{stockKey, userKey}, qty, userID, maxQuota, 900).Result()
if err != nil {
return "", fmt.Errorf("loi evalsha redis tren shard %d: %w", currentShard, err)
}
code := res.(int64)
switch {
case code >= 0:
// Trừ kho thành công: tạo mã giữ chỗ reservation token
reservationID := fmt.Sprintf("RES-%d-%d-%d", skuID, currentShard, time.Now().UnixNano())
return reservationID, nil
case code == -2:
// Người dùng đã mua hết hạn mức
return "", ErrQuotaExceeded
case code == -3:
return "", ErrUninitialized
case code == -1:
// Shard này đã hết hàng; tiếp tục kiểm tra shard kế tiếp
continue
}
}
// 3. Toàn bộ các shard đều trả về -1: Đánh dấu mặt hàng đã hoàn toàn hết sạch
m.soldOutFlags.Store(skuID, true)
return "", ErrSoldOut
}
// RevertStock hoàn trả tồn kho khi đơn hàng bị hủy hoặc quá hạn thanh toán.
func (m *InventoryManager) RevertStock(ctx context.Context, skuID int64, shardID int, userID int64, qty int32) error {
stockKey := fmt.Sprintf("item:stock:%d:shard_%d", skuID, shardID)
userKey := fmt.Sprintf("item:user_limit:%d", skuID)
_, err := m.rdb.EvalSha(ctx, m.revertSHA, []string{stockKey, userKey}, qty, userID).Result()
if err != nil {
return fmt.Errorf("khong the hoan tra ton kho tren shard %d: %w", shardID, err)
}
// Xóa cờ hết hàng cục bộ để người mua khác có cơ hội đặt
m.soldOutFlags.Delete(skuID)
return nil
}
5. Quy Trình Tổng Thể Đặt Hàng Flash Sale Và Đồng Bộ Sổ Cái Bất Đồng Bộ
Việc ghi đồng bộ dữ liệu giao dịch xuống MySQL hay PostgreSQL trong các sự kiện Flash Sale chắc chắn sẽ giết chết toàn bộ hệ thống cơ sở dữ liệu. Thay vào đó, Shopee phân tách vòng đời đặt hàng thành hai giai đoạn hoàn toàn độc lập:
- Giai Đoạn Đồng Bộ (Giữ Chỗ Trong Bộ Nhớ): Thực thi dưới 2 mili-giây. Redis Lua trừ kho, tạo ra
OrderReservationToken, đẩy một sự kiệnOrderPlacedEventvào hàng đợi Apache Kafka và phản hồi ngay mãHTTP 200 Acceptedvề cho ứng dụng điện thoại của người mua. - Giai Đoạn Bất Đồng Bộ (Ghi Sổ Cái Cơ Sở Dữ Liệu): Các nhóm worker pod đọc từng mẻ (batch) 200 đơn hàng từ Kafka, thực thi câu lệnh SQL cập nhật lạc quan hàng loạt vào cơ sở dữ liệu phân tán TiDB NewSQL.
sequenceDiagram
autonumber
actor User as Khách Hàng Điện Thoại
participant GW as API Gateway / Envoy
participant FlashSvc as Dịch Vụ Flash Sale (Go)
participant Redis as Cụm Redis Cluster (Sub-Key Shards)
participant Kafka as Hàng Đợi Kafka (orders.placed)
participant Consumer as Worker Xử Lý Đơn Hàng
participant DB as Sổ Cái Phân Tán TiDB
User->>GW: POST /api/v1/flashsale/checkout
GW->>FlashSvc: Chuyển tiếp RPC kèm Khóa Bất Biến (Idempotency Key)
FlashSvc->>FlashSvc: 1. Kiểm tra nhanh trong bộ đệm FreeCache
FlashSvc->>Redis: 2. Gọi EVALSHA atomic_deduct_inventory.lua
Note over Redis: Trừ kho & ghi hạn mức người dùng trong 0.8ms
Redis-->>FlashSvc: Trả về remaining_stock >= 0
FlashSvc->>Kafka: 3. Đẩy sự kiện OrderPlaced (Nén Snappy)
FlashSvc-->>GW: Trả về Reservation Token (HTTP 200)
GW-->>User: Hiển thị "Giữ Hàng Thành Công! Chuyển Sang Thanh Toán" (12ms)
Note over Kafka,Consumer: Quy trình ghi sổ cái diễn ra bất đồng bộ phía sau
Kafka->>Consumer: Đọc từng mẻ 200 sự kiện đơn hàng
Consumer->>DB: Thực thi Bulk INSERT đơn hàng + Đồng bộ kho lạc quan
DB-->>Consumer: Hoàn tất giao dịch (Đảm bảo chuẩn ACID)
Câu Lệnh Cập Nhật Sổ Cái Lạc Quan Trong Cơ Sở Dữ Liệu
Khi consumer bất đồng bộ cập nhật sổ cái tồn kho trong cơ sở dữ liệu chính thức, nó sử dụng lệnh giảm tồn kho có điều kiện:
-- Cập nhật sổ cái an toàn trong cơ sở dữ liệu lưu trữ vĩnh viễn
UPDATE product_ledger
SET physical_stock = physical_stock - :batch_deducted_qty,
reserved_stock = reserved_stock + :batch_deducted_qty,
version = version + 1
WHERE sku_id = :sku_id
AND physical_stock >= :batch_deducted_qty;
Nếu việc cập nhật cơ sở dữ liệu thất bại do đứt đoạn mạng hoặc xung đột tương tranh, consumer Kafka sẽ không commit offset của partition đó. Kafka sẽ gửi lại mẻ tin nhắn, và các khóa bất biến (idempotency key) được lưu trong bảng order_reservations sẽ đảm bảo người mua không bao giờ bị trừ tiền hai lần hoặc làm sai lệch sổ sách kế toán.
Phân Tích Sự Cố Thực Tế: Vụ Sập Cụm Redis Do Điểm Nhiệt 11.11
Trong một chiến dịch 11.11 trước khi kỹ thuật phân mảnh sub-key được áp dụng thành quy chuẩn, Shopee đã đối mặt với một sự cố nghiêm trọng:
- T-00:00 (Mở Bán): Cổng Flash Sale mở bán chiếc máy tính bảng giảm giá 70% với 2.000 sản phẩm. Chỉ trong vòng 120 mili-giây, lưu lượng đổ dồn vào khóa
item:stock:4082chạm mốc 720.000 QPS. - T+00:02 (Node Master Bị Treo): Node Redis Master nắm giữ Hash Slot 11422 bị nghẽn 100% CPU. Vòng lặp đơn luồng bị khóa chặt xử lý hàng đợi socket, khiến gói tin nhịp tim (
CLUSTER PING) bị rớt mạng. - T+00:05 (Bão Bầu Chọn Lại Master): Cụm Redis kết luận node này đã chết và kích hoạt failover. Tuy nhiên, node Replica vừa được đôn lên Master lại lập tức hứng trọn 720.000 QPS và sụp đổ chỉ sau 1,5 giây hoạt động.
- T+00:15 (Sập Dây Chuyền Toàn Hệ Thống): Các pod Go phía trước cạn kiệt kết nối mạng vì phải chờ đợi timeout từ Redis, kích hoạt circuit breaker ngắt mạch và đánh rớt 94% lưu lượng toàn sàn.
Nguyên nhân gốc rễ không phải do toàn cụm Redis thiếu năng lực — cụm 128 node khi đó chỉ hoạt động dưới 8% tải tổng thể — mà do sự phân bổ lưu lượng bị lệch cực độ (điểm nhiệt tập trung). Việc áp dụng thuật toán sub-key đã xoay chuyển hoàn toàn cục diện: 2.000 sản phẩm được chia đều cho 20 hash slot độc lập (item:stock:4082:shard_0 tới shard_19), kéo tải mỗi node từ 720.000 QPS xuống còn 36.000 QPS, giúp toàn bộ giao dịch hoàn tất êm đẹp trong độ trễ 1,4ms p99.
Chứng Minh Toán Học Về Độ Phân Tán Slot Của Sub-Key
Redis Cluster phân bổ các khóa vào 16.384 Hash Slot logic bằng thuật toán chuẩn CRC16:
$$\text{Slot} = \text{CRC16}(\text{key}) \pmod{16384}$$
Khi tên khóa không chứa hash tag ({...}), toàn bộ chuỗi ký tự item:stock:<sku_id>:shard_<shard_id> sẽ được băm. Do CRC16 có đặc tính phân tán tuyết lở (avalanche effect) rất tốt (chỉ cần thay đổi 1 ký tự số shard cũng làm thay đổi ngẫu nhiên ~50% các bit đầu ra), việc tăng chỉ số shard từ 0 đến $N-1$ bảo đảm các khóa được rải đều trên toàn bộ không gian 16.384 slot.
Với cụm gồm $M$ node máy chủ master ($M \ge 16$), xác suất xảy ra xung đột khi có từ hai sub-key trở lên rơi vào cùng một máy chủ vật lý được tính theo bài toán nghịch lý ngày sinh (Generalized Birthday Problem):
$$P(\text{xung đột}) \approx 1 - \exp\left(-\frac{N(N-1)}{2M}\right)$$
Với cấu hình $M = 32$ master node và $N = 10$ sub-key, xác suất để tải bị dồn cục bộ là cực kỳ thấp. Đồng thời, trình giám sát Kubernetes sẽ tự động di chuyển slot (slot migration) nếu phát hiện có hai sub-key cùng nằm trên một node vật lý chịu tải cao.
6. Ma Trận Đánh Đổi Kỹ Thuật Và Các Giải Pháp Bị Bác Bỏ
Thiết kế một động cơ Flash Sale đòi hỏi sự cân bằng nghiêm ngặt giữa tốc độ ghi và tính toàn vẹn dữ liệu:
| Quyết Định Kiến Trúc | Phương Án Bị Bác Bỏ | Lý Do Đánh Đổi & Căn Cứ Thực Tế |
|---|---|---|
| Phân mảnh Sub-Key ($N$ khóa) | Dùng duy nhất một Key cho mỗi SKU | Một khóa duy nhất tập trung 1.000.000 QPS vào đúng một lõi Redis, gây tràn bộ đệm socket và làm sập máy chủ. |
Kịch bản Lua nguyên tử (EVALSHA) | Dùng khối WATCH / MULTI / EXEC | Giao dịch lạc quan của Redis sụp đổ dưới tải tương tranh cực cao; hơn 99% lệnh EXEC bị hủy do xung đột dữ liệu, lãng phí tài nguyên mạng. |
| Ngắt mạch sớm trong RAM | Liên tục truy vấn Redis để kiểm tra hết hàng | Khi sản phẩm đã hết sạch hàng, việc gửi 500.000 request/giây vào Redis chỉ để nhận số 0 là sự lãng phí băng thông khổng lồ. RAM cục bộ trả lời ngay trong 5µs. |
| Hàng đợi Kafka bất đồng bộ | Giao dịch 2PC (Two-Phase Commit) trực tiếp | Giao dịch 2PC phân tán mất từ 50ms đến 200ms cho mỗi lượt thanh toán, giới hạn thông lượng của sàn ở mức dưới 5.000 TPS. |
Câu Hỏi Thường Gặp (FAQ)
Làm thế nào kiến trúc sub-key xử lý vấn đề tồn kho dư thừa phân tán không đều giữa các shard?
-1, client Go sẽ tự động thử tiếp shard $(i + 1) \pmod N$ ngay trong cùng kết nối mạng. Khi tổng số hàng còn lại trên tất cả các shard giảm xuống dưới $N \times 2$, một dịch vụ giám sát tập trung sẽ kích hoạt kịch bản gom tồn kho dư thừa về một shard duy nhất, bảo đảm 100% sản phẩm đều được bán hết mà không bị báo hết hàng giả mạo.Điều gì xảy ra nếu node Redis Master bị sập trong lúc Flash Sale đang diễn ra trước khi kịp đồng bộ sang Replica?
WAIT của Redis Cluster cho các mặt hàng giá trị cực cao hoặc đẩy sự kiện đặt hàng vào Kafka ngay lập tức. Kể cả khi cụm Failover bầu Replica lên làm Master và bị thiếu hụt một vài lượt trừ, sự kiện trong Kafka vẫn đóng vai trò là nguồn chân lý tối thượng (source of truth). Sổ cái cơ sở dữ liệu phía sau sẽ tự động đối soát và cân bằng lại số liệu trong quá trình kết toán.Tại sao nên sử dụng EVALSHA thay vì gửi trực tiếp mã nguồn Lua script trên mỗi request?
SCRIPT LOAD lúc khởi động ứng dụng, client chỉ cần truyền chuỗi băm SHA1 40 ký tự (EVALSHA). Lượng dữ liệu mạng truyền tải giảm hơn 95% và Redis thực thi trực tiếp bytecode đã được biên dịch sẵn trong bộ nhớ.Làm thế nào Shopee ngăn chặn các tập lệnh bot tự động gom sạch hàng Flash Sale trong tích tắc?
Tài Liệu Tham Khảo Kỹ Thuật
Để mở rộng kiến thức về các hệ thống tài chính và giao dịch thời gian thực chịu tải cực cao:
- Kiến trúc giao dịch chịu tải đỉnh cao Alipay Double 11
- Thiết kế vi dịch vụ ngân hàng và toàn vẹn sổ cái
- Bản đồ đọc và mục lục hệ thống kiến trúc
Bước Tiếp Theo
Mời bạn tiếp tục theo dõi Bài 3: Tấm Khiên Lưu Lượng — WAF, Rate Limiting & Kafka Peak Shaving để tìm hiểu cách thức Shopee xây dựng các phòng tuyến lọc lưu lượng và sử dụng Kafka làm hồ chứa đệm giúp san phẳng các đỉnh sóng truy cập khổng lồ.
