Bài trước đi qua các khối xây dựng của một hệ thống: ước lượng, cache, database, hàng đợi, xử lý lỗi. Bài cuối của seri ghép chúng lại, với một điều kiện của thời nay: phần lớn code do agent AI viết. Không phải gợi ý từng dòng, mà là agent đọc repo, lên kế hoạch, sửa nhiều file, chạy test, rồi mở rộng hệ thống qua hàng trăm phiên làm việc.
Câu hỏi đầu tiên nghe có vẻ triết lý nhưng rất thực tế: nếu AI viết code, con người còn làm gì? Câu trả lời của bài này: con người làm những việc mà code không tự suy ra được. Đưa ra vấn đề, ràng buộc và luật kinh doanh. Ra những quyết định khó đảo ngược. Cung cấp ngữ cảnh. Định nghĩa thế nào là "đúng" và kiểm chứng nó. Và có một ràng buộc chi phối tất cả: agent không nhớ gì giữa các phiên. Mọi thứ nó biết về hệ thống là những gì nằm trong repo và trong prompt. Nói cách khác, tài liệu là trí nhớ của hệ thống.
Ví dụ xuyên suốt là một hệ thống bán vé sự kiện: khán giả chọn ghế cho một concert, giữ chỗ trong vài phút, thanh toán, nhận vé điện tử, rồi được soát vé ở cổng sân vận động. Nghe như một trang thương mại điện tử nhỏ, nhưng nó gom gần như mọi bài toán khó của system design vào một chỗ: hàng nghìn người tranh cùng một chiếc ghế, tiền thật đi qua một bên thứ ba có thể báo kết quả chậm hoặc báo hai lần, một ngày mở bán mà lượng truy cập gấp hàng trăm nghìn lần ngày thường, và một cổng soát vé phải chạy được cả khi mạng ở sân vận động nghẽn. Đây là một thiết kế giả định để minh họa, không phải kiến trúc thật của bất kỳ sản phẩm nào.
Lộ trình: những gì chỉ con người đưa ra được, ước lượng (ngày thường và ngày mở bán), chia hệ thống thành các khối và quyết định có tách thành microservice hay không, lõi không được sai (không bán trùng ghế), thanh toán và webhook, ngày mở bán với phòng chờ, soát vé khi mất mạng, quyết định nào của người và nào của AI, ngữ cảnh cho agent (đo trên chính repo của trang này), viết tài liệu để AI đọc hiểu, mở rộng một tính năng, kiểm chứng, rồi chia vai giữa người và AI.
Hai thí nghiệm về tranh chấp được chạy thật bằng Python 3.12 và SQLite, nhiều luồng ghi đồng thời vào cùng một database; số token được đếm bằng bộ tách token o200k_base trên repo của trang này. Các con số về tải là ước lượng, và được ghi rõ.
Những gì chỉ con người đưa ra được
Agent giỏi suy luận từ những gì nó đọc được. Nhưng có những thứ không nằm trong bất kỳ file nào, vì chúng nằm trong đầu người đặt hàng, trong hợp đồng với ban tổ chức, trong luật. Thiếu những thứ này, agent vẫn viết code, nhưng là code cho một sản phẩm nó tự đoán ra. Bản đề bài:
# Hệ thống bán vé v1: đề bài
## Mục tiêu
Bán vé có chọn ghế cho concert và sự kiện lớn; khán giả nhận vé điện tử, soát vé bằng mã QR.
Thành công = bán hết một concert 50.000 ghế mà không sập trang, không bán trùng ghế.
## Người dùng và quy mô
Ngày thường: vài nghìn lượt xem mỗi ngày. Ngày mở bán concert lớn: ~300.000 người
vào trong 10 phút đầu. Ban tổ chức: vài chục đơn vị, tạo sự kiện qua trang quản trị.
## Luật kinh doanh
- Tối đa 4 vé mỗi tài khoản mỗi sự kiện.
- Ghế được giữ 10 phút từ lúc chọn; quá hạn chưa thanh toán thì trả lại.
- Ai vào phòng chờ trước thì được mua trước.
- Hoàn tiền: chỉ khi sự kiện bị hủy, hoàn 100% trong 7 ngày.
## Ràng buộc
- Thanh toán qua hai cổng thanh toán trong nước (thẻ nội địa, ví điện tử); không tự lưu số thẻ.
- Xuất hóa đơn điện tử cho mỗi đơn hàng.
- Đội: hai kỹ sư cùng agent; ra bản đầu trong 3 tháng, trước concert đầu tiên.
## Tiêu chí chấp nhận (đo được)
- 0 ghế bán cho hai người; 0 đơn bị trừ tiền mà không có vé (có test và đối soát hằng ngày).
- Ngày mở bán: trang không sập; phòng chờ cho biết vị trí trong hàng; bước thanh toán p95 < 2 giây.
- Soát vé ở cổng chạy được cả khi mất mạng, mỗi vé chỉ vào được một lần.
## Không làm trong v1
Chợ bán lại vé, giá thay đổi theo nhu cầu, ứng dụng di động riêng (web trên điện thoại là đủ).
Mỗi mục trả lời một câu hỏi mà agent không thể tự trả lời đúng:
- Mục tiêu và tiêu chí thành công: không có nó, mọi quyết định đánh đổi đều thành đoán.
- Luật kinh doanh: giữ ghế bao lâu, mỗi người mua bao nhiêu vé, ai được mua trước, khi nào hoàn tiền. Đây là những con số và quy tắc ban tổ chức quyết, không phải kỹ sư hay agent. Một agent thiếu thông tin sẽ chọn "giữ 15 phút" vì nó nghe hợp lý, và không ai phát hiện cho tới khi hợp đồng với ban tổ chức nói 10.
- Ràng buộc: cổng thanh toán nào, hóa đơn điện tử, đội bao nhiêu người, hạn khi nào. Agent không biết công ty bạn đã ký với cổng thanh toán nào.
- Tiêu chí chấp nhận đo được: "không bán trùng" và "không mất tiền" phải thành test và đối soát chạy được.
- Phạm vi không làm (non-goals): agent có xu hướng làm thêm cho "đầy đủ"; một danh sách "không làm" chặn điều đó từ gốc.
Repo của trang này có đúng loại tài liệu đó: PRODUCT.md ghi những sự thật bền vững về sản phẩm, với một dòng tiêu đề rất đáng học: "Offer (as stated by the owner; don't invent other claims)". Những gì thuộc về sự thật kinh doanh thì người cung cấp, và AI được dặn rõ là không được bịa thêm.
Ước lượng: ngày thường và ngày mở bán
Hệ thống này có hai khuôn mặt, và áp kỹ năng ở bài trước cho từng khuôn mặt (tất cả là ước lượng):
Ngày thường: vài nghìn lượt xem / ngày ≈ dưới 1 request / giây
Ngày mở bán: 300.000 người trong 10 phút ≈ 500 người mới / giây
mỗi người xem sơ đồ ghế, làm mới mỗi 2 giây
→ 300.000 ÷ 2 ≈ 150.000 lượt đọc / giây
Ngày mở bán gấp hàng trăm nghìn lần ngày thường, trong vài chục phút. Nhưng nhìn kỹ hơn vào phần ghi:
50.000 ghế ÷ ~2,5 vé mỗi đơn = 20.000 đơn
nếu bán hết trong ~20 phút ≈ 1.000 đơn / phút ≈ 17 đơn / giây
mỗi đơn vài lần thử giữ ghế (ghế đã bị người khác lấy) ≈ 50 lần ghi / giây
17 đơn mỗi giây là con số mà một database Postgres xử lý thoải mái. Vấn đề của ngày mở bán không phải là ghi nhiều, mà là ba thứ khác:
- 150.000 lượt đọc mỗi giây vào sơ đồ ghế, nếu để tất cả chạm tới database.
- Tranh chấp: hàng trăm người bấm vào cùng một ghế hàng đầu trong cùng một giây; chỉ một người được phép thắng.
- 300.000 người cho 20.000 đơn: phần lớn sẽ không mua được, và hệ thống phải nói thật điều đó, sớm, thay vì để họ chờ rồi báo lỗi.
Mỗi vấn đề có một khối riêng ở các mục dưới: cache cho phần đọc, cập nhật có điều kiện cho tranh chấp, phòng chờ cho đám đông.
Các khối của hệ thống, và có nên tách thành microservice
Liệt kê theo trách nhiệm, mỗi khối là một việc mà hệ thống phải làm:
| Khối | Trách nhiệm | Dữ liệu | Đặc điểm tải |
|---|---|---|---|
| CDN + chống bot | Phục vụ trang tĩnh, sơ đồ ghế đã cache, chặn bot | Gánh phần lớn 150.000 lượt đọc | |
| Phòng chờ | Xếp hàng khi mở bán, cho người vào theo nhịp | Redis | Chỉ bận ngày mở bán |
| Tài khoản | Đăng ký, đăng nhập bằng mã OTP qua điện thoại | Postgres | Nhẹ |
| Sự kiện | Sự kiện, địa điểm, sơ đồ chỗ ngồi, giá theo khu | Postgres, cache | Đọc nhiều, ít đổi |
| Kho ghế | Trạng thái từng ghế: trống, đang giữ, đã bán | Postgres | Lõi không được sai, tranh chấp cao |
| Đơn hàng | Giỏ, giữ chỗ có hạn, điều phối cả luồng mua | Postgres | Ghi vừa phải |
| Thanh toán | Nói chuyện với cổng thanh toán, nhận webhook, đối soát | Postgres | Bên ngoài chậm, có thể báo lặp |
| Phát vé | Tạo vé điện tử có chữ ký, mã QR | Postgres, lưu trữ file | Sau khi thanh toán |
| Thông báo | Email, SMS, tin nhắn đẩy | Hàng đợi | Chạy nền, chịu được chậm |
| Soát vé | Ứng dụng quét QR ở cổng, chạy cả khi mất mạng | Bản sao trên thiết bị | Bùng nổ trước giờ diễn |
| Quản trị ban tổ chức | Tạo sự kiện, xem doanh thu, xuất báo cáo | Bản sao chỉ đọc | Nhẹ |
| Hóa đơn, báo cáo | Hóa đơn điện tử, thống kê | Hàng đợi, bản sao | Chạy nền |
Giữa các khối là một hàng đợi sự kiện (message bus): OrderPaid, TicketIssued, EventCancelled... Thanh toán xong không gọi thẳng tới thông báo hay hóa đơn; nó phát sự kiện, và ai cần thì nghe (Observer ở quy mô hệ thống, như ở bài design pattern).
Mười hai khối không có nghĩa là mười hai service triển khai riêng. Mỗi service tách ra là một thứ phải deploy, giám sát, gọi qua mạng (đắt hơn gọi hàm hàng nghìn lần, theo bảng độ trễ ở bài trước), và mất đi khả năng gói nhiều thay đổi vào một giao dịch database. Với đội hai kỹ sư, cách thực tế là modular monolith: một ứng dụng, chia module rõ ràng theo đúng bảng trên, mỗi module sở hữu bảng dữ liệu của riêng nó và chỉ nói chuyện với module khác qua hàm công khai hoặc sự kiện, đúng tinh thần SOLID. Chỉ tách ra thành service riêng những khối có lý do vận hành riêng:
- Phòng chờ: phải chịu 500 người mới mỗi giây và không được kéo sập phần còn lại; chạy riêng, có thể ở rìa mạng.
- Worker thông báo, hóa đơn: chạy nền, chậm cũng không sao, không được tranh tài nguyên với luồng mua.
- Soát vé: chạy trên thiết bị ở cổng, bản chất đã là một ứng dụng riêng.
Ranh giới module rõ ràng hôm nay là thứ cho phép tách service ngày mai, khi con số đòi hỏi. Đây chính là loại quyết định cửa một chiều mà con người phải chọn (mục sau).
Lõi không được sai: không bán một ghế cho hai người
Cách viết tự nhiên nhất để giữ một ghế, và cách mà một agent viết vội cũng có thể viết, là đọc rồi mới ghi:
status = db.execute("SELECT status FROM seats WHERE id = ?", (seat,)).fetchone()[0]
if status == "free":
... # kiểm tra giới hạn 4 vé, tính giá
db.execute("UPDATE seats SET status = 'sold' WHERE id = ?", (seat,))
db.execute("INSERT INTO orders (seat_id) VALUES (?)", (seat,))
Code này qua được mọi test chạy tuần tự. Thí nghiệm: 50 ghế, 400 lượt mua nhắm ngẫu nhiên vào các ghế đó, 8 luồng chạy đồng thời trên cùng một database SQLite. Rồi so với cách cập nhật có điều kiện, gộp "kiểm tra" và "ghi" làm một câu lệnh:
cur = db.execute("UPDATE seats SET status = 'sold' WHERE id = ? AND status = 'free'", (seat,))
if cur.rowcount == 1: # chỉ đúng một người làm câu lệnh này đổi được một dòng
db.execute("INSERT INTO orders (seat_id) VALUES (?)", (seat,))
| Cách | Việc giữa đọc và ghi | Số đơn (3 lần chạy) | Ghế bán cho từ hai người trở lên |
|---|---|---|---|
| Đọc rồi ghi | 1 ms (kiểm tra giới hạn, tính giá) | 65, 68, 61 | 14, 12, 10 |
| Đọc rồi ghi | không làm gì | 54, 57, 57 | 4, 6, 7 |
| Cập nhật có điều kiện | 50, 50, 50 | 0, 0, 0 |
Chỉ với 50 ghế và 8 luồng, cách đọc rồi ghi đã bán dư 11–18 đơn, và ngay cả khi không có việc gì xen giữa, vẫn dư 4–7. Lý do: giữa lúc một người đọc "ghế trống" và lúc họ ghi "đã bán", người khác cũng đọc được "ghế trống". Ngày mở bán thật, với hàng trăm người bấm cùng một ghế, khe hở đó không phải chuyện hiếm mà là chuyện chắc chắn.
Cách cập nhật có điều kiện cho đúng 50 đơn cho 50 ghế, cả ba lần. Database đảm bảo chỉ một câu lệnh đổi được dòng đó từ free sang thứ khác; người tới sau thấy rowcount = 0 và được báo "ghế vừa có người chọn". Các cách tương đương: SELECT ... FOR UPDATE để khóa dòng trong giao dịch, hoặc ràng buộc UNIQUE (event_id, seat_id) trên bảng vé. Điều quan trọng là database là trọng tài duy nhất, không phải code ứng dụng, không phải một bản sao trạng thái trong cache.
Giữ chỗ 10 phút thêm một trạng thái: ghế held với held_until. Câu lệnh giữ ghế chấp nhận cả ghế trống lẫn ghế có held_until đã qua (WHERE status = 'free' OR held_until < now()), nên ghế bị bỏ dở tự quay lại mà không cần chờ một tác vụ dọn dẹp chạy đúng giờ.
Thanh toán: tiền đi qua một bên thứ ba
Luồng mua là một chuỗi bước trải qua nhiều khối và một hệ thống bên ngoài:
- Kho ghế giữ ghế (cập nhật có điều kiện,
held_until= bây giờ + 10 phút). - Đơn hàng tạo đơn
pending, kèm số tiền tính bằng số nguyên đồng, không bao giờ là số thực. - Người dùng được chuyển sang cổng thanh toán.
- Cổng thanh toán gọi lại webhook báo kết quả, và người dùng được chuyển về trang của mình.
- Thanh toán ghi nhận, kho ghế chuyển ghế sang
sold, đơn hàng sangpaid, và phát sự kiệnOrderPaid. - Phát vé tạo vé có chữ ký; thông báo gửi email và SMS; hóa đơn xuất hóa đơn điện tử.
Mỗi bước có thể hỏng, và thiết kế phải trả lời trước từng trường hợp:
| Chuyện gì xảy ra | Phải làm gì |
|---|---|
| Webhook tới hai, ba lần cho cùng một giao dịch | Xử lý idempotent theo mã giao dịch của cổng |
| Người dùng trả tiền xong, webhook tới sau khi hết 10 phút giữ ghế | Nếu ghế còn: bán; nếu đã về người khác: hoàn tiền tự động, báo rõ |
| Webhook không bao giờ tới | Hỏi lại cổng thanh toán theo lịch; đối soát cuối ngày |
Ghi đơn paid xong thì máy chết, sự kiện OrderPaid chưa kịp phát |
Outbox: ghi trạng thái và sự kiện trong cùng một giao dịch |
Thí nghiệm thứ hai đo trường hợp đầu tiên, vì nó hay gặp hơn người ta nghĩ: cổng thanh toán gửi lại webhook khi không nhận được phản hồi đủ nhanh. 1.000 đơn, mỗi đơn được báo 1–3 lần (tổng 1.611 lần), 8 luồng xử lý đồng thời. Cách thứ nhất kiểm tra trạng thái đơn trước khi phát vé, nghe có vẻ đã an toàn:
if db.execute("SELECT status FROM orders WHERE id = ?", (order_id,)).fetchone()[0] == "paid":
return
... # đánh dấu đã trả tiền, phát vé
Cách thứ hai ghi mã giao dịch vào một bảng có khóa chính, và chỉ phát vé nếu dòng đó là dòng mới:
cur = db.execute("INSERT OR IGNORE INTO payments (txn_id, order_id) VALUES (?, ?)", (txn, order_id))
if cur.rowcount == 1: # lần đầu thấy giao dịch này
... # đánh dấu đã trả tiền, phát vé, trong cùng giao dịch
| Cách | Vé phát ra cho 1.000 đơn (2 lần chạy) | Đơn có từ hai vé trở lên |
|---|---|---|
| Kiểm tra trạng thái rồi ghi | 1.003 và 1.001 | 3 và 1 |
| Idempotent theo mã giao dịch | 1.000 và 1.000 | 0 |
Cách kiểm tra trạng thái gần như đúng: 1–3 vé thừa trên 1.000 đơn. Chính vì gần như đúng nên nó nguy hiểm: test tuần tự luôn xanh, chạy thử vài lần cũng xanh, và lỗi chỉ hiện ra ở ngày mở bán, dưới dạng vài vé trùng mã ở cổng soát vé. Cùng một bài học với thí nghiệm ghế: kiểm tra rồi ghi là hai bước, và ở giữa hai bước là một khe hở.
Cuối cùng là đối soát: mỗi đêm, một tác vụ tải báo cáo giao dịch từ cổng thanh toán và so với bảng payments. Đơn có tiền mà không có vé, vé có mà không có tiền: báo cho người. Đối soát là lưới an toàn cho mọi trường hợp mà các bước trên bỏ sót, và là cách duy nhất để tiêu chí "0 đơn bị trừ tiền mà không có vé" là một con số kiểm tra được thay vì một lời hứa.
Ngày mở bán: phòng chờ và cache
Ba vấn đề của ngày mở bán, ba lời giải:
150.000 lượt đọc mỗi giây vào sơ đồ ghế. Sơ đồ ghế được dựng thành một file JSON cho mỗi khu, cache ở CDN với thời hạn 1–2 giây. Người dùng có thể thấy một ghế "trống" đã bị lấy cách đây một giây; không sao, vì sơ đồ chỉ là để chọn, còn quyết định cuối cùng là câu lệnh cập nhật có điều kiện ở kho ghế. Đây là một lựa chọn nhất quán có chủ đích (bài trước): phần hiển thị chấp nhận cũ một chút, phần bán thì nhất quán tuyệt đối. Máy chủ gốc chỉ phải dựng lại mỗi file khoảng một lần mỗi giây, thay vì 150.000 lần.
300.000 người cho 20.000 đơn. Phòng chờ (virtual waiting room): mọi người vào lúc mở bán nhận một vị trí trong hàng (ngẫu nhiên cho những ai tới trước giờ mở, theo thứ tự cho những ai tới sau, đúng luật "ai vào trước mua trước" mà đề bài ghi), rồi được cho vào trang mua theo một nhịp cố định. Nhịp đó tính bằng định luật Little ở bài trước:
mỗi người ở trong trang mua ~5 phút (chọn ghế, thanh toán)
muốn tối đa ~5.000 người trong trang mua cùng lúc
→ nhịp cho vào = 5.000 ÷ 5 phút = 1.000 người / phút
Với nhịp đó, vé hết sau khoảng 20 phút, và người ở vị trí thứ 100.000 trong hàng gần như chắc chắn không mua được. Phòng chờ tốt nói thật điều đó: hiện vị trí, ước lượng thời gian, và báo "đã hết vé" ngay khi kho ghế về 0, thay vì để 280.000 người chờ một tiếng.
Bot và đầu cơ. Giới hạn 4 vé mỗi tài khoản, tài khoản xác thực bằng số điện thoại, giới hạn tốc độ theo tài khoản và theo IP, kiểm tra bot ở CDN. Không cách nào chặn được hết; mục tiêu là làm cho việc dùng bot đắt hơn lợi ích.
Và trước mỗi lần mở bán lớn: chạy thử tải với đúng hình dạng đó (vài trăm nghìn người ảo vào phòng chờ, vài nghìn người mua cùng lúc tranh những ghế đẹp nhất), trên môi trường giống production. Ước lượng chỉ cho biết nên thử gì; chạy thử mới cho biết hệ thống có chịu được hay không.
Soát vé khi mất mạng
Mười lăm phút trước giờ diễn, 50.000 người dồn về các cổng, và mạng di động quanh sân vận động thường nghẽn đúng lúc đó. Soát vé không thể phụ thuộc vào việc hỏi máy chủ cho mỗi lần quét.
- Vé có chữ ký số: mã QR chứa mã vé, sự kiện, khu, và một chữ ký tạo bằng khóa bí mật của hệ thống. Ứng dụng soát vé giữ khóa công khai, nên kiểm tra được vé thật hay giả mà không cần mạng.
- Tải trước danh sách vé hợp lệ cho thiết bị trước giờ mở cổng, kèm danh sách vé đã bị hủy hay đã chuyển nhượng.
- Mỗi vé chỉ vào một lần: mỗi thiết bị nhớ những vé đã quét, và đồng bộ với các thiết bị khác khi có mạng. Khi mất mạng hoàn toàn, hai cổng khác nhau có thể cùng cho một vé chụp màn hình vào trong vài phút. Đó là một đánh đổi nhất quán phải ghi rõ trong tài liệu và được ban tổ chức chấp nhận, kèm cách giảm thiểu (mỗi khu chỉ đi qua một nhóm cổng, nên vé khu A bị quét ở cổng khu B là đáng ngờ ngay).
Quyết định nào của người, quyết định nào của AI
Không phải quyết định nào cũng cần người. Một cách chia hữu ích là cửa một chiều và cửa hai chiều: quyết định đảo ngược được với chi phí thấp (tên biến, cách chia hàm, thư viện tiện ích nhỏ) là cửa hai chiều, để agent tự quyết theo quy ước của repo. Quyết định đảo ngược rất tốn kém, hoặc dính tới tiền và hợp đồng, là cửa một chiều, và phải do người quyết:
| Quyết định | Loại | Ai quyết |
|---|---|---|
| Luật kinh doanh: thời gian giữ ghế, giới hạn vé, hoàn tiền, thứ tự phòng chờ | Hợp đồng với ban tổ chức | Người (ghi trong đề bài) |
| Modular monolith hay microservice, tách khối nào | Một chiều: định hình cả đội và cả hạ tầng | Người, sau khi AI đề xuất |
| Cổng thanh toán, nhà cung cấp SMS, hóa đơn điện tử | Một chiều: hợp đồng, tích hợp | Người |
| Cách đảm bảo không bán trùng (trọng tài là database) | Một chiều: sai là mất tiền và uy tín | Người, theo đề xuất và thí nghiệm của AI |
| Đánh đổi khi soát vé mất mạng | Phải được ban tổ chức chấp nhận | Người |
| Mô hình dữ liệu (ghế, đơn, vé, thanh toán) | Một chiều: dữ liệu thật sẽ nằm trong đó | Người, sau khi AI đề xuất |
| Cấu trúc bên trong một module | Hai chiều | AI, theo quy ước |
| Tên hàm, cách tách hàm, test cho từng hàm | Hai chiều | AI, người review |
Với cửa một chiều, quy trình hiệu quả là: AI đề xuất và thử, người chọn, ghi lại. Agent rất giỏi liệt kê phương án, và giỏi cả việc viết ngay một thí nghiệm như hai thí nghiệm ở trên để so sánh chúng. Con người giỏi cân những thứ không nằm trong số đo: ban tổ chức chịu được gì, đội mình vận hành được bao nhiêu thứ, rủi ro nào chấp nhận được. Quyết định xong phải được ghi lại, vì phiên sau agent không nhớ. Hình thức hay dùng là ADR (architecture decision record), mỗi quyết định một file ngắn:
# ADR-003: Trạng thái ghế nằm ở Postgres, đổi bằng cập nhật có điều kiện
Trạng thái: chấp nhận (2026-10-17)
## Bối cảnh
Ngày mở bán, hàng trăm người tranh cùng một ghế. Tiêu chí: 0 ghế bán cho hai người.
Thí nghiệm: docs/experiments/seat-race.md (đọc rồi ghi bán dư 11–18 đơn trên 50 ghế).
## Quyết định
Mỗi ghế một dòng trong bảng `seats`. Mọi thay đổi trạng thái đi qua `inventory.hold()`,
`inventory.sell()`, `inventory.release()`, mỗi hàm là một câu UPDATE có điều kiện
trên trạng thái hiện tại; `rowcount` quyết định ai thắng.
## Hệ quả
+ Database là trọng tài duy nhất; đúng bất kể có bao nhiêu máy ứng dụng.
+ ~50 lần ghi mỗi giây lúc cao điểm, nằm thoải mái trong khả năng của một Postgres.
- Sơ đồ ghế hiển thị cho người dùng có thể cũ 1–2 giây (cache), chấp nhận được.
## Đã cân nhắc
Bộ đếm và khóa trong Redis: nhanh hơn, nhưng thành nguồn sự thật thứ hai phải đồng bộ
với database; mất Redis giữa lúc mở bán là mất trạng thái giữ ghế.
Khóa phân tán: thêm một hệ thống để vận hành cho một vấn đề database đã giải được.
Phần "Đã cân nhắc" quan trọng không kém phần quyết định. Không có nó, sáu tháng sau một agent được giao "tăng tốc giữ ghế" sẽ đề xuất chuyển trạng thái ghế sang Redis, với lý lẽ rất thuyết phục, và không ai nhớ vì sao đã không chọn nó.
Ngữ cảnh cho agent gồm những gì
Agent chỉ biết những gì nằm trong cửa sổ ngữ cảnh của nó, và bài về harness đã đo rằng mọi token trong đó đều tốn tiền và thời gian ở mỗi lượt. Ngữ cảnh tốt không phải là nhiều nhất, mà là đúng thứ cần, vào đúng lúc. Nó có nhiều lớp:
| Lớp | Nội dung | Khi nào được đọc |
|---|---|---|
File hướng dẫn gốc (CLAUDE.md, AGENTS.md) |
Repo là gì, lệnh chạy test và lint, quy ước chính, những điều cấm, chỉ tới mục lục tài liệu | Mọi phiên, tự động |
Mục lục tài liệu (docs/README.md) |
Mỗi tài liệu một dòng: tên và nội dung | Khi bắt đầu một việc |
| Tài liệu theo chủ đề | Kiến trúc, các module, máy trạng thái, luật kinh doanh, ADR | Khi việc chạm tới chủ đề đó |
| Code mẫu | Một module làm "chuẩn" để bắt chước | Khi viết module tương tự |
| Test và lệnh kiểm tra | Định nghĩa "đúng" chạy được, kể cả test đồng thời | Sau mỗi thay đổi |
| Đặc tả của việc đang làm | Mục tiêu, tiêu chí chấp nhận, phạm vi không làm | Đầu phiên, do người viết |
| Công cụ (MCP, CLI) | Chạy thử tải, đọc log staging, xem lỗi CI | Khi cần, qua lời gọi công cụ |
Repo của trang bạn đang đọc được tổ chức đúng theo cách đó, và đếm token cho thấy vì sao lớp mục lục quan trọng:
| Phần của repo | Số file | Token |
|---|---|---|
Mục lục docs/README.md |
1 | 488 |
Một tài liệu chủ đề, ví dụ docs/conventions.md |
1 | 1.107 |
Toàn bộ tài liệu (docs/, PRODUCT.md, DESIGN.md, README.md) |
15 | 23.231 |
Toàn bộ code Python (src/) |
52 | 29.866 |
Test (tests/) |
16 | 15.816 |
| Template HTML | 43 | 21.411 |
Một agent được giao sửa phần cache sẽ đọc mục lục (488 token), thấy dòng "caching.md: What Cloudflare and browsers cache...", rồi chỉ mở đúng file đó, thay vì nạp cả 23.000 token tài liệu. Đó là tiết lộ dần (progressive disclosure), cùng nguyên lý với skills trong bài về harness. Repo nhỏ như trang này, cả code lẫn tài liệu cộng lại khoảng 90.000 token, vẫn vừa một cửa sổ ngữ cảnh lớn. Một hệ thống bán vé với mười hai module sau hai năm có thể lớn gấp chục, gấp trăm lần, và lúc đó mục lục tốt là khác biệt giữa agent tìm đúng module và agent sửa nhầm chỗ.
Lớp file hướng dẫn gốc nên ngắn: nó được đọc ở mọi phiên, nên mỗi dòng thừa là chi phí lặp lại mãi mãi. Với một hệ thống đụng tới tiền, phần "Không được" là phần quan trọng nhất:
# Hệ thống bán vé
Bán vé sự kiện có chọn ghế. Sản phẩm và luật kinh doanh: PRODUCT.md. Tài liệu: docs/README.md (đọc mục lục trước).
## Lệnh
- Test: `uv run pytest` · Lint: `./scripts/lint.sh` · Test đồng thời: `uv run pytest -m concurrency`
- Mọi thay đổi phải qua test và lint; thay đổi trong `inventory/`, `orders/`, `payments/` phải qua cả test đồng thời.
## Quy ước
- Mỗi module một package trong `src/`, sở hữu bảng của riêng nó; module khác chỉ gọi hàm công khai
trong `<module>/api.py` hoặc nghe sự kiện. Xem docs/modules.md.
- Module mẫu để bắt chước: `src/events/`.
- Quyết định kiến trúc nằm trong docs/adr/; đừng đi ngược một ADR mà không hỏi.
## Không được
- Đổi trạng thái ghế ngoài `inventory.hold()`, `inventory.sell()`, `inventory.release()`.
- Viết kiểu "đọc rồi ghi" cho bất cứ thứ gì có tranh chấp; dùng cập nhật có điều kiện hoặc ràng buộc UNIQUE.
- Xử lý webhook thanh toán mà không idempotent theo mã giao dịch của cổng.
- Dùng số thực cho tiền; tiền là số nguyên đồng.
- Gọi cổng thanh toán, SMS, hóa đơn thật trong test; dùng đồ giả trong tests/fakes/.
- Đổi luật kinh doanh (thời gian giữ ghế, giới hạn vé, hoàn tiền) mà không có yêu cầu trong PRODUCT.md.
Dòng "Module mẫu để bắt chước" đáng giá hơn nhiều trang hướng dẫn: bài clean code đã cho thấy agent bắt chước code xung quanh. Còn dòng "Đổi trạng thái ghế ngoài inventory.hold()..." biến quyết định trong ADR-003 thành một quy tắc kiểm tra được: chỉ cần một lệnh lint tìm mọi câu UPDATE seats nằm ngoài module inventory.
Viết tài liệu để AI đọc hiểu và mở rộng
Tài liệu viết cho agent cũng là tài liệu tốt cho người, chỉ là lỗi của nó lộ ra nhanh hơn: một người đọc tài liệu sai sẽ hỏi đồng nghiệp, còn agent sẽ làm theo đúng cái sai đó. Vài quy tắc:
- Sự thật bền vững, không phải nhật ký. "Ghế được giữ 10 phút, đặt trong
src/inventory/config.py, theo hợp đồng ban tổ chức" thay vì "tuần trước bọn mình thống nhất là...". Bảng hơn đoạn văn khi nội dung là danh sách sự thật. - Máy trạng thái viết thành bảng. Ghế (
free → held → sold,held → freekhi hết hạn), đơn hàng (pending → paid → refunded,pending → expired), vé (issued → checked_in,issued → transferred). Mỗi chuyển trạng thái một dòng: từ đâu, tới đâu, ai được làm, điều kiện gì. Đây là loại tài liệu agent dùng tốt nhất, vì nó biến thành code và test gần như trực tiếp. - Một chủ đề một file, có mục lục. Như
docs/README.mdở trên: agent tìm được file đúng từ một dòng mô tả. - Ghi vì sao, không chỉ cái gì. Code đã nói "cái gì". Tài liệu và ADR giữ "vì sao" và "đã cân nhắc gì": đó là thứ ngăn agent "tối ưu" ngược lại một quyết định có chủ đích.
- Ghi rõ điều cấm và ranh giới.
PRODUCT.mdcủa trang này viết "don't invent other claims";docs/conventions.mdviết "Never call blocking I/O directly insideasync def". Một câu cấm rõ ràng hiệu quả hơn mười câu khuyên chung chung. - Cùng một tên ở mọi nơi. "Giữ chỗ" là
holdở tài liệu, ở code, ở tên bảng, không chỗ thìreserve, chỗ thìlock, chỗ thìbooking. Agent tìm bằng chữ; tên không nhất quán là tài liệu không tìm được. Một bảng thuật ngữ ngắn (docs/glossary.md) giải quyết chuyện này. - Cập nhật tài liệu trong cùng thay đổi. Đưa vào định nghĩa "xong": tính năng mới thì cập nhật tài liệu liên quan, quyết định mới thì thêm ADR. Tài liệu sai còn tệ hơn không có tài liệu, nên khi một phần không còn đúng, xóa nó.
Cây tài liệu của repo, sau vài tháng:
CLAUDE.md hướng dẫn cho agent, ngắn, đọc mọi phiên
PRODUCT.md sản phẩm và luật kinh doanh: giữ ghế, giới hạn vé, hoàn tiền, phòng chờ
docs/
README.md mục lục: mỗi file một dòng
architecture.md các khối, luồng mua, sự kiện giữa các module, service nào chạy riêng
modules.md mỗi module: sở hữu bảng nào, hàm công khai nào, nghe sự kiện nào
state-machines.md ghế, đơn hàng, vé, thanh toán: mọi chuyển trạng thái
payments.md luồng webhook, idempotency, hoàn tiền, đối soát
on-sale.md phòng chờ, cache sơ đồ ghế, chống bot, kịch bản chạy thử tải
check-in.md vé có chữ ký, chạy khi mất mạng, đánh đổi đã được chấp nhận
experiments/ seat-race.md, webhook-duplicates.md, ... (lệnh, kết quả, ngày chạy)
glossary.md sự kiện, khu, ghế, giữ chỗ, đơn, vé, chuyển nhượng...
adr/ 001-modular-monolith.md, 002-postgres.md, 003-trang-thai-ghe.md, ...
Mở rộng một tính năng: chuyển nhượng vé
Sau concert đầu tiên, yêu cầu được hỏi nhiều nhất là chuyển vé cho bạn bè. Người viết đặc tả, ngắn nhưng đủ những gì agent không tự suy ra được:
# Tính năng: chuyển nhượng vé
## Mục tiêu
Người giữ vé chuyển vé cho một tài khoản khác, miễn phí, trước giờ diễn.
## Tiêu chí chấp nhận
- Người nhận phải có tài khoản đã xác thực số điện thoại; giới hạn 4 vé tính cả vé được nhận.
- Sau khi chuyển, mã QR cũ không vào cổng được nữa; người nhận có mã QR mới.
- Khóa chuyển nhượng 2 giờ trước giờ mở cổng (để danh sách soát vé kịp cập nhật).
- Hai yêu cầu chuyển cùng một vé cùng lúc: chỉ một yêu cầu thành công (có test đồng thời).
## Không làm
Bán lại có thu tiền, chuyển một phần vé trong đơn có nhiều vé cho nhiều người cùng lúc.
Rồi agent làm việc, và mỗi bước dựa vào một lớp ngữ cảnh đã chuẩn bị:
- Đọc
CLAUDE.md(tự động), rồi mục lục. - Mở
state-machines.md: vé có thêm chuyển trạng tháiissued → transferred, và vé mới cho người nhận bắt đầu ởissued. Mởcheck-in.mdđể biết danh sách vé hủy được tải xuống thiết bị soát vé thế nào, và vì sao cần khóa 2 giờ. - Đọc
glossary.md, dùng đúng "chuyển nhượng" (transfer), không đặt ra từ mới. - Bắt chước cấu trúc của module mẫu, đặt tính năng vào module
tickets, gọiordersqua hàm công khai để kiểm tra giới hạn 4 vé. - Viết test trước cho các tiêu chí chấp nhận, đặc biệt là test đồng thời: hai luồng cùng chuyển một vé, đúng một luồng thắng; và vé cũ bị từ chối ở bộ soát vé giả lập. Cổng SMS dùng đồ giả, đúng kiểu đồ giả ở bài SOLID.
- Viết code cho tới khi test, lint và test đồng thời đều qua.
- Cập nhật
state-machines.md,check-in.md,glossary.md, và đề xuất ADR-011: vì sao khóa chuyển nhượng 2 giờ trước giờ mở cổng.
Người review diff, máy trạng thái, và ADR, không phải gõ code. Câu hỏi khi review không phải "code có chạy không" (test đã trả lời), mà là "có đúng điều mình muốn không, và có kẽ hở nào không": người nhận có lách được giới hạn 4 vé bằng cách nhận từ nhiều người không, vé đã chuyển có còn nằm trong email cũ với mã QR hợp lệ không.
Kiểm chứng: định nghĩa "đúng" bằng thứ chạy được
Agent viết code nhanh hơn người nhiều lần, nên điểm nghẽn chuyển sang kiểm chứng. Một hệ thống do AI viết cần những lớp kiểm chứng mà người không phải đọc từng dòng:
- Test đồng thời cho mọi chỗ có tranh chấp: giữ ghế, thanh toán, chuyển nhượng. Hai thí nghiệm trong bài này chính là dạng test đó: nhiều luồng, cùng một database, kiểm tra một bất biến sau khi chạy (số đơn không vượt số ghế, mỗi đơn đúng một vé). Test tuần tự không bao giờ bắt được loại lỗi này.
- Kiểm tra bất biến trên production: một truy vấn chạy mỗi phút, đếm ghế có hai vé, đơn
paidkhông có vé, vé không có đơn. Kết quả phải luôn bằng 0; khác 0 là báo động ngay. - Đối soát thanh toán hằng ngày với báo cáo của cổng thanh toán.
- Chạy thử tải trước mỗi lần mở bán lớn, với phòng chờ, cache và luồng mua thật trên môi trường giống production.
- Hàng rào cứng cho những điều tuyệt đối không được làm: hooks chặn agent chạy lệnh vào database production hay đọc file chứa khóa của cổng thanh toán (bài về harness), khóa ký vé chỉ nằm trong kho bí mật.
Chia vai giữa người và AI
| Việc | Con người | Agent AI |
|---|---|---|
| Vấn đề, mục tiêu, người dùng | Đưa ra | Hỏi lại chỗ mơ hồ |
| Luật kinh doanh: giữ ghế, giới hạn vé, hoàn tiền | Đưa ra (từ hợp đồng với ban tổ chức) | Biến thành máy trạng thái, code và test |
| Ràng buộc: cổng thanh toán, hóa đơn, đội, thời hạn | Đưa ra | Tính ra hệ quả (ước lượng, nhịp phòng chờ) |
| Quyết định cửa một chiều | Chọn và chịu trách nhiệm | Liệt kê phương án, viết thí nghiệm so sánh, viết ADR |
| Quyết định cửa hai chiều | Đặt quy ước | Tự quyết theo quy ước |
| Code, test, migration | Review diff | Viết, chạy, sửa tới khi qua, kể cả test đồng thời |
| Tài liệu | Viết phần sản phẩm, luật kinh doanh và "không được" | Giữ máy trạng thái và phần kỹ thuật luôn khớp với code |
| Kiểm chứng | Định nghĩa "đúng" (bất biến, tiêu chí), xem đối soát và số đo | Chạy test, thử tải, báo kết quả thật |
Tóm tắt
| Ý chính | Trong hệ thống bán vé |
|---|---|
| Tài liệu là trí nhớ của hệ thống | Agent không nhớ gì giữa các phiên; mọi thứ nó biết nằm trong repo |
| Người đưa ra những gì code không suy ra được | Mục tiêu, luật kinh doanh, ràng buộc, tiêu chí đo được, phạm vi không làm |
| Ngày mở bán không phải bài toán ghi | ~17 đơn/giây, nhưng ~150.000 lượt đọc/giây và hàng trăm người tranh một ghế |
| Nhiều khối, ít service | 12 khối trong một modular monolith; chỉ tách phòng chờ, worker nền, soát vé |
| Database là trọng tài | Đọc rồi ghi bán dư 11–18 đơn trên 50 ghế; cập nhật có điều kiện: đúng 50 |
| Thanh toán idempotent | Kiểm tra trạng thái vẫn phát dư 1–3 vé trên 1.000 đơn; theo mã giao dịch: đúng 1.000 |
| Ngày mở bán | Cache sơ đồ ghế 1–2 giây, phòng chờ 1.000 người/phút theo định luật Little, chạy thử tải |
| Soát vé mất mạng | Vé có chữ ký, danh sách tải trước, đánh đổi được ban tổ chức chấp nhận |
| Cửa một chiều cho người, cửa hai chiều cho AI | Luật kinh doanh, kiến trúc, nhà cung cấp do người chọn; ghi lại bằng ADR |
| Ngữ cảnh theo lớp, tiết lộ dần | Mục lục 488 token trước, rồi đúng tài liệu cần, thay vì cả 23.000 |
| Mở rộng tính năng | Người viết đặc tả và review; agent đọc máy trạng thái, bắt chước, viết test đồng thời, cập nhật tài liệu |
| Kiểm chứng | Test đồng thời, bất biến trên production, đối soát, thử tải, hàng rào cứng |
Seri này đi từ một hàm 26 dòng tới một hệ thống mà phần lớn code do AI viết, nhưng câu hỏi ở mọi tầng vẫn là một: thay đổi nào sắp tới, và cấu trúc nào làm cho nó rẻ? Ở tầng hàm, câu trả lời là tên rõ và hàm nhỏ. Ở tầng lớp, là SOLID và vài pattern. Ở tầng hệ thống, là đo trước, ước lượng trước. Và khi người viết code là AI, thêm một điều: những gì con người biết mà không viết ra thì với hệ thống, coi như không tồn tại.