Flash sale: chống bán quá số lượng
2/8
Bài 2 / 8~13 phútChống bán quá số lượngMiễn phí… lượt xem

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ộcGiá trịNghĩa là gì
Sản phẩm1 SKU, tồn kho 1.000Chỉ một sản phẩm, ai cũng tranh cùng một con số
Người chờ lúc mở bán200.000Số người đã đăng ký nhắc giờ
Cửa sổ dồn tảiMột nửa số người bấm trong 2 giây đầu100.000 lượt trong 2 giây
Không bán quá0 đơn vượt 1.000Bấ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ếtp99 dưới 1 giâyNgười thua cũng phải biết sớm
Thời hạn thanh toánGiữ chỗ 10 phútHết hạn mà chưa trả tiền thì sản phẩm quay về kho
Bỏ giữa chừng30% người giữ chỗ không trả tiềnHàng quay về kho thành từng đợt
Một ngườiTối đa 1 sản phẩmChặ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.

Thử đ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ểmChuyện đang xảy ra
0 đến 1 msK1 giữ khoá, K2 và K3 chờ
1 đến 2 msK2 giữ khoá, K3 chờ
2 đến 3 msK3 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).

Bốn kết nối cùng UPDATE một row: kết nối 1 giữ khoá, kết nối 2 đến 4 chờ lần lượt dài dần, các khoảng giữ khoá nối đuôi nhau trên trục thời gian

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.

Hai thanh cùng thang tuyến tính: tải đỉnh 50.000 lượt mỗi giây dài gấp chừng 42 lần thanh trần một row 1.200 lượt mỗi giây

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:

Bash
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.

SQL
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 =:

Bash
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, -n và cách đọc dòng tps =. Đọc phần script tuỳ chỉnh nếu bạn muốn thêm \set random cho 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

Tóm tắt

  • Việc cần làm sau bài này: chạy hai lệnh pgbench trê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_connections mặc định 100 là ứng viên đầu tiên cần ước lượng.

5. Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Vì 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?
  2. Q2
    Bạ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?
  3. Q3
    Vớ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.
  4. Q4
    Vì 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 đề?
  5. Q5
    Giả 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.

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

Đếm đơn rồi ghi: vì sao bật transaction vẫn bán quá