Ràng buộc và ước lượng: một row tồn kho gánh được bao nhiêu lượt mua?
Tự đặt bảng ràng buộc cho một đợt flash sale, ước lượng tải đỉnh, rồi đo bằng pgbench xem một row bị cập nhật dồn dập gánh được bao nhiêu lượt mỗi giây trên máy bạn.
TL;DR: Trước khi chọn bất kỳ kiến trúc nào, hãy chốt ràng buộc bằng số. Case này đặt 1.000 sản phẩm, 200.000 người chờ, một nửa bấm trong 2 giây đầu, nghĩa là tải đỉnh khoảng 50.000 lượt mỗi giây. Một row tồn kho trong Postgres chỉ cập nhật được cỡ 1.200 lượt mỗi giây khi 50 kết nối cùng tranh nó trên máy tác giả, nên tải đỉnh gấp chừng 40 lần trần của một row. Thêm kết nối không nâng được trần đó, và bạn sẽ tự đo con số này trên máy mình bằng pgbench (công cụ benchmark đi kèm Postgres).
Sáu giờ chiều, đội marketing báo: "Tối nay mở bán 1.000 chiếc, đã có 200.000 người đăng ký nhắc giờ". Bạn nghĩ ngay tới UPDATE items SET stock = stock - 1, rồi tới Redis, hàng đợi, shard. Dừng lại. Chưa có một con số nào về tải thì mọi lựa chọn đó đều là đoán.
Bài này làm hai việc. Một là công bố bảng ràng buộc mà cả case dùng lại, để mọi bài sau cùng nói về một đợt bán. Hai là biến bảng đó thành hai con số so được với nhau: tải đỉnh mà đợt bán đổ vào, và trần mà một row tồn kho chịu được. Chưa có thiết kế nào ở đây, chỉ có số và câu hỏi.
1. Bảng ràng buộc: những con số cả case đứng trên
Bảng dưới là ràng buộc tự đặt cho case này. Đó không phải số của một công ty thật nào, và ý đồ là vậy: một đề bài thiết kế mà thiếu số thì mỗi người tự hiểu một kiểu, rồi tranh cãi về kiến trúc khi thật ra đang tranh cãi về đề. Các bài 02 đến 05 trích lại đúng bộ số này, không đặt số mới.
| Ràng buộc | Giá trị | Nghĩa là gì |
|---|---|---|
| Sản phẩm | 1 SKU, tồn kho 1.000 | Chỉ một sản phẩm, ai cũng tranh cùng một con số |
| Người chờ lúc mở bán | 200.000 | Số người đã đăng ký nhắc giờ |
| Cửa sổ dồn tải | Một nửa số người bấm trong 2 giây đầu | 100.000 lượt trong 2 giây |
| Không bán quá | 0 đơn vượt 1.000 | Bất biến cứng, không có "thỉnh thoảng sai một vài đơn" |
| Phản hồi còn hay hết | p99 dưới 1 giây | Người thua cũng phải biết sớm |
| Thời hạn thanh toán | Giữ chỗ 10 phút | Hết hạn mà chưa trả tiền thì sản phẩm quay về kho |
| Bỏ giữa chừng | 30% người giữ chỗ không trả tiền | Hàng quay về kho thành từng đợt |
| Một người | Tối đa 1 sản phẩm | Chặn một người gom nhiều suất |
p99 nghĩa là 99% lượt phản hồi nhanh hơn ngưỡng đó, còn 1% chậm nhất được phép vượt. Người dùng ghi nhớ đuôi chậm hơn trung bình, nên ràng buộc phản hồi viết bằng p99 chứ không bằng trung bình (đọc thêm: tail latency ở khoá Dữ liệu phân tán, khoá này không nằm trong tiên quyết của case).
Hai dòng cần nhìn kỹ nhất là "0 đơn bán quá" và "p99 dưới 1 giây". Chúng kéo ngược chiều nhau: muốn tuyệt đối đúng thì cho từng lượt mua xếp hàng một, muốn phản hồi nhanh thì không được để ai chờ lâu. Cả case xoay quanh việc giữ cả hai.
2. Tải đỉnh bao nhiêu lượt mỗi giây?
Phép tính bằng giấy, kiểu back-of-envelope (ước lượng thô bằng vài phép nhân chia, đủ để biết bậc độ lớn chứ không cần chính xác):
- Một nửa của 200.000 người bấm trong 2 giây đầu: 100.000 lượt.
- Chia cho 2 giây: 50.000 lượt mỗi giây.
Con số đó là tải đỉnh, chỉ kéo dài vài giây; thiết kế phải sống qua đúng khoảnh khắc này.
Giờ so với thứ bạn định dùng để chốt mỗi lượt mua: một row items trong Postgres, trừ tồn kho bằng một câu UPDATE. Trong module này, dòng của bảng Postgres viết là row, còn hàng hoá gọi là sản phẩm hay tồn kho. Row bị mọi người cùng tranh có tên riêng là hàng nóng (hot row): một row bị mọi người cùng tranh. Hàng nóng đó gánh được bao nhiêu lượt mỗi giây? Đừng đọc tiếp vội, hãy đoán trước.
Bạn chạy cùng một câu UPDATE trừ tồn kho trên đúng một row, trước bằng 1 kết nối, sau bằng 50 kết nối chạy song song. Máy bạn nhiều nhân, ổ SSD nhanh. TPS (số transaction mỗi giây) của 50 kết nối sẽ gấp mấy lần 1 kết nối? Viết ra một con số, rồi mới sang phần sau.
Lời giải: không gấp lần nào. Trên máy tác giả (Apple M-series 10 nhân, Postgres 16 trong Docker), 1 kết nối đạt khoảng 1.800 TPS và 50 kết nối đạt khoảng 1.200 TPS. Tức 50 kết nối còn chậm hơn một chút, tỉ lệ khoảng 0,7. Lý do là mỗi UPDATE phải giữ khoá row đến khi COMMIT, nên 50 kết nối không chạy song song trên row đó, chúng xếp hàng nhau. Thêm kết nối chỉ thêm người xếp hàng, không thêm quầy.
Phép tính nhẩm cho bạn thấy trần này từ đâu ra. Giả sử mỗi UPDATE giữ khoá row 1 ms (từ lúc lấy khoá tới COMMIT). Ba kết nối K1, K2, K3 cùng nhắm một row:
| Thời điểm | Chuyện đang xảy ra |
|---|---|
| 0 đến 1 ms | K1 giữ khoá, K2 và K3 chờ |
| 1 đến 2 ms | K2 giữ khoá, K3 chờ |
| 2 đến 3 ms | K3 giữ khoá |
Ba lượt mất 3 ms, nên mỗi giây tối đa 1.000 ms ÷ 1 ms = 1.000 lượt, dù có 3 hay 50 kết nối. Muốn trần cao hơn chỉ có cách rút ngắn thời gian giữ khoá. Đọc ngược số của tác giả: 1.200 đến 1.800 lượt mỗi giây ứng với mỗi lượt giữ khoá cỡ 0,5 đến 0,8 ms (tính tay từ 1.000 ÷ TPS, không phải số đo riêng).

Từ đây ra con số của case. Lấy trần của một row đồng thời là TPS ở -c 50, cỡ 1.200 lượt mỗi giây (số của tác giả, máy khác thì tự đo):
- Tải đỉnh ÷ trần = 50.000 ÷ 1.200 ≈ 42, làm tròn thành chừng 40 lần.
Chú ý độ lớn, đừng chú ý chữ số lẻ. Máy bạn có thể ra 900 hay 3.000 thì tỉ lệ vẫn là "hàng chục lần". Chính vì thế bài dạy theo tỉ lệ: số tuyệt đối đổi theo phần cứng, tỉ lệ thì giữ nguyên bậc.

3. Tự đo trần của một row bằng pgbench
pgbench là công cụ benchmark đi kèm Postgres: nó mở nhiều kết nối, mỗi kết nối chạy lặp lại một script SQL, rồi báo TPS ở dòng cuối. Với -f, một "transaction" được đếm là một lần chạy trọn script. Bạn cần một Postgres chạy được (cài sẵn hoặc docker compose up -d postgres nếu bạn có repo này).
Bước 1, tạo database lab riêng và bảng một row, tồn kho đặt cực lớn để không bao giờ cạn trong lúc đo:
createdb flashlab
psql flashlab -c "CREATE TABLE items (id int PRIMARY KEY, stock int NOT NULL);"
psql flashlab -c "INSERT INTO items VALUES (1, 1000000000);"
Bước 2, script buy.sql: một lượt mua là một transaction chứa một câu UPDATE có điều kiện. Điều kiện stock > 0 nằm ngay trong câu ghi, để tồn kho không bao giờ xuống âm.
BEGIN;
UPDATE items SET stock = stock - 1 WHERE id = 1 AND stock > 0;
COMMIT;
Bước 3, chạy hai lần, ghi lại dòng tps =:
pgbench -n -f buy.sql -c 1 -j 1 -T 10 flashlab
pgbench -n -f buy.sql -c 50 -j 5 -T 10 flashlab
-c là số kết nối, -j là số luồng của pgbench, -T 10 chạy 10 giây, -n bỏ bước vacuum vì script tự viết không dùng các bảng mặc định của pgbench. Điền số của bạn vào bảng, cột giữa là số tác giả đo để đối chiếu cỡ độ lớn:
Số kết nối (-c) | TPS máy tác giả | TPS máy bạn |
|---|---|---|
| 1 | ≈ 1.800 | ? |
| 50 | ≈ 1.200 | ? |
Tỉ lệ -c 50 ÷ -c 1 | ≈ 0,7 | ? |
Tính tải đỉnh ÷ TPS -c 50 của bạn. Con số đó là "tỉ lệ vượt trần" của bạn, và là thứ bạn mang theo suốt case. Số lệch hẳn một bậc so với bảng thì nghi ngờ ổ đĩa hoặc máy đang bận trước.
Thử ngẫmnếu máy bạn ra -c 50 cao hơn hẳn -c 1, bạn nghi điều gì trong cách đo trước khi tin kết luận xếp hàng?
4. Câu hỏi chưa ai trả lời
Ghép hai nửa lại. 1.000 lượt mua thành công ở khoảng 1.200 lượt mỗi giây chỉ mất dưới một giây. Sau đó tồn kho về 0, và UPDATE ... WHERE stock > 0 không khớp row nào nên không giành khoá, trả về rất nhanh. Vậy thì nếu row tồn kho chỉ nóng trong khoảnh khắc đầu, cái gì sẽ vỡ trước khi 50.000 lượt mỗi giây ập tới?
Chưa có câu trả lời trong bài này.
Mẹo nhẩm: đây là bài toán ghế trong quán cà phê. Mỗi giây vào 10 khách, mỗi khách ngồi 0,5 giây thì lúc nào cũng có 10 × 0,5 = 5 ghế có người. Kết nối giống ghế: mỗi lượt mua ngồi giữ một kết nối đến khi xong. Còn 20 ms chỉ là giả định cho một lượt gồm vài round-trip tới DB trên mạng nội bộ, bạn thay bằng số đo của mình.
Một manh mối để bạn tự ước lượng: Postgres mặc định max_connections là 100, và mỗi kết nối là một process riêng. Nếu mỗi lượt mua giữ kết nối khoảng 20 ms, 50.000 lượt mỗi giây cần bao nhiêu kết nối cùng lúc? Giữ phép tính đó lại, bài 05 sẽ dùng nó.
Thử ngẫmước lượng số kết nối cần cho 50.000 lượt/giây nếu mỗi lượt giữ kết nối 5 ms thay vì 20 ms, và kết luận "hàng chục lần" còn đứng không?
Đào sâu: nguồn để tự kiểm số của mình
Số của tác giả chỉ là một điểm mẫu. Các tài liệu dưới đây cho bạn công cụ để tự đo và tự kiểm.
Khoá row mà một UPDATE lấy được giữ cho tới khi transaction kết thúc bằng COMMIT hoặc ROLLBACK, không nhả sau câu lệnh. Đó chính là cơ chế khiến 50 kết nối cùng ghi một row phải xếp hàng nhau ở phần đo trên.
- pgbench (PostgreSQL 16): mô tả
-c,-j,-T,-f,-nvà cách đọc dòngtps =. Đọc phần script tuỳ chỉnh nếu bạn muốn thêm\set randomcho các lần đo sau. - max_connections: mặc định thường là 100, chỉ đổi được khi khởi động lại server. Con số này quay lại ở bài 05.
Liên hệ các bài khác
- Bài 02: Phương án ngây thơ, đếm đơn rồi ghi: dùng đúng bảng ràng buộc này để tái hiện lỗi bán quá, vì sao bật transaction vẫn không đủ.
- Bài 05: Tải vượt trần, chặn cửa hay nâng trần: quay lại tỉ lệ chừng 40 lần của bài này và phép tính kết nối ở trên.
Tóm tắt
- Việc cần làm sau bài này: chạy hai lệnh
pgbenchtrên máy bạn, điền bảng TPS, rồi tính tỉ lệ vượt trần của chính bạn. - Câu hỏi mở chuyển sang các bài sau: nếu một row chỉ nóng dưới một giây thì cái gì vỡ trước, và
max_connectionsmặc định 100 là ứng viên đầu tiên cần ước lượng.
5. Tự kiểm tra
- Q1Vì sao bài ghi tải đỉnh là 50.000 lượt mỗi giây chứ không lấy 100.000 lượt chia cho cả giờ mở bán?
- Q2Bạn đo được -c 1 đạt 2.000 TPS và -c 50 đạt 1.400 TPS trên máy mình. Kết quả này mâu thuẫn hay phù hợp với lý thuyết, và vì sao?
- Q3Với trần đo được 1.200 TPS ở -c 50, tính tỉ lệ vượt trần của đợt bán, và nói tỉ lệ đó đổi thế nào khi máy khác cho trần 3.000 TPS.
- Q4Vì sao 1.000 lượt mua thành công chỉ mất dưới một giây mà lại không kết luận được rằng row tồn kho không phải vấn đề?
- Q5Giả sử mỗi lượt mua giữ một kết nối 20 ms. Với 50.000 lượt mỗi giây cần khoảng bao nhiêu kết nối cùng lúc, và so với max_connections mặc định 100 thì sao?
Bài tiếp theo: Phương án ngây thơ: đếm đơn rồi ghi
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.
Flash sale — tổng quan case chống bán quá số lượng
Bài tiếpĐếm đơn rồi ghi: vì sao bật transaction vẫn bán quá
Thảo luận & góp ý
Hỏi đáp về bài này