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

Ảnh chụp đoạn mã nền tối một endpoint gom 11 bài học so với bản viết trong năm phút với Go net/http pgx PostgreSQL 16 nginx 1.27 proxy_cache phía trước GET /products, bản ngây thơ đúng về chức năng và chỉ thế gồm page lấy từ query, pool.Query select id name price descr from products order by id limit 20 offset với ghi chú phần 1 trang sâu đọc rồi vứt, json Encode map items out với ghi chú không ETag Cache-Control nén và trả cả mô tả 256 ký tự màn hình không hiện, bản gom bài học cùng dữ liệu cùng màn hình gồm after lấy từ query, pool.Query select id name price from products where id lớn hơn tham số order by id limit 20 với ghi chú phần 1 keyset sâu bao nhiêu cũng vậy, resp gồm items và next_cursor là ID phần tử cuối với ghi chú phần 9 chỉ trường màn hình cần, b bằng json.Marshal resp, et bằng hex sha256 b 8 byte, đặt ETag với ghi chú phần 5 hỏi còn đúng không bằng 0 byte, đặt Cache-Control public max-age 5 stale-while-revalidate 30 phần 6, đặt Vary Accept-Encoding phần 4 cộng 7 cache tách bản nén, nếu If-None-Match bằng et thì 304 và return, gzip.NewWriterLevel w 6 Write b Close với ghi chú phần 7 21 KB thành 2 KB, những phần không nằm trong endpoint này nhưng nằm trong checklist gồm phần 2 Idempotency-Key cho POST, phần 3 problem+json 4xx 5xx đúng nghĩa, phần 8 429 cộng Retry-After, phần 10 kiểm ở biên cộng ràng buộc DB, phần 11 ngân sách thời gian đi cùng request huỷ theo context

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:

Ảnh chụp output thật nền tối origin 2.000 xuống 6 byte 8,5 MB xuống 28 KB với Go 1.23 pgx v5 PostgreSQL 16 qua nginx 1.27 tới 20 client song song nhân 100 request, 2.000 request 70 phần trăm lật 5 trang đầu 30 phần trăm nhảy tới trang 5.000 client gửi gzip và If-None-Match, bảng cho thấy ngây thơ 2000 trên 2000 request tới origin 2000 query DB 8492.4 KB qua dây p50 2.94 ms p99 30.12 ms tổng 0.45 s, gom bài học cache lạnh 93 trên 2000 tới origin 93 query p50 0.24 ms p99 11.28 ms tổng 0.06 s, gom bài học cache ấm 6 trên 2000 tới origin 6 query 27.7 KB p50 0.37 ms p99 1.68 ms tổng 0.05 s, trạng thái 2.000 phản hồi cache ấm gồm 304 nhân 1880 nginx so ETag client gửi với bản trong cache trả 0 byte không chạm origin, HIT nhân 19 thân từ cache, STALE nhân 6 hết max-age trả bản cũ ngay làm mới ở nền, UPDATING nhân 95 đang làm mới vẫn trả bản cũ, mỗi con số đến từ phần nào gồm origin 2000 xuống 6 do phần 6 Cache-Control cộng SWR nginx trả lời thay và phần 5 ETag 304 thay vì thân, byte 8,5 MB xuống 28 KB do phần 7 gzip 10 lần cộng phần 9 bỏ mô tả 7 lần cộng phần 5 304 0 byte, p99 30 xuống 1,7 ms do phần 1 keyset trang 5.000 hết đọc rồi vứt 100.000 dòng và phần 6 SWR không chờ hết hạn, nói thẳng không có gì ở đây là thư viện hay kiến trúc mới là sáu header và một mệnh đề WHERE cái giá dữ liệu cũ tối đa 5 giây không nhảy thẳng tới trang N phải nhớ Vary cả ba đã đo riêng

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ó ETag và trả 304 cho If-None-Match; ETag tính được trước khi render nếu endpoint đắt.
  • Có Cache-Control tường minh ở mọi endpoint — kể cả no-store; dữ liệu theo người dùng là private hoặc no-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-Match và 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/CHECK vẫ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+json với type ổ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 Sunset và 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ề

  1. 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.
  2. 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.
  3. 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