Mười một phần vừa rồi mỗi phần đo một thứ: phân trang offset so với keyset, khoá idempotency cho retry, hợp đồng lỗi và cái giá của 500 bừa, phiên bản API qua cache, ETag và 304, Cache-Control trên cache dùng chung, nén, giới hạn tần suất, N+1 qua HTTP, validation ở biên và ràng buộc, và timeout, retry, deadline xuyên suốt. Phần này gom chúng lại theo hai cách: một endpoint có đủ các bài học đo cạnh bản "viết trong năm phút", và một khung quyết định kèm checklist để dùng khi rà soát API thật.
Một endpoint, sáu header và một mệnh đề WHERE

Hình 1: Hai handler cho cùng một màn hình. Bản dưới dài hơn mười dòng, và mười dòng đó là toàn bộ khác biệt.
Bản ngây thơ đúng về chức năng: nó trả đúng 20 sản phẩm. Bản gom bài học trả đúng 20 sản phẩm đó, cộng thêm: keyset thay offset (phần 1), bỏ cột mô tả mà màn hình không hiện (phần 9), ETag để client hỏi "còn đúng không" bằng 0 byte (phần 5), Cache-Control với stale-while-revalidate để nginx trả lời thay origin (phần 6), gzip với Vary: Accept-Encoding để cache tách đúng bản (phần 4 và 7). Không có thư viện nào được thêm vào go.mod.
Đo thật: 2.000 request, origin chỉ thấy 6
Lưu lượng là 20 client song song, mỗi client 100 request: 70% lật năm trang đầu (phần nóng của mọi danh sách), 30% nhảy tới trang 5.000 (người dùng cuộn sâu hoặc job quét). Client gửi Accept-Encoding: gzip và If-None-Match của lần trước cho cùng URL — tức là hành vi mặc định của mọi trình duyệt và hầu hết SDK:

Hình 2: Kết quả thật, hai lần chạy cho bản gom bài học: cache lạnh (lần đầu) và cache ấm (lần hai, trong cửa sổ stale-while-revalidate).
| ngây thơ | gom bài học (lạnh) | gom bài học (ấm) | |
|---|---|---|---|
| request tới origin | 2.000 | 93 | 6 |
| query database | 2.000 | 93 | 6 |
| byte qua dây | 8,5 MB | — | 27,7 KB |
| p99 | 30,12 ms | 11,28 ms | 1,68 ms |
Origin từ 2.000 xuống 6. 1.880 phản hồi là 304 do nginx tự trả — nó giữ bản trong cache, so ETag client gửi lên, và không cần hỏi origin. Thêm 19 HIT và 101 lần STALE/UPDATING (trả bản cũ trong khi làm mới ở nền). Origin và database chỉ thấy 6 request trong 2.000 — mỗi request đó là một lần làm mới nền khi max-age=5 hết.
Byte từ 8,5 MB xuống 27,7 KB — 300 lần. Ba thứ cộng dồn: gzip cắt 10 lần, bỏ cột mô tả cắt 7 lần, và 304 là 0 byte thân. Không cái nào trong ba cái đó là "tối ưu hoá" theo nghĩa phải nghĩ — chúng là mặc định của HTTP mà bản ngây thơ không bật.
p99 từ 30 ms xuống 1,7 ms. Hai nguyên nhân: trang 5.000 không còn đọc-rồi-vứt 100.000 dòng nhờ keyset, và lúc cache hết hạn không có client nào phải chờ nhờ stale-while-revalidate. Con số 11,28 ms ở lần chạy lạnh là 93 request đầu cùng lúc chạm origin — chính là cơn bão lúc khởi động mà phần 6 đo, và là lý do cache ấm mới là trạng thái bình thường.
Và cái giá, nói rõ: dữ liệu cũ tối đa 5 giây (cộng cửa sổ SWR), không nhảy thẳng tới trang N, và Vary là một dòng mà quên là sai 50% client. Mỗi cái đã đo riêng ở phần của nó; ở đây chúng chỉ cộng lại.
Khung quyết định
Mỗi chủ đề trong sê-ri quy về một câu hỏi và một mặc định. Mặc định là điểm xuất phát, không phải luật — nhưng đi chệch khỏi nó thì nên có một phép đo lý do.
| câu hỏi | mặc định | đổi khi | phần |
|---|---|---|---|
| Phân trang thế nào? | keyset, trả next_cursor |
cần dãy số trang và dữ liệu nhỏ, ổn định | 1 |
| Request ghi có retry được không? | Idempotency-Key bắt buộc cho POST tạo mới |
thao tác tự nhiên idempotent (PUT đè toàn bộ) | 2 |
| Lỗi trả thế nào? | problem+json, 4xx cho client, 5xx chỉ khi server sai |
không bao giờ | 3 |
| Đổi hợp đồng thế nào? | chỉ thêm, không đổi; khi bắt buộc thì phiên bản trong URL | header Accept nếu không có cache dùng chung và kỷ luật Vary |
4 |
| Client hỏi "còn đúng không" được không? | ETag từ version, If-None-Match → 304 |
thân dựng từ cache sẵn: ETag băm thân đủ | 5 |
| Cache dùng chung được giữ bao lâu? | max-age ngắn + stale-while-revalidate; private/no-store cho dữ liệu cá nhân |
không bao giờ để trống header này | 6 |
| Nén không? | gzip-6 hoặc br-4, gzip_min_length ≥ 256, Vary |
giữa service nội bộ ≥ 1 Gbps; thân đã nén sẵn | 7 |
| Chặn client gửi quá nhiều? | token bucket nguyên tử, RateLimit-*, Retry-After |
— | 8 |
| Client cần dữ liệu liên quan? | ?expand= với một query IN, có trần độ sâu; bulk ?ids= có trần số id |
— | 9 |
| Kiểm đầu vào ở đâu? | giới hạn thân → biên gom lỗi theo trường → ràng buộc DB | không bỏ tầng nào | 10 |
| Ai chịu trách nhiệm chờ? | một ngân sách ở tầng ngoài, truyền xuống, huỷ theo context; retry ở một tầng | — | 11 |
Checklist trước khi đưa API lên production
Mỗi dòng là một thứ đã đo trong sê-ri và từng gây lỗi im lặng ở đâu đó.
Đọc
- Endpoint danh sách dùng keyset hoặc có trần
page; cột sắp xếp duy nhất và có index khớp. - Có
ETagvà trả 304 choIf-None-Match; ETag tính được trước khi render nếu endpoint đắt. - Có
Cache-Controltường minh ở mọi endpoint — kể cảno-store; dữ liệu theo người dùng làprivatehoặcno-store. - Thân phụ thuộc header (nén, ngôn ngữ, phiên bản) thì có
Vary; kiểm bằng cách gọi qua cache với hai client khác nhau. - Bật nén với ngưỡng tối thiểu; không nén thân chứa nhị phân base64.
- Endpoint trả danh sách kèm liên quan: test tích hợp đếm số query ≤ 2.
Ghi
- POST tạo mới nhận
Idempotency-Key, khoá lưu kèm vân tay thân, trả lại phản hồi cũ cho cùng key. - PUT/PATCH hỗ trợ
If-Matchvà trả 412 khi lệch. - Giới hạn kích thước thân trước khi parse (413).
- Validation ở biên gom mọi lỗi theo trường; ràng buộc
UNIQUE/CHECKvẫn có trong database và mã lỗi được dịch (23505 → 409).
Lỗi và giới hạn
- Mọi lỗi là
application/problem+jsonvớitypeổn định; không bao giờ trảerr.Error()của tầng dưới. - 500 chỉ cho lỗi server thật, kèm mã tra cứu đối chiếu được với log.
- Có giới hạn tần suất nguyên tử (Lua hoặc tương đương), trả
RateLimit-*vàRetry-After; quyết định fail-open hay fail-closed khi Redis chết. - Client chính thức (SDK) retry 5xx/429 với backoff và tôn trọng
Retry-After, không retry 4xx.
Thời gian
- Có một ngân sách thời gian ở tầng ngoài cùng, truyền xuống qua header/metadata, mỗi tầng trừ phần đã dùng.
- Mọi lời gọi ra ngoài (HTTP, DB, Redis) nhận context của request — không có
context.Background()ở giữa chuỗi. - Retry chỉ ở một tầng; request ghi retry có khoá idempotency.
Phiên bản
- Hợp đồng ghi rõ "server có thể thêm trường, client không được từ chối trường lạ".
- Mỗi phiên bản có ngày
Sunsetvà số liệu ai còn gọi.
Đánh đổi cần cân nhắc
Checklist không thay được phép đo. Mọi con số trong sê-ri này là của một máy, loopback, PostgreSQL và Redis cùng host. Tỉ lệ (90 lần, 300 lần, 2,1 lần) mang đi được; con số tuyệt đối thì không. Trước khi áp một mục checklist lên hệ thống thật, chạy lại phép đo tương ứng trên hệ thống đó — mỗi bài đều có code đủ để chạy lại trong vài phút.
Không phải endpoint nào cũng cần cả 11 thứ. Endpoint nội bộ giữa hai service trong cùng rack không cần nén (phần 7), có thể không cần cache (phần 6), nhưng vẫn cần deadline (phần 11) và hợp đồng lỗi (phần 3). Checklist là danh sách câu hỏi để trả lời có ý thức, kể cả câu trả lời là "không cần, vì đã đo".
Thứ sê-ri không đo: xác thực và phân quyền (một sê-ri riêng), HTTP/2 và HTTP/3 (sê-ri mạng đã chạm), GraphQL và gRPC như lựa chọn thay REST (gRPC đã có hai sê-ri), và tài liệu hoá API — OpenAPI — thứ biến mọi hợp đồng ở trên thành thứ máy kiểm được. Nếu phải chọn một việc tiếp theo sau checklist, đó là việc đó.
Ba ý mang về
- Mười một bài học cộng lại là sáu header và một mệnh đề WHERE: cùng dữ liệu, cùng màn hình, origin từ 2.000 request xuống 6, byte từ 8,5 MB xuống 28 KB, p99 từ 30 ms xuống 1,7 ms — không thư viện mới, không kiến trúc mới.
- Mỗi mặc định trong khung quyết định có một phép đo đứng sau và một cái giá đã ghi rõ; đi chệch mặc định thì nên có phép đo của riêng mình, không phải ý kiến.
- Lỗi API phần lớn là im lặng — trang sâu chậm dần, cache trả nhầm bản, A làm việc vào hư không, 48 dòng trùng — nên checklist phải được kiểm bằng cách gọi thật qua cache, với hai client, với 50 request đồng thời, chứ không phải bằng cách đọc code.
Nguồn
- RFC 9110 — HTTP Semantics: https://www.rfc-editor.org/rfc/rfc9110
- RFC 9111 — HTTP Caching: https://www.rfc-editor.org/rfc/rfc9111
- RFC 9457 — Problem Details for HTTP APIs: https://www.rfc-editor.org/rfc/rfc9457
- Zalando — RESTful API Guidelines: https://opensource.zalando.com/restful-api-guidelines/
- Google — API Design Guide: https://cloud.google.com/apis/design
- Stripe — API reference (idempotency, expand, versioning, rate limits): https://docs.stripe.com/api