Mạng không tin cậy — timeout, retry, 8 fallacies
Mạng có thể trễ, mất gói, chia cắt — và bạn không phân biệt được node chết với node chậm. Timeout, retry và 8 fallacies of distributed computing.
TL;DR: Trong hệ phân tán, các node chỉ nói chuyện qua mạng bất đồng bộ — gói tin có thể trễ tuỳ ý, mất hẳn, hoặc tới sai thứ tự. Hệ quả nghiệt ngã nhất: khi gửi request mà không nhận được trả lời, bạn không thể phân biệt node kia đã chết, đang chậm, hay chỉ gói trả lời bị mất. Công cụ duy nhất để đoán một node đã chết là timeout — một phỏng đoán, không phải sự thật. Bài này giải thích vì sao retry an toàn cần idempotency, và điểm lại 8 fallacies of distributed computing — giả định sai lập trình viên hay mang từ máy đơn lên.
Ở Module 4 bạn thấy 2PC "blocking" vì coordinator có thể chết giữa chừng và participant không biết chờ tới bao giờ. Gốc rễ của "không biết chờ tới bao giờ" nằm ở đây: mạng không cho bạn một tín hiệu rõ ràng nào để biết bên kia còn sống hay đã chết. Cả module này xoay quanh đúng một sự thật khó chịu đó, và bài đầu tiên đặt nền: mạng là thứ không tin cậy mà mọi node buộc phải dựa vào.
1. Analogy — gửi thư mà không có hồi báo
Hình dung bạn gửi một lá thư yêu cầu ("chuyển giúp 100k cho C") tới một người bạn ở xa, qua một bưu điện không có dịch vụ báo phát. Bạn bỏ thư vào hòm rồi chờ thư hồi âm ("đã chuyển xong"). Một tuần trôi qua, không có hồi âm. Chuyện gì đã xảy ra? Có thể lá thư đi bị thất lạc (bạn kia chưa từng nhận). Có thể bạn kia đã nhận, đã chuyển tiền, nhưng lá thư hồi âm thất lạc. Có thể bạn kia đang ốm, thư nằm chờ trên bàn cả tuần. Từ phía bạn — người gửi — cả ba khả năng trông giống hệt nhau: chỉ là "không có hồi âm".
Đó chính xác là tình cảnh một node gửi request qua mạng và chờ response.
| Gửi thư | Hệ phân tán |
|---|---|
| Lá thư yêu cầu | Request gói tin đi |
| Thư hồi âm | Response gói tin về |
| Bưu điện không báo phát | Mạng bất đồng bộ, không đảm bảo giao |
| "Cả tuần không hồi âm" | Timeout hết mà chưa có response |
| Thư đi thất lạc / hồi âm thất lạc / bạn ốm | Request mất / response mất / node chậm hoặc chết |
| Không phân biệt được ba khả năng | Không phân biệt được node chết với node chậm |
Người gửi chỉ quan sát được một thứ: "có hồi âm trong thời hạn hay không". Mọi nguyên nhân khác nhau (mất thư đi, mất thư về, người nhận chậm) đều dồn về cùng một quan sát. Mạng che mất nguyên nhân — chỉ để lại triệu chứng.
2. Vì sao không phân biệt được "node chậm" với "node chết"?
Đây là câu hỏi trung tâm của cả module. Khi node A gửi request tới node B rồi chờ, có bốn điểm mà mọi thứ có thể hỏng — và A không quan sát được điểm nào đã hỏng:
flowchart LR A["Node A<br/>(gui request)"] -->|"1. request co the mat"| B["Node B"] B -->|"2. B co the cham (GC, tai cao)"| B2["Node B<br/>(dang xu ly?)"] B2 -->|"3. B co the chet giua chung"| X["(khong response)"] B2 -->|"4. response co the mat"| A
Từ phía A, cả bốn trường hợp cho cùng một quan sát: "gửi đi, không thấy response trong thời hạn". A không có cách nào nhìn xuyên mạng để biết B đang ở trạng thái nào. Mạng chỉ chuyển bit; nó không kèm theo một "báo cáo sức khoẻ" trung thực về B.
Bạn đã học các dạng lỗi mạng ở tầng TCP/IP: gói tin bị drop khi hàng đợi router đầy, bị trễ khi mạng nghẽn, kết nối bị reset, hoặc cả một nhánh mạng bị chia cắt (network partition). Bài này đứng cao hơn một tầng: chấp nhận các lỗi đó là có thật và không tránh được, rồi hỏi — hệ phần mềm phải suy luận thế nào khi bên dưới là một mạng như vậy?
Điểm mấu chốt là phi đối xứng: A biết chắc nó đã gửi, nhưng A không biết B có nhận hay có xử lý hay không. Thông tin chỉ chảy một chiều rõ ràng (A biết hành động của chính A); chiều ngược lại (trạng thái của B) bị mạng che. Bạn không thể xây một "máy dò node chết" hoàn hảo trên một mạng bất đồng bộ — đây không phải hạn chế kỹ thuật tạm thời mà là một giới hạn nền tảng.
3. Timeout — một phỏng đoán, không phải sự thật
Nếu không có tín hiệu trung thực, làm sao một hệ quyết định "coi B là đã chết" để chuyển việc sang node khác? Câu trả lời thực dụng duy nhất: timeout. Đặt một mốc thời gian; quá mốc mà chưa có response thì coi như B chết và hành động tiếp (retry, failover, báo lỗi).
Nhưng timeout là một phỏng đoán dựa trên giả định "nếu B còn sống thì đã trả lời trong khoảng này". Giả định đó có thể sai, vì mạng bất đồng bộ không có giới hạn trên cho độ trễ. Và chọn giá trị timeout là một đánh đổi không có lời giải hoàn hảo:
| Đặt timeout | Ưu | Nhược |
|---|---|---|
| Ngắn (vd 100ms) | Phát hiện lỗi nhanh, chuyển việc sớm | Dễ báo "chết oan": B chỉ chậm (GC, tải cao) đã bị coi là chết → chuyển việc thừa, thậm chí chạy trùng |
| Dài (vd 30s) | Ít báo chết oan | Khi B chết thật, cả hệ treo chờ lâu → người dùng thấy chậm, tài nguyên bị giữ |
Tệ hơn: timeout ngắn có thể tạo hiệu ứng dây chuyền. Giả sử B chỉ đang chậm vì tải cao. A timeout, coi B chết, chuyển toàn bộ tải của B sang node C. Giờ C quá tải và chậm theo, bị node khác timeout, tải lại dồn tiếp sang D... Một node chậm thoáng qua biến thành sự cố lan rộng chỉ vì timeout đặt quá nhạy. Đây là lý do timeout thường đi kèm cơ chế thích ứng (đo RTT thực tế, backoff) thay vì một hằng số cứng.
4. Retry cần idempotency — worked example
Khi timeout, phản ứng tự nhiên là retry (gửi lại). Nhưng nhớ mục 2: một request "tưởng thất bại" có thể đã chạy thành công, chỉ là response bị mất trên đường về. Retry lúc đó = chạy lần hai. Với thao tác đọc thì vô hại; với thao tác thay đổi trạng thái (trừ tiền, tạo đơn) thì retry mù có thể gây hậu quả kép.
Đi qua một ví dụ cụ thể. Client gửi "trừ 100k từ tài khoản X":
Lan 1:
Client -> Server: POST /withdraw {acct: X, amount: 100k}
Server: tru 100k thanh cong, so du 500k -> 400k
Server -> Client: 200 OK <-- goi tin nay BI MAT
Client: timeout, tuong that bai
Lan 2 (retry mu):
Client -> Server: POST /withdraw {acct: X, amount: 100k}
Server: tru them 100k nua! so du 400k -> 300k <-- TRU HAI LAN
Client tưởng trừ một lần, thực tế trừ hai lần. Lỗi này chỉ xuất hiện khi response đầu bị mất — nên nó hiếm, khó tái hiện, và rất khó debug.
Cách sửa đúng là làm thao tác idempotent (chạy lại nhiều lần cho cùng một kết quả như chạy một lần). Kỹ thuật phổ biến: client sinh một idempotency key duy nhất cho ý định giao dịch, gửi kèm mỗi lần (kể cả retry). Server nhớ key đã xử lý:
Lan 1:
Client -> Server: POST /withdraw {key: "abc-123", acct: X, amount: 100k}
Server: key "abc-123" chua thay -> tru 100k, ghi lai key da xu ly
Server -> Client: 200 OK <-- van BI MAT
Lan 2 (retry, CUNG key):
Client -> Server: POST /withdraw {key: "abc-123", acct: X, amount: 100k}
Server: key "abc-123" DA xu ly -> KHONG tru lai, tra ve ket qua cu
Server -> Client: 200 OK <-- lan nay ve duoc
Retry giờ an toàn: dù client gửi lại bao nhiêu lần với cùng key, tiền chỉ trừ đúng một lần. Đây chính là lý do idempotency là điều kiện tiên quyết của retry — điều bạn đã gặp ở bài tránh distributed transaction khi saga cần idempotency để chịu được retry.
Retry là phản xạ đúng khi gặp timeout, nhưng retry mà không đảm bảo idempotency là công thức gây lỗi trùng lặp âm thầm.
❌ catch (timeout) { retry(request); } // request tru tien / tao don
-> neu response dau bi mat, thao tac chay HAI lan
✅ Sinh idempotency key cho moi y dinh; server khu trung theo key
✅ Hoac thiet ke thao tac tu no idempotent (PUT ghi de, khong INCREMENT)
Quy tắc: trước khi thêm retry, hỏi "nếu request này đã chạy rồi mà mình gửi lại thì sao?". Nếu câu trả lời không phải "vô hại", cần idempotency trước.
5. 8 fallacies of distributed computing
Nhiều lỗi hệ phân tán bắt nguồn từ việc lập trình viên mang giả định của máy đơn lên mạng. L. Peter Deutsch và đồng nghiệp ở Sun Microsystems đúc kết thành 8 fallacies of distributed computing — 8 điều mà người mới ngầm tin là đúng nhưng thực tế đều sai:
| # | Ngộ nhận (fallacy) | Thực tế |
|---|---|---|
| 1 | Mạng đáng tin cậy | Gói tin mất, kết nối đứt — thường xuyên |
| 2 | Độ trễ bằng không | Mỗi hop tốn thời gian; gọi mạng chậm hơn gọi hàm cỡ hàng chục nghìn tới hàng triệu lần |
| 3 | Băng thông vô hạn | Băng thông hữu hạn, nghẽn khi tải cao |
| 4 | Mạng an toàn | Có kẻ nghe lén, giả mạo, tấn công |
| 5 | Cấu trúc mạng không đổi | Node thêm/bớt, đường đi đổi, IP đổi |
| 6 | Chỉ một người quản trị | Nhiều đội, nhiều tổ chức, chính sách khác nhau |
| 7 | Chi phí vận chuyển bằng không | Serialize, băng thông, hạ tầng đều tốn tiền |
| 8 | Mạng đồng nhất | Thiết bị, hệ điều hành, phiên bản khác nhau |
Hai fallacy đầu (đáng tin, độ trễ 0) trực tiếp là nội dung bài này: mạng không đáng tin, và độ trễ không bằng 0, không có trần. Bốn thước đo ở Module 1 (nhất là tail latency) và cả module này tồn tại chính vì những ngộ nhận đó phải bị gỡ bỏ khi thiết kế hệ thật.
TCP đã cố che bớt sự không tin cậy của mạng: nó dùng sequence number và ACK để phát hiện gói mất và truyền lại, sắp xếp lại gói đến sai thứ tự. Nhưng TCP chỉ giấu được một phần: nó không thể biến độ trễ vô biên thành hữu hạn, không cứu được khi cả kết nối bị chia cắt, và bản thân việc truyền lại làm độ trễ tăng khó lường. Vì vậy ở tầng ứng dụng, bạn vẫn phải đối mặt với timeout và retry — TCP không xoá được fallacy #1 và #2.
6. Pitfall — lập trình phân tán như gọi hàm cục bộ
Cạm bẫy lớn nhất là viết code phân tán như thể mọi lời gọi qua mạng đều thành công tức thì như gọi một hàm trong bộ nhớ.
❌ result = remoteService.charge(order); // coi nhu goi ham cuc bo
// -> khong xu ly timeout, khong retry, khong idempotency
// -> khi mang loi: treo vo han, hoac mat/trung giao dich
✅ Moi loi goi mang: dat timeout tuong minh; retry co backoff;
thao tac thay doi trang thai phai idempotent; xu ly ca 3 ket cuc
(thanh cong / that bai / KHONG BIET)
Điểm khó nhất: lời gọi mạng có ba kết cục, không phải hai. Ngoài "thành công" và "thất bại rõ ràng" còn có "không biết" (timeout) — trạng thái mà máy đơn không có. Bỏ quên nhánh "không biết" là nguồn của phần lớn bug phân tán.
Hướng đúng: coi mọi ranh giới mạng là nơi có thể hỏng, và thiết kế cho nhánh "không biết" ngay từ đầu — timeout tường minh, retry idempotent, và ở tầng cao hơn là các cơ chế bạn sẽ gặp sau (fencing token ở bài 04, consensus ở Module 6) để hệ vẫn đúng đắn dù từng lời gọi không đáng tin.
7. 📚 Đào sâu — nguồn gốc & tài liệu
- Designing Data-Intensive Applications (Kleppmann) — Chương 8 "The Trouble with Distributed Systems", mục "Unreliable Networks" — phân tích network faults thực tế, phần "Detecting Faults" và "Timeouts and Unbounded Delays" là nền cho mục 2–3 của bài.
- DDIA — Chương 8, mục "Synchronous Versus Asynchronous Networks" — vì sao mạng datacenter thường bất đồng bộ (không có giới hạn trễ đảm bảo như mạng điện thoại chuyển mạch).
- 8 fallacies of distributed computing — đúc kết bởi L. Peter Deutsch và cộng sự tại Sun Microsystems (đầu thập niên 1990), James Gosling bổ sung fallacy thứ 8. Danh sách gốc được lưu và giải thích rộng rãi; đọc để thấy mỗi fallacy dẫn tới một lớp lỗi thật.
Ghi chú: DDIA mở Chương 8 bằng "partial failure" (bài 04 của module) rồi đi xuống mạng và đồng hồ — bài này lấy phần mạng làm nền, các bài sau đi tiếp đồng hồ (02) và causal order (03).
8. Liên hệ các bài khác
- Bài 02 — Đồng hồ không tin cậy: mạng không tin cậy khiến bạn muốn dựa vào thời gian để suy luận thứ tự — nhưng bài sau cho thấy đồng hồ cũng không đáng tin, đóng luôn lối thoát đó.
- Bài 04 — Partial failure & fencing token: "không phân biệt được chậm với chết" ở bài này chính là gốc của split-brain — một node bị coi là chết (timeout) nhưng thực ra còn sống và vẫn ghi dữ liệu.
- Module 4 — Tránh distributed transaction: idempotency ở mục 4 là điều kiện để saga chịu được retry — cùng một nguyên lý, áp ở tầng giao dịch.
- Module 6 — Consensus & Raft: vì không có máy dò chết hoàn hảo, hệ cần một cách để đa số thống nhất "ai còn sống, giá trị nào đúng" — đó là consensus.
9. Tóm tắt
- Mạng phân tán là bất đồng bộ: gói tin có thể trễ tuỳ ý (không có trần), mất, hoặc đến sai thứ tự.
- Khi không có response, bạn không phân biệt được node chết, node chậm, hay response bị mất — thông tin về bên kia bị mạng che, phi đối xứng.
- Timeout là cách duy nhất để "đoán" node đã chết, nhưng chỉ là phỏng đoán: ngắn thì báo chết oan (và gây dây chuyền), dài thì phát hiện lỗi chậm.
- Retry cần idempotency vì request "tưởng thất bại" có thể đã chạy — dùng idempotency key hoặc thiết kế thao tác tự idempotent.
- 8 fallacies of distributed computing liệt kê các giả định máy-đơn sai lầm; hai cái đầu (mạng đáng tin, độ trễ 0) chính là nội dung bài này.
- Lời gọi mạng có ba kết cục (thành công / thất bại / không biết) — bỏ quên nhánh "không biết" là nguồn của phần lớn bug phân tán.
10. Tự kiểm tra
Q1Vì sao một node gửi request rồi timeout KHÔNG thể kết luận chắc chắn rằng node kia đã chết?▸
Vì timeout chỉ cho biết "không có response trong thời hạn", mà quan sát đó tương ứng với nhiều nguyên nhân không phân biệt được: request đi bị mất (node kia chưa từng nhận), node kia đang chậm (GC, tải cao) nên chưa trả lời, node kia chết thật, hoặc node kia đã xử lý xong nhưng response về bị mất.
Mạng bất đồng bộ không đảm bảo giới hạn trên cho độ trễ, nên "chưa trả lời" không đồng nghĩa "sẽ không bao giờ trả lời". Thông tin về trạng thái bên kia bị mạng che — node gửi chỉ chắc chắn về hành động của chính nó, không về bên nhận.
Q2Đặt timeout ngắn và đặt timeout dài mỗi cái đánh đổi gì? Vì sao timeout quá ngắn có thể gây sự cố lan rộng?▸
Timeout ngắn phát hiện lỗi nhanh nhưng dễ báo "chết oan": một node chỉ chậm thoáng qua bị coi là chết, gây chuyển việc thừa. Timeout dài ít báo oan nhưng khi node chết thật, hệ treo chờ lâu, giữ tài nguyên và làm người dùng thấy chậm.
Timeout quá ngắn gây dây chuyền: node B chậm vì tải cao bị coi là chết, tải của B dồn sang C; C quá tải và chậm theo, lại bị timeout, tải dồn tiếp sang D... Một node chậm thoáng qua leo thang thành sự cố toàn hệ. Vì vậy nên đo RTT thực tế và dùng backoff thay vì một hằng số cứng.
Q3Trong ví dụ rút tiền ở mục 4, chính xác thì tình huống nào khiến retry gây trừ tiền HAI lần, và idempotency key chặn nó ra sao?▸
Trừ hai lần chỉ xảy ra khi server đã xử lý thành công lần đầu (đã trừ tiền) nhưng gói response 200 OK bị mất trên đường về. Client timeout, tưởng thất bại, retry — và vì thao tác không idempotent, server trừ thêm lần nữa.
Idempotency key chặn bằng cách gắn một định danh duy nhất cho ý định giao dịch, gửi kèm cả lần đầu lẫn retry. Server ghi nhớ key đã xử lý; khi thấy lại cùng key, nó không thực thi lại mà trả về kết quả cũ. Nhờ vậy dù client gửi lại bao nhiêu lần, tiền chỉ trừ đúng một lần.
Q4Người ta nói lời gọi mạng có 'ba kết cục' chứ không phải hai. Kết cục thứ ba là gì, và vì sao bỏ quên nó lại nguy hiểm?▸
Ngoài "thành công" và "thất bại rõ ràng", kết cục thứ ba là "không biết" — timeout: bạn không nhận được câu trả lời nào và không thể kết luận thao tác đã chạy hay chưa. Máy đơn (gọi hàm cục bộ) không có trạng thái này.
Bỏ quên nhánh "không biết" nguy hiểm vì code sẽ hoặc treo vô hạn chờ response không bao giờ đến, hoặc mặc định coi là thất bại rồi retry mù — gây trùng lặp nếu thao tác thực ra đã thành công. Phần lớn bug phân tán tinh vi nằm đúng ở nhánh này vì nó hiếm và khó tái hiện.
Q5TCP đã có sequence number, ACK và truyền lại. Vậy vì sao tầng ứng dụng vẫn phải tự xử lý timeout và retry — TCP chưa 'che' hết sự không tin cậy sao?▸
TCP che được một phần: phát hiện gói mất và truyền lại, sắp xếp lại gói đến sai thứ tự trong một kết nối. Nhưng nó không biến độ trễ vô biên thành hữu hạn (fallacy #2 vẫn đúng), không cứu được khi cả kết nối bị chia cắt (partition) hay đầu kia chết, và bản thân việc truyền lại làm độ trễ tăng khó lường.
Khi một node chết hoặc mạng partition, TCP chỉ có thể báo "kết nối lỗi" sau timeout của chính nó — nó không cho tầng ứng dụng biết thao tác nghiệp vụ đã chạy hay chưa. Vì vậy ứng dụng vẫn phải tự đặt timeout, retry idempotent và xử lý nhánh "không biết" ở mức nghiệp vụ.
Q6Chọn hai trong '8 fallacies of distributed computing' và mô tả một lỗi thật mà mỗi ngộ nhận đó gây ra nếu lập trình viên tin nó.▸
Fallacy #1 (mạng đáng tin): nếu tin mạng luôn giao được, lập trình viên viết lời gọi mạng không timeout, không retry. Khi gói mất hoặc node chết, luồng treo vô hạn hoặc mất giao dịch — đúng các vấn đề mục 2–4 của bài.
Fallacy #2 (độ trễ bằng 0): nếu tin gọi mạng nhanh như gọi hàm, người ta thiết kế vòng lặp gọi remote nhiều lần (kiểu N+1) hoặc gọi tuần tự nhiều service. Trên máy đơn chạy tức thì; qua mạng mỗi lời gọi tốn cỡ hàng chục nghìn tới hàng triệu lần một lời gọi hàm, khiến p99 latency tăng vọt và hệ chậm dưới tải thật.
(Có thể chọn cặp khác — ví dụ #4 mạng an toàn dẫn tới quên mã hoá/xác thực, #5 cấu trúc mạng không đổi dẫn tới hardcode IP.)
Bài tiếp theo: Đồng hồ không tin cậy — clock skew, monotonic vs wall clock
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên