Hệ thống Dữ liệu Phân tán/Partial failure & fencing token — chống split-brain
30/38
Bài 30 / 38~12 phútRắc rối của hệ phân tánMiễn phí lượt xem

Partial failure & fencing token — chống split-brain

Trong hệ phân tán một phần chết mà phần khác sống (partial failure). Lease + fencing token chống một node 'zombie' (bị cho là chết) ghi bậy gây split-brain.

TL;DR: Trên một máy đơn, lỗi thường là "tất cả hoặc không". Hệ phân tán có chế độ lỗi nham hiểm hơn: partial failure — một phần chết trong khi phần khác vẫn chạy, không phần nào có bức tranh đầy đủ. Điều này sinh ra split-brain: một node bị tưởng đã chết (do timeout, thường vì process pause như GC dừng dài) nhưng còn sống, và khi "thức dậy" vẫn tưởng giữ quyền, ghi đè lên node đã thay thế nó. Lời giải kinh điển là lease (khoá có hạn) cộng fencing token: một số tăng dần cấp quyền ghi, tài nguyên từ chối token cũ hơn token đã thấy — chặn "zombie" cơ học.

Ba bài trước cho bạn các mảnh: mạng không tin cậy (01), đồng hồ không tin cậy (02), và cách suy thứ tự bằng nhân quả (03). Bài này ghép chúng vào một sự cố thực tế đắt giá — split-brain — và trình bày một cơ chế phòng thủ hoạt động được ngay cả khi bạn không thể biết node nào còn sống. Đây là bài "independent": sau khi đọc cơ chế, bạn sẽ tự thiết kế fencing cho một tình huống mới trước khi xem đáp án mẫu.

1. Analogy — chìa khoá phòng họp và người bị tưởng đã về

Một phòng họp chỉ cho một người dùng mỗi lúc; ai giữ chìa thì được vào. An đang giữ chìa và ở trong phòng. Quản lý gọi An mãi không thấy trả lời (An đang đeo tai nghe, không nghe điện) → quản lý kết luận An đã về, cấp một chìa mới cho Bình vào dùng phòng. Vấn đề: An chưa về — chỉ là không nghe điện. Giờ cả hai cùng ở trong phòng, cùng "tin mình có quyền". Nếu cả hai cùng ghi lên bảng, nội dung loạn.

Làm sao chặn? Đánh số thứ tự lên mỗi chìa: chìa của An số 33, chìa mới của Bình số 34. Ổ khoá của cái bảng chỉ chấp nhận số lớn nhất từng thấy. Khi Bình (số 34) ghi bảng, ổ ghi nhận "đã thấy 34". Lúc An (số 33) định ghi, ổ thấy 33 < 34từ chối. An bị chặn mà không cần ai xác nhận An còn sống hay đã chết — chỉ nhờ so số.

Phòng họpHệ phân tán
Chìa khoá vào phòngLock / quyền làm leader
Chỉ một người mỗi lúcMutual exclusion
An không nghe điệnProcess pause (GC) — node im lặng
Quản lý tưởng An vềTimeout → coi node đã chết
Cấp chìa mới cho BìnhCấp lại lock cho node khác
Cả hai cùng trong phòngSplit-brain (hai node cùng quyền)
Số thứ tự trên chìaFencing token (tăng dần)
Ổ bảng chỉ nhận số lớn nhấtStorage từ chối token cũ
💡 Cách nhớ

Không cố biết chắc ai còn sống (bất khả — bài 01). Thay vào đó làm cho kẻ "sống dậy muộn" tự vô hại: token của nó cũ, mọi tài nguyên đều từ chối. Phòng thủ dời từ "phát hiện chết" sang "chặn hành động của quá khứ".

2. Partial failure là gì và vì sao nó khó hơn lỗi máy đơn?

Trên một máy, phần lớn lỗi là tất-định và toàn phần: hoặc chương trình chạy, hoặc nó crash — và khi crash thì cả nó dừng. Bạn không có tình huống "một nửa RAM còn tin được, một nửa thì không". Đó là một thế giới có thể suy luận: hoặc ổn, hoặc hỏng hẳn.

Partial failure là chế độ lỗi đặc trưng của hệ phân tán: một phần hệ hỏng trong khi phần khác vẫn chạy bình thường, và — tệ nhất — không phần nào biết bức tranh đầy đủ. Node A có thể chạy hoàn hảo trong khi node B chết; hoặc mạng giữa A và B đứt trong khi cả hai đều sống (network partition); hoặc B chạy nhưng đĩa của nó đầy nên chỉ một số thao tác lỗi. Ghép với sự thật ở bài 01 — A không phân biệt được B chết, B chậm, hay chỉ mạng A–B đứt — ta có một hệ nơi mỗi node chỉ thấy một góc, và các góc đó mâu thuẫn nhau: A tin "B chết", B tin "tôi vẫn sống".

flowchart LR
  A["Node A: 'B da chet'<br/>(timeout)"] -.->|"mang dut hoac B pause"| B["Node B: 'toi van song,<br/>van giu quyen'"]
  A --> C["Cap quyen cho node C"]
  B --> W1["B van ghi du lieu"]
  C --> W2["C cung ghi du lieu"]
  W1 --> SB["SPLIT-BRAIN:<br/>hai node cung ghi"]
  W2 --> SB

Chính sự mâu thuẫn giữa các góc nhìn — chứ không phải bản thân việc một node chết — mới là thứ khó. Một node chết hẳn và mọi node đồng ý "nó chết" thì dễ xử lý (chuyển việc). Nguy hiểm là khi các node bất đồng về việc ai còn sống.

3. Process pause — vì sao một node "sống" lại bị tưởng chết

Split-brain nghe như phải cần một lỗi hiếm, nhưng nguyên nhân phổ biến nhất lại rất đời thường: process pause. Một tiến trình đang chạy có thể bị đóng băng vô hình trong hàng trăm mili-giây tới nhiều giây, mà bản thân nó không hề biết mình đã dừng:

  • GC stop-the-world: bộ dọn rác của runtime (JVM, Go, .NET) có thể dừng toàn bộ tiến trình để dọn heap — với heap lớn, một lần dừng có thể kéo dài hàng giây.
  • Swap / page fault: OS đẩy bộ nhớ tiến trình ra đĩa; khi tiến trình chạm vào, nó phải chờ đọc lại từ đĩa — chậm hàng nghìn lần.
  • VM bị treo (live migration, host quá tải): máy ảo có thể bị "đông cứng" khi hypervisor bận, rồi chạy tiếp như không có gì xảy ra.

Điểm chí mạng: từ bên trong, tiến trình bị pause không cảm nhận được thời gian đã trôi. Nó thực thi dòng lệnh A, bị pause 10 giây ngay sau đó, rồi thực thi dòng B như thể chỉ vừa mới xong A. Trong 10 giây đó, các node khác đã timeout, kết luận nó chết, và cấp quyền cho node khác. Khi nó "thức dậy" ở dòng B, nó vẫn tin mình đang giữ lock/leader — nhưng thực tế quyền đã bị thu hồi. Nó trở thành zombie: sống, hoạt động, nhưng làm việc dựa trên một sự thật đã lỗi thời.

Đây cũng là lý do không thể dựa "tôi vừa kiểm tra là mình còn giữ lock" để an toàn: giữa lúc kiểm tra và lúc ghi, một GC pause có thể chen vào, và lock đã đổi chủ mà tiến trình không hay. Kiểm tra thời gian (bài 02) cũng không cứu được: đồng hồ có thể đã nhảy trong lúc pause.

Nhớ lại Gossip

Bạn đã học gossip — cách các node lan truyền thông tin trạng thái (ai còn sống, ai vừa gia nhập) cho nhau theo kiểu "tin đồn", mỗi vòng mỗi node kể cho vài node ngẫu nhiên. Gossip giúp hệ hội tụ về một bức tranh thành viên chung mà không cần một máy điều phối trung tâm. Nhưng gossip không xoá được partial failure: nó phát hiện node nghi chết bằng chính timeout (bài 01), nên vẫn có thể đánh dấu nhầm một node đang pause là "chết". Vì vậy phát hiện thành viên (gossip) và chống zombie (fencing) là hai lớp khác nhau — gossip quyết định "coi ai là chết", fencing bảo vệ dữ liệu quyết định đó sai.

4. Lease + fencing token — chặn zombie một cách cơ học

Vì không thể phát hiện zombie đáng tin, ta vô hiệu hoá nó. Hai mảnh:

Lease — một lock có hạn dùng. Thay vì "giữ lock tới khi trả", node thuê quyền trong một khoảng (ví dụ 10 giây) và phải gia hạn liên tục. Nếu node chết/pause, lease hết hạn và hệ tự do cấp cho node khác. Nhưng lease một mình không đủ: node pause vẫn có thể thức dậy sau khi lease hết mà vẫn tưởng mình còn giữ (nó đâu biết đã pause). Cần mảnh thứ hai.

Fencing token — mỗi lần cấp quyền, bộ cấp phát (lock service) kèm một số nguyên tăng dần đơn điệu. Node phải đính token này vào mọi thao tác ghi tới tài nguyên. Tài nguyên (storage) nhớ token lớn nhất từng thấytừ chối mọi ghi mang token nhỏ hơn:

sequenceDiagram
  participant C1 as Client 1 (token 33)
  participant L as Lock service
  participant S as Storage
  C1->>L: xin lock
  L-->>C1: cap lock + token 33
  Note over C1: GC pause dai...
  L-->>L: lease het han
  L-->>C1: (client 2 xin, cap token 34)
  Note over C1: thuc day, van tuong giu lock
  C1->>S: ghi X (token 33)
  Note over S: da thay token 34 > 33
  S-->>C1: TU CHOI (token cu)

Cơ chế này đẹp ở chỗ không cần biết ai còn sống. Storage không hỏi "client 1 còn sống không?" — câu hỏi bất khả trả lời. Nó chỉ so số: token 33 nhỏ hơn 34 đã thấy → từ chối. Zombie tự vô hại vì token của nó chắc chắn cũ hơn token của kẻ thay thế (token chỉ tăng). Đây là ý tưởng cốt lõi: dời trách nhiệm an toàn từ "phát hiện chết chính xác" (bất khả) sang "so sánh số tăng dần" (tầm thường) — và điểm enforce phải nằm ở tài nguyên (storage), không phải ở client, vì client zombie không thể tự biết để lùi bước.

Lưu ý token phải do một nguồn duy nhất, tăng đơn điệu cấp — đúng loại đảm bảo mà một dịch vụ consensus (ZooKeeper, etcd — Module 6) cung cấp. Đó là một trong những lý do consensus tồn tại: sinh ra một dãy số mà mọi node đồng ý về thứ tự.

5. Đến lượt bạn — tự thiết kế fencing

Bạn đã có cơ chế. Giờ tự áp vào một tình huống mới trước khi xem đáp án mẫu. Đọc kỹ, tự phác giải pháp ra giấy (hoặc trong đầu) rồi mới mở <SelfCheck>.

Tình huống. Một hệ lưu file dùng lock để đảm bảo chỉ một worker ghi vào file report.pdf mỗi lúc. Worker xin lock từ một lock service, ghi file lên một object storage (ví dụ S3), rồi trả lock. Gần đây đội gặp sự cố: thỉnh thoảng report.pdf bị hỏng (nội dung của hai worker trộn vào nhau), dù lock "hoạt động đúng". Điều tra thấy các worker chạy JVM heap lớn, đôi khi GC pause 8–12 giây.

Hãy tự trả lời trước khi mở đáp án:

  1. Giải thích chính xác chuỗi sự kiện khiến file hỏng dù lock service không có bug.
  2. Thiết kế cơ chế fencing token cho hệ này: token do ai cấp, đính vào đâu, và object storage cần làm gì để chặn ghi cũ?
  3. Nếu object storage không hỗ trợ so sánh token (chỉ ghi đè mù), bạn còn cách nào để vẫn fence được không?
Tự kiểm tra
Q1
Đáp án mẫu — (1) chuỗi sự kiện làm hỏng report.pdf dù lock đúng; (2) thiết kế fencing token; (3) nếu storage không so token được thì làm sao?

(1) Chuỗi sự kiện: Worker 1 xin được lock và chuẩn bị ghi. Ngay trước khi gửi dữ liệu, nó dính GC pause 10 giây và đóng băng — không biết mình đã dừng. Trong 10 giây đó, lease của worker 1 hết hạn (hoặc lock service timeout coi nó chết), nên lock service cấp lock cho worker 2, và worker 2 ghi bản của nó lên report.pdf. Rồi worker 1 thức dậy đúng chỗ nó dừng, vẫn tin mình giữ lock, và ghi tiếp — đè hoặc trộn lên bản của worker 2. Lock service không có bug: nó đã đúng quy trình thu lock của 1 và cấp cho 2. Vấn đề là worker 1 thành zombie và storage không biết ghi của nó đã lỗi thời — split-brain do process pause.

(2) Thiết kế fencing token: lock service kèm một số nguyên tăng đơn điệu mỗi lần cấp lock (worker 1 nhận 33, worker 2 nhận 34) — số này phải từ một nguồn duy nhất tăng dần (dịch vụ consensus như ZooKeeper/etcd). Worker đính token vào mỗi request ghi. Object storage lưu "token lớn nhất từng ghi thành công cho report.pdf" và từ chối ghi mang token nhỏ hơn: worker 2 (34) ghi thì storage nhớ 34; worker 1 (33) thức dậy ghi, storage thấy 33 < 34 → từ chối. File không hỏng, và storage không cần biết ai còn sống — chỉ so số.

(3) Nếu storage không so token được: (a) Conditional write / compare-and-set — nhiều object storage hỗ trợ ghi có điều kiện (precondition If-Match theo ETag/version); dùng token làm version, ghi chỉ thành công nếu version kỳ vọng khớp. (b) Nhét token vào tên đối tượng — ghi report.pdf.v34 thay vì đè, rồi cập nhật con trỏ "bản mới nhất" qua một thao tác atomic có fencing; bản report.pdf.v33 của worker cũ vô hại vì không ai trỏ tới. (c) Lớp trung gian có fencing — mọi ghi qua một service nhỏ giữ token lớn nhất và từ chối token cũ trước khi chuyển xuống storage. Điểm chung: chỗ enforce phải là thứ zombie không vượt qua được và dựa trên so sánh atomic một giá trị tăng dần, không dựa client tự giác.

6. Pitfall — tin "tôi vừa kiểm tra là mình còn giữ lock"

Pitfall — check-then-act qua process pause

Cạm bẫy phổ biến là kiểm tra quyền rồi hành động, tưởng hai bước liền nhau:

❌ if (lockService.stillHolding(me)) {   // kiem tra: dung, con giu
       // <-- GC PAUSE 10s chen vao day; lock da doi chu
       storage.write(data);              // hanh dong: da la zombie
   }

✅ token = lockService.acquire();        // nhan token tang dan
   storage.write(data, token);           // storage tu choi neu token cu
   // -> an toan DU cho process pause chen giua

Vấn đề: giữa stillHoldingwrite, một pause có thể khiến lock đổi chủ mà tiến trình không hay (nó không cảm nhận thời gian trôi — mục 3). Fencing token an toàn vì việc kiểm tra dời xuống storage tại đúng thời điểm ghi, không phải ở client trước đó.

Hướng đúng: đừng bao giờ tách "kiểm tra quyền" khỏi "thực hiện ghi" bằng một khoảng có thể bị pause. Gắn token vào chính thao tác ghi và để tài nguyên phán xử tại thời điểm ghi — đó là điểm duy nhất có bức tranh đúng, và là điểm zombie không lách qua được.

7. 📚 Đào sâu — nguồn gốc & tài liệu

📚 Đào sâu — Partial failure, pauses & fencing

8. Liên hệ các bài khác

  • Bài 01 — Mạng không tin cậy: "không phân biệt được chậm với chết" là gốc của split-brain — timeout đánh dấu nhầm một node đang pause là chết.
  • Bài 02 — Đồng hồ không tin cậy: trong một process pause, đồng hồ có thể nhảy; nên không thể dùng thời gian để tự kiểm "mình còn giữ lock" — fencing bằng số tăng dần né hẳn thời gian.
  • Bài 03 — Causal order: fencing token là một dãy tăng đơn điệu — họ hàng với logical clock; cả hai dùng "số tăng dần" thay cho thời gian vật lý để áp một thứ tự đáng tin.
  • Module 6 — Consensus & Raft: nguồn cấp token tăng đơn điệu, duy nhất, mà mọi node đồng ý chính là thứ consensus tạo ra — bài này cho một động lực cụ thể vì sao cần consensus.

9. Tóm tắt

  • Partial failure: một phần hệ chết trong khi phần khác chạy, không node nào có bức tranh đầy đủ — khác hẳn máy đơn "all-or-nothing"; cái khó là các node bất đồng về ai còn sống.
  • Process pause (GC stop-the-world, swap, VM freeze) đóng băng một tiến trình vài giây mà nó không hay → bị node khác timeout coi là chết.
  • Split-brain: node bị tưởng chết thức dậy, vẫn tưởng giữ quyền → hai node cùng ghi → hỏng dữ liệu.
  • Lease (lock có hạn) tự thu quyền khi node im lặng, nhưng một mình không đủ (zombie thức dậy vẫn tưởng giữ).
  • Fencing token: quyền ghi kèm số tăng đơn điệu; tài nguyên từ chối token nhỏ hơn số lớn nhất đã thấy → zombie tự vô hại mà không cần biết ai còn sống. Điểm enforce phải ở storage, token phải do nguồn duy nhất tăng dần (consensus) cấp.
  • Nguyên lý: dời an toàn từ "phát hiện chết chính xác" (bất khả) sang "so sánh số tăng dần" (tầm thường).

10. Tự kiểm tra

Tự kiểm tra
Q1
Partial failure khác lỗi trên máy đơn thế nào, và vì sao 'các node bất đồng về ai còn sống' mới là phần khó nhất?

Trên máy đơn, lỗi thường toàn phần và tất định: hoặc chạy, hoặc crash hẳn — có thể suy luận rõ ràng. Partial failure là một phần hệ hỏng trong khi phần khác chạy, và không phần nào biết bức tranh đầy đủ: A có thể sống khi B chết, hoặc mạng A–B đứt khi cả hai đều sống.

Phần khó nhất không phải một node chết (nếu mọi node đồng ý "nó chết" thì chỉ việc chuyển việc), mà là các node bất đồng: A tin "B chết", B tin "tôi còn sống". Vì mỗi node chỉ thấy một góc và các góc mâu thuẫn, không có một "sự thật" chung để dựa vào — đó là gốc của split-brain.

Q2
Vì sao một tiến trình bị GC pause 10 giây lại nguy hiểm hơn là 'chỉ chậm một chút'? Điều gì khiến nó không tự biết để dừng lại?

Nguy hiểm vì từ bên trong, tiến trình bị pause không cảm nhận được thời gian đã trôi: nó chạy dòng lệnh A, bị đóng băng 10 giây ngay sau đó, rồi chạy dòng B như thể vừa xong A. Trong 10 giây đó, các node khác đã timeout, coi nó chết, và cấp quyền cho node khác.

Nó không tự biết để dừng vì pause là vô hình với chính nó — không có ngoại lệ, không tín hiệu. Nên "tôi vừa kiểm tra là còn giữ lock rồi mới ghi" cũng không an toàn: pause có thể chen vào giữa kiểm tra và ghi. Kiểm tra đồng hồ cũng vô ích vì đồng hồ có thể đã nhảy trong lúc pause (bài 02).

Q3
Fencing token chống zombie 'mà không cần biết ai còn sống'. Giải thích chính xác cơ chế đó và vì sao nó luôn chặn được token cũ.

Mỗi lần cấp quyền, bộ cấp phát kèm một số nguyên tăng đơn điệu (token). Node đính token vào mọi ghi. Tài nguyên (storage) lưu token lớn nhất từng thấy và từ chối ghi mang token nhỏ hơn. Vì token chỉ tăng, kẻ thay thế luôn có token lớn hơn kẻ bị thay; nên khi zombie (token cũ) thức dậy và ghi, số của nó chắc chắn nhỏ hơn số storage đã thấy → bị từ chối.

Nó không cần biết ai còn sống vì storage không hỏi "client này còn sống không" (câu hỏi bất khả trả lời — bài 01) mà chỉ so hai số. An toàn được dời từ "phát hiện chết chính xác" sang "so sánh số tăng dần", một việc tầm thường và tất định.

Q4
Vì sao điểm kiểm tra fencing token phải nằm ở STORAGE (tài nguyên) chứ không ở client? Đặt ở client thì hỏng ở đâu?

Vì client zombie không biết mình đã lỗi thời — nó vẫn tin mình giữ quyền. Nếu để client tự kiểm "token của mình còn hợp lệ không rồi mới ghi", chính client zombie sẽ trả lời "hợp lệ" (theo hiểu biết đã cũ của nó) và vẫn ghi. Kẻ cần bị chặn lại là kẻ tự cho mình quyền.

Storage là điểm duy nhất thấy được thứ tự thật của các token (nó thấy token 34 trước khi zombie token 33 tới) và là điểm zombie buộc phải đi qua để gây hại. Đặt kiểm tra ở đó biến việc phòng thủ thành một phép so sánh atomic mà không client nào lách được — kể cả client đang tin sai về trạng thái của mình.

Q5
Fencing token cần một dãy số 'tăng đơn điệu, duy nhất, mọi node đồng ý'. Vì sao đây lại là lý do người ta cần tới consensus (Module 6)?

Nếu để mỗi node tự sinh token, hai node có thể sinh trùng số hoặc sinh số không theo một thứ tự chung — phá vỡ đảm bảo "kẻ sau luôn có số lớn hơn". Cần một nguồn duy nhất phát ra một dãy số mà mọi node đồng ý về thứ tự, kể cả khi mạng và đồng hồ không tin cậy.

Tạo ra một dãy số như vậy — một quyết định chung, nhất quán, bền vững giữa các node dù có lỗi — chính là bài toán consensus. Các dịch vụ như ZooKeeper/etcd cung cấp đúng thứ đó (ví dụ zxid tăng dần), nên chúng thường là nơi cấp fencing token. Bài này là một động lực cụ thể cho thấy vì sao consensus (Module 6) cần thiết chứ không hàn lâm.

Bài tiếp theo: Module 5 — Tổng kết & cheat sheet

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

Đặt 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