Lập trình 22/09/2026 8 phút

Phân trang offset và keyset: đo thật trên 400.000 dòng, trang sâu chậm 90 lần và trùng dữ liệu khi có bài mới

Mở đầu sê-ri thiết kế API bằng thứ gần như API nào cũng có: phân trang. Mình dựng HTTP server Go với hai cách — `?page=N` dùng OFFSET và `?after=<id>` dùng keyset — trên bảng reviews 400.000 dòng và đo qua HTTP. Ở trang 1–100 hai cách bằng nhau; tới trang 19.999 offset mất 13,62 ms còn keyset vẫn 0,15 ms. EXPLAIN lộ lý do: offset đọc 200.000 dòng index để trả 20. Duyệt hết bảng: 28,5 giây so với 0,96 giây. Và khi có 5 review mới chèn vào giữa hai lần lật trang, offset trả 5 dòng trùng, keyset không trùng dòng nào.

Lập trình 22/09/2026 9 phút

Idempotency-Key: đo thật client timeout rồi retry — không khoá tạo 6 đơn cho một lần mua, có khoá tạo đúng 1

Client gọi POST /orders, server đã tạo đơn nhưng phản hồi không về kịp, client retry. Mình dựng server Go với ba bản: không khoá, khoá idempotency trong Redis bằng SET NX, và khoá trong PostgreSQL bằng ON CONFLICT nằm cùng giao dịch với đơn hàng. Đo thật: client timeout 10 ms retry 5 lần — bản không khoá tạo 6 đơn, hai bản có khoá tạo đúng 1. 50 request đồng thời cùng key: 50 đơn so với 1. Hai bản có khoá khác nhau ở chỗ xử lý request trùng đang chạy: Redis từ chối ngay bằng 409, PostgreSQL bắt chờ rồi trả lại phản hồi cũ. Chi phí: khoảng 0,6 ms mỗi request, và request lặp lại chỉ còn 0,22 ms.

Lập trình 22/09/2026 9 phút

Hợp đồng lỗi của API: đo thật — trả 500 cho lỗi của client biến 300 request sai thành 900 request thừa và 63% báo động giả

Cùng một lõi nghiệp vụ, ba cách trả lỗi: cứ lỗi là 500 kèm err.Error(), mọi thứ đều 200 kèm trường success, và RFC 9457 Problem Details với mã đúng loại. Đo bằng một client có chính sách retry chuẩn — retry 5xx, không retry 4xx — trên 1.000 request có 300 request sai dữ liệu: bản 500 nhận 1.900 request thay vì 1.000, tỉ lệ 5xx trên server lên 63,2% dù server hoàn toàn khoẻ, và mỗi request lỗi mất 290 ms thay vì 0,03 ms. Bản mọi-thứ-đều-200 thì ngược lại: 300 lỗi mang mã 200 và server chết vẫn xanh trên biểu đồ. Kèm chuyện bản 500 lộ tên bảng, tên ràng buộc và địa chỉ database ra ngoài.

Lập trình 22/09/2026 8 phút

Phiên bản API: đo thật qua nginx cache — đánh phiên bản bằng header mà quên Vary là 100/200 client nhận sai bản

Ba cách đổi hợp đồng API khi còn client cũ đang gọi: phiên bản trong URL, phiên bản trong header Accept, và không đánh phiên bản mà chỉ thêm trường. Mình đặt nginx proxy_cache trước server Go rồi cho hai client xen kẽ đòi v1 và v2: cách URL và cách header có Vary: Accept đều đúng 200/200, còn header mà quên Vary thì 100/200 client nhận sai phiên bản — cache trả bản v1 cho client v2 mà không có lỗi nào. Đo thêm: client dùng decoder chặt vỡ ngay khi server chỉ thêm một trường, và lớp chuyển đổi v2 sang v1 tốn 14–17 µs, nằm trong nhiễu đo — cái giá của phiên bản không nằm ở CPU.

Lập trình 22/09/2026 8 phút

ETag và request có điều kiện: đo thật 1.000 lần poll — 304 cắt 62 MB xuống 0,1 MB, nhưng chỉ ETag từ version mới cắt được việc của server

Client poll một danh sách 200 sản phẩm 1.000 lần trong khi dữ liệu không đổi. Không ETag: 62,2 MB qua dây và 1.000 query nặng. ETag băm từ thân phản hồi: 999 lần 304, còn 0,1 MB — nhưng server vẫn chạy đủ 1.000 query và p50 y hệt 0,24 ms, vì phải render xong mới biết là không đổi. ETag tính từ version trong database: cũng 0,1 MB, nhưng chỉ 1 query nặng và p50 còn 0,098 ms. Cùng ETag đó dùng cho If-Match trên PUT: hai người cùng sửa một sản phẩm, không kiểm thì bản của người đầu mất không dấu vết, có If-Match thì người sau nhận 412.

Lập trình 22/09/2026 8 phút

Cache-Control trên HTTP API: đo thật qua nginx — max-age=5 cắt 99% request tới origin, và lúc hết hạn stale-while-revalidate cho 50 client đồng thời chỉ lọt 1

Origin Go mất 40 ms mỗi request, nginx proxy_cache đứng trước làm cache dùng chung, client bắn 300 request trong 12 giây cho từng chỉ thị Cache-Control. Không header, no-store và private: cả 300 tới origin, p50 44 ms. max-age=5: 3 tới origin, p50 0,8 ms, dữ liệu cũ tối đa 5,93 giây. Thứ max-age không chữa được là khoảnh khắc hết hạn: 50 client gọi đúng lúc đó thì 50 request lọt tới origin; thêm stale-while-revalidate=30 thì chỉ 1 lọt, 49 client còn lại nhận bản cũ trong 4 ms. Kèm chuyện private bị cache dùng chung bỏ qua, và s-maxage để tách hạn cho cache dùng chung với trình duyệt.