📖 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:

  1. 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.
  2. 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.
  3. 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:

  1. Lõi CPU gánh luồng xử lý Redis shard bị nghẽn ở mức 100%.
  2. Hàng đợi nhận gói tin socket TCP (client-output-buffer-limit / net input buffer) bị tràn bộ đệm.
  3. Độ 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:

  1. 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.
  2. Kịch bản Lua nguyên tử được gửi tới shard $i$ để trừ tồn kho.
  3. 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$.
  4. 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:

  1. 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ện OrderPlacedEvent vào hàng đợi Apache Kafka và phản hồi ngay mã HTTP 200 Accepted về cho ứng dụng điện thoại của người mua.
  2. 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:4082 chạ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úcPhươ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 SKUMộ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 / EXECGiao 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 RAMLiên tục truy vấn Redis để kiểm tra hết hàngKhi 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ếpGiao 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?

Khi số lượng tồn kho được chia đều cho $N$ sub-key, việc người dùng truy cập ngẫu nhiên có thể dẫn tới tình trạng shard 1 đã hết nhẵn trong khi shard 2 vẫn còn sót lại 5 sản phẩm. Shopee xử lý điều này bằng thuật toán tràn shard tự động: nếu shard ban đầu trả về -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?

Cơ chế nhân bản của Redis là bất đồng bộ theo mặc định, đồng nghĩa với việc node Master sập có thể làm mất vài giao dịch trừ kho vừa diễn ra trong microsecond cuối cùng. Shopee khắc phục điều này bằng cách sử dụng lệnh 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?

Việc gửi toàn bộ đoạn mã Lua dài 50 dòng qua đường truyền mạng cho mỗi request trong số 1.000.000 request/giây sẽ tiêu tốn một lượng băng thông mạng khổng lồ và buộc trình thông dịch Lua của Redis phải liên tục phân tích cú pháp lại script. Bằng cách nạp trước script thông qua lệnh 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?

Trước khi một request có thể chạm tới động cơ trừ kho Redis, nó bắt buộc phải đi qua một trạm kiểm soát tại lớp mạng biên để xác thực ‘Token Mua Hàng’ (Purchase Token). Token này chỉ được cấp sau khi ứng dụng vượt qua bài kiểm tra Proof-of-Work (PoW) hoặc CAPTCHA hành vi tại tầng CDN. Ngoài ra, trạm kiểm soát giới hạn số lượng token phát ra đúng bằng $2 \times$ số lượng tồn kho thực tế, đảm bảo hơn 99,8% các công cụ bot tự động bị chặn đứng ngay từ rìa mạng trước khi tiêu tốn tài nguyên tính toán của các microservice nội bộ.

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:


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ồ.