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

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

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ề
- 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.
- 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.
- 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
- Shopify — Toxiproxy (README, danh sách toxic): https://github.com/Shopify/toxiproxy
- Marc Brooker — Exponential Backoff And Jitter (AWS Architecture Blog): https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
- Martin Fowler — CircuitBreaker: https://martinfowler.com/bliki/CircuitBreaker.html
- Google SRE Book — Handling Overload và Addressing Cascading Failures: https://sre.google/sre-book/addressing-cascading-failures/
- Linux
tc-netem(8): https://man7.org/linux/man-pages/man8/tc-netem.8.html - pgx — Connect và context: https://pkg.go.dev/github.com/jackc/pgx/v5
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.