Mười phần trước đo từng cặp client–server. Phần này đo một chuỗi: người dùng gọi gateway, gateway gọi service A, A gọi service B. Mỗi tầng được viết bởi một người khác, vào một thời điểm khác, và mỗi người đều "làm đúng": đặt timeout cho lời gọi của mình, retry khi lỗi. Cộng lại, hệ thống có hành vi mà không ai trong số họ thiết kế. Bài này đo hành vi đó, và đo cách chữa: một ngân sách thời gian đi cùng request qua mọi tầng.

Một ngân sách, truyền xuống, huỷ ngược lên

Ảnh chụp đoạn mã nền tối deadline xuyên suốt ngân sách đi cùng request huỷ cùng context với Go net/http chuỗi người dùng gateway A B, B trả 70 phần trăm sau 10 ms 30 phần trăm sau 400 ms ngân sách 250 ms, gateway ngân sách 250 ms lần đầu tối đa 120 ms còn thì thử lại với phần còn lại gồm han bằng time.Now cộng 250 ms, vòng for lan nhỏ hơn 2 và chưa ok, con bằng time.Until han, nếu con nhỏ hơn 30 ms thì break vì còn quá ít đừng gửi request biết sẽ không kịp, tm bằng con và nếu lần đầu mà tm lớn hơn 120 ms thì tm bằng 120 ms để chừa chỗ lần hai, ctx cancel bằng context.WithTimeout Background tm, NewRequestWithContext tới A, đặt header X-Deadline-Ms bằng tm trừ 5 để báo A còn bao nhiêu, Do request rồi cancel, A đọc ngân sách còn lại truyền context của request xuống B gồm ms đọc từ header X-Deadline-Ms, ctx cancel bằng context.WithTimeout r.Context theo ms với ghi chú gateway huỷ thì A huỷ, defer cancel, NewRequestWithContext ctx tới B với ghi chú A huỷ thì kết nối B đóng thì B huỷ, B làm việc trong select dừng khi context bị huỷ gồm case time.After d thì Write kết quả, case r.Context.Done thì return vì người gọi đã bỏ đừng làm nốt 400 ms vào hư không, ba bản sai đem so gồm không timeout thì người dùng chờ B chậm bao lâu cũng chờ, mỗi tầng tự đặt timeout gateway 250 ms A 1000 ms thì A chạy nốt sau khi gateway đã bỏ đi, mỗi tầng tự retry gateway 3 lần và A 3 lần thì một request người dùng thành tới 9 request B

Hình 1: Gateway giữ ngân sách và báo phần còn lại cho A qua header. A đặt timeout từ con số đó và truyền context của request xuống B. B dừng khi context bị huỷ.

Ba mảnh, mỗi mảnh một việc:

  • Gateway giữ ngân sách 250 ms — con số người dùng chịu được, không phải con số của bất kỳ service nào. Lần gọi đầu giới hạn 120 ms để nếu hỏng vẫn còn chỗ thử lại; nếu phần còn lại quá ít thì không gửi, vì một request biết trước sẽ không kịp chỉ tốn tài nguyên của người khác.
  • A đọc phần còn lại từ header X-Deadline-Ms (gRPC làm đúng chuyện này bằng grpc-timeout) và đặt timeout cho lời gọi B từ con số đó — không phải từ một hằng số trong cấu hình của A. Quan trọng hơn: A dùng r.Context() chứ không phải context.Background(), nên khi gateway ngắt kết nối, lời gọi của A tới B bị huỷ theo.
  • B làm việc trong select với r.Context().Done(): khi A ngắt, B dừng thay vì chạy hết 400 ms rồi ghi kết quả vào một kết nối đã đóng.

Đo thật: bốn kịch bản, cùng một B

Ảnh chụp output thật nền tối retry mỗi tầng p99 771 ms 98 lần A làm vào hư không với Go 1.23 chuỗi gateway A B, B chậm 30 phần trăm 400 ms, 200 request mỗi kịch bản cùng dãy ngẫu nhiên, bảng bốn kịch bản cùng B cùng dãy chậm nhanh cho thấy kịch bản 1 không timeout không retry 200 trên 200 thành công p50 14 ms p99 410 ms B trên request 1.00 A vô ích 0 B dừng sớm 0, kịch bản 2 gateway timeout 250 A timeout 1000 tự đặt 139 trên 200 p50 14 ms p99 260 ms B 1.00 A vô ích 61 B dừng sớm 0, kịch bản 3 gateway retry 3 nhân 250 A retry 3 nhân 300 194 trên 200 p50 14 ms p99 771 ms B 2.10 A vô ích 98 B dừng sớm 140, kịch bản 4 deadline 250 xuyên suốt cộng context retry 1 180 trên 200 p50 14 ms p99 255 ms B 1.30 A vô ích 0 B dừng sớm 80, đọc từng cột thành công 1 là 100 phần trăm nhưng p99 410 ms người dùng chờ B chậm bao lâu cũng chờ, 2 là 139 mọi lần B chậm đều thành 504 không ai thử lại, 3 là 194 retry cứu được với giá p99 771 ms tức 3 nhân 250 và B nhận gấp đôi, 4 là 180 thử lại đúng một lần trong ngân sách 20 lần cả hai lượt đều gặp B chậm, A vô ích ở 2 và 3 gateway đã ngắt lúc 250 ms A dùng context.Background vẫn chờ B hết 400 ms rồi trả vào hư không 61 và 98 lần còn kịch bản 4 là 0 vì A dùng r.Context, B dừng sớm ở 3 là 140 A tự timeout 300 ms rồi huỷ B nhưng chính A lại không bị ai huỷ còn 4 là 80 gateway huỷ A huỷ B huỷ cả chuỗi dừng cùng lúc, nói thẳng timeout không phải một con số ở mỗi tầng nó là một ngân sách đi cùng request co lại qua từng tầng retry chỉ nên có ở một tầng ngoài cùng retry ở mọi tầng là nhân lên theo luỹ thừa

Hình 2: Kết quả thật, 200 request người dùng mỗi kịch bản với cùng dãy ngẫu nhiên chậm/nhanh của B. Hai cột cuối là thứ bảng đo thông thường không có.

kịch bản thành công p99 B nhận / request A làm vô ích
1. không timeout, không retry 200/200 410 ms 1,00 0
2. mỗi tầng tự đặt timeout 139/200 260 ms 1,00 61
3. mỗi tầng tự retry 194/200 771 ms 2,10 98
4. deadline xuyên suốt + context 180/200 255 ms 1,30 0

Kịch bản 1 — không timeout. 100% thành công, và đó là điểm duy nhất tốt. p99 410 ms: khi B chậm, người dùng chờ đúng bấy nhiêu, và nếu B treo hẳn thì chờ vô hạn. Không có kịch bản nào khác đạt 200/200, vì mọi kịch bản còn lại đều chọn bỏ cuộc ở 250 ms — con số thành công giảm là cái giá đã tính trước, không phải lỗi.

Kịch bản 2 — mỗi tầng tự đặt timeout. Gateway 250 ms, A 1.000 ms cho lời gọi B, hai con số do hai người chọn độc lập. Gateway bỏ đi lúc 250 ms; A không biết, vì A dùng context.Background(), nên nó chờ B hết 400 ms rồi trả kết quả vào một kết nối đã đóng — 61 lần trong 200 request. Service A tốn CPU, giữ goroutine, giữ kết nối tới B cho những request không còn ai cần. Ở tải cao, đây chính là cách một service "chết vì việc đã bị huỷ": tài nguyên bị chiếm bởi request mà client đã quên từ lâu.

Kịch bản 3 — mỗi tầng tự retry. Gateway retry 3 lần, A retry 3 lần. Thành công lên 194/200, nhìn vào mỗi cột đó thì có vẻ tốt. Nhìn tiếp: p99 771 ms — gấp ba ngân sách, vì 3 lần × 250 ms. B nhận 2,1 request cho mỗi request người dùng, và trần lý thuyết là 9. A làm việc vào hư không 98 lần: gateway đã retry sang một request A mới, trong khi request A cũ vẫn đang retry B. Đây là retry amplification — khi B thật sự quá tải (chứ không phải ngẫu nhiên chậm 30%), mỗi tầng retry là một hệ số nhân lên tải của B, đúng lúc B cần ít tải nhất. Cột "B dừng sớm" 140 trông như điều tốt, nhưng đó là A tự huỷ B sau 300 ms rồi gọi lại — không phải vì người dùng đã bỏ đi.

Kịch bản 4 — deadline xuyên suốt. 180/200 thành công (20 lần cả hai lượt đều gặp B chậm — xác suất 0,3 × 0,3 = 9%, khớp). p99 255 ms: chưa bao giờ vượt ngân sách quá 5 ms. B nhận 1,3 request mỗi request người dùng — chỉ retry một lần, ở một tầng, trong ngân sách. A làm vô ích 0 lần, và 80 lần B dừng sớm thật sự vì người dùng đã bỏ: gateway huỷ → A huỷ → B huỷ, cả chuỗi dừng cùng một lúc.

Vì sao timeout là ngân sách chứ không phải con số

Timeout đặt ở mỗi tầng trả lời câu hỏi sai: "tôi sẵn sàng chờ tầng dưới bao lâu?". Câu hỏi đúng là "người dùng còn sẵn sàng chờ bao lâu?", và câu trả lời đó thay đổi theo thời gian trôi qua. Nếu gateway có 250 ms và đã dùng 180 ms cho xác thực và gọi service khác, thì A chỉ còn 70 ms — bất kể cấu hình của A nói gì. Chỉ có cách truyền con số đó xuống (header, metadata gRPC) và để mỗi tầng tự trừ đi phần mình đã dùng.

Và huỷ phải đi ngược lên: khi tầng trên bỏ cuộc, mọi tầng dưới phải biết. Trong Go, đó là r.Context() — net/http huỷ nó khi client ngắt kết nối, và nếu bạn truyền nó xuống mọi lời gọi ra ngoài (HTTP, database, Redis), cả chuỗi dừng miễn phí. Một context.Background() ở giữa chuỗi là chỗ ngắt dây: từ đó trở xuống không ai biết người dùng đã đi.

Đánh đổi cần cân nhắc

Retry ở một tầng, và tầng đó phải biết lỗi nào đáng retry. Kịch bản 4 chỉ retry ở gateway. Quy tắc chung: retry ở tầng ngoài cùng (nơi biết ngân sách người dùng) hoặc tầng trong cùng (nơi biết lỗi của phụ thuộc cụ thể), không bao giờ cả hai. Và chỉ retry lỗi tạm thời — timeout, 503, lỗi kết nối — chứ không retry 4xx, như phần 3 đã đo. Với request ghi, retry cần khoá idempotency ở phần 2, nếu không "thử lại" là "tạo đơn đôi".

Lần đầu ngắn hay dài. Gateway trong demo giới hạn lần đầu 120 ms để chừa chỗ cho lần hai. Đổi lại, 120 ms có thể cắt ngang một request lẽ ra thành công ở 150 ms. Chọn con số này từ phân phối độ trễ thật: đặt lần đầu ở p95–p99 của đường đi bình thường, phần còn lại cho lần hai. Một biến thể là hedged request — gửi lần hai song song sau p95 mà không huỷ lần đầu, lấy cái về trước — đổi thêm tải lấy độ trễ đuôi thấp hơn.

Huỷ không phải lúc nào cũng an toàn. B dừng giữa chừng khi context bị huỷ là tốt với request đọc; với request ghi, dừng giữa chừng có thể để lại trạng thái dở dang. Chỗ ghi cần hoặc giao dịch (database tự rollback khi kết nối đóng), hoặc cố ý không huỷ bước commit cuối — dùng context.WithoutCancel cho đúng đoạn đó và để phần còn lại vẫn huỷ được.

Ngân sách qua header là thoả thuận, không phải cưỡng chế. A có thể bỏ qua X-Deadline-Ms; gRPC chuẩn hoá grpc-timeout nên mọi thư viện đều đọc, còn với REST thì phải tự quy ước và ghi vào hợp đồng API. Phần cưỡng chế được là context: dù A bỏ qua header, gateway ngắt kết nối vẫn huỷ r.Context() của A — với điều kiện A truyền nó xuống.

Số đo ba service trong một tiến trình. Độ trễ mạng giữa các tầng gần bằng 0, nên 5 ms "chi phí mạng" trừ trong header là lạc quan; qua mạng thật, phần trừ phải lớn hơn và tính cả serialize. Kết luận về số lần làm việc vô ích và hệ số nhân retry không phụ thuộc điều đó.

Ba ý mang về

  1. Timeout là một ngân sách đi cùng request, không phải một con số ở mỗi tầng: mỗi tầng tự đặt là gateway bỏ đi lúc 250 ms còn A vẫn chờ B tới 400 ms — 61 lần làm việc vào hư không, tài nguyên bị chiếm cho request không ai cần.
  2. Retry chỉ ở một tầng, trong ngân sách: retry ở mọi tầng cho 194/200 thành công nhưng p99 771 ms, B nhận gấp 2,1 lần (trần 9 lần), và 98 lần làm việc vô ích — hệ số nhân đó đánh vào B đúng lúc B yếu nhất.
  3. Huỷ phải truyền ngược lên qua context: r.Context() ở mọi lời gọi ra ngoài làm cả chuỗi dừng khi người dùng bỏ đi (80 lần B dừng sớm, 0 việc vô ích); một context.Background() ở giữa là chỗ ngắt dây.

Nguồn

Phần sau là phần cuối: gom mười một phép đo thành một khung thiết kế và một checklist rà soát API trước khi lên production.