🇬🇧 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
- 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.
- 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.
- 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:
- 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.
- 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
- 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.
- Khởi tạo key số lượng tồn kho:
- 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)
- Tránh việc toàn bộ cache cùng hết hạn tại một thời điểm:
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ật | Monolith Truyền Thống (MySQL Single Lock) | Microservices Đọc/Ghi SQL Trực Tiếp | Kiế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ạt | Cao khi network partition | Hoàn toàn bị loại trừ (0%) |
| Bảo Vệ Khỏi Botnets / Scripts | Hoàn toàn không (Chết tại database) | Có WAF cơ bản | Lọc 92% qua Edge TLS Fingerprint & Waiting Room |
| Trạng Thái Cơ Sở Dữ Liệu SQL | Sụp đổ, cạn kiệt Connection Pool | Quá 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?
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?
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?
Tại sao lại cần chia nhỏ tồn kho thành nhiều Virtual Buckets trên Redis?
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
- Kiến Trúc Đồng Bộ Tồn Kho: Xem cách thiết lập CDC với PostgreSQL tại Đồng Bộ Tồn Kho Thời Gian Thực Với Kafka, Debezium & Redis.
- Mở Rộng Database Quy Mô Lớn: Kỹ thuật sharding với Vitess và TiDB tại MySQL Horizontal Scaling: Vitess & GORM Sharding.
- Kiến Trúc Thanh Toán PayPay: Xem cách PayPay xử lý hàng triệu giao dịch thanh toán tại Kiến Trúc Mở Rộng Hệ Thống PayPay Nhật Bản.
- Hệ Thống Alipay Đêm 11.11: Nghiên cứu kiến trúc thanh toán chịu tải đỉnh cao thế giới tại Alipay Double 11 Architecture & Peak TPS.
- Chuỗi Bài Phân Tích Shopee: Khám phá toàn bộ 10 chương tại Series Kiến Trúc Hệ Thống Shopee.
