Đếm đơn rồi ghi: vì sao bật transaction vẫn bán quá
Thiết kế đơn giản nhất: tồn kho bằng 1.000 trừ số đơn đã có, còn thì INSERT đơn mới. Tự chạy 50 kết nối song song, đếm số đơn bán quá, và chỉ ra chỗ nó vỡ.
TL;DR: Cách ngây thơ nhất để không bán quá là không lưu tồn kho, chỉ đếm số đơn đã có rồi chèn thêm nếu còn chỗ. Bất biến cần giữ là "tối đa 1.000 đơn", và nó nằm trong một phép đếm chứ không nằm trên row nào. Bài này bắt bạn tự chạy 50 kết nối song song ở ba mức isolation, tự đếm số đơn vượt, rồi tự chỉ ra mức nào chặn được và với giá nào.
Phương án đầu tiên bạn dễ nghĩ ra cho flash sale thường là phương án ngắn nhất: một bảng orders, mỗi lượt mua đếm xem đã có bao nhiêu đơn, còn dưới 1.000 thì chèn đơn mới. Không cột stock, không phải nhớ trừ, không phải nhớ cộng lại khi huỷ. Đọc qua thấy đúng, chạy một người dùng thì đúng thật. Bài này bắt bạn tự chạy nó với 50 kết nối song song, đếm số đơn vượt 1.000 bằng chính máy của bạn, rồi tự chỉ ra nó vỡ ở đâu trước khi đọc lời giải.
1. Ràng buộc và phương án ngây thơ
Bài này không đặt số mới. Bạn dùng đúng bảng ràng buộc đã chốt ở bài 01: Ràng buộc và ước lượng:
| Ràng buộc | Giá trị |
|---|---|
| Hàng | 1 SKU, tồn kho 1.000 |
| Người chờ lúc mở bán | 200.000 |
| Tải đỉnh | khoảng 50.000 lượt/giây (một nửa số người bấm trong 2 giây đầu) |
| Không bán quá | 0 đơn vượt 1.000, bất biến cứng |
| Phản hồi còn/hết | p99 dưới 1 giây |
| Thời hạn thanh toán | giữ chỗ 10 phút, giả định 30% bỏ không trả |
| Một người | tối đa 1 sản phẩm |
Dòng quan trọng với bài này là dòng thứ tư: 0 đơn vượt 1.000. Mọi phương án chỉ được chấm theo dòng đó trước, rồi mới tới tốc độ.
Phương án ngây thơ chỉ có một bảng và một đoạn logic:
CREATE TABLE orders (
id serial PRIMARY KEY,
item_id int NOT NULL
);
mỗi lượt mua:
1. đếm số đơn của item 1
2. nếu đếm được dưới 1.000 thì INSERT một đơn mới
3. nếu không thì trả "hết hàng"
Tồn kho còn lại chính là 1.000 trừ COUNT(*) các đơn. Vì "cho an toàn", bạn bọc cả ba bước trong BEGIN ... COMMIT và dễ nghĩ: có transaction rồi thì database lo.
2. Tự chạy: ba mức isolation, đếm đơn bán quá
Tạo database lab riêng, rồi lưu script này thành ngay-tho.sql. Đây là script pgbench: \gset nhét kết quả đếm vào biến cnt, \if quyết định có chèn hay không.
BEGIN;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT count(*) AS cnt FROM orders WHERE item_id = 1 \gset
\if :cnt < 1000
INSERT INTO orders (item_id) VALUES (1);
\endif
COMMIT;
Chạy ba lần, mỗi lần đổi đúng dòng SET TRANSACTION ISOLATION LEVEL thành READ COMMITTED, REPEATABLE READ, rồi SERIALIZABLE. Trước mỗi lần nhớ xoá sạch đơn cũ:
psql lab -c "TRUNCATE orders RESTART IDENTITY"
pgbench -c 50 -j 5 -T 10 --no-vacuum --max-tries 3 -f ngay-tho.sql lab
psql lab -c "SELECT count(*) - 1000 AS ban_qua FROM orders"
--max-tries 3 (pgbench từ bản 15) cho pgbench tự thử lại transaction bị huỷ vì lỗi serialization (mặc định là 1, tức không thử lại; xem tài liệu pgbench). Cột ban_qua dương nghĩa là bạn đã bán đơn thứ 1.001 trở đi.
Trước khi chạy lần hai: ở Repeatable Read, mỗi transaction đọc từ snapshot riêng và Postgres phát hiện được xung đột ghi. Theo bạn ban_qua sẽ là bao nhiêu, và vì sao? Viết ra con số bạn tin, kèm một câu giải thích, rồi mới chạy.
Kết quả của tác giả (Postgres 16, Docker trên máy 10 nhân, tồn kho 1.000, 50 kết nối), ghi thành khoảng qua nhiều lần chạy vì mỗi lần chạy ra một số khác. Máy bạn sẽ lệch, nhưng hình dạng phải giống:
| Isolation | Bán quá | Transaction lỗi serialization |
|---|---|---|
| Read Committed | +38 đến +49 | 0 |
| Repeatable Read | +28 đến +45 | 0 |
| Serializable | 0 | failed 2% đến 19%, retried 2% đến 20% |
Trước khi sang mục 3, viết hai câu của riêng bạn: điểm vỡ của phương án này nằm ở đâu, và vì sao Repeatable Read vẫn để lọt. Đáp án mẫu nằm ở câu hỏi đầu của phần tự kiểm tra; so sau khi đã tự viết.
3. Vì sao Repeatable Read không cứu được?
Chạy tay với số nhỏ: còn đúng 1 chỗ (bảng có 999 đơn, trần 1.000), ba người A, B, C bấm gần như cùng lúc.
| Bước | A | B | C |
|---|---|---|---|
| 1. đếm | thấy 999 | thấy 999 | thấy 999 |
| 2. quyết | còn chỗ | còn chỗ | còn chỗ |
| 3. chèn + commit | đơn 1.000 | đơn 1.001 | đơn 1.002 |
Cả ba đếm trước khi ai kịp chèn nên không ai thấy người kia. Bảng lên 1.002, vượt 2. Với 50 kết nối thì tối đa 50 người cùng thấy 999, vượt tối đa 49.
Đọc lại bảng: bán quá vài chục đơn, mỗi lần chạy một số khác, nhưng chưa lần nào vượt 49. Với 50 kết nối, lúc bảng có 999 đơn, tối đa 50 transaction có thể cùng đếm ra 999 trước khi có ai commit. Cả 50 cùng thấy "còn một chỗ", cả 50 cùng chèn, bảng lên 1.049. Trần bán quá của phương án này là số kết nối trừ 1, và số đo thật nằm dưới trần đó tuỳ độ lệch thời gian giữa các kết nối.

Đếm rồi chèn là hai câu lệnh riêng. Ở giữa chúng không có gì giữ cho kết quả đếm còn đúng.
Bài đó đã dạy hai thứ bạn cần ở đây. Thứ nhất, đọc rồi ghi dựa trên giá trị đã đọc ở Read Committed là kiểu lost update, và cách chữa quen thuộc là khoá row bằng SELECT ... FOR UPDATE. Thứ hai, write skew: hai transaction cùng đọc một điều kiện, cùng thấy nó đúng, mỗi bên ghi một chỗ khác rồi điều kiện sai đi mà không bên nào ghi đè bên nào. Phần còn lại của mục này chỉ ra hình dạng write skew đó khi chuyển sang đếm đơn.
Cái mới so với bài trước nằm ở chỗ ghi vào đâu. Ở ví dụ gán task, hai transaction sửa chung một row nên Repeatable Read còn có cơ hội báo xung đột. Ở đây mỗi transaction chèn một row mới của riêng mình:

Repeatable Read chỉ báo lỗi could not serialize access due to concurrent update khi hai transaction cùng sửa hoặc khoá một row mà bên kia đã commit (tài liệu Postgres, Repeatable Read). Row số 1 và row số 2 là hai row khác nhau, không có row nào bị chạm hai lần, nên không có gì để bắt. Mỗi bên vẫn thấy snapshot cũ của mình ("999 đơn") suốt cả transaction, đúng như mức này hứa, nên đơn vẫn lọt qua giống hệt Read Committed.
Thử cứu bằng khoá: SELECT ... FOR UPDATE khoá các row mà truy vấn trả về. Truy vấn của bạn chỉ đếm các đơn đã có, và thứ cần chặn là đơn chưa tồn tại. Không có row nào để khoá cho đơn tương lai. Thêm nữa, Postgres không cho gắn FOR UPDATE vào câu có hàm gộp: SELECT count(*) ... FOR UPDATE báo FOR UPDATE is not allowed with aggregate functions.
Thử ngẫmngưỡng 950 đủ cho 50 kết nối (trần bán quá là số kết nối trừ 1), vậy với 200 kết nối thì sao và bạn bán thiếu bao nhiêu?
4. Serializable chặn được: cái giá và điểm vỡ thật
Serializable chặn thế nào và cái giá
Chỉ Serializable giữ được 0 đơn vượt. Trực giác: Serializable giống người gác sổ ghi lại "ai đã nhìn con số nào". A đếm ra 999 thì gác sổ ghi "A đã nhìn số đơn của item 1". A và B đều đếm ra 999 rồi đều chèn: mỗi người làm sai con số người kia đã nhìn, nên gác sổ bắt một người làm lại từ đầu. Không ai bị chờ, chỉ có người bị huỷ và phải thử lại. Đó là cái giá bạn sắp đọc trong số đo.
Về kỹ thuật, Postgres làm điều này bằng Serializable Snapshot Isolation: nó âm thầm ghi lại những gì mỗi transaction đã đọc (predicate lock, hiện trong pg_locks dưới tên SIReadLock), và khi một lần ghi của transaction khác sẽ làm kết quả đọc đó sai đi và hai bên phụ thuộc chéo nhau, nó huỷ một bên với could not serialize access due to read/write dependencies among transactions (tài liệu Postgres, Serializable). Các lock này không chặn ai chờ, chỉ để phát hiện.
Cái giá hiện ngay trong số đo. Với pgbench từ bản 15, hai chỉ số này đo hai thứ khác nhau:
| Chỉ số | Đếm gì |
|---|---|
failed transactions | transaction vẫn thua sau khi đã hết 3 lượt thử |
retried | mọi lượt phải thử lại, kể cả lượt sau đó thành công, nên là tập lớn hơn |
Ví dụ 100 lượt mua: 80 lượt trót lọt ngay; 20 lượt bị huỷ lần đầu và thử lại; trong 20 lượt đó 15 lượt thành công ở lần thử 2 hoặc 3, 5 lượt hết 3 lần vẫn thua. Khi đó retried = 20 (20%), failed = 5 (5%). Người dùng chỉ thấy lỗi ở 5 lượt này, nhưng DB đã chạy tới 20 lượt làm lại.
Qua nhiều lần chạy, tác giả thấy failed từ 2% đến 19%, retried từ 2% đến 20%, và TPS thấp hơn Read Committed từ 1,5 tới 9 lần; mỗi lần chạy ra một số khác. Mỗi lượt thử lại là một lần đếm lại toàn bộ đơn và một lần chèn lại, tức tải tăng thêm đúng vào lúc hệ thống đang ngộp. Tự đọc dòng tps, failed và retried của pgbench ở lần chạy Serializable rồi so với hai lần trước. Đừng coi tỉ lệ TPS là tuyệt đối, vì khi đủ 1.000 đơn script chỉ còn đếm, nên phép đo này dạy về tỉ lệ chứ không dạy về giá trị tuyệt đối. Ngoài ra ứng dụng buộc phải có sẵn vòng thử lại cho SQLSTATE 40001 (mã lỗi chuẩn của Postgres, nghĩa là serialization_failure, chính là thông báo could not serialize access ở trên), nếu không người mua nhận lỗi thay vì "còn" hoặc "hết".
Đặt cạnh ràng buộc: tải đỉnh 50.000 lượt/giây, p99 dưới 1 giây. Một phép đếm quét ngày càng nhiều đơn, cộng với có lúc tới một phần năm lượt phải làm lại, là công thức dễ làm trượt cả hai. Phương án này đúng nhưng đắt.
Điểm vỡ thật: bất biến nằm trong một phép đếm
Điểm vỡ thật không phải "thiếu transaction" hay "chọn sai isolation level". Bất biến "tối đa 1.000 đơn" nằm trong một phép đếm trên cả tập row, không nằm trên một row nào mà bạn có thể khoá hay so sánh phiên bản. Muốn cơ chế quen thuộc (khoá row, UPDATE có điều kiện) làm việc được, bạn phải có một row đại diện cho con số "còn bao nhiêu". Đưa tồn kho về một row là việc của bài 03: Trừ tồn kho trên hàng nóng, và đó cũng là lúc bạn gặp vấn đề ngược lại: một hàng nóng gánh 50.000 lượt/giây.

Thử ngẫmbạn chặn được bán quá bằng Serializable nhưng p99 vỡ. Bạn đổi gì ở ứng dụng thay vì đổi isolation?
5. Đào sâu
- PostgreSQL 16, Transaction Isolation: mục Repeatable Read giải thích snapshot và điều kiện sinh lỗi
concurrent update; mục Serializable mô tả predicate lockSIReadLockvà nghĩa vụ thử lại transaction khi gặp SQLSTATE40001. - PostgreSQL 16, pgbench:
\gset,\if,--max-triesdùng trong script ở mục 2. - Đọc thêm (khoá ngoài, không phải prerequisite): Anomaly và serializability trong data-phan-tan nhìn lại cùng họ lỗi này ở hệ phân tán.
Liên hệ các bài khác
- Bài 01: bảng ràng buộc mà mọi phương án phải chấm theo.
- Bài 03: sửa lỗi này bằng một row tồn kho.
- Bài 04: trả lại tồn kho bị giữ mà không ai thanh toán.
6. Tự kiểm tra
- Q1Phương án đếm đơn rồi chèn vỡ ở đâu, và vì sao Repeatable Read vẫn để lọt?
- Q2Vì sao ở 50 kết nối, Read Committed bán quá tối đa 49 đơn chứ không phải hàng trăm?
- Q3Bạn thêm
SELECT ... FOR UPDATEvào câu đếm. Nó có cứu được không? - Q4Serializable giữ được 0 đơn vượt. Vì sao bạn vẫn không dừng ở đó?
- Q5Bạn đo lại trên máy mình và Repeatable Read chỉ bán quá +12 đơn, thấp hơn khoảng +28 đến +45 của tác giả. Kết luận nào đúng?
Bài tiếp theo: 03 — Trừ tồn kho trên hàng nóng.
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.
Ràng buộc và ước lượng: một row tồn kho gánh được bao nhiêu lượt mua?
Bài tiếpTrừ tồn kho trên hàng nóng: ba cách, ba cái giá
Thảo luận & góp ý
Hỏi đáp về bài này