Phần 10 ép lịch chạy bên trong tiến trình để lỗi đồng thời lộ ra. Phần này ép thứ nằm ngoài tiến trình: đường mạng tới cơ sở dữ liệu. Mọi client đều có timeout, retry, backoff, có khi cả circuit breaker — và gần như không bao giờ được kiểm thử, vì trong môi trường test cơ sở dữ liệu luôn trả lời trong 2 ms. Toxiproxy đứng giữa client và PostgreSQL, cho test tiêm lỗi rồi gỡ ngay, và đo xem từng cơ chế bảo vệ có làm đúng điều nó hứa không.

Thiết lập: một proxy, năm kiểu hỏng

Ảnh chụp đoạn mã nền tối fault injection trong test tiêm lỗi vào đường mạng không sửa code với Toxiproxy 2.9 đứng giữa client Go pgx và PostgreSQL 16 toxic bật tắt qua HTTP tc netem trên dist-lab để so sánh, bốn thứ client phải có và từng thứ được đo riêng gồm context.WithTimeout cho ngân sách thời gian cả connect và query, pgx.Connect rồi QueryRow, vòng for thử lại tới retries lần với breaker.allow trả errBreakerOpen khi mở nghĩa là thất bại ngay 0 ms không chạm mạng, call rồi breaker.record, time.Sleep backoff theo mode none hoặc exp 100 nhân 2 mũ a hoặc jitter ngẫu nhiên trong khoảng đó, breaker closed sang open sau 5 lỗi liên tiếp sang half-open sau 2 giây cho đúng 1 lượt thăm dò sang closed nếu thành công, các toxic mỗi cái một kiểu hỏng gồm latency 300 jitter 100 là chậm nhưng đến, timeout 0 là treo kết nối mở không bao giờ trả lời, reset_peer toxicity 0.3 là 30 phần trăm kết nối bị RST, limit_data 200 byte cắt phản hồi gây EOF giữa chừng, POST proxies pg enabled false tắt hẳn gây connection refused, test thật tự tiêm tự gỡ khẳng định hành vi gồm TestTimeoutBudgetWhenUpstreamHangs thêm toxic hang rồi t.Cleanup gỡ, gọi với 500 ms, Fatal nếu không phải timeout hoặc quá 700 ms, TestRetrySurvivesConnectionReset với reset_peer 0.5 và 20 lượt đọc tối đa 4 lần thử, test còn lại kiểm retry khi reset 50 phần trăm 20 lượt 4 lần thử

Hình 1: Client có timeout, retry với backoff và circuit breaker. Toxiproxy nhận lệnh tiêm lỗi qua HTTP nên test tự bật và tự gỡ trong t.Cleanup.

Client là Go với pgx, mỗi lượt gọi là mở kết nối mới rồi chạy một truy vấn nhỏ. Bốn cơ chế bảo vệ: context.WithTimeout bao cả connect lẫn query; vòng retry với backoff none, exp (100 ms × 2ⁿ) hoặc jitter (ngẫu nhiên trong khoảng đó); breaker mở sau 5 lỗi liên tiếp, 2 giây sau cho đúng một lượt thăm dò. Toxiproxy chạy như một container nữa trên cùng mạng, proxy cổng 15432 về PostgreSQL; năm toxic dùng trong bài: latency, timeout (treo), reset_peer, limit_data (cắt phản hồi) và tắt hẳn proxy. Để đối chiếu, một lượt chạy riêng từ container dist-lab dùng tc netem làm mất gói ở tầng IP — thứ Toxiproxy không làm được.

Đo thật

Ảnh chụp output thật nền tối timeout 1 giây thua latency 300 ms retry nhân 4 tải 0 phần trăm thêm với go-lab qua toxi-lab tới pg-lab baseline qua proxy p50 1,8 ms p99 2,6 ms 200 trên 200, B latency 300 cộng trừ 100 ms chiều về timeout client bao nhiêu thì đủ gồm timeout 200 0 trên 50, 350 0 trên 50, 500 0 trên 50, 1000 0 trên 50, 1200 0 trên 50, 1500 10 trên 20, 2000 20 trên 20 p50 1514 ms, latency 100 ms cố định cho p50 536 ms nghĩa là một lượt gọi bằng khoảng 5 lượt phản hồi connect SCRAM hai lần query nên trễ nhân 5, C phía sau treo hoặc tắt hẳn gồm không timeout vẫn treo sau 20 giây phải giết, timeout 500 ms lỗi sau 505 ms 5 trên 5, proxy tắt refused sau 0,3 ms nghĩa là chết nhanh rẻ hơn treo gấp hơn 1000 lần, D 30 phần trăm kết nối bị reset retry có ích gồm retries 0 69,0 phần trăm ok p99 2,6 ms, retries 1 94,0 p99 106 ms, retries 3 98,5 p99 410 ms lý thuyết 99,2, phản hồi cụt 200 byte retry vô ích gồm retries 0 0 trên 50 eof 0,08 giây, retries 3 0 trên 50 eof 20,2 giây 200 lần thử lỗi cấu trúc thử lại chỉ tốn, E bão retry 50 client phía sau chậm 2 giây timeout 300 ms 3 retry 6 giây gồm none 0 trên 250 thành công 1000 lần thử 167 mỗi giây đỉnh 50 mỗi 100 ms vì 50 client đồng bộ nhau mãi, exp 0 trên 200 800 lần 100 mỗi giây đỉnh 50 thưa hơn vẫn đồng bộ, jitter 0 trên 218 872 lần 130 mỗi giây đỉnh 20 tản ra tải nhân 4 vẫn còn kết quả vẫn 0, F treo 0 tới 8 giây rồi hồi chạy 14 giây timeout 300 ms nghỉ 50 ms gồm không breaker 118 lượt 22 timeout chờ 300 ms tức 6,6 giây treo 0 fail nhanh 95 ok hồi phục ngay lập tức, breaker 5 lỗi 2 giây 203 lượt 7 timeout là 5 cộng 2 thăm dò 100 fail nhanh 0 ms 88 ok hồi phục sau 0,43 giây đợi lượt thăm dò kế, mốc 1,75 giây closed sang open 3,75 thăm dò hỏng 6,09 thăm dò hỏng 8,43 thăm dò thành công sang closed, G tc netem ở tầng gói dist-lab tới PostgreSQL thẳng 200 lượt timeout 5 giây gồm không netem p50 1,4 p99 4,1 ms, loss 5 phần trăm p50 5,8 p99 1266 ms, loss 20 phần trăm p50 221 p99 3094 max 4332 ms 200 trên 200 ok, delay 50 cộng trừ 20 p50 341 ms, loss 20 phần trăm cộng retries 2 p99 2271 ms nghĩa là mất gói không gây lỗi gây đuôi dài RTO 200 ms, ghi chú hai test Go thật timeout sau 511 ms với ngân sách 500 ms PASS, 20 lượt đọc 50 phần trăm reset 4 lần thử hỏng 0 PASS, toxic được gỡ trong t.Cleanup

Hình 2: Mỗi toxic phơi ra một tính chất khác của client. Baseline qua proxy p50 1,8 ms, nên mọi con số phía dưới là do lỗi tiêm vào.

tình huống không bảo vệ có bảo vệ ghi chú
latency 300±100 ms timeout 1.000 ms: 0/50 timeout 2.000 ms: 20/20 1 lượt gọi ≈ 5 lượt phản hồi
phía sau treo treo > 20 s timeout 500 ms: lỗi sau 505 ms tắt hẳn: refused sau 0,3 ms
reset 30% kết nối 69,0% ok retry 3: 98,5% p99 2,6 → 410 ms
phản hồi cụt 200 B 0/50 trong 0,08 s retry 3: 0/50 trong 20,2 s thử lại chỉ tốn
bão retry, 50 client 167 lần thử/s, đỉnh 50/100 ms jitter: 130/s, đỉnh 20/100 ms cả ba: 0% thành công
treo 8 s rồi hồi 22 lượt chờ 300 ms breaker: 7 lượt chờ, 100 fail 0 ms hồi muộn 0,43 s
netem loss 20% p50 221 ms, p99 3.094 ms, 200/200 ok retry 2: p99 2.271 ms mất gói = đuôi dài, không phải lỗi

Timeout 1 giây thua latency 300 ms. Cả 50 lượt với timeout 1.000 ms đều hỏng, dù "300 ms" nghe nhỏ hơn "1 giây" ba lần. Toxic latency làm chậm từng gói chiều về, và một lượt gọi PostgreSQL không phải một gói: mở kết nối, hai bước SCRAM, truy vấn, mỗi bước chờ máy chủ trả lời. Đo với latency 100 ms cố định: p50 536 ms — khoảng 5 lượt phản hồi. Nên với 300 ms, một lượt gọi mất ~1,5 giây (p50 1.514 ms khi timeout đủ lớn), và ngưỡng đủ nằm giữa 1.500 (10/20) và 2.000 ms (20/20). Bài học không phải "đặt timeout 2 giây" mà là: ngân sách thời gian phải tính theo số lượt đi-về của giao thức, và pool kết nối tồn tại một phần để không trả giá 5 lượt mỗi truy vấn.

Không timeout thì treo, treo đắt hơn chết hơn 1.000 lần. Toxic timeout giữ kết nối mở và không trả lời: lượt gọi không có ngân sách thời gian vẫn treo sau 20 giây và phải bị giết. Cùng lỗi đó với 500 ms: thất bại sau 505 ms. Tắt hẳn proxy: connection refused sau 0,3 ms. Một dịch vụ chết hẳn gần như vô hại với client; dịch vụ treo mới nguy hiểm, vì nó giữ goroutine, kết nối và thời gian của người gọi. Test "phía sau tắt" là test dễ và ít giá trị nhất; test "phía sau treo" là thứ phải có.

Retry chữa được reset, không chữa được phản hồi cụt — và chi phí của việc thử nhầm là 20 giây. Với 30% kết nối bị reset, retry 0/1/3 cho 69%/94%/98,5% (lý thuyết 1 − 0,3⁴ = 99,2%), giá là p99 từ 2,6 lên 410 ms. Với limit_data cắt phản hồi sau 200 byte: 0/50 ở retry 0 trong 0,08 giây, và vẫn 0/50 ở retry 3 nhưng mất 20,2 giây và 200 lần thử. Lỗi tạm thời và lỗi cấu trúc nhìn giống nhau ở chỗ cùng ném error; chúng khác nhau ở chỗ cái thứ hai không bao giờ đổi kết quả. Client cần phân loại lỗi trước khi quyết định thử lại (reset → thử; EOF giữa phản hồi hay lỗi cú pháp → không), và test fault injection là chỗ duy nhất kiểm được bảng phân loại đó.

Bão retry: backoff đổi hình dạng tải, không đổi kết quả. 50 client, phía sau chậm 2 giây, timeout 300 ms, 3 retry, 6 giây: không backoff 1.000 lần thử (167/s), đỉnh 50 lần mỗi 100 ms kéo dài suốt — 50 client bị timeout cùng lúc, thử lại cùng lúc, mãi mãi đồng bộ. Exp backoff hạ xuống 100/s nhưng đỉnh vẫn 50: chúng vẫn thức dậy cùng nhịp. Jitter mới tản đỉnh xuống 20/100 ms. Nhưng cả ba cùng 0% thành công và cùng nhân tải lên 4 lần: khi phía sau chậm hơn timeout, retry chỉ là cách dồn thêm việc cho nó đúng lúc nó không làm nổi. Jitter làm bão bớt nhọn; thứ dập bão là breaker hoặc ngừng thử.

Breaker: 7 lượt chờ thay vì 22, và một giá nhỏ khi hồi. Treo 8 giây rồi gỡ, chạy 14 giây, nghỉ 50 ms giữa hai lượt. Không breaker: 22 lượt mỗi lượt chờ đủ 300 ms (6,6 giây treo gộp), rồi hồi ngay. Breaker 5 lỗi/2 giây: mở sau 1,75 giây, thăm dò hỏng ở 3,75 và 6,09, thăm dò thành công ở 8,43 → đóng lại. Chỉ 7 lượt chờ 300 ms (5 lỗi đầu + 2 thăm dò), 100 lượt fail nhanh 0 ms. Giá: hồi muộn 0,43 giây so với lúc lỗi hết, vì phải đợi lượt thăm dò kế. Test này kiểm được cả ba chuyển trạng thái và cả độ trễ hồi phục — thứ không có cách nào kiểm nếu cơ sở dữ liệu luôn trả lời 2 ms.

netem phơi ra lớp lỗi khác: không hỏng, chỉ chậm. Mất gói 20% ở tầng IP: 200/200 thành công với timeout 5 giây, nhưng p50 221 ms (một lần retransmit, RTO tối thiểu 200 ms của Linux) và p99 3,1 giây, tối đa 4,3 giây. Retry 2 lần kéo p99 xuống 2,3 giây, không hơn. Toxiproxy làm việc ở tầng TCP nên không mô phỏng được chuyện này; ngược lại netem không "cắt phản hồi sau 200 byte" được. Hai công cụ bổ cho nhau, không thay nhau.

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

Test fault injection là test tích hợp, với toàn bộ chi phí của nó. Cần container Toxiproxy, cần cơ sở dữ liệu thật, cần chạy tuần tự vì toxic là trạng thái chung — hai test song song cùng sửa proxy pg sẽ phá nhau. Mỗi test phải gỡ toxic trong t.Cleanup, kể cả khi fail; một toxic sót lại làm cả bộ test sau đỏ với lý do không liên quan. Đổi lại, đây là cách duy nhất chứng minh timeout, retry và breaker tồn tại — đọc code không đủ, vì context.WithTimeout bị quên truyền xuống một tầng là chuyện xảy ra mỗi ngày.

Toxic có toxicity: test có xác suất là test giòn — đo được. TestRetrySurvivesConnectionReset tiêm reset 50%, 20 lượt đọc, tối đa 4 lần thử, khẳng định "hỏng 0". Lần chạy đầu: PASS. Tính thử: mỗi lượt hỏng cả 4 lần với xác suất 0,5⁴ = 6,25%, 20 lượt → xác suất ít nhất một lượt hỏng là 1 − 0,9375²⁰ ≈ 72%. Chạy -count=20: đỏ 17/20. Lần PASS đầu tiên là may mắn 28%, và nếu dừng ở đó thì bài này đã in một test giòn làm ví dụ mẫu. Bản sửa không chạm tới xác suất: reset 100%, khẳng định lỗi được phân loại là reset và vòng retry thử đúng 4 lần rồi dừng — 20/20 xanh. Khẳng định đúng của test fault injection là "cơ chế bảo vệ được kích hoạt và dừng đúng lúc", còn tỉ lệ thành công dưới lỗi ngẫu nhiên là việc của bài đo, như bảng ở trên, không phải của test.

Phạm vi đo. Một client, một proxy, một cơ sở dữ liệu trên cùng máy; "5 lượt phản hồi" là của pgx với SCRAM và sslmode=disable, driver khác hay có TLS sẽ nhiều hơn. Breaker ở đây là bản tối giản 40 dòng để đo được; thư viện thật có cửa sổ trượt và ngưỡng theo tỉ lệ, nhưng ba trạng thái và độ trễ hồi phục thì giống nhau.

Ba ý mang về

  1. Timeout phải tính theo số lượt đi-về của giao thức, không theo "độ trễ mạng": latency 300 ms thắng timeout 1.000 ms vì một lượt gọi là ~5 lượt phản hồi. Và phía sau treo đắt hơn phía sau chết hơn 1.000 lần — test cái treo.
  2. Retry cần bảng phân loại lỗi: reset → 69% lên 98,5%; phản hồi cụt → 0/50 thành 0/50 cộng 20 giây. Backoff và jitter làm bão retry bớt nhọn (đỉnh 50 → 20) nhưng không đổi kết quả; thứ dập bão là breaker.
  3. Fault injection là cách duy nhất kiểm cơ chế bảo vệ — và tự nó là test tích hợp có trạng thái chung: tuần tự, gỡ trong t.Cleanup, khẳng định tất định.

Nguồn

Phần sau gom mười một phần lại: một khung chọn loại test theo câu hỏi cần trả lời, bảng chi phí – giá trị từ số đo thật, và checklist CI để bộ test giữ được giá trị sau khi viết xong.