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

Trình bày case flash sale trong 45 phút phỏng vấn thiết kế

Đổi thiết kế flash sale bạn đã ráp thành một buổi trình bày 45 phút: hỏi ràng buộc, ước lượng, endpoint và dữ liệu, sơ đồ, đào sâu, mỗi câu dựa vào số đã đo.

TL;DR: Bài này là một buổi luyện có bấm giờ. Người phỏng vấn đưa một đề mơ hồ về đợt bán giày giới hạn, bạn có 45 phút để đi từ câu hỏi ràng buộc tới phần đào sâu. Bạn tự chia phút cho năm bước, tự chọn endpoint (địa chỉ HTTP mà client gọi vào) và bảng dữ liệu, tự quyết hai trục để đào sâu, rồi mới so với bản trình bày mẫu. Tiêu chí chính là neo: mỗi câu bạn nói phải truy ngược được về một dòng trong bảng ràng buộc hoặc một số đo bạn tự lấy trong các bài trước, không dựa vào tên công nghệ. Thang Mid, Senior, Staff ở mục 7.1 chỉ để tham khảo.

Các bài trước cho bạn đủ vật liệu: bảng ràng buộc, số đo trần một row, ba quyết định kèm cái giá, và một thiết kế ba lớp. Biết là một chuyện. Nói lại được trong 45 phút, trước một người cứ ngắt lời hỏi "sao không dùng Redis?", là chuyện khác hẳn.

1. Đề bài

Người phỏng vấn nói đúng một câu:

"Thiết kế giúp tôi trang bán 1.000 đôi giày bản giới hạn. Mở bán lúc 20:00."

Không có số người dùng, không có yêu cầu độ trễ, không nói gì về thanh toán. Việc của bạn là biến câu đó thành một buổi trình bày 45 phút, nói to thành tiếng như đang đứng trước bảng trắng. Đặt đồng hồ, ghi âm lại nếu được, và đừng mở các bài trước trong lúc nói.

Luật chơi:

  • Tự hỏi ra ràng buộc. Người phỏng vấn sẽ trả lời bằng đúng bảng ràng buộc của case khi bạn hỏi trúng, còn không hỏi thì họ không nói.
  • Mỗi lựa chọn phải kèm một cái giá, và cái giá đó phải có số.
  • Hết 45 phút thì dừng, kể cả khi chưa nói xong. Phần chưa kịp nói cũng là dữ liệu cho lần sau.

2. Đầu vào, quá trình, đầu ra

Nội dung
Đầu vàoCâu đề ở mục 1, cộng những gì bạn đã đo trên Postgres của mình ở các bài 01 đến 05
Quá trìnhNăm bước: hỏi ràng buộc, ước lượng, endpoint và dữ liệu, sơ đồ tổng, đào sâu; năm phút cuối dành cho câu "đổi ràng buộc thì sao"
Đầu raMột bảng trắng (hoặc tờ giấy) có bảng ràng buộc ở góc, danh sách endpoint, các bảng dữ liệu, một sơ đồ đi theo đường của một lượt mua, và hai trục đã đào sâu

Endpoint là một địa chỉ HTTP mà client gọi vào để làm một việc, ví dụ POST /sales/:id/holds. Trong các bài trước bạn chỉ làm việc với SQL và pgbench, nên đây là lần đầu module bắt bạn nói hệ thống nhận yêu cầu qua cửa nào.

3. Bài này ráp lại những gì

Mỗi bước trong buổi trình bày lấy vật liệu từ một bài và chứng minh một outcome của module:

BướcLấy từ bàiKiểm điều gì
Hỏi ràng buộcBài 01Estimate tải đỉnh và trần một row
Ước lượngBài 01, Bài 05Estimate tải đỉnh và trần một row
Endpoint và dữ liệuBài 03, Bài 04Choose cơ chế trừ tồn kho; Design luồng giữ chỗ
Sơ đồ tổngBài 07Cả ba trục quyết định
Đào sâuBài 02 đến Bài 05Diagnose bán quá; Choose cơ chế; Design giữ chỗ; Choose chặn cửa hay nâng trần
Đổi ràng buộcBài 06, Bài 07Justify vì sao hệ thống thật chọn khác

Cả buổi kiểm outcome cuối của module: Justify thiết kế flash sale trong một buổi phỏng vấn 45 phút bằng ràng buộc và số đo của chính mình.

4. Starter: khung để bạn tự điền

Chép khung dưới ra giấy và điền hết các ô trống trước khi bấm giờ. Cột phút cộng lại phải bằng 45.

BướcPhút (từ đến)Bạn sẽ nói gìDựa vào số nào
1. Hỏi ràng buộc_________
2. Ước lượng_________
3. Endpoint và dữ liệu_________
4. Sơ đồ tổng_________
5. Đào sâu (chọn hai trục)_________
Đổi ràng buộc_________

Thêm hai khung nữa cho phần endpoint và dữ liệu. Bao nhiêu dòng là do bạn quyết, thêm hay bớt tuỳ thiết kế:

Plain Text
Endpoint | Ai gọi | Việc nó làm | Trả về gì
_______  | ______ | ___________ | _________
...

Bảng dữ liệu | Cột chính | Ràng buộc giữ bất biến
____________ | _________ | ______________________
...

5. Gợi ý

Đọc khi bị kẹt ở một ô, không đọc trước. Mỗi gợi ý là một câu hỏi, câu trả lời vẫn là việc của bạn.

  • Bước 1: bảng ràng buộc của bài 01 có tám dòng. Dòng nào mà người phỏng vấn không tự nói nhưng thiếu nó thì bạn không ước lượng được gì?
  • Bước 2: tải đỉnh bằng số lượt trong cửa sổ dồn tải chia cho độ dài cửa sổ. Số kết nối bận cùng lúc bằng số lượt mỗi giây nhân với thời gian mỗi lượt giữ kết nối. Tự tính cả hai, rồi đặt cạnh trần một row và trần kết nối của Postgres. Cái nào vỡ trước?
  • Bước 3: có bao nhiêu loại người gọi vào hệ thống lúc 20:00? Loại nào đông nhất, và loại đó có cần chạm DB không? Có bên nào gọi vào mà không phải người dùng?
  • Bước 3: với mỗi endpoint, hỏi "client mất mạng rồi gọi lại thì chuyện gì xảy ra". Endpoint nào mà gọi hai lần ra hai kết quả khác nhau thì đang có lỗi.
  • Bước 4: vẽ theo đường đi của một lượt mua, từ ngoài vào trong, thay vì vẽ một đống hộp rồi nối mũi tên.
  • Bước 5: trục nào bạn có số đo của chính mình thì nói trục đó trước.

6. Lời giải: một bản trình bày mẫu

Đây là một cách chia, không phải cách duy nhất. Bạn chia phút khác mà vẫn kịp đào sâu thì vẫn đạt.

6.1 Năm bước theo phút

BướcPhútBạn nói gì, lấy từ đâu
1. Hỏi ràng buộc0–5Hỏi lại tồn kho, số người chờ, cửa sổ dồn tải, giới hạn mỗi người, hạn thanh toán, tỉ lệ bỏ giữa chừng, yêu cầu phản hồi. Viết bảng của bài 01 vào góc bảng
2. Ước lượng5–10100.000 lượt trong 2 giây là 50.000 lượt mỗi giây; một row gánh cỡ 1.200 ở 50 kết nối, tức tải đỉnh gấp chừng 40 lần. Kết nối bận: 50.000 × 0,002 = 100 (giả định 2 ms mỗi lượt như bài 05; nếu 20 ms như bài 01 thì 1.000), vượt 97 kết nối ứng dụng dùng được. Kết luận: kết nối vỡ trước khoá row
3. Endpoint và dữ liệu10–15Ba endpoint, hai bảng và một worker ở mục 6.2 đến 6.4
4. Sơ đồ tổng15–25Ba lớp của bài 07, đi theo đường của một lượt mua
5. Đào sâu25–40Để người phỏng vấn chọn, hoặc tự đề xuất hai trong ba trục: cơ chế trên một row (bài 03), giữ chỗ và callback trễ (bài 04), chặn cửa hay nâng trần (bài 05)
Đổi ràng buộc40–45Đổi một dòng trong bảng, nói quyết định nào lật, quyết định nào giữ nguyên

Bước 1 tốn tới năm phút là có chủ đích: chính bước này cho người nghe thấy bạn có tự đặt ràng buộc trước khi vẽ hay không, và mọi con số về sau đều đứng trên nó. Bước 2 thì ngược lại: nói nhanh, viết ba phép tính lên bảng, chốt một câu kết luận rồi đi tiếp.

Bài 01 đã đo: 1 kết nối được khoảng 1.800 TPS, 50 kết nối chỉ còn khoảng 1.200 TPS trên máy tác giả, vì mọi UPDATE xếp hàng sau khoá row. Trong buổi phỏng vấn, nói số của máy bạn, rồi nói tỉ lệ: tải đỉnh gấp hàng chục lần trần, máy nào cũng vậy.

Câu "kết nối vỡ trước khoá row" ở bước 2 cần nói kèm lý do, không thì nghe như học thuộc. Một nghìn lượt thắng chỉ mất chưa tới một giây; sau đó câu UPDATE ... WHERE stock > 0 trả 0 row mà không chờ khoá. Còn 50.000 lượt mỗi giây thì vẫn đổ vào, mỗi lượt vẫn cần một kết nối, và Postgres 16 với max_connections mặc định 100 (giữ lại 3 cho superuser) sẽ từ chối kết nối thứ 98 của ứng dụng bằng FATAL: remaining connection slots are reserved for roles with the SUPERUSER attribute.

6.2 Ba endpoint

Lúc 20:00 có ba loại người gọi với ba mức tải rất khác nhau, nên tách thành ba endpoint để mỗi cửa được bảo vệ theo cách riêng.

EndpointAi gọiViệc nó làmTrả về
GET /sales/:id/statusMọi người đang chờ, đông nhấtĐọc cờ hết hàng và vị trí trong phòng chờ, không chạm DBCòn hàng, hết hàng, hoặc vị trí đang chờ
POST /sales/:id/holdsChỉ lượt đã qua phòng chờTạo chỗ giữ và trừ stock trong một transaction201 kèm hold_id, expires_at; 409 khi đã hết
POST /payments/callbackCổng thanh toán, không phải người dùngChuyển chỗ giữ từ pending sang paid bằng UPDATE có điều kiện200 cả khi cổng giao lại

Cờ hết hàng cần nói cho đúng. Nếu nó nằm trong bộ nhớ ứng dụng, mỗi instance (một tiến trình server đang chạy) chỉ bật cờ khi chính nó thấy UPDATE trả 0 row. Chạy mười instance thì có mười bản cờ, bật lệch nhau vài mili giây; muốn đồng bộ thì đặt cờ ở cache dùng chung. Instance chưa có cờ vẫn gửi lượt xuống DB và nhận 0 row, kết quả vẫn đúng, chỉ tốn tải. Và cờ chỉ được sai theo hướng từ chối oan, không bao giờ theo hướng báo còn khi đã hết.

6.3 Idempotent: gọi lại không đổi kết quả

Đây là chỗ quan trọng nhất của phần endpoint. Một thao tác idempotent là thao tác gọi một lần hay nhiều lần đều để lại cùng một trạng thái. HTTP chỉ coi PUT, DELETE và các method chỉ đọc là idempotent (RFC 9110 §9.2.2); POST thì không, nên hai endpoint POST ở trên phải tự làm cho mình idempotent.

Với POST /holds, client mất mạng sau khi gửi rồi gọi lại là chuyện thường. Câu INSERT ... ON CONFLICT DO NOTHING của bài 04 chèn 0 row ở lần thứ hai. Đừng trả lỗi cho trường hợp đó: đọc lại chỗ còn sống của người này rồi trả chính hold_id cũ, để người dùng không thấy một lỗi giả cho đúng thứ họ đã có.

Với callback, cổng thanh toán giao lại khi không nhận được 200, nên một lần thanh toán có thể tới hai, ba lần. Câu chốt ở bài 04 tự chặn được chuyện chốt trùng:

SQL
UPDATE holds SET status = 'paid'
WHERE id = :hold_id AND status = 'pending' AND expires_at > now();

Một row bị ảnh hưởng là chốt xong. Không row nào thì đọc status: paid nghĩa là callback giao lại, trả 200 và không làm gì; expired, hoặc pending mà đã quá hạn, nghĩa là zombie, tức người đã trả tiền khi chỗ giữ đã hết hạn. Chiều ngược lại cũng cần: lúc gọi cổng thanh toán, gửi hold_id làm khoá idempotency để cổng không trừ tiền hai lần khi bạn retry.

Bài 04 đã chọn chính sách cho zombie: hoàn tiền tự động, thử trừ lại nếu còn hàng, hoặc gia hạn chỗ giữ khi người dùng bắt đầu thanh toán. Trong phỏng vấn, chỉ cần nói bạn chọn cách nào và cái giá của nó, đừng giảng lại cả ba.

6.4 Hai bảng và một worker

Dữ liệu chỉ có hai bảng của bài 04: items giữ stock với CHECK (stock >= 0), và holds giữ status, expires_at cùng unique partial index trên (user_id, item_id) cho chỗ còn sống. Worker thu hồi không phải endpoint, nó là một job quét holds theo expires_at bằng FOR UPDATE SKIP LOCKED, đánh dấu expired rồi cộng trả stock trong cùng một câu.

Không có endpoint POST /orders là cố ý: đơn hàng thật chỉ thành hình khi callback chốt paid, nên tách giữ chỗ khỏi thanh toán là quyết định của bài 04 chứ không phải quên.

Sơ đồ ba lớp của bài 07 cho thấy cả ba endpoint nằm ở đâu trên đường đi của một lượt mua:

Đường đi một lượt mua qua lớp chặn cửa, câu UPDATE có điều kiện trên Postgres, chỗ giữ 10 phút, rồi thanh toán hoặc worker thu hồi

6.5 Câu hay nghe và câu nên nói

Cùng một ý, nói kèm con số thì nghe như người đã làm, nói trống thì nghe như thuộc bài:

Câu hay ngheCâu nên nói
"Dùng Redis cho nhanh.""Tải đỉnh gấp khoảng 40 lần trần một row. Redis đỡ được kết nối nhưng thêm một source of truth, replication lại bất đồng bộ nên failover có thể mất lượt trừ. Tôi sẽ đối soát và để Postgres giữ lời cuối."
"Khoá row lại là xong.""Một câu UPDATE có điều kiện, trả 0 row là hết. Khoá giữ tới COMMIT nên transaction phải ngắn, không gọi thanh toán bên trong."
"Chia 64 shard thì gấp 64 lần.""Trên máy tôi, 64 shard chỉ được khoảng 8 lần, và pool kết nối vẫn cạn trước."
"Thêm PgBouncer.""Pooler chỉ cho default_pool_size lượt, mặc định 20, chạm Postgres; khi đã nâng max_client_conn (mặc định 100), phần dư xếp hàng tới 120 giây. Lỗi kết nối biến thành độ trễ, p99 dưới 1 giây vẫn vỡ nếu không chặn cửa."
Không nhắc gì tới người trả tiền sau hạn"Chỗ giữ hết hạn mà callback tới sau thì người đó là zombie; tôi chọn hoàn tiền tự động, cái giá là khiếu nại tăng theo số zombie."

Bài 05 đo được 64 shard gấp 8,3 lần trên máy 10 nhân của tác giả và tính ra 100 kết nối bận so với 97 khả dụng. Hai số đó là lý do bạn chọn chặn cửa cộng cờ hết hàng, và cũng là câu trả lời cho mọi câu "sao không dùng X" ở bảng trên.

7. Mở rộng

7.1 Tự chấm bản trình bày

Nghe lại bản ghi âm, rồi làm ba việc theo thứ tự:

  1. Với mỗi câu khẳng định, đánh dấu nó neo vào dòng ràng buộc nào hoặc số đo nào của bạn.
  2. Đếm những câu không neo được vào đâu, thường là câu chỉ có tên công nghệ.
  3. Với mỗi câu không neo, viết lại thành câu có số, hoặc gạch bỏ.

Sau đó mới đối chiếu với thang ba cấp. Đây là thang gợi ý, mỗi công ty đặt kỳ vọng khác nhau, đừng coi nó là chuẩn ngành:

CấpĐủ khi bạn
MidĐưa ra UPDATE có điều kiện đúng, giải thích được vì sao đếm đơn rồi ghi vẫn bán quá
SeniorGắn cái giá có số cho từng lớp, tự chỉ ra kết nối cạn trước khoá row, xử lý được callback trễ và callback giao lại
StaffNói được thiết kế lật khi ràng buộc đổi, và kế hoạch vận hành: kiểm bất biến ngay trong đợt bán, đối soát khi có Redis, điều gì xảy ra nếu primary Postgres failover giữa đợt, chặn bot

Cột Staff có hai thứ case này chưa dạy kỹ. Failover của primary Postgres giữa đợt bán chưa có bài nào; bài 05 mới nói tới chuyện Redis mất lượt trừ khi failover. Chống bot thì chỉ chạm qua ở case Ticketmaster của bài 06. Biết mình chưa trả lời được câu nào cũng là một phần của buổi luyện, và nói thẳng "phần này tôi chưa đo" vẫn tốt hơn bịa.

7.2 Làm lại với ràng buộc khác

Chạy lại cả buổi 45 phút, mỗi lần đổi một dòng, và để ý bước nào trong lời giải phải viết lại:

  • 1.000 sản phẩm thành 100.000 sản phẩm: hàng nóng còn nguội đi sau một giây không?
  • Mỗi người được mua 5 đôi: unique partial index và câu UPDATE phải đổi gì?
  • Hạn thanh toán 10 phút thành 30 giây: endpoint nào chịu thêm tải, zombie tăng hay giảm?

8. Điều bạn vừa làm được

Bạn vừa tự thiết kế ba endpoint lần đầu trong module, và biết chỗ nào phải idempotent vì mạng sẽ giao lại.

Đào sâu

  • RFC 9110 §9.2.2 Idempotent Methods: định nghĩa idempotent, và chỉ PUT, DELETE cùng các method an toàn được coi là idempotent, POST không nằm trong đó.
  • Stripe: Idempotent requests: Stripe lưu status code và body của lần gọi đầu theo khoá idempotency, retry cùng khoá nhận lại đúng kết quả đó; khoá có thể bị dọn sau ít nhất 24 giờ.
  • PgBouncer configuration: default_pool_size mặc định 20, query_wait_timeout mặc định 120 giây.

Liên hệ các bài khác

Bài tiếp theo

Case flash sale khép lại ở đây. Muốn đào sâu phần chia dữ liệu mà bài 05 chỉ chạm qua, đọc thêm (không phải tiên quyết) Key range vs hash trong data-phan-tan.

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