Bài 17 · Nâng cao · 22 phút
Flash sale: chống oversell
Biên soạn bởi Nguyễn Anh Tuấn
Bán số lượng có hạn ở giá hời, hàng nghìn người tranh mua, ai thanh toán xong trước thì được - tình huống căng nhất của web bán hàng. Đào sâu chống oversell và data integrity: vì sao "đọc tồn rồi trừ" bị race condition; trừ tồn ATOMIC bằng một câu UPDATE flash_sale SET con_lai = con_lai - 1 WHERE con_lai > 0; giữ chỗ (reservation) có hạn giờ rồi xác nhận khi thanh toán, trả suất về kho khi hết hạn; chốt đơn idempotent để bấm hai lần hay webhook lặp cũng chỉ trừ một suất; và các bất biến đem ra test tải đồng thời (con_lai >= 0, da_ban + con_lai + dang_giu == tong_suat).
Mèo con mở một flash sale: 100 suất một sản phẩm ở giá hời, ai thanh toán xong trước thì mua được. Đến giờ mở bán, hàng nghìn người bấm cùng một lúc. Đây là tình huống căng nhất của một web bán hàng, và là chỗ hai loại bug lộ rõ:
- ▸Bán lố (oversell): chỉ 100 suất mà hệ thống chốt 105 đơn - 5 khách trả tiền nhưng không có hàng.
- ▸Tính trùng (double-charge): khách bấm Thanh toán hai lần, hoặc webhook báo lại - bị trừ tiền/trừ tồn hai lần.
- ▸Cả hai đều là chuyện ĐỒNG THỜI (concurrency) và TOÀN VẸN DỮ LIỆU (data integrity).
Bài này là phòng thí nghiệm sống cho ba thứ đã chạm ở các bài trước: khoá & race condition, idempotency, và cấp phát an toàn từ bài kho giftcard. Flash sale ghép cả ba lại ở mức khó nhất.
Cách ai cũng nghĩ ra đầu tiên: đọc con_lai, thấy còn thì trừ 1. Chạy một mình thì đúng. Nhưng khi A và B bấm gần như cùng lúc, cả hai cùng đọc thấy "còn 1", rồi cả hai cùng trừ - bán 2 suất cho 1 suất. Đó là race condition:
- ▸Lỗi nằm ở khoảng GIỮA đọc và trừ: người khác chen vào đúng lúc đó.
- ▸Đây cùng họ với "không trừ lố số dư" ở bài khoá số dư - chỉ là trừ TỒN thay vì trừ TIỀN.
- ▸Sửa bằng cách KHÔNG tách đọc và trừ: dồn vào một câu lệnh atomic (Step kế).
Cách đúng là để database làm trọng tài: dồn "kiểm còn hàng" và "trừ" vào MỘT câu lệnh không thể bị chen ngang. Database tự khoá dòng trong lúc chạy, nên hai request không bao giờ cùng trừ một suất:
trừ tồn atomic (Postgres) - kiểm và trừ trong cùng một câu, không tách rời
UPDATE flash_sale SET con_lai = con_lai - 1
WHERE id = :id AND con_lai > 0
RETURNING con_lai;
-- khớp 1 dòng -> trừ thành công, khách giành được suất
-- khớp 0 dòng -> con_lai đã = 0 -> HẾT SUẤT, báo khách, KHÔNG bán - ▸Điều kiện con_lai > 0 nằm NGAY trong câu UPDATE: hết hàng thì khớp 0 dòng, không bao giờ xuống âm.
- ▸Không cần SELECT trước rồi mới UPDATE - chính việc tách đôi đó sinh ra race.
- ▸Cùng nguyên lý với FOR UPDATE SKIP LOCKED khi cấp phát mã giftcard: để database khoá, đừng tự kiểm bằng tay.
Tới lượt mèo con
gõ trong ô Claude Code
Trong service xử lý flash sale, thay phần trừ tồn "đọc con_lai rồi trừ"
bằng MỘT câu UPDATE atomic của Postgres (qua Drizzle):
UPDATE flash_sale SET con_lai = con_lai - 1
WHERE id = :id AND con_lai > 0 RETURNING con_lai.
Nếu câu lệnh khớp 0 dòng thì trả về "hết suất", không trừ gì thêm.
Đừng SELECT con_lai trước rồi mới UPDATE.
Rồi viết một test: tạo một flash sale 50 suất, mô phỏng 200 lượt mua
chạy ĐỒNG THỜI (Promise.all), và kiểm: đúng 50 lượt thành công, 150
lượt nhận "hết suất", con_lai cuối cùng = 0 và KHÔNG bao giờ âm.
Xong khi: test xanh, con_lai không xuống dưới 0, tổng suất bán ra = 50. "Mua được deal" tính ở lúc thanh toán xong, không phải lúc bấm. Nếu trừ suất ngay lúc bấm rồi khách bỏ giỏ, suất đó bị giam vô ích. Cách đúng là giữ chỗ tạm (reservation) có hạn giờ:
vòng đời một suất flash sale
vào thanh toán -> GIỮ CHỖ 1 suất (con_lai-1, dang_giu+1), đặt hạn 5 phút
thanh toán xong -> CHỐT (dang_giu-1, da_ban+1) [suất thành đơn]
quá 5 phút chưa trả tiền -> TRẢ suất về kho (dang_giu-1, con_lai+1) - ▸Giữ chỗ cũng trừ atomic như Step 3 - chỉ khác là vào ô dang_giu thay vì bán hẳn.
- ▸Hết hạn thì trả suất về cho người khác, để suất không bị giam bởi giỏ hàng bỏ quên.
- ▸Redis hợp để đếm/đặt hạn giữ chỗ cho nhanh; Postgres là nguồn chân lý cuối khi chốt đơn.
- ▸"Ai nhanh tay chốt đơn trước" = ai giữ được chỗ rồi trả tiền kịp trong hạn giờ.
Tới lượt mèo con
gõ trong ô Claude Code
Thêm cơ chế giữ chỗ (reservation) cho flash sale:
- Khi khách bấm "Mua deal" và vào trang thanh toán: giữ atomic 1 suất
(UPDATE flash_sale SET con_lai = con_lai - 1, dang_giu = dang_giu + 1
WHERE id = :id AND con_lai > 0), tạo một reservation gắn order_id với
hạn het_han = now + 5 phút. Khớp 0 dòng thì báo "hết suất".
- Khi thanh toán xong (webhook SePay xác nhận): chốt suất đó
(dang_giu - 1, da_ban + 1), đổi order sang 'paid'.
- Thêm một job (dùng BullMQ đã có) chạy mỗi phút: tìm reservation đã quá
het_han mà chưa thanh toán, TRẢ suất về kho (dang_giu - 1, con_lai + 1)
và huỷ order treo.
Xong khi: giữ chỗ rồi bỏ không trả tiền, sau 5 phút con_lai tự tăng lại
1, và suất đó bán được cho người mua khác. Lúc cao điểm, khách sốt ruột bấm Thanh toán hai lần; webhook SePay (bài Nạp tiền QR) cũng có thể báo về nhiều lần. Phải chắc một đơn chỉ trừ suất và thu tiền đúng một lần:
chốt đơn idempotent - mã giả (Claude viết bằng code Next.js)
def chot_don(order_id):
don = lay_don(order_id)
if don.trang_thai == 'paid': # đã chốt rồi
return don # bỏ qua, KHÔNG trừ/thu lần hai
# chốt: dang_giu-1, da_ban+1, order -> 'paid' (trong một transaction)
... - ▸Khoá theo mã đơn / mã giao dịch: đã xử lý rồi thì lần gọi sau bỏ qua, trả lại kết quả cũ.
- ▸Gói "chốt suất + đổi trạng thái đơn" trong MỘT transaction - hoặc xong cả, hoặc không gì.
- ▸Bấm hai lần hay webhook lặp đều cho cùng một kết quả: một suất, một lần thu tiền.
Tới lượt mèo con
gõ trong ô Claude Code
Làm cho bước chốt đơn flash sale (khi thanh toán xong) idempotent:
- Trước khi chốt, kiểm trạng thái đơn. Nếu đã 'paid' thì trả về kết quả
cũ ngay, KHÔNG trừ suất hay cộng da_ban lần nữa.
- Gói chốt suất (dang_giu - 1, da_ban + 1) và đổi order sang 'paid' trong
CÙNG một transaction.
Rồi viết test gọi hàm chốt đơn 5 lần liên tiếp cho cùng một order_id và
kiểm: da_ban chỉ tăng 1, đơn ở trạng thái 'paid', không có suất nào bị
trừ thừa.
Xong khi: gọi chốt nhiều lần cho một đơn cũng chỉ ra một suất bán, một
lần thu tiền. Gom lại, một flash sale đúng phải giữ vài bất biến (invariant) không bao giờ được vỡ - bất kể bao nhiêu người bấm cùng lúc. Đây là thước đo cuối cùng, và là thứ đem ra test:
bất biến để test - chống oversell & lệch tồn
con_lai >= 0 luôn luôn # không bao giờ bán âm
da_ban + con_lai + dang_giu == tong_suat # không suất nào bốc hơi/đẻ ra
mỗi order 'paid' chốt đúng 1 suat # không double-charge
không order nào chốt khi con_lai + dang_giu = 0 # hết thì thôi Trung thực
Tới lượt mèo con
gõ trong ô Claude Code
Cho một subagent chuyên data-integrity rà toàn bộ luồng flash sale (giữ
chỗ, chốt đơn, dọn quá hạn) và viết một bộ test tải đồng thời:
- Tạo flash sale tong_suat = 30.
- Mô phỏng 500 lượt mua đồng thời, một phần bỏ ngang không trả tiền (để
job dọn quá hạn chạy).
- Sau khi mọi thứ lắng, KIỂM các bất biến: con_lai >= 0; da_ban +
con_lai + dang_giu == 30; tổng suất 'paid' không vượt 30; không đơn nào
chốt khi đã hết suất.
Xong khi: chạy bộ test nhiều lần đều xanh, không lần nào con_lai âm hay
tổng suất lệch khỏi 30. Bước tiếp theo
Câu hỏi thường gặp
Oversell là bán nhiều hơn số hàng đang có - vd flash sale chỉ 100 suất giá hời mà hệ thống chốt 105 đơn. Xảy ra khi nhiều người mua cùng lúc và hệ thống trừ tồn sai cách. Hậu quả: 5 khách trả tiền nhưng không có hàng, phải hoàn tiền và mất uy tín. Bài này lo đúng việc đó: bán đúng số suất, không hơn một cái.
Vì giữa lúc đọc và lúc trừ, người khác chen vào. A đọc con_lai = 1 (thấy còn), B cũng đọc con_lai = 1 (cũng thấy còn), rồi cả hai cùng trừ - thành bán 2 suất cho 1 suất, con_lai = -1. Đây là race condition, đúng họ với bài toán khoá số dư. Cách đúng là trừ ATOMIC bằng một câu lệnh database có điều kiện, không tách đọc và trừ ra.
Là dồn "kiểm còn hàng" và "trừ" vào MỘT câu lệnh database không thể bị chen ngang: UPDATE flash_sale SET con_lai = con_lai - 1 WHERE id = ? AND con_lai > 0. Database tự khoá dòng trong lúc chạy, nên hai request không thể cùng trừ một suất. Nếu câu lệnh khớp 0 dòng (con_lai đã = 0) thì nghĩa là hết suất - báo cho khách, không bán.
Vì "mua được deal" tính ở lúc THANH TOÁN XONG, không phải lúc bấm. Nếu trừ luôn lúc bấm rồi khách bỏ giỏ, suất đó bị giam vô ích. Cách đúng: lúc vào thanh toán thì GIỮ CHỖ một suất (có hạn, vd 5 phút); thanh toán xong thì xác nhận (chốt hẳn); quá 5 phút không trả tiền thì TRẢ suất về kho cho người khác. "Ai nhanh tay chốt đơn trước" chính là ai giữ chỗ rồi trả tiền kịp.
Là làm sao một lần mua chỉ trừ tồn và thu tiền ĐÚNG MỘT LẦN, dù khách bấm Thanh toán hai lần, hay webhook SePay (bài Nạp tiền QR) báo về nhiều lần. Khoá theo mã đơn / mã giao dịch: nếu đơn đã chốt rồi thì bỏ qua, không trừ suất lần hai, không thu tiền lần hai. Đây là phần nối thẳng từ bài idempotency thanh toán.
Vì bug oversell chỉ ló ra khi nhiều request chạy CÙNG LÚC - chạy tuần tự thì không bao giờ thấy. Cách kiểm: mô phỏng vài trăm lượt mua đồng thời cho một flash sale chỉ vài chục suất, rồi đối chiếu các bất biến: con_lai không bao giờ âm, số suất bán ra đúng bằng số suất ban đầu, không đơn nào chốt khi đã hết. Đúng tinh thần "tiền/tồn sai một ly đi một dặm" của khoá.