Tải vượt trần hàng chục lần: chặn cửa hay nâng trần?
Tải đỉnh gấp vài chục lần trần một row tồn kho. Chặn cửa bằng phòng chờ, hàng đợi, hay nâng trần bằng chia N shard, đếm trên Redis: mỗi đường trả giá gì.
TL;DR: Tải đỉnh 50.000 lượt/giây gấp khoảng 40 lần trần một row tồn kho (khoảng 1.200 lượt/giây ở 50 kết nối). Bài này đặt bốn phương án cạnh nhau: hai cách chặn tải ở cửa (phòng chờ, queue đơn: hàng đợi tin nhắn) và hai cách nâng trần của hàng nóng (chia tồn kho thành N shard (N row, mỗi row giữ một phần tồn kho), đếm trên Redis: kho key-value trong RAM). Bạn chọn trước, rồi đối chiếu cái gì vỡ trước dưới ràng buộc này.
Tải đỉnh 50.000 lượt/giây. Trần đo ở bài 01 là khoảng 1.200 lượt/giây trên một row, nên phép chia ra khoảng 40 lần. Bài 03 cho thấy 1.000 lượt bán thành công mất khoảng một giây, rồi UPDATE ... WHERE stock > 0 không khớp row nào. Vậy 49.000 lượt còn lại đang làm hỏng cái gì? Bạn sẽ chọn một trong bốn phương án trước, rồi mới đếm.
1. Ràng buộc và bốn phương án
Bảng ràng buộc chuẩn của module: 1 SKU, tồn kho 1.000, 200.000 người chờ, tải đỉnh khoảng 50.000 lượt/giây, p99 dưới 1 giây, mỗi người tối đa 1 sản phẩm.
Một hàng nóng ở 50 kết nối chỉ nhận khoảng 1.200 lượt/giây trên máy tác giả, và thêm kết nối không thêm thông lượng. Hãy thay bằng số bạn tự đo.
Hình dung bốn phương án như bốn cách cho một quán chỉ có một quầy thu ngân phục vụ 200.000 khách.
| Phương án | Ví dụ đời thường |
|---|---|
| A. Phòng chờ | Bảo vệ đứng cửa, mỗi giây chỉ cho 1.000 khách vào quán |
| B. Queue đơn | Phát số thứ tự cho mọi người, một thu ngân gọi số lần lượt, khách ngồi chờ kết quả |
| C. Chia N shard | Mở N quầy, mỗi quầy giữ một phần tồn kho |
| D. Redis | Lễ tân ghi nhanh lên bảng trắng trước, nhân viên chép vào sổ cái sau; bảng và sổ có thể lệch nhau |
Vài thuật ngữ chưa giải thích ở trên. Lệnh DECR của Redis giảm một số đi 1 nguyên tử, nghĩa là không lượt nào chen vào giữa; Lua là ngôn ngữ script mà Redis chạy nguyên khối, cũng không lượt nào chen vào giữa. Poll là bên hỏi đi hỏi lại xem xong chưa, push là bên kia chủ động báo khi xong. Replication là sao chép sang máy bản sao; failover là chuyển sang bản sao khi máy chính chết. Source of truth là nơi giữ bản ghi có lời cuối khi hai nơi lệch nhau; đối soát là so hai nơi lưu rồi sửa chỗ lệch.
Chọn một phương án trước đã.
Ràng buộc: 1 SKU · tồn kho 1.000 · đỉnh 50.000 lượt/giây · trần một row ≈ 1.200 lượt/giây ở 50 kết nối (tải đỉnh gấp khoảng 40 lần) · p99 phản hồi < 1 giây · max_connections 100
Tải gấp khoảng 40 lần trần. Bạn đặt chặn ở cửa, hay nâng trần của hàng nóng, hay cả hai?
Chọn trước khi đọc tiếp — phần sau phân tích từng cái giá.
Nền: trần đo ở bài 01
2. Khi 1.000 lượt đã bán xong, cái gì vỡ?
Khoá hàng nóng. Chỉ nghẽn trong chưa đầy một giây đầu, khi 1.000 lượt thật sự đổi stock. Hết hàng thì câu UPDATE có điều kiện (bài 03) trả 0 row ngay mà không xếp hàng chờ ai. Lưu ý thiết kế giữ chỗ ở bài 04 đặt INSERT INTO holds trước UPDATE, nên lượt đến sau khi hết hàng vẫn ghi một tuple rồi ROLLBACK, tức còn sinh ghi và dead tuple; cờ hết hàng ở mục 3 nên chặn trước cả bước INSERT đó.
Kết nối. max_connections mặc định là 100, trong đó superuser_reserved_connections mặc định giữ 3, nên ứng dụng dùng được 97. Mỗi kết nối Postgres là một process hệ điều hành riêng. Số kết nối bận cùng lúc bằng số lượt đến mỗi giây nhân với thời gian mỗi lượt giữ kết nối. Giả định không có pooler (bộ gom kết nối dùng chung). Ở giả định lạc quan 2 ms mỗi lượt (chỉ gồm round-trip và một câu UPDATE): 50.000 × 0,002 = 100 kết nối bận, chạm trần và vượt 97 khả dụng. Ở 20 ms như phép tính bài 01 thì cần 1.000. Cả hai đều chạm hoặc vượt trần, và thực tế nặng hơn vì mỗi lượt còn xếp hàng sau khoá row.
Phản hồi cho người thua. Ràng buộc p99 dưới 1 giây áp cho cả người không mua được; request xếp hàng chờ kết nối rảnh thì tính vào độ trễ đó.
Công bằng. Nếu "ai được vào mua" chỉ là "ai may mắn nhận được kết nối", bạn chưa thiết kế gì cả.
Thử ngẫmnếu hệ thống của bạn có 5.000 sản phẩm, mỗi sản phẩm bán trong cùng khoảnh khắc, tải đổ vào chia ra nhiều shard. Nút nào vỡ trước, shard hay kết nối?
3. Hai họ phương án: chặn cửa và nâng trần
A và B cùng một họ: chặn cửa, giữ phần lớn người mua ở ngoài DB. C và D cùng họ nâng trần: để nhiều lượt hơn đi qua phần lõi tồn kho. Hai họ can thiệp ở hai vị trí khác nhau.

Chặn cửa: phòng chờ và queue
Phòng chờ cho từng đợt người vào theo tốc độ bạn chọn, chẳng hạn 1.000 người/giây, dưới trần 1.200. Khi đó DB không bao giờ thấy 50.000 lượt/giây, kết nối không cạn, và 200.000 người xả hết trong khoảng 200 giây. Cái giá lộ ra ngay: người ở cuối đợi hơn ba phút rồi mới biết đã hết hàng từ lâu. Còn công bằng: FIFO thì ai đến trước và mạng tốt thắng, ngẫu nhiên thì bấm sớm không còn lợi nhưng người chờ lâu cũng không được ưu tiên.
Queue đơn khác ở chỗ chặn cửa nằm sau request: nhận yêu cầu vào queue (rẻ, nhận được 50.000/giây), một consumer lấy từng yêu cầu trừ tuần tự. Một consumer duy nhất không tranh khoá với ai và bị giới hạn bởi độ trễ mỗi lượt (khoảng 1.800 lượt/giây ở một kết nối): 1.000 lượt thành công tốn khoảng 0,6 giây. Phần còn lại là từ chối rẻ, nếu bạn đặt cờ "hết hàng" trong bộ nhớ của ứng dụng: khi DB trả 0 row thì cờ bật, những lượt đến sau nhận "hết hàng" ngay mà không chạm DB. Cái giá: người dùng không nhận kết quả ngay, và queue phải bền, vì queue mất thì đơn mất.
Cờ "hết hàng" áp dụng được cho cả phòng chờ lẫn queue, và bạn phải nói rõ cái giá của nó: cờ trễ vài trăm ms so với thực tế, và tồn kho quay về khi chỗ giữ hết hạn (300 sản phẩm theo bài 04) nên báo "hết hàng" vĩnh viễn là tước cơ hội của người đến cuối. Hãy báo "hết hàng, có thể có hàng trả lại". Chiều ngược lại, cờ nói còn mà DB hết, thì DB vẫn quyết lượt cuối nhờ AND stock > 0.
Nâng trần: chia N shard và đếm trên Redis
Chia N shard nghe hợp lý: hàng nóng vì mọi lượt đập vào một row, vậy chia 1.000 tồn kho thành N shard, mỗi lượt chọn ngẫu nhiên một shard. Mục 4 bắt bạn tự đo.
Redis chuyển phép trừ sang một lệnh nguyên tử (DECR, hoặc script Lua) trong bộ nhớ, rồi ghi đơn xuống Postgres sau. Phép trừ trong bộ nhớ không đi qua Postgres, nên tải đổ vào Redis chứ không đổ vào kết nối DB (chỉ người thắng mới ghi Postgres), nhưng bạn đổi lấy hai source of truth. Tài liệu Redis nói rõ replication mặc định là bất đồng bộ, và WAIT (lệnh chờ N bản sao xác nhận) không biến Redis thành hệ nhất quán mạnh: lượt trừ đã trả lời "thành công" vẫn có thể mất khi failover. Hệ quả: Redis nói "bán" mà Postgres chưa có đơn, hoặc mất lượt trừ và bán lại sản phẩm đó. Muốn bất biến "0 đơn vượt 1.000" còn đúng, bạn phải đối soát, và Postgres phải có lời cuối.
4. Tự đo: chia N shard tăng được bao nhiêu?
Dựng bảng chia shard, mỗi shard stock rất lớn để mọi lượt đều thành công (ta đo thông lượng, không đo hết hàng):
-- doi N thanh 1, 4, 16, 64 roi chay lai tu dau
DROP TABLE IF EXISTS stock_shards;
CREATE TABLE stock_shards (id int PRIMARY KEY, stock int NOT NULL);
INSERT INTO stock_shards SELECT g, 1000000000 FROM generate_series(1, 16) g;
Script pgbench, lưu thành shard.sql (ở đây N = 16):
\set sid random(1, 16)
BEGIN;
UPDATE stock_shards SET stock = stock - 1 WHERE id = :sid AND stock > 0;
COMMIT;
pgbench -n -c 50 -j 5 -T 10 -f shard.sql <ten_database_lab>
Chia N = 64 shard thì thông lượng gấp bao nhiêu lần N = 1? Viết con số bạn tin trước khi chạy, rồi vẽ TPS theo N = 1, 4, 16, 64. Chỉ sau khi có số đo mới so với đường thẳng "gấp N lần".
Số tác giả đo trên Postgres 16 qua hai lần chạy, mỗi lần ra một số khác: so với N = 1, N = 4 gấp 1,4 đến 2,5 lần, N = 16 gấp 4,2 đến 4,5 lần, N = 64 gấp 8,3 lần. Lần đo này N = 1 ra khoảng 1.800 TPS ở 50 kết nối, cao hơn 1.200 của bài 01: máy dao động giữa các lần đo, nên hãy so tỉ lệ trong cùng một lượt đo. Máy bạn ra số khác; giữ hình dạng: tăng nhưng dưới tuyến tính, còn xa mức 40 lần.

Vì sao dưới tuyến tính? Trước hết chỉ có 50 kết nối, nên tối đa 50 shard bận cùng lúc, không bao giờ 64. Xung đột chỉ loãng đi. Như ném 50 viên bi vào 64 ô, một số ô nhận hai viên, nên số ô có bi chỉ cỡ 35 chứ không phải 50: 50 kết nối chọn ngẫu nhiên trên 64 shard chạm trung bình khoảng 35 shard khác nhau (64·(1−(63/64)^50)), nên riêng chuyện đụng shard chưa chặn ở mức 8 lần. Trần thật nằm ở min(N, số kết nối, giới hạn của máy), và nhiều khả năng giới hạn đó là CPU, WAL và commit chung của máy 10 nhân; đây là giả thuyết chưa đo, bạn tự kiểm bằng top hoặc pg_stat_activity trong lúc chạy. Dù nguyên nhân là gì, 8,3 lần chỉ đúng cho máy này, máy khác ra số khác. N = 64 cũng không cứu được pool 100 kết nối.
Mặt trái nằm ở dữ liệu. Khi một shard về 0 mà shard khác còn hàng, lượt trúng shard rỗng trả 0 row và phải thử shard khác, tức thêm một round-trip. Cuối đợt các shard lệch nhau, và muốn biết "còn bao nhiêu" thì cộng N shard.

Thử ngẫmnếu chọn shard theo user_id thay vì ngẫu nhiên, 50 kết nối có đụng nhau ít hơn không, và người dùng bấm lại có bị dồn vào cùng một shard rỗng không?
5. Ghép lại: chọn theo cái gì đang vỡ trước
| Tình huống | Vỡ trước | Hướng chọn |
|---|---|---|
| 1.000 sản phẩm, bán xong trong chưa đầy một giây | kết nối, công bằng | chặn cửa (A hoặc B) + cờ hết hàng; chia N shard gần như thừa, Redis đỡ được kết nối nhưng thêm source of truth |
| Tồn kho lớn, bán kéo dài, lượt thành công/giây vượt trần một row | chính hàng nóng | thêm nâng trần (C, hoặc D nếu chấp nhận đối soát) |
| Nhu cầu kéo dài, ai cũng có cơ hội | công bằng | phòng chờ FIFO |
| Đợt bán ngắn, tồn kho có hạn | công bằng | phòng chờ ngẫu nhiên: bấm sớm không còn lợi |
Bẫy hay gặp:
❌ Chia 64 shard rồi tin rằng đã giải xong bài toán 40 lần.
# wrong: assume 64 shards means 64x, so skip the measurement
# 1200 * 64 = 76800 per second, "enough" for 50000
✅ Đo từng N trên máy của bạn: trên máy tác giả chỉ được khoảng 8 lần, và kết nối vẫn cạn.
# right: measure each N, and watch connections while it runs
pgbench -n -c 50 -j 5 -T 10 -f shard.sql <ten_database_lab>
psql -c "SELECT count(*) FROM pg_stat_activity WHERE state = 'active'"
❌ Tin Redis đã trừ thì Postgres sẽ có đơn.
-- wrong: Redis said yes, so just record the order
INSERT INTO orders (user_id, item_id) VALUES (:user_id, 1);
✅ Coi Postgres là nơi có lời cuối, đối soát định kỳ, và chấp nhận mất lượt trừ khi failover như một kịch bản đã tính.
-- right: Postgres has the last word, so guard the stock first
UPDATE items SET stock = stock - 1 WHERE id = 1 AND stock > 0;
-- 1 row: INSERT the order in the same transaction. 0 rows: ROLLBACK.
-- the reconcile job compares count(orders) with the Redis counter
Tóm tắt
- Việc cần làm: tự đo mức tăng của N shard trên máy bạn, đặt cờ hết hàng trước cả bước
INSERT, và chọn phương án theo cái gì vỡ trước dưới ràng buộc của bạn.
6. Đào sâu và liên hệ
- PostgreSQL 16: Connection Settings: giá trị mặc định của
max_connections(thường là 100, chỉ đổi được khi khởi động lại server), và các kết nối dành riêng cho superuser. - Redis replication: replication mặc định bất đồng bộ, và
WAITkhông biến Redis thành hệ nhất quán mạnh. - pgbench:
\set,random,-c/-j/-Tdùng trong script ở mục 4.
Liên hệ các bài khác
- 01 — Ràng buộc và ước lượng: nguồn của trần một row và phép tính kết nối 20 ms; bài này thêm giả định 2 ms để thấy cả hai đều chạm trần.
- 03 — Trừ tồn kho trên hàng nóng: giải thích vì sao
UPDATEcó điều kiện trả 0 row khi hết hàng. - 04 — Giữ chỗ có hạn: giải thích vì sao hàng nóng lại nóng lên mỗi khi chỗ giữ hết hạn.
Đọc thêm (khoá khác, chưa nằm trong tiền đề của course này): Key range vs hash bàn về chia dữ liệu theo khoá, cùng họ với việc chia tồn kho thành N shard; Log-based broker mô tả loại queue bền mà phương án B cần.
7. Tự kiểm tra
- Q1Đổi ràng buộc: 100.000 sản phẩm, bán kéo dài khoảng 20 giây ở đỉnh (tức ~5.000 lượt bán thành công mỗi giây), trần một row 1.200 lượt/giây. Bạn chọn gì, và cái giá bạn chấp nhận là gì? Tự quyết trước khi mở đáp án.
- Q2Vì sao với 1.000 sản phẩm, chia thêm N shard gần như không giúp ích, trong khi chặn cửa thì có?
- Q3Cờ "hết hàng" ở tầng ứng dụng được phép sai theo chiều nào, và vì sao DB vẫn quyết lượt cuối?
- Q4Phòng chờ cho 1.000 người/giây vào, 200.000 người chờ. Người đứng cuối biết kết quả sau bao lâu, và bạn rút ngắn nó thế nào mà không phá công bằng?
- Q5Dùng Redis DECR để trừ tồn kho rồi ghi Postgres sau. Kịch bản nào làm bất biến "0 đơn vượt 1.000" bị phá, và bạn cần thêm gì?
Bài tiếp theo: Đối chiếu thực tế
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.
Giữ chỗ có hạn: trừ lúc bấm Mua hay lúc thanh toán?
Bài tiếpĐối chiếu thực tế: hệ thống bán vé và sàn TMĐT đã làm gì?
Thảo luận & góp ý
Hỏi đáp về bài này