🇬🇧 Read the English version of this article on tanhdev.com

Answer-first: Kiến trúc Shopee Flash Sale hấp thụ hàng triệu TPS nhờ mô hình phòng thủ 4 lớp: chặn 90% bot tại Edge CDN qua TLS JA4, điều tiết Token Bucket tại Gateway, giữ chỗ tồn kho nguyên tử bằng Redis Lua script và đệm Kafka bất đồng bộ ghi TiDB. Giải pháp triệt tiêu tranh chấp khóa dòng triệt để.

Điều kiện tiên quyết: Cần có kiến thức nền tảng về cơ chế concurrency trong Go, giao dịch phân tán trên TiDB/MySQL và kiến trúc cụm Redis Cluster chịu tải cao.

Để tìm hiểu toàn bộ hệ sinh thái vi dịch vụ của Shopee, hãy tham khảo Chuỗi Bài Kiến Trúc Shopee (Shopee Architecture Series) và bài viết Chương 2: Flash Sale Engine.


1. Thách Thức Kỹ Thuật Đêm 11.11: Bản Chất Của Điểm Nghẽn Cạnh Tranh Ghi (Write Contention)

Đúng 00:00:00 đêm 11.11 hoặc 12.12, hàng chục triệu người dùng trên khắp Đông Nam Á đồng loạt nhấn nút chốt đơn trên cùng một trang sản phẩm duy nhất (ví dụ: 1.000 chiếc điện thoại flagship giảm giá 90%).

Trong vòng 250 mili-giây đầu tiên, lưu lượng truy cập có thể tăng vọt gấp 1.200 lần so với ngày thường. Nếu áp dụng tư duy mở rộng máy chủ theo chiều ngang thông thường (thêm máy chủ ứng dụng, thêm read-replicas), hệ thống chắc chắn sẽ sụp đổ hoàn toàn do sự tắc nghẽn vật lý:

flowchart TD
    subgraph Bottleneck ["Sự Sụp Đổ Của Kiến Trúc CRUD Thông Thường"]
        R1["50.000 Request Mua Hàng"] --> App1["App Server 1"]
        R2["50.000 Request Mua Hàng"] --> App2["App Server 2"]
        R3["50.000 Request Mua Hàng"] --> AppN["App Server N"]
        
        App1 -->|"UPDATE stock = stock - 1"| LockRow["Khóa Cứng Dòng SKU-101"]
        App2 -->|"UPDATE stock = stock - 1"| LockRow
        AppN -->|"UPDATE stock = stock - 1"| LockRow
        
        LockRow --> DBDeath["Hàng Đợi Khóa Quá Tải -> DB Treo -> OOM -> Cạn Pool -> HTTP 504"]
    end

1.1. Ba Cơn Ác Mộng Của Hệ Thống Cổ Điển

  1. Tranh chấp khóa dòng cấp cơ sở dữ liệu (Row-Level Lock Contention): Khi hàng ngàn kết nối cùng cố gắng cập nhật giá trị tồn kho của cùng một bản ghi, database engine (InnoDB hoặc PostgreSQL) bắt buộc phải xếp hàng tuần tự. P99 latency bùng nổ từ 2ms lên hơn 10.000ms. Hàng ngàn tiến trình chờ giữ khóa (lock wait) khiến toàn bộ connection pool cạn kiệt trong 2 giây.
  2. Cơn bão thử lại (Retry Storms / Thundering Herd): Khi người dùng thấy ứng dụng quay vòng tròn hoặc báo lỗi kết nối, hành vi tự nhiên là liên tục bấm nút mua lại nhiều lần. Số lượng request nhân lên theo cấp số nhân, tạo thành hiệu ứng thăng hoa trùng lặp đánh sập toàn bộ các dịch vụ phụ trợ từ cổng thanh toán đến quản lý tài khoản.
  3. Mạng lưới botnets tự động (Coupon Scraping Bots): Hơn 75% lưu lượng truy cập trong 2 giây đầu tiên không phải là người dùng thật, mà là các script tự động giả lập client để vét sạch kho hàng trong tích tắc bằng các kết nối mạng tốc độ cao.

2. Kiến Trúc Phòng Thủ 4 Lớp (4-Layer Defense Model)

Chiến lược then chốt của Shopee là: Chặn và lọc tải càng xa cơ sở dữ liệu càng tốt. Chỉ những request hợp lệ đã được kiểm duyệt nghiêm ngặt mới được phép chạm vào lớp xử lý tồn kho.

flowchart TD
    Users["Hàng Triệu Khách Hàng & Botnets"] --> Layer1["Lớp 1: Edge CDN & WAF (Cloudflare/Akamai)"]
    
    subgraph EdgeLayer ["Lớp 1: Lọc Tải Biên (Chặn 90% Requests)"]
        Layer1 --> StaticAssets["Trả Về Static Content (Ảnh, CSS, JS) Ngay Tại Edge"]
        Layer1 --> BotDefense["Phát Hiện Vân Tay Thiết Bị & Chữ Ký HMAC Token"]
    end

    BotDefense -->|"Lưu Lượng Hợp Lệ"| Layer2["Lớp 2: API Gateway & Dynamic Rate Limiting"]

    subgraph GatewayLayer ["Lớp 2: Điều Tiết Lưu Lượng"]
        Layer2 --> TokenBucket["Envoy Token Bucket Filter (Giới Hạn User/IP/Device)"]
        Layer2 --> Blacklist["Chặn Tức Thời Các IP Nghi Ngờ"]
    end

    TokenBucket -->|"Lưu Lượng Đã Điều Tiết"| Layer3["Lớp 3: Phòng Chờ Ảo (Virtual Waiting Room)"]

    subgraph QueueLayer ["Lớp 3: Điều Tiết Dòng Chảy"]
        Layer3 --> FairQueue["Hàng Đợi Xếp Hàng Công Bằng (Kafka/Redis Stream)"]
        Layer3 --> Polling["Client Giữ Kết Nối Chờ Cấp Phiếu Mua Hàng"]
    end

    FairQueue -->|"Cấp Phiếu Thứ Tự"| Layer4["Lớp 4: Flash Sale Engine (Core Processing)"]

    subgraph CoreLayer ["Lớp 4: Xử Lý Tồn Kho Tốc Độ Cao"]
        Layer4 --> RedisLua["Redis Cluster: Trừ Tồn Kho Nguyên Tử Sub-ms"]
        Layer4 --> KafkaOrder["Đẩy Đơn Hàng Thành Công Vào Apache Kafka"]
        KafkaOrder --> TiDBStore[("TiDB Distributed SQL & Sharded MySQL")]
    end

3. Quy Trình Pre-Heating: Làm Nóng Cache Trước Giờ Mở Bán

Để tránh tình trạng Cache Avalanche (Đổ vỡ cache) khi sự kiện bắt đầu lúc 00:00:00, Shopee kích hoạt quy trình tự động làm nóng cache (Cache Pre-heating) trước đó 30 phút:

  1. Lớp L1 Cache Cục Bộ (In-Memory BigCache trên Pod Go):
    • Nạp toàn bộ thông tin tĩnh của sản phẩm (tiêu đề, ảnh, mô tả, thông số kỹ thuật) trực tiếp vào bộ nhớ RAM của các Pod Go thông qua allegro/bigcache.
    • Tuyệt đối không để request tra cứu chi tiết sản phẩm lọt xuống Redis hay Database trong giờ cao điểm.
  2. Lớp L2 Cache Phân Tán (Redis Cluster):
    • Khởi tạo key số lượng tồn kho: stock:{SKU_ID} = 1000.
    • Nạp trước danh sách mã giảm giá (Vouchers) và thông tin cấu hình chương trình Flash Sale.
  3. Thiết Lập Random TTL (Thời Gian Sống Ngẫu Nhiên):
    • Tránh việc toàn bộ cache cùng hết hạn tại một thời điểm: TTL = Base TTL (2h) + Random(0, 600 giây)

4. Mô Hình Cache-Ahead: Trừ Kho Trên RAM, Ghi Bất Đồng Bộ Xuống SQL

Thay vì thực hiện giao dịch ghi đồng bộ xuống cơ sở dữ liệu quan hệ, Shopee tách rời quy trình thành hai pha độc lập:

sequenceDiagram
    autonumber
    actor User as Khách Hàng
    participant GW as API Gateway
    participant Engine as Flash Sale Service (Go)
    participant Redis as Redis Cluster (Speed Layer)
    participant Kafka as Apache Kafka (Async Queue)
    participant Worker as Order Consumer Worker
    participant TiDB as TiDB Cluster (Truth Layer)

    User->>GW: POST /api/flash-sale/buy (Kèm Token Phòng Chờ)
    GW->>Engine: Xác thực tính hợp lệ của Token
    
    rect rgb(245, 255, 250)
        note over Engine, Redis: Pha 1: Trừ Kho Tức Thì Trên RAM (< 1ms)
        Engine->>Redis: Thực thi Lua Script Trừ Tồn Kho Nguyên Tử
        Redis-->>Engine: Trả về: Thành Công (Số dư còn lại: 850)
    end

    Engine->>Kafka: Phát sự kiện order.created (Khóa partition = sku_id)
    Engine-->>User: Trả về HTTP 200: "Đã Giữ Hàng Thành Công! Chờ Thanh Toán"

    rect rgb(255, 250, 240)
        note over Kafka, TiDB: Pha 2: Ghi Bền Vững Xuống Database Bất Đồng Bộ
        Kafka->>Worker: Consume theo Batch (1.000 đơn/lần)
        Worker->>TiDB: BATCH INSERT đơn hàng & Trừ tồn kho pháp lý
        Worker-->>Kafka: Commit Offset
    end

Nhờ mô hình Cache-Ahead, người dùng nhận được phản hồi chốt đơn chỉ sau chưa đầy 5 mili-giây. Cơ sở dữ liệu quan hệ phía sau không bị sốc tải mà có thể xử lý việc lưu trữ bền vững một cách êm ái, kéo dài trong suốt 10-15 phút sau đó.


5. Tầng Cơ Sở Dữ Liệu Phân Tán: Vitess Sharding & TiDB HTAP

Khi hàng triệu đơn hàng được xác nhận trong vòng 15 phút, việc ghi dữ liệu vào một cụm MySQL đơn lẻ (dù có gắn ổ cứng NVMe Enterprise) sẽ nhanh chóng đạt đến giới hạn I/O vật lý (Disk IOPS limit).

Shopee kết hợp hai chiến lược lưu trữ dữ liệu hiện đại:

5.1. Vitess Sharding Cho Giao Dịch Đơn Hàng (OLTP)

Vitess bọc phía trên cụm MySQL truyền thống, chia bảng orders thành hàng trăm shard vật lý dựa trên hàm băm của user_id:

  • Một người dùng luôn truy vấn đúng shard chứa đơn hàng của mình.
  • Tải ghi được dàn đều trên 64 máy chủ MySQL vật lý khác nhau, cho phép hệ thống đạt hơn 150.000 TPS ghi bền vững.

5.2. TiDB Distributed NewSQL Cho Báo Cáo Thời Gian Thực (HTAP)

Đối với đội ngũ vận hành và đối tác thương hiệu, việc theo dõi doanh thu Flash Sale theo thời gian thực (Real-time Analytics) là bắt buộc. TiDB với kiến trúc lưu trữ kết hợp TiKV (hàng ngang phục vụ ghi OLTP) và TiFlash (cột dọc phục vụ phân tích OLAP) cho phép chạy các câu truy vấn phân tích doanh số khổng lồ mà không gây ảnh hưởng đến hiệu năng của luồng ghi đơn hàng.


6. Mã Nguồn Production: Go Rate Limiter, HMAC Queue Validator & Redis Lua Script

6.1. Mã Redis Lua Script Trừ Kho Phân Tán Virtual Buckets

Để giải quyết bài toán một Hot SKU làm quá tải một CPU core của Redis, tồn kho được chia đều thành các Virtual Buckets. Mã Lua script dưới đây tự động kiểm tra bucket được gán và sinh mã giao dịch giữ hàng:

-- ========================================================================
-- SHOPEE FLASH SALE: LUA SCRIPT TRỪ TỒN KHO PHÂN TÁN VIRTUAL BUCKET
-- KEYS[1]: Bucket Tồn kho cụ thể (Ví dụ: "stock:{SKU_1001}:b3")
-- KEYS[2]: Key Token lũy đẳng đơn hàng (Ví dụ: "idempotent:{SKU_1001}:USR_8899")
-- ARGV[1]: Số lượng mua (thường là 1 trong Flash Sale)
-- ARGV[2]: TTL của Token giữ chỗ (giây, ví dụ 900 cho 15 phút)
-- ========================================================================

-- Kiểm tra xem khách hàng này đã từng giữ chỗ thành công hay chưa (Chống mua gom)
if redis.call("EXISTS", KEYS[2]) == 1 then
    return {err = "USER_ALREADY_PURCHASED"}
end

local stock = tonumber(redis.call("GET", KEYS[1]) or "0")
local qty = tonumber(ARGV[1])

if stock < qty then
    return {err = "BUCKET_EXHAUSTED"}
end

-- Trừ kho nguyên tử
redis.call("DECRBY", KEYS[1], qty)

-- Khóa token người dùng kèm TTL 15 phút
redis.call("SET", KEYS[2], "RESERVED", "EX", tonumber(ARGV[2]))

return stock - qty

6.2. Mã Go Service: Điều Tiết Phòng Chờ Ảo & Chốt Đơn Chống Bão

package flashsale

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"hash/fnv"
	"time"

	"github.com/redis/go-redis/v9"
	"github.com/segmentio/kafka-go"
)

type PurchaseRequest struct {
	SKUID           string `json:"sku_id"`
	UserID          string `json:"user_id"`
	QueueToken      string `json:"queue_token"`
	SignedTimestamp int64  `json:"timestamp"`
}

type OrderEvent struct {
	OrderID   string `json:"order_id"`
	SKUID     string `json:"sku_id"`
	UserID    string `json:"user_id"`
	CreatedAt int64  `json:"created_at"`
}

type FlashSaleEngine struct {
	redisClient *redis.ClusterClient
	kafkaWriter *kafka.Writer
	secretKey   []byte
	luaScript   string
}

func NewFlashSaleEngine(rdb *redis.ClusterClient, kw *kafka.Writer, secret []byte) *FlashSaleEngine {
	lua := `
	if redis.call("EXISTS", KEYS[2]) == 1 then
		return {err = "USER_ALREADY_PURCHASED"}
	end
	local stock = tonumber(redis.call("GET", KEYS[1]) or "0")
	local qty = tonumber(ARGV[1])
	if stock < qty then
		return {err = "BUCKET_EXHAUSTED"}
	end
	redis.call("DECRBY", KEYS[1], qty)
	redis.call("SET", KEYS[2], "RESERVED", "EX", tonumber(ARGV[2]))
	return stock - qty
	`
	return &FlashSaleEngine{
		redisClient: rdb,
		kafkaWriter: kw,
		secretKey:   secret,
		luaScript:   lua,
	}
}

// ValidateQueueToken xác thực chữ ký số HMAC của vé phòng chờ tránh người dùng fake request
func (e *FlashSaleEngine) ValidateQueueToken(req PurchaseRequest) bool {
	now := time.Now().Unix()
	if now-req.SignedTimestamp > 300 || req.SignedTimestamp > now+5 {
		return false
	}

	payload := fmt.Sprintf("%s:%s:%d", req.UserID, req.SKUID, req.SignedTimestamp)
	mac := hmac.New(sha256.New, e.secretKey)
	mac.Write([]byte(payload))
	expectedSignature := hex.EncodeToString(mac.Sum(nil))

	return hmac.Equal([]byte(req.QueueToken), []byte(expectedSignature))
}

// ExecutePurchase thực thi giữ chỗ hàng trong tích tắc
func (e *FlashSaleEngine) ExecutePurchase(ctx context.Context, req PurchaseRequest) (string, error) {
	if !e.ValidateQueueToken(req) {
		return "", errors.New("mã vé phòng chờ không hợp lệ hoặc đã hết hạn")
	}

	// Băm UserID để chọn 1 trong 10 virtual buckets (b0 đến b9)
	h := fnv.New32a()
	h.Write([]byte(req.UserID))
	bucketID := h.Sum32() % 10

	stockKey := fmt.Sprintf("stock:{%s}:b%d", req.SKUID, bucketID)
	idempotentKey := fmt.Sprintf("idempotent:{%s}:%s", req.SKUID, req.UserID)

	// Gọi Lua script trên Redis
	res, err := e.redisClient.Eval(ctx, e.luaScript, []string{stockKey, idempotentKey}, 1, 900).Result()
	if err != nil {
		return "", fmt.Errorf("lỗi thực thi trừ kho Redis: %w", err)
	}

	// Sinh Order ID độc nhất
	orderID := fmt.Sprintf("FS-%s-%d-%d", req.SKUID, bucketID, time.Now().UnixNano())

	// Bắn sự kiện bất đồng bộ vào Apache Kafka
	event := OrderEvent{
		OrderID:   orderID,
		SKUID:     req.SKUID,
		UserID:    req.UserID,
		CreatedAt: time.Now().Unix(),
	}
	payload, _ := json.Marshal(event)

	err = e.kafkaWriter.WriteMessages(ctx, kafka.Message{
		Key:   []byte(req.SKUID), // Phân vùng Kafka theo SKU ID để đảm bảo thứ tự
		Value: payload,
	})
	if err != nil {
		fmt.Printf("[CRITICAL] Không thể gửi message vào Kafka cho đơn hàng %s: %v\n", orderID, err)
	}

	_ = res
	return orderID, nil
}

7. Kịch Bản Kiểm Thử Tải Phân Tán (Distributed Load Testing Với k6)

Để chứng minh kiến trúc đủ năng lực vượt qua đợt sóng 11.11, đội ngũ kiểm thử hiệu năng triển khai kịch bản tải phân tán k6 Operator trên 50 Pods Kubernetes:

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  scenarios: {
    flash_sale_spike: {
      executor: 'ramping-arrival-rate',
      startRate: 1000,
      timeUnit: '1s',
      preAllocatedVUs: 5000,
      maxVUs: 50000,
      stages: [
        { duration: '30s', target: 5000 },   // Khởi động trước giờ G
        { duration: '10s', target: 100000 }, // Đỉnh tải 00:00:00 (100.000 RPS)
        { duration: '1m',  target: 100000 }, // Duy trì bão tải
        { duration: '30s', target: 10000 },  // Hạ nhiệt
      ],
    },
  },
  thresholds: {
    http_req_duration: ['p(99)<20'], // P99 latency phải dưới 20ms
    http_req_failed: ['rate<0.01'],   // Tỷ lệ lỗi dưới 1%
  },
};

export default function () {
  const userId = `USR_${__VU}_${__ITER}`;
  const payload = JSON.stringify({
    sku_id: 'SKU_IPHONE16_PRO',
    user_id: userId,
    queue_token: 'VALID_HMAC_SHA256_TOKEN_FROM_QUEUE',
    timestamp: Math.floor(Date.now() / 1000),
  });

  const params = {
    headers: { 'Content-Type': 'application/json' },
  };

  const res = http.post('https://api.shopee.local/api/flash-sale/buy', payload, params);

  check(res, {
    'status is 200 or 409': (r) => r.status === 200 || r.status === 409,
  });
}

8. Bảng So Sánh Toàn Diện Các Mô Hình Kiến Trúc Chịu Tải Flash Sale

Tiêu Chí Kỹ ThuậtMonolith Truyền Thống (MySQL Single Lock)Microservices Đọc/Ghi SQL Trực TiếpKiến Trúc Shopee 4 Lớp + Redis Cache-Ahead
Throughput Tối Đa (TPS)~250 - 400 TPS / SKU~2.500 TPS> 1.200.000 TPS (Nguyên tử & Bền vững)
Độ Trễ Phản Hồi (P99)> 10.000 ms (Nghẽn lock wait)~1.500 ms (Deadlocks)< 4.2 ms (Sub-millisecond)
Rủi Ro Bán Vượt Mức (Overselling)Rất cao khi timeout hàng loạtCao khi network partitionHoàn toàn bị loại trừ (0%)
Bảo Vệ Khỏi Botnets / ScriptsHoàn toàn không (Chết tại database)Có WAF cơ bảnLọc 92% qua Edge TLS Fingerprint & Waiting Room
Trạng Thái Cơ Sở Dữ Liệu SQLSụp đổ, cạn kiệt Connection PoolQuá tải CPU, I/O bão hòaỔn định, tải ghi trải đều qua Kafka & TiDB HTAP

9. Câu Hỏi Thường Gặp (FAQ)

Làm thế nào để Shopee ngăn chặn tình trạng khách hàng dùng script bot gom hết hàng Flash Sale?

Shopee kết hợp ba lớp phòng thủ chống bot: (1) Phân tích vân tay thiết bị (Device Fingerprinting) và chữ ký bảo mật TLS JA3/JA4 tại Edge CDN; (2) Phòng chờ ảo (Virtual Waiting Room) yêu cầu hoàn thành thử thách CAPTCHA động hoặc nhận token HMAC có thời hạn ngắn (dưới 5 phút); (3) Giới hạn mỗi tài khoản người dùng chỉ được phép mua 1 sản phẩm duy nhất thông qua Idempotency Key được lưu trên Redis.

Tại sao Shopee lựa chọn TiDB kết hợp MySQL Sharding thay vì cơ sở dữ liệu quan hệ truyền thống?

Trong đêm Flash Sale, lượng dữ liệu đơn hàng và lịch sử thanh toán sinh ra lên tới hàng trăm triệu bản ghi chỉ trong vài giờ. Cơ sở dữ liệu quan hệ truyền thống sẽ nhanh chóng cạn kiệt dung lượng đĩa và suy giảm hiệu năng chỉ mục. TiDB là hệ thống cơ sở dữ liệu NewSQL phân tán theo chuẩn HTAP, cho phép mở rộng quy mô lưu trữ theo chiều ngang không giới hạn (vừa xử lý giao dịch OLTP vừa chạy báo cáo doanh thu OLAP theo thời gian thực).

Nếu khách hàng giật được hàng trên Redis nhưng không chịu thanh toán thì sao?

Mỗi khi giữ chỗ thành công trên Redis, một thông điệp đếm ngược (Delay Queue hoặc Temporal Timer) có thời hạn 15 phút sẽ được kích hoạt. Nếu sau 15 phút không nhận được xác nhận thanh toán từ ngân hàng, hệ thống sẽ tự động hủy đơn, gọi script Lua để hoàn trả lại số lượng tồn kho vào Redis bucket và phát thông báo mở bán lại cho các khách hàng đang chờ trong hàng đợi.

Tại sao lại cần chia nhỏ tồn kho thành nhiều Virtual Buckets trên Redis?

Do Redis vận hành trên mô hình đơn luồng (Single-threaded Event Loop), toàn bộ các thao tác trên một key sẽ do một CPU core duy nhất đảm nhận. Nếu một Hot SKU nhận được 200.000 lượt ghi mỗi giây, core đó sẽ bị nghẽn 100%. Bằng cách chia tồn kho thành 10 bucket ngẫu nhiên (b0 đến b9), tải xử lý được phân phối đều sang 10 Redis core khác nhau, giúp throughput tăng lên gấp 10 lần mà không sợ tranh chấp.

10. Tài Liệu Kỹ Thuật Liên Quan


🤝 Kết nối với tôi

Bạn đang gặp phải những thách thức tương tự về kiến trúc hệ thống, mở rộng quy mô (scaling) hay dịch chuyển (migration)? Hãy kết nối với tôi trên LinkedIn, theo dõi GitHub của tôi, hoặc gửi một email để trao đổi nhé.