Thiết kế API thực chiến cho backend
Sê-ri 12 phần về thiết kế HTTP API: phân trang, idempotency, phiên bản, hợp đồng lỗi, nén, ETag, giới hạn tần suất. Mỗi bài đo thật bằng Go và PostgreSQL trong Docker.
8/12 phần đã đăng
Lập trình
1
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.
22/09/2026
· 8 phút đọc
2
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.
22/09/2026
· 9 phút đọc
3
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.
22/09/2026
· 9 phút đọc
4
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.
22/09/2026
· 8 phút đọc
5
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.
22/09/2026
· 8 phút đọc
6
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.
22/09/2026
· 8 phút đọc
7
Nén response API: đo thật gzip và brotli — JSON 21 KB còn 2,2 KB trong 125 µs, brotli-11 tốn 16,85 ms cho thêm 2%, và thân 11 byte nén xong thành 35 byte
Đo nén trên JSON thật từ PostgreSQL, từ 308 byte tới 2,09 MB: gzip-6 đưa 21,2 KB xuống 2,2 KB (10,6%) với 125 µs nén và 26 µs giải. Mức nén gần như không quan trọng với JSON — gzip-1 và gzip-9 chênh 0,3 điểm phần trăm — nhưng brotli-11 tốn 16,85 ms, gấp 135 lần brotli-4, để mua thêm 1 điểm. Hai trường hợp nén làm hại: thân dưới 100 byte phình thêm 24 byte, và JSON chứa ảnh base64 chỉ còn 75% nhưng tốn CPU gấp 17 lần. Kèm thương lượng Accept-Encoding qua HTTP thật, header Vary bắt buộc, và điểm hoà vốn theo băng thông tính từ số đo.
22/09/2026
· 8 phút đọc
8
Giới hạn tần suất API: đo thật trên Redis — bản đọc-rồi-ghi cho qua 50/50 khi giới hạn là 20, fixed window lọt gấp đôi ở biên cửa sổ, và client phớt lờ 429 gửi 52.853 request để lấy 100 thành công
Token bucket trên Redis 7 viết hai cách: hai lệnh rời (HMGET rồi HSET) và một script Lua nguyên tử. 50 request đến cùng lúc với giới hạn 20: bản rời cho qua 50/50 ở hai trên ba lần chạy, bản Lua đúng 20/50 mọi lần. Fixed window cho lọt 200 request trong 0,6 giây quanh biên cửa sổ khi giới hạn là 100/10 giây; sliding log đúng 100. Server trả RateLimit-Limit, RateLimit-Remaining và Retry-After: client cần 100 request thành công ở 20 request/giây — phớt lờ 429 gửi 52.853 request, retry cố định 50 ms gửi 175, tôn trọng Retry-After gửi 104, cả ba cùng mất 4 giây.
22/09/2026
· 8 phút đọc
Còn 4 phần nữa sẽ lần lượt được đăng.