Giữ chỗ có hạn: trừ lúc bấm Mua hay lúc thanh toán?
Người bấm Mua rồi bỏ đi giam sản phẩm của người khác. Thiết kế giữ chỗ có TTL, worker thu hồi chỗ hết hạn bằng SKIP LOCKED, và callback thanh toán không trừ trùng.
TL;DR: Trừ tồn kho lúc bấm Mua mà không có hạn thì 30% người bỏ đi giam vĩnh viễn 300 trong 1.000 sản phẩm; trừ lúc thanh toán thì người trả tiền xong mới biết hết hàng. Giữ chỗ có hạn 10 phút trả hàng về kho sau hạn, đổi lại bạn phải nuôi worker thu hồi và quyết định xử lý người trả tiền sau khi chỗ đã hết hạn.
Mở bán lúc 20:00, 1.000 sản phẩm, 200.000 người chờ. Sau hai giây, hàng chục nghìn người đã bấm Mua. Một phần trong số họ đi tìm thẻ, nhập OTP, hoặc đơn giản là đóng tab. Sản phẩm của những người này nằm đâu? Không trả lời được thì bạn hoặc bán quá, hoặc để người thật sự muốn mua nhìn chữ "hết hàng" trong khi 300 sản phẩm không ai trả tiền.
1. Trừ tồn kho lúc nào: thanh toán, bấm Mua hay giữ chỗ?
Bài này dùng các ràng buộc chuẩn của bài 01, không đặt số mới: giữ chỗ 10 phút, 30% người giữ chỗ bỏ không trả tiền, mỗi người tối đa 1 sản phẩm.
Bài 03 cho bạn một câu UPDATE items SET stock = stock - 1 WHERE id = 1 AND stock > 0 an toàn trên một row. Câu hỏi còn lại là nó chạy khi nào: lúc thanh toán thành công, lúc bấm Mua không hạn, hay lúc bấm Mua kèm một chỗ giữ có hạn.
2. Hai thời điểm bị ràng buộc loại, một thời điểm còn lại
Ở đây ràng buộc đã chọn thay bạn, nên không cần DesignChoice. Quyết định thật nằm ở phần sau.
Trừ lúc thanh toán thì phá p99. Chưa ai bị trừ nên người thứ 1.001 vẫn đi tới trang thanh toán: nếu 5% trong 100.000 người bấm ở 2 giây đầu qua được cổng, đó là 5.000 giao dịch cho 1.000 sản phẩm và 4.000 lượt hoàn tiền. Người dùng chỉ biết còn hay hết sau cổng, tính bằng phút.
Trừ lúc bấm Mua không hạn thì không bán quá nhưng bán thiếu: 300 sản phẩm bị giam. Phản hồi nhanh và không bán quá, nhưng người bấm rồi bỏ đi giữ sản phẩm mãi mãi: 30% × 1.000 = 300 sản phẩm không về lại kệ, trong khi người đến sau thấy "hết hàng".
Giữ chỗ có hạn còn lại. Chỗ giữ là một hợp đồng ngắn: bạn có 10 phút, quá hạn thì sản phẩm về kho. Cùng 300 sản phẩm của người bỏ đi, nhưng bị giam tối đa 10 phút. Cái giá nằm ở ba chỗ: worker thu hồi không giẫm chân nhau; người vẫn trả tiền sau khi chỗ hết hạn; và vì chỗ giữ được tạo gần như cùng lúc nên cũng hết hạn cùng lúc ở phút thứ 10, 300 sản phẩm về kho một loạt và hàng nóng bị đập thêm một đợt.
Thử ngẫmcác chỗ giữ tạo gần cùng lúc nên hết hạn cùng lúc ở phút thứ 10. Bạn đổi gì ở lúc tạo chỗ giữ để 300 sản phẩm về kho rải đều thay vì một loạt?
3. Giữ chỗ trên Postgres: mỗi người một chỗ, trừ kho cùng transaction
Hai bảng. holds lưu chỗ giữ, và một unique partial index chỉ trên chỗ còn sống (pending, paid) là thứ chặn một người giữ hai chỗ.
CREATE TABLE items (
id int PRIMARY KEY,
stock int NOT NULL CHECK (stock >= 0)
);
CREATE TABLE holds (
id bigserial PRIMARY KEY,
user_id int NOT NULL,
item_id int NOT NULL REFERENCES items (id),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'paid', 'expired')),
expires_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX one_live_hold_per_user ON holds (user_id, item_id)
WHERE status IN ('pending', 'paid');
Vì sao không dùng UNIQUE (user_id, item_id) thường? Worker đánh dấu expired chứ không xoá, nên người có chỗ hết hạn bấm Mua lại sẽ chèn 0 row và không bao giờ giữ lại được. Index partial bỏ dòng expired ra khỏi phép kiểm.
Luồng bấm Mua nằm trọn trong một transaction:
BEGIN;
INSERT INTO holds (user_id, item_id, expires_at)
VALUES (:user_id, 1, now() + interval '10 minutes')
ON CONFLICT (user_id, item_id) WHERE status IN ('pending', 'paid') DO NOTHING;
-- 0 rows: this user already has a live hold -> ROLLBACK
UPDATE items SET stock = stock - 1 WHERE id = 1 AND stock > 0;
-- 0 rows: sold out -> ROLLBACK (the hold just inserted is undone too)
COMMIT;
Bài đó đã dạy ON CONFLICT DO NOTHING là "ensure exists": chạy một lần hay mười lần đều ra cùng một trạng thái. Người bấm Mua hai lần hoặc client retry sau timeout chèn 0 row ở lần thứ hai, và bạn không phải bắt lỗi unique violation.
INSERT đứng trước UPDATE hàng nóng nên khoá hàng nóng chỉ bị giữ ở đoạn cuối, đúng tinh thần bài 03; UPDATE trả 0 row thì ROLLBACK huỷ luôn chỗ giữ vừa chèn.
Chạy pgbench với script dưới: mỗi lượt chọn ngẫu nhiên một trong 5.000 user (cố ý để trùng người), \gset đọc số row và \if rẽ nhánh COMMIT hoặc ROLLBACK.
-- hold.sql
\set uid random(1, 5000)
BEGIN;
WITH ins AS (
INSERT INTO holds (user_id, item_id, expires_at)
VALUES (:uid, 1, now() + interval '10 minutes')
ON CONFLICT (user_id, item_id) WHERE status IN ('pending', 'paid') DO NOTHING
RETURNING 1
) SELECT count(*) AS inserted FROM ins \gset
\if :inserted = 1
WITH u AS (
UPDATE items SET stock = stock - 1 WHERE id = 1 AND stock > 0 RETURNING 1
) SELECT count(*) AS got FROM u \gset
\if :got = 1
COMMIT;
\else
ROLLBACK;
\endif
\else
ROLLBACK;
\endif
Đặt INSERT INTO items VALUES (1, 1000);, chạy pgbench -n -c 20 -j 4 -T 8 -f hold.sql mydb, rồi kiểm bất biến. Trong một lần chạy của tác giả (Postgres 16, 20 kết nối, khoảng 175.000 giao dịch): stock về đúng 0, có đúng 1.000 chỗ giữ, và tổng stock + chỗ đang giữ bằng 1.000.
SELECT i.stock,
count(*) FILTER (WHERE h.status IN ('pending', 'paid')) AS held,
i.stock + count(*) FILTER (WHERE h.status IN ('pending', 'paid')) AS total
FROM items i LEFT JOIN holds h ON h.item_id = i.id
GROUP BY i.id;
4. Worker thu hồi: nhiều worker, không giẫm chân nhau
Chỗ giữ hết hạn phải trả hàng về kho. Nhiều worker cùng SELECT thì nhặt trúng cùng một dòng và trả kho hai lần. FOR UPDATE SKIP LOCKED giải đúng chỗ này: dòng nào đang bị worker khác khoá thì bị bỏ qua, mỗi worker nhặt một tập không trùng nhau.
Hình dung hai nhân viên dọn bàn: SELECT thường thì cả hai cùng dọn bàn số 1 (cộng trả kho hai lần); SKIP LOCKED là quy tắc "bàn nào đang có người dọn thì qua bàn kế tiếp".
Mỗi chỗ giữ hết hạn chỉ bị đúng một worker nhặt, nên stock chỉ được cộng một lần.
WITH expired AS (
SELECT id, item_id FROM holds
WHERE status = 'pending' AND expires_at < now()
ORDER BY expires_at
FOR UPDATE SKIP LOCKED
LIMIT 100
), released AS (
UPDATE holds h SET status = 'expired'
FROM expired e WHERE h.id = e.id
RETURNING h.item_id
)
UPDATE items i SET stock = i.stock + r.n
FROM (SELECT item_id, count(*) AS n FROM released GROUP BY item_id) r
WHERE i.id = r.item_id;
Đánh dấu hết hạn và cộng trả kho nằm cùng một câu nên không có khoảnh khắc sản phẩm đã rời chỗ giữ mà chưa về kho. Chỗ hết hạn đánh dấu expired thay vì xoá, để bước sau phân biệt được với callback trùng.

Theo tài liệu Postgres 16, SKIP LOCKED cho một cái nhìn không nhất quán về dữ liệu: lượt này bỏ sót row nào, lượt sau nhặt.

Chạy thử trên 1.000 chỗ pending hạn 4 giây (stock = 0). Lưu câu thu hồi ở trên thành reclaim.sql, rồi mở hai terminal.
Callback giả lập (pay.sql) là câu UPDATE ở mục 5 với :hid ngẫu nhiên từ 1 đến 1.000 (\set hid random(1, 1000)) và \sleep 20 ms đứng trước.
pgbench -n -c 4 -j 2 -T 9 -f reclaim.sql mydb # terminal 1: 4 workers
pgbench -n -c 8 -j 2 -T 9 -f pay.sql mydb # terminal 2: 8 callbacks
Ba lần chạy của tác giả cho 764 đến 776 chỗ paid và 224 đến 236 chỗ expired; stock luôn đúng bằng số chỗ expired và total luôn 1.000: không chỗ nào vừa được trả tiền vừa được trả kho.
Thử ngẫmnếu worker chết suốt 15 phút, người mua và tồn kho trông thế nào?
5. Người trả tiền sau khi hết hạn thì sao?
5.1 Callback chốt đúng một lần
Callback đi qua mạng nên bị giao lại, đến muộn, hoặc đến đồng thời với worker; pending chỉ chuyển sang paid một lần, bằng một UPDATE có điều kiện.
Điền điều kiện còn thiếu để callback giao lại hai lần không chốt hai lần, và callback đến sau hạn không ghi đè chỗ đã trả kho:
UPDATE holds SET status = 'paid' WHERE id = :hold_id AND ______;
Ghi ra hai vế của điều kiện trước khi đọc tiếp.
Lời giải:
UPDATE holds SET status = 'paid'
WHERE id = :hold_id AND status = 'pending' AND expires_at > now();
Bẫy hay gặp là ghi paid không kiểm trạng thái, ghi đè chỗ đã expired mà sản phẩm đã bán cho người khác. Vế status = 'pending' chặn callback giao lại và chỗ đã expired; vế expires_at > now() chặn chỗ quá hạn mà worker chưa kịp quét. Kiểm số row: 1 là chốt xong; 0 thì đọc status (và expires_at) của chỗ giữ để phân biệt ba khả năng.

status đọc ra | Nghĩa là | Việc cần làm |
|---|---|---|
paid | callback bị giao lại, đã chốt từ lần trước | không làm gì, trả OK (idempotent) |
expired | người này trả tiền sau khi hết hạn: zombie (ví dụ ở mục 5.2) | quyết định ở mục 5.2 |
pending mà expires_at đã quá now() | zombie mà worker chưa quét: sản phẩm vẫn nằm ở chỗ giữ | coi là zombie, xử lý như dòng trên |
Gọi cổng thanh toán nhớ gửi khoá idempotency (một mã định danh do bạn đặt, ví dụ hold_id, để cổng nhận ra lần gọi lặp) để retry không tính tiền hai lần (Stripe).
5.2 Zombie: chọn chính sách
Zombie là gì, qua một ví dụ. An bấm Mua lúc 20:00:01 và có chỗ giữ đến 20:10:01, rồi mải đi tìm thẻ. Lúc 20:10:05 worker thấy chỗ của An hết hạn, đánh dấu expired và trả hàng về kho. Lúc 20:10:20 Bình bấm Mua và lấy đúng sản phẩm vừa được trả về. Lúc 20:11:00 An nhập xong OTP, cổng thanh toán báo "đã thu tiền" và gọi callback. An đã trả tiền nhưng chỗ của An đã chết và sản phẩm đã có chủ khác: An là zombie. Ba cách xử lý dưới đây là ba cách đối xử với An.
Zombie là chỗ ràng buộc không chọn thay bạn: ba cách đều sống được, mỗi cách một giá.
Ràng buộc: 1 SKU · tồn kho 1.000 · giữ chỗ 10 phút · 30% người giữ chỗ bỏ không trả tiền · người trả tiền sau khi chỗ đã hết hạn và sản phẩm đã về kho
Với ràng buộc này, bạn chấp nhận cái giá nào?
Chọn trước khi đọc tiếp — phần sau phân tích từng cái giá.
5.3 Hồi sinh không trừ trùng
Phương án B là đoạn SQL duy nhất cần cẩn thận, và phải khớp cả hai dạng zombie. Dạng pending quá hạn mà worker chưa quét thì sản phẩm chưa rời chỗ giữ, chỉ cần chốt, không trừ kho lần nữa:
UPDATE holds SET status = 'paid'
WHERE id = :hold_id AND status = 'pending' AND expires_at <= now();
-- 1 row: done. 0 rows: try the expired case below
Dạng expired thì sản phẩm đã về kho nên phải trừ lại:
BEGIN;
UPDATE items SET stock = stock - 1 WHERE id = 1 AND stock > 0;
-- 0 rows: sold out -> ROLLBACK, refund
UPDATE holds SET status = 'paid' WHERE id = :hold_id AND status = 'expired';
-- 0 rows: another callback already revived it -> ROLLBACK
COMMIT;
Dòng ROLLBACK thứ hai quan trọng: hai callback song song đều trừ được kho ở dòng đầu, chỉ một cái qua dòng sau. "Hồi sinh" là chuyển chỗ expired của zombie thành paid.
Với stock = 5 và chỗ expired của An, hai callback chạy song song diễn ra như sau:
| Bước | Callback 1 | Callback 2 |
|---|---|---|
| 1 | trừ kho, stock từ 5 xuống 4 (giữ khoá row items tới khi COMMIT) | UPDATE items phải chờ khoá |
| 2 | UPDATE holds thấy chỗ expired, thành paid, rồi COMMIT | vẫn chờ |
| 3 | xong | được khoá, trừ kho, stock từ 4 xuống 3 |
| 4 | UPDATE holds thấy chỗ đã paid nên 0 row, ROLLBACK, stock về 4 |
Không có ROLLBACK ở bước 4 thì stock lệch còn 3 trong khi chỉ một chỗ được hồi sinh.
Còn một lối vỡ nữa: nếu người này đã bấm Mua lại và đang có chỗ pending hoặc paid mới, câu UPDATE hồi sinh vi phạm unique partial index và ném unique_violation. Bắt lỗi đó, ROLLBACK (trả luôn lượt trừ kho) rồi hoàn tiền, hoặc kiểm chỗ còn sống trước khi hồi sinh.
Thử cho 8 kết nối cùng hồi sinh một chỗ expired. Chuẩn bị: tạo sẵn một chỗ expired có id = 1 và đặt stock = 242. Script revive.sql bọc hai UPDATE ở trên trong WITH ... \gset và \if giống hold.sql: COMMIT khi cả hai trúng, ROLLBACK nếu không. Chạy 8 kết nối, 800 lượt:
pgbench -n -c 8 -j 2 -t 100 -D hold_id=1 -f revive.sql mydb
Kết quả: stock chỉ giảm đúng 1, từ 242 xuống 241, ba lần chạy đều vậy. Chọn A hay B là chọn sản phẩm đó đáng giá hơn với người đã trả tiền hay với người đang chờ.
Đừng giữ FOR UPDATE qua lời gọi cổng thanh toán (bẫy bài 03). Thứ tự đúng: giữ chỗ bằng transaction ngắn, gọi cổng ngoài mọi transaction, rồi callback chuyển trạng thái.
Đào sâu
Tài liệu chính thức:
- PostgreSQL 16, The Locking Clause —
NOWAIT,SKIP LOCKED. - PostgreSQL 16, INSERT ON CONFLICT —
DO NOTHING,conflict_target,index_predicate.
Đọc thêm (khoá ngoài): Partial failure và fencing về lease và người giữ quyền đã hết hạn.
Liên hệ các bài khác
- Bài 03: trừ tồn kho trên hàng nóng, cơ chế trừ mà bài này đặt vào transaction giữ chỗ.
- Bài 05: tải vượt trần, khi hàng nóng lại nóng sau mỗi đợt hết hạn.
Tóm tắt
- Việc cần làm khi thiết kế giữ chỗ: nuôi worker thu hồi, đánh dấu
expiredthay vì xoá, và chọn chính sách cho zombie bằng cái giá bạn chịu được.
6. Tự kiểm tra
- Q1Vì sao trừ tồn kho lúc thanh toán thành công không đáp ứng p99 dưới 1 giây, dù nó không bao giờ giam sản phẩm?
- Q2Hai worker cùng chạy câu thu hồi. Nếu bỏ
SKIP LOCKED, chuyện gì xảy ra, và nếu giữ nó nhưng quên đánh dấu chỗ giữ trong cùng câu thì chuyện gì xảy ra? - Q3Callback thanh toán cho một chỗ giữ trả về 0 row bị ảnh hưởng. Làm sao biết đó là callback trùng hay zombie, và vì sao phải phân biệt?
- Q4Zombie có ba lối ra: hoàn tiền, thử giữ lại nếu còn hàng, hoặc gia hạn chỗ khi người dùng bắt đầu thanh toán. Mỗi lối trả cái giá nào, và bạn chọn cái nào dưới ràng buộc 1.000 sản phẩm, 30% bỏ không trả?
- Q5Tình huống mới: đổi hạn giữ chỗ từ 10 phút xuống 2 phút. Hãy tự phân tích: được gì, mất gì dưới ràng buộc cũ. Trước khi mở đáp án, hãy ghi ra ít nhất một số liệu và một rủi ro.
- Q6Tình huống mới: mỗi người được giữ tối đa 2 chỗ thay vì 1. Hãy tự viết lại ràng buộc duy nhất và các bước của luồng bấm Mua, rồi chỉ ra câu thu hồi hay callback có phải đổi không.
Bài tiếp theo: Tải vượt trần: chặn cửa hay nâng trần?
Bài này có giúp bạn hiểu bản chất không?
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Trừ tồn kho trên hàng nóng: ba cách, ba cái giá
Bài tiếpTải vượt trần hàng chục lần: chặn cửa hay nâng trần?
Thảo luận & góp ý
Hỏi đáp về bài này