Trong bài này
Phiên mở bán
Streamer giơ một chiếc điện thoại lên. “Một trăm chiếc, nửa giá, bắt đầu ngay bây giờ.” Trong khung chat, hai mươi nghìn ngón tay đã lơ lửng trên nút Mua. Backend chẳng có gì lạ: một API, một connection pool, một Postgres, và một câu UPDATE hoàn toàn đúng, chỉ trừ kho khi còn hàng. Ba giây sau dashboard đỏ lòm. Không phải riêng trang chiếc điện thoại, mà là mọi thứ: đăng nhập, tìm kiếm, những trang sản phẩm chẳng ai định mua.
Đây cùng họ với bug ở bài đặt lịch khám và bài vé concert cuối cùng, kèm một bước ngoặt: các bài đó hỏi “làm sao để hai người không cùng nhận một món?” và kết thúc bằng một câu lệnh atomic. Câu lệnh đó ở đây vẫn đúng. Cái đổi là tỉ lệ. Hai mươi nghìn người tranh một trăm món, nên mười chín nghìn chín trăm người trong số họ chắc chắn thua, và câu hỏi thiết kế thành: hệ thống nói “không” rẻ đến mức nào. Mình gặp tình huống này lần đầu dưới dạng câu hỏi phỏng vấn về một nền tảng live-commerce, và đáp án mình ước đã trả lời hôm đó chính là phần còn lại của bài này.
- request trên mỗi món
- 20,000 : 100
- người mua sẽ nghe “hết hàng”
- 99.5%
- để 100 người thắng đi qua một row lock, mỗi người ~20 ms
- ≈ 2 s
- tầng: cổng trong RAM, sự thật trong Postgres
- 2
Vì sao câu query đúng vẫn chết
Đây là câu lệnh, cùng dạng với các bài kia:
UPDATE sale_stock
SET sold = sold + 1
WHERE sale_id = 42 AND sold < total
RETURNING sold;
-- 1 row -> you got one
-- 0 rows -> sold outCâu lệnh chẳng có gì sai. Rắc rối nằm ở vật lý bên dưới. UPDATE lấy một row lock và giữ nó tới lúc commit, mà row lock chỉ cho một writer tại một thời điểm. Cả hai mươi nghìn request cùng nhắm vào một dòng (sale_id = 42), nên chừng nào còn hàng, database xếp họ thành hàng một và phục vụ lần lượt. Nếu mỗi lượt ghi giữ lock khoảng một mili giây (con số ước chừng, tùy ổ đĩa và cấu hình replication), dòng đó chịu được cỡ một nghìn lượt ghi mỗi giây, và một câu UPDATE trơn sẽ bán hết một trăm chiếc trong một phần mười giây. Nhưng checkout thật đâu có trơn: cùng transaction đó còn insert đơn, ghi một dòng sổ cái, đi lại vài vòng giữa app và database, có khi còn chờ một replica đồng bộ rồi mới commit. Cứ cho là thời gian giữ lock kéo lên 20 ms. Một trăm người thắng giờ cần hai giây để đi hết hàng, và cả hai mươi nghìn request đều ập tới trong lúc hàng còn đang nhích. Client timeout, người ta retry, và hàng càng dài. (Khi chiếc cuối cùng đã commit, Postgres kiểm tra lại WHERE trên phiên bản mới của dòng, nên người thua thôi chờ lock. Còn nếu code khóa trước bằng SELECT ... FOR UPDATE rồi mới kiểm tra kho trong app, thì cả hai mươi nghìn người đều phải lần lượt cầm lock, và hàng dài hai mươi giây dù mỗi lượt chỉ 1 ms.)
Giờ tới phần giết các trang khác. App nói chuyện với Postgres qua connection pool, giả sử một trăm connection. Một trăm request đầu chiếm hết, và mỗi connection đó đều đang đậu chờ cùng một lock. Từ request 101 trở đi thậm chí không bắt đầu được: nó chờ một connection rảnh. Người dùng đăng nhập cũng cần một connection, và không còn cái nào. Một hot row đã biến thành sự cố cả site. Bài view counter vẽ chuỗi này cho một workload khác; hình thì y hệt.
Nói “không” trước khi tới database
Hãy nhìn lưu lượng cho thật thà. Trong hai mươi nghìn request, một trăm cái sẽ thành công, phần còn lại đã thua ngay từ khi chiếc điện thoại thứ một trăm có chủ. Vậy mà ở thiết kế ngây thơ, mỗi request đều đi cùng một chuyến đắt đỏ: qua app, vào pool, lên hàng đợi lock chừng nào còn hàng, rồi quay ra với dòng “hết hàng”. Hình dung người gác cửa nhà hát với một trăm chiếc vòng tay. Ông không cho đám đông vào phòng vé rồi nhờ nhân viên tra sổ cho từng người. Ông đếm vòng tay ngay cửa, và khi hết, ông nói to điều đó với mọi người phía sau.
Người gác cửa đó là một cái cổng đặt trước database. Nó cần hai tính chất: phải nhanh (trả lời trong dưới một mili giây, từ bộ nhớ) và phải atomic (một trăm chiếc điện thoại nghĩa là đúng một trăm người thắng, không phải một trăm lẻ ba). Redis hợp: các lệnh của nó chạy trên một thread, nên một lệnh không thể xen vào giữa lệnh khác.
Cái cổng: một script Lua
Một lệnh DECR đơn lẻ thì đã atomic. Nhưng một đợt sale thật còn cần “mỗi người một chiếc”, tức là hai key: bộ đếm kho và một set người mua. Làm GET, rồi DECR, rồi SADD từ ứng dụng sẽ lại là chu trình read-modify-write của bài vé concert. Cách chữa cùng một ý tưởng, chuyển vào Redis: script Lua chạy như một khối không bị ngắt, nên không gì xen được vào giữa các bước.
-- KEYS[1] = {sale:42}:stock KEYS[2] = {sale:42}:buyers
-- ARGV[1] = user id ARGV[2] = qty
if redis.call("SISMEMBER", KEYS[2], ARGV[1]) == 1 then return -1 end -- already bought
local stock = tonumber(redis.call("GET", KEYS[1]) or "0")
if stock < tonumber(ARGV[2]) then return 0 end -- sold out
redis.call("DECRBY", KEYS[1], ARGV[2])
redis.call("SADD", KEYS[2], ARGV[1])
return 1 -- reservedimport Redis from "ioredis";
import { randomUUID } from "node:crypto";
const redis = new Redis(process.env.REDIS_URL!);
redis.defineCommand("reserve", { numberOfKeys: 2, lua: RESERVE_LUA }); // loaded once, called by SHA
type Reserve = { status: "ok"; reservationId: string } | { status: "sold_out" | "dup" };
export async function tryReserve(saleId: number, userId: string): Promise<Reserve> {
const tag = {sale:${saleId}}; // hash tag: both keys land on one cluster slot
const r = await (redis as any).reserve(${tag}:stock, ${tag}:buyers, userId, 1);
if (r !== 1) return { status: r === 0 ? "sold_out" : "dup" };
return { status: "ok", reservationId: randomUUID() }; // goes into the queue message
}Để người thắng nhỏ giọt vào Postgres
Chỉ một trăm request qua được cổng. Giờ chúng là một workload bình thường, nhưng vẫn không nên ùa vào database trong một đợt, và người dùng không cần đơn được ghi xong trước khi trang trả lời. Nên mỗi người thắng thả một message vào hàng đợi (SQS, Kafka, thứ gì bền vững cũng được) và nhận lại “bạn đã giữ được, thanh toán trong mười phút”. Một nhóm worker nhỏ xả hàng đợi theo nhịp mà database ưa.
Worker làm sổ sách thật, và nó kiểm tra lại kho trong Postgres thay vì tin Redis. Việc này nhìn thừa và lại là dòng quan trọng nhất của thiết kế. Redis là cái cổng, người gác cửa nhanh nhưng hơi hay quên. Postgres là sổ cái. Nếu có lúc cổng cho lọt thừa một người (lát nữa sẽ nói vì sao), câu update có điều kiện là thứ chặn không cho một vụ bán lố thành đơn thật.
CREATE TABLE sale_orders (
reservation_id uuid PRIMARY KEY, -- minted at reserve time, travels in the queue message: the idempotency key
sale_id int NOT NULL,
user_id bigint NOT NULL,
qty int NOT NULL,
status text NOT NULL DEFAULT 'PENDING_PAYMENT',
created_at timestamptz NOT NULL DEFAULT now()
);
-- at most one LIVE order per buyer per sale; an EXPIRED row must not block buying again
CREATE UNIQUE INDEX one_live_order ON sale_orders (sale_id, user_id)
WHERE status IN ('PENDING_PAYMENT', 'PAID');
BEGIN;
INSERT INTO sale_orders (reservation_id, sale_id, user_id, qty)
VALUES ('0b9e…', 42, 1001, 1)
ON CONFLICT (reservation_id) DO NOTHING; -- 0 rows: a retried message, stop here and ack it
-- 23505 on one_live_order: a duplicate buyer slipped past the gate,
-- ROLLBACK and run release.lua (that unit went to nobody)
UPDATE sale_stock
SET sold = sold + 1
WHERE sale_id = 42 AND sold < total; -- 0 rows: the gate leaked. ROLLBACK, answer "sold out",
-- and do NOT give the unit back to the gate
COMMIT; -- plus the ledger row, in this same transactionKhóa chính reservation_id làm cho worker chạy hai lần vẫn an toàn. Hàng đợi giao ít nhất một lần, nên cùng một message sẽ đến lần nữa sau khi worker sập, và lần giao thứ hai không được đổi gì. Với mười worker thay vì hai mươi nghìn client, lock trên sale_stock chỉ có mười bên tranh, chứ không phải cả internet. Vì sao mỗi lần giữ chỗ một id mới mà không dùng (sale_id, user_id)? Bản đầu của mình dùng cặp đó làm khóa, và nó có một lỗi âm thầm: sau khi một lần giữ chỗ hết hạn được release, cùng người đó có thể giữ chỗ lại, và worker sẽ đọc message mới như một lần retry của message cũ. Nó ack, không đơn nào được ghi, và món hàng nằm ở cổng cho không ai. Unique index một phần giữ được luật thật (mỗi người một đơn còn hiệu lực) mà không dính bẫy đó. Và vì cổng chỉ nói “bạn đã giữ được” sau khi enqueue thành công, người mua vẫn có thể nghe “hết hàng” ngay sau đó nếu sổ cái từ chối đơn. Thế là thật thà, và tốt hơn nhiều so với chiều ngược lại.

Khi hai tầng bất đồng với nhau
Hai nơi lưu nghĩa là hai sự thật, và thiết kế chỉ trung thực chừng nào nó trả lời được “nếu chúng lệch nhau thì sao?”. Đi qua từng kiểu hỏng; mỗi kiểu có một cách phòng giản dị.
| Sự cố | Chuyện xảy ra | Cách phòng |
|---|---|---|
| Người thắng không thanh toán | Kho vẫn bị trừ trong Redis cho không ai | Giữ chỗ có hạn (10 phút). Hết hạn, trả kho về cổng đúng một lần |
| Gửi queue lỗi sau khi reserve | Có giữ chỗ nhưng không có đơn phía sau | Release ngay khi lỗi. Làm lưới an toàn: ghi thời điểm của từng lần giữ chỗ vào Redis (một sorted set) để job quét tìm ra những lần giữ chỗ không bao giờ thành đơn |
| Worker sập giữa chừng | Message được giao lại | Khóa chính reservation_id: lần retry không làm gì |
Redis mất một lệnh SADD hoặc INCRBY | Một người giữ chỗ được hai lần, hoặc cổng báo hết hàng trong khi Postgres còn hàng | Unique index một phần từ chối lần trùng. Một job đối soát định kỳ so kho Redis cộng các lần giữ chỗ đang mở với total − sold rồi sửa bộ đếm |
| Redis failover và mất vài lần ghi gần nhất | Bộ đếm cao hơn thật, cổng cho lọt thêm người | ==!Câu UPDATE có điều kiện trong Postgres từ chối phần thừa==; báo những người đó “hết hàng” và đừng release: món đó vốn không tồn tại, và mỗi lần từ chối lại tiêu bớt một đơn vị dư của bộ đếm |
Hàng về Redis đáng một câu riêng. Redis replicate bất đồng bộ, nên một lần failover có thể đưa lên một replica chưa kịp thấy vài lệnh DECRBY cuối. Đó là cùng một sự đánh đổi mà bài leader failover nói tới, và là lý do thiết kế này không bao giờ để Redis có tiếng nói cuối cùng về một đơn hàng. Cái cổng thỉnh thoảng hào phóng quá chỉ tốn vài lời xin lỗi. Cái cổng là bản ghi duy nhất thì tốn một trăm lẻ ba chiếc điện thoại đã bán.
Trả kho về, đúng một lần
Trả kho cũng có bẫy riêng. Nếu job quét hết hạn và handler thanh toán-thất-bại cùng chạy cho một người mua, một lệnh INCR cẩu thả sẽ trả món hàng hai lần và cổng giờ tin vào một chiếc điện thoại không tồn tại. Cách chữa dùng set người mua làm biên lai: chỉ bên gọi nào có SREM thật sự xóa được thứ gì mới được trả kho.
-- KEYS[1] = {sale:42}:stock KEYS[2] = {sale:42}:buyers
-- ARGV[1] = user id ARGV[2] = qty
if redis.call("SREM", KEYS[2], ARGV[1]) == 1 then -- only the first release finds the receipt
redis.call("INCRBY", KEYS[1], ARGV[2])
return 1
end
return 0 -- already released, do nothingCòn một mảnh dễ quên: Redis không phải nơi duy nhất đang giữ món hàng đó. Dòng đơn và bộ đếm sold trong Postgres vẫn tính nó. Nên job quét đi theo thứ tự sổ cái trước, cổng sau, và bước ở sổ cái đồng thời làm trọng tài khi một khoản thanh toán muộn tới đúng lúc hết hạn:
BEGIN;
UPDATE sale_orders SET status = 'EXPIRED'
WHERE reservation_id = '0b9e…' AND status = 'PENDING_PAYMENT'; -- 0 rows: already paid, stop
UPDATE sale_stock SET sold = sold - 1 WHERE sale_id = 42; -- run only if the line above hit 1 row
COMMIT;
-- then call release.lua; its SREM keeps a duplicate sweep from returning the unit twice.
-- If the process dies between COMMIT and release.lua, the row is already EXPIRED and the
-- sweeper will not pick it again: retry release for EXPIRED rows until it succeeds.Phía thanh toán phải chơi cùng luật. Handler của nó đánh dấu đơn PAID bằng cùng điều kiện WHERE status = 'PENDING_PAYMENT'; nếu lệnh này trúng 0 dòng vì giữ chỗ đã hết hạn, tiền đã tới cho một đơn không còn tồn tại, và kết cục thật thà duy nhất là hoàn tiền. Thêm một cái giá của hash tag ở cổng: nó ghim cả một đợt sale vào một shard Redis. Với cái cổng nhỏ thế này thì ổn, nhưng nghĩa là SKU nóng là chuyện của một node, không phải của cả cluster.
Khi nào đừng xây cái này
Một cổng Redis cộng hàng đợi là nhiều bộ phận chuyển động, và đa số cửa hàng không bao giờ cần. Cái thang thật thà, rẻ nhất trước:
| Cách làm | Đủ dùng khi | Hỏng khi |
|---|---|---|
UPDATE có điều kiện | Vài trăm người mua mỗi món mỗi giây | Hàng nghìn người chạm một dòng và pool đầy người chờ |
| Chia kho thành nhiều ô | Chấp nhận được “ô số 3 hết rồi, thử lại”; ý tưởng sharded counter | Những món cuối: mọi ô gần cạn và người mua thấy hết hàng giả |
| Cổng Redis + hàng đợi | Hàng chục nghìn request cho một lượng hàng nhỏ, biết trước | Bạn bỏ qua bước đối soát và tin Redis như sổ cái |
| Phòng chờ ảo | Cầu vượt xa cả cái cổng; cho người vào theo từng đợt | Bạn cần trả lời tức thì và không thể bắt người ta chờ |
Kết: làm cho lời “không” rẻ
Khi chín mươi chín phần trăm lưu lượng sẽ thua, việc thật của hệ thống là nói “không” thật rẻ và nói “có” thật cẩn thận.
Duy Anh, ghi chú về flash sale
Ứng viên trả lời “bỏ kho vào Redis” có một nửa đáp án. Nửa mang lại công việc là câu hỏi tiếp theo: khi Redis và Postgres bất đồng thì sao, làm sao trả kho một lần và chỉ một lần, và vì sao worker vẫn kiểm tra database? Ba câu trả lời đó là khác biệt giữa một thiết kế sống sót qua một phiên mở bán và một slide trông đẹp trong buổi phỏng vấn. Lần tới có phiên mở bán, mình thà làm người gác cửa còn hơn làm phòng vé.
- Nhiều người mua, ít món hàng? Từ chối người thua trước khi tới database, trong bộ nhớ, atomic (một script Lua).
- Người thắng đi qua hàng đợi và một câu
UPDATEcó điều kiện; database vẫn là sổ cái duy nhất. - Mọi lần giữ chỗ đều có hạn, và trả kho phải idempotent: biên lai là lệnh
SREM. - Dùng bậc thấp nhất mà sống được qua đỉnh tải thật. Đa số hệ thống dừng ở bậc đầu.
Câu hỏi phỏng vấn để luyện
- 20.000 người mua 100 món trong một giây bằng một câu
UPDATEcó điều kiện. Giải thích, có số liệu, vì sao cả site sập. - Vì sao bước reserve cần script Lua thay vì
GETrồiDECRtrong app? Redis Cluster thêm ràng buộc gì? - Redis failover và mất vài lần trừ cuối. Thiết kế của bạn tránh bán lố thế nào?
- Làm sao trả kho cho một lần giữ chỗ chưa thanh toán đúng một lần khi có hai đường code cùng kích hoạt được?
