Giao diện
Đồng thuận
Consensus (đồng thuận) là bài toán làm cho nhiều node cùng thống nhất một giá trị, hoặc một chuỗi giá trị theo cùng một thứ tự, kể cả khi có node chết hay mạng bị chia cắt. Đây là nền của distributed lock, bầu leader, lưu cấu hình cluster, và mọi database cần linearizable mà vẫn chạy được khi có máy hỏng. Trang này giải thích Raft, thuật toán consensus dễ hiểu và phổ biến nhất hiện nay, bằng mô phỏng và bằng số đo thật trên một cluster etcd 3 node trong lab/dong-thuan/.
Consensus giải bài toán gì
Một thuật toán consensus phải đảm bảo:
- Agreement: mọi node không bị lỗi cùng quyết định một giá trị.
- Validity: giá trị được quyết định phải là giá trị có node đề xuất, không phải giá trị bịa ra.
- Termination: mọi node còn sống cuối cùng đều đi tới quyết định.
Với một chuỗi giá trị thay vì một giá trị, consensus trở thành log replication: mọi node có cùng một log theo cùng thứ tự, và mỗi node áp dụng log đó vào bản sao dữ liệu của mình (state machine replication). Đây cũng chính là total order broadcast mà trang Replication nhắc tới: thứ cần có để làm lock, kiểm tra trùng, hay bất cứ việc gì đòi hỏi linearizable.
Consensus cần đa số: cluster 2f + 1 node chịu được f node chết. Lý do là hai đa số bất kỳ luôn có ít nhất một node chung, nên một quyết định đã được đa số ghi nhận không thể bị một đa số khác phủ nhận mà không ai biết. Vì vậy cluster thường có 3 hoặc 5 node, và luôn là số lẻ: 4 node cũng chỉ chịu được 1 node chết như 3 node (bảng của etcd).
Có một giới hạn lý thuyết: kết quả FLP (Fischer, Lynch, Paterson, 1985) chứng minh rằng trong một mạng hoàn toàn không có giới hạn thời gian, không thuật toán tất định nào đảm bảo luôn đi tới quyết định khi có dù chỉ một node có thể chết. Các thuật toán thực tế như Paxos và Raft xử lý giới hạn này bằng cách dùng timeout để tiến lên (bầu leader mới khi lâu không nghe thấy leader), nhưng không bao giờ dùng thời gian để đảm bảo đúng. Mạng chậm bất thường chỉ làm hệ thống tạm dừng, không làm nó quyết định sai.
Raft
Raft (Ongaro và Ousterhout, 2014) chia bài toán thành ba phần: bầu leader, sao chép log, và an toàn.
Term và ba vai trò
Thời gian được chia thành các term đánh số tăng dần. Mỗi term bắt đầu bằng một lần bầu cử, và có nhiều nhất một leader. Mỗi node ở một trong ba vai trò:
- Follower: chỉ trả lời tin nhắn của leader và candidate.
- Candidate: đang ứng cử làm leader.
- Leader: nhận mọi lệnh ghi từ client, sao chép log cho follower, và gửi heartbeat đều đặn.
Node nào thấy một tin nhắn mang term lớn hơn term của mình thì cập nhật term và lùi về làm follower. Nhờ vậy một leader cũ bị cô lập, khi liên lạc lại được, sẽ biết mình đã bị thay.
Bầu leader
Follower không nhận được heartbeat trong một khoảng election timeout thì tăng term, tự vote cho mình, và gửi RequestVote tới các node khác. Mỗi node chỉ vote cho một candidate trong mỗi term. Candidate được đa số phiếu thì thành leader.
Nếu nhiều follower cùng hết timeout một lúc thì phiếu bị chia nhỏ và không ai đủ đa số. Raft tránh chuyện này bằng election timeout ngẫu nhiên: bài báo lấy ví dụ chọn ngẫu nhiên trong khoảng 150–300 ms, nên thường chỉ một node hết timeout trước và kịp thắng. etcd mặc định gửi heartbeat mỗi 100 ms và dùng election timeout 1.000 ms; tài liệu etcd khuyên election timeout gấp ít nhất 10 lần round trip giữa các node.
Điều kiện để được bầu (bài báo, mục 5.4.1): node chỉ vote cho candidate có log ít nhất mới bằng log của mình. Log mới hơn là log có entry cuối mang term lớn hơn; nếu term bằng nhau thì log dài hơn mới hơn. Vì candidate phải được đa số vote, và mỗi entry đã commit nằm ở đa số node, nên leader mới luôn có đủ mọi entry đã commit.
Sao chép log và commit
Leader thêm lệnh ghi vào log của mình, rồi gửi AppendEntries cho các follower. Mỗi AppendEntries kèm vị trí và term của entry đứng ngay trước các entry mới. Follower thấy entry đứng trước không khớp thì từ chối, và leader lùi lại gửi từ vị trí sớm hơn cho tới khi khớp; các entry của follower khác với leader bị ghi đè. Log chỉ chảy một chiều, từ leader sang follower, và leader không bao giờ ghi đè log của chính mình.
Một entry được coi là commit khi leader thấy nó đã nằm ở đa số node. Lúc đó leader áp dụng entry và trả lời client. Có một quy tắc tinh tế (mục 5.4.2): leader chỉ commit entry của term hiện tại bằng cách đếm số node đã có nó. Entry của term cũ, dù đã nằm ở đa số, vẫn có thể bị một leader sau ghi đè, nên chỉ được commit gián tiếp khi một entry của term hiện tại đứng sau nó được commit. Vì vậy leader mới thường ghi ngay một entry rỗng (no-op) ở đầu term.
Chạy thử, bấm vào node để giết nó, và chia cắt mạng:
t = 0 · election timeout 10–19 tick, heartbeat mỗi 3 tick, tin nhắn mất 1 tick
Mạng
Client ghi vàoBấm vào một node để giết hoặc hồi sinh nó.
leadercandidate / tin nhắn bầu cửAppendEntries
Log của từng node (ô đặc: đã commit; số là term của entry)
Sự kiện gần đây
| 1 | 2 | 3 | 4 | 5 | 6 | |
|---|---|---|---|---|---|---|
| S1 | ||||||
| S2 | ||||||
| S3 | ||||||
| S4 | ||||||
| S5 |
Thử: chạy cho tới khi có leader, ghi vài lần, rồi cắt leader ra một mình, ghi vào leader cũ và leader mới, sau đó nối lại mạng. Mô phỏng rút gọn: không có snapshot, thay đổi thành viên, pre-vote; tick là đơn vị thời gian giả định.
Thử cắt leader ra một mình rồi ghi vào nó. Entry đó nằm trong log của leader cũ nhưng không bao giờ được commit, vì leader cũ không có đa số. Khi nối lại mạng, leader cũ thấy term lớn hơn, lùi về làm follower, và entry chưa commit bị ghi đè bằng log của leader mới. Client đã gửi lệnh đó không nhận được trả lời thành công, nên không có lệnh ghi đã báo OK nào bị mất.
Đọc linearizable
Leader trả lời lệnh đọc ngay từ dữ liệu của mình thì có thể sai: nó có thể đã bị thay mà chưa biết. Bài báo Raft (mục 8) cần hai bước để đọc linearizable mà không phải ghi gì vào log:
- Leader phải biết entry nào đã commit, nên đầu term nó commit một entry no-op.
- Trước khi trả lời một lệnh đọc, leader trao đổi heartbeat với đa số để chắc mình vẫn là leader.
Bài báo cũng nêu cách nhanh hơn là leader dựa vào lease theo thời gian, nhưng cách này phụ thuộc giả định độ lệch đồng hồ có giới hạn. etcd mặc định đọc linearizable, và cho đọc serializable để trả lời ngay từ dữ liệu của node được hỏi, nhanh hơn nhưng có thể cũ.
Chọn thời gian
Bài báo đưa ra bất đẳng thức broadcastTime ≪ electionTimeout ≪ MTBF. Thời gian gửi tin nhắn tới mọi node và nhận trả lời phải nhỏ hơn election timeout một bậc, để heartbeat kịp đến và không có bầu cử vô cớ. Election timeout phải nhỏ hơn nhiều so với thời gian trung bình giữa hai lần máy hỏng (MTBF). Khi leader chết, hệ thống không ghi được trong khoảng chừng một election timeout.
Đo thật trên cluster etcd
Cluster etcd 3.5.21 với 3 node trong Docker trên cùng một máy, cấu hình mặc định. Chia cắt mạng bằng docker network disconnect. Hai lượt ngày 2026-10-21:
| Thử nghiệm | Kết quả |
|---|---|
| Ghi, gửi tới leader | p50 0,85–0,88 ms |
| Ghi, gửi tới follower (follower chuyển cho leader) | p50 0,78–0,85 ms |
| Đọc linearizable trên follower | p50 0,69–0,77 ms |
| Đọc serializable trên follower | p50 0,47–0,56 ms |
Trên cùng một máy, round trip giữa các node gần như bằng 0, nên ghi qua leader hay qua follower đều chưa tới 1 ms. Qua mạng thật, mỗi lệnh ghi tốn thêm ít nhất một round trip tới đa số node.
Cắt một follower khỏi mạng, rồi ghi giá trị mới qua leader:
| Trên follower bị cắt | Kết quả |
|---|---|
| Đọc serializable | Trả giá trị cũ |
| Đọc linearizable | Lỗi context deadline exceeded sau 2 giây |
| Nối lại mạng | Follower có giá trị mới sau 0,9–1,0 giây |
Cắt leader khỏi mạng, lặp 6 lần qua hai lượt:
| Kết quả | |
|---|---|
| Phía đa số ghi được lại sau | 1.314–1.971 ms (5 trên 6 lần trong khoảng 1,3–1,4 giây) |
| Term | Tăng thêm 1 mỗi lần |
| Ghi vào leader cũ (bị cắt) | Lỗi sau khoảng 2,1 giây |
| Nối lại mạng | Leader cũ trở thành follower ở term mới |
Thời gian phục hồi khớp với election timeout mặc định 1 giây cộng phần ngẫu nhiên và thời gian bầu. Trong 1,3 giây đó, cluster không nhận ghi.
Cắt hai node, node còn lại một mình: ghi bị treo cho tới khi client hết 3 giây chờ; đọc serializable vẫn trả giá trị đang có; đọc linearizable thì lỗi. Sau khi nối lại, term tăng thêm 2, tức là đã có hai lần bầu cử trong lúc bị chia cắt.
Khác với mô phỏng ở trên, leader cũ của etcd bị cắt ra không giữ vai trò leader mãi: lệnh ghi vào nó bị lỗi thay vì nằm chờ. Cơ chế cho leader tự lùi xuống khi không còn nghe thấy đa số không có trong thuật toán Raft cơ bản của bài báo, nên mô phỏng không có.
Split brain và fencing token
Raft đảm bảo bên trong cluster không có hai leader cùng commit được trong một term. Nhưng thế giới bên ngoài cluster vẫn có thể thấy hai “leader”: leader cũ bị cô lập chưa biết mình bị thay, và nếu nó đang giữ một tài nguyên bên ngoài (một file, một job đang chạy) thì nó vẫn tiếp tục làm. Cách chặn là gửi kèm một số tăng dần từ cluster, ví dụ term hay revision, tới tài nguyên bên ngoài, và tài nguyên từ chối mọi số cũ hơn số từng nhận. Đó chính là fencing token ở trang Distributed lock.
Raft, Paxos và các hệ thống dùng chúng
Paxos (Leslie Lamport) có trước Raft và chứng minh cùng các đảm bảo, nhưng nổi tiếng là khó hiểu và khó cài đặt đúng; Raft được thiết kế với mục tiêu chính là dễ hiểu. Multi-Paxos khi chạy với một leader ổn định thì hoạt động gần giống Raft. Một số hệ thống thực tế:
| Hệ thống | Thuật toán | Dùng để |
|---|---|---|
| etcd | Raft | Lưu trạng thái của Kubernetes, lock, leader election |
| Consul | Raft | Service discovery, cấu hình |
| ZooKeeper | Zab | Điều phối, lock, cấu hình (Kafka cũ, HBase) |
| Kafka KRaft | Biến thể của Raft | Lưu metadata của Kafka, thay ZooKeeper |
| MySQL Group Replication | Biến thể của Paxos | Replication có tự failover, không mất lệnh ghi đã commit |
| CockroachDB, TiDB | Raft cho từng khoảng dữ liệu | Database phân tán linearizable |
| Spanner | Paxos cho từng nhóm | Database phân tán toàn cầu |
Với ứng dụng web thông thường, ít khi phải tự cài consensus. Cách hay gặp là dùng một hệ có sẵn (etcd, Consul, ZooKeeper) cho lock và bầu leader, hoặc chọn một database đã chạy consensus bên trong.
Câu hỏi phỏng vấn hay gặp
Vì sao cluster consensus có số node lẻ? Vì cần đa số. Thêm một node để thành số chẵn không giúp chịu thêm lỗi (4 node vẫn chỉ chịu được 1 node chết, như 3 node), mà mỗi lệnh ghi lại phải chờ thêm một node.
Leader chết thì sao? Follower không nhận heartbeat quá election timeout thì ứng cử; node có log đủ mới và được đa số vote thành leader của term mới. Trong khoảng đó cluster không ghi được; đo trên etcd mặc định là khoảng 1,3 giây.
Mạng chia đôi thì có hai leader không? Phía thiểu số có thể vẫn có một node tưởng mình là leader, nhưng nó không commit được gì vì không có đa số. Chỉ phía đa số bầu được leader commit được. Khi nối lại, node cũ thấy term lớn hơn và lùi về follower.
Vì sao không dùng quorum đọc ghi kiểu Cassandra thay cho consensus? Quorum không xếp các lệnh ghi theo một thứ tự chung và không linearizable (Replication). Consensus xếp mọi lệnh ghi vào một log chung trước khi áp dụng.
Ghi nhớ
- Consensus làm nhiều node đồng ý một chuỗi giá trị theo cùng thứ tự (log replication). Cluster 2f + 1 node chịu được f node chết; dùng 3 hoặc 5 node.
- Raft: term tăng dần, mỗi term nhiều nhất một leader; election timeout ngẫu nhiên tránh chia phiếu; chỉ vote cho candidate có log ít nhất mới bằng mình.
- Entry commit khi đa số có; leader chỉ commit entry của term hiện tại bằng cách đếm, nên ghi no-op ở đầu term. Entry chưa commit của leader cũ có thể bị ghi đè.
- Timeout chỉ dùng để tiến lên (bầu leader mới), không dùng để đảm bảo đúng. Đo trên etcd mặc định: leader bị cắt thì khoảng 1,3 giây sau phía đa số mới ghi được.
- Đọc linearizable phải xác nhận leader còn là leader; đọc serializable nhanh hơn nhưng node bị cô lập trả dữ liệu cũ.
- Bên ngoài cluster vẫn cần fencing token, vì leader cũ có thể chưa biết mình bị thay.
Nguồn tham khảo
Đã đối chiếu (đọc nguồn ngày 2026-10-21):
| Nguồn | Dùng cho phần |
|---|---|
| Diego Ongaro, John Ousterhout, In Search of an Understandable Consensus Algorithm (Raft), 2014 | Election timeout ngẫu nhiên (ví dụ 150–300 ms), chia phiếu, điều kiện được bầu (§5.4.1), chỉ commit entry của term hiện tại (§5.4.2), log chỉ chảy từ leader, broadcastTime ≪ electionTimeout ≪ MTBF, đọc linearizable với no-op và heartbeat đa số, lease phụ thuộc độ lệch đồng hồ (§8) |
| etcd docs, Tuning | Heartbeat mặc định 100 ms, election timeout mặc định 1.000 ms, election timeout gấp ít nhất 10 lần round trip, tối đa 50 giây |
| etcd docs, Run etcd clusters inside containers và gRPC gateway | Image, các tham số dựng cluster, API JSON dùng trong lab |
| etcd docs, FAQ (đã dùng ở trang Distributed lock) | Số node lẻ, bảng đa số và số node hỏng chịu được |
Từ kiến thức chung, chưa đối chiếu lần này:
- Michael Fischer, Nancy Lynch, Michael Paterson, Impossibility of Distributed Consensus with One Faulty Process (1985), kết quả FLP.
- Leslie Lamport, The Part-Time Parliament (1998) và Paxos Made Simple (2001); Multi-Paxos với leader ổn định gần giống Raft.
- Thuật toán của các hệ thống trong bảng: Zab của ZooKeeper, KRaft của Kafka, Paxos trong MySQL Group Replication (XCom), Raft trong Consul, CockroachDB, TiDB.
- Cơ chế leader tự lùi xuống khi mất đa số và pre-vote của etcd; lab chỉ quan sát được kết quả (ghi vào leader cũ bị lỗi, term tăng thêm 2 sau khi mất đa số).
Code đã dùng để chạy thử
Dựng cluster bằng npm run lab -- raft:up (tải image etcd khoảng 84 MB lần đầu), chạy bằng npm run lab -- dong-thuan (khoảng 30 giây), xoá bằng npm run lab -- raft:down. Không cần lab:up.
lab/dong-thuan/docker-compose.yml: cluster etcd 3 node
yml
# Cluster etcd 3 node cho trang distributed/dong-thuan. Tách riêng khỏi lab MySQL/PostgreSQL.
# docker compose -f dong-thuan/docker-compose.yml up -d (npm run raft:up)
# docker compose -f dong-thuan/docker-compose.yml down -v (npm run raft:down)
# Client API (JSON gateway) mở trên 127.0.0.1:23791, 23792, 23793. Mặc định của etcd: heartbeat 100 ms, election timeout 1000 ms.
x-etcd: &etcd
image: gcr.io/etcd-development/etcd:v3.5.21
networks: [raft]
services:
etcd1:
<<: *etcd
container_name: lab-etcd1
ports: ['127.0.0.1:23791:2379']
command: >
/usr/local/bin/etcd --name etcd1 --data-dir /etcd-data
--listen-peer-urls http://0.0.0.0:2380 --initial-advertise-peer-urls http://etcd1:2380
--listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://etcd1:2379
--initial-cluster etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
--initial-cluster-state new --initial-cluster-token lab-raft
etcd2:
<<: *etcd
container_name: lab-etcd2
ports: ['127.0.0.1:23792:2379']
command: >
/usr/local/bin/etcd --name etcd2 --data-dir /etcd-data
--listen-peer-urls http://0.0.0.0:2380 --initial-advertise-peer-urls http://etcd2:2380
--listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://etcd2:2379
--initial-cluster etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
--initial-cluster-state new --initial-cluster-token lab-raft
etcd3:
<<: *etcd
container_name: lab-etcd3
ports: ['127.0.0.1:23793:2379']
command: >
/usr/local/bin/etcd --name etcd3 --data-dir /etcd-data
--listen-peer-urls http://0.0.0.0:2380 --initial-advertise-peer-urls http://etcd3:2380
--listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://etcd3:2379
--initial-cluster etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
--initial-cluster-state new --initial-cluster-token lab-raft
networks:
raft:
name: lab-raftlab/dong-thuan/raft.mjs: latency, cắt follower, cắt leader, mất đa số
js
// Trang distributed/dong-thuan: Raft trên cluster etcd 3 node thật (npm run raft:up). Chỉ cần Docker.
// Chia cắt mạng bằng `docker network disconnect`: container vẫn chạy nhưng không liên lạc được với node khác.
// Node bị cắt thì không gọi được từ máy host, nên đọc trên node đó chạy bằng `docker exec … etcdctl`.
// 1. Latency ghi và đọc: gửi tới leader hay follower, đọc linearizable hay serializable.
// 2. Cắt một follower: đọc serializable trên nó ra giá trị cũ, đọc linearizable thì lỗi.
// 3. Cắt leader: phía đa số mất bao lâu để bầu leader mới và ghi được; leader cũ không ghi được nữa. Lặp 3 lần.
// 4. Cắt hai node: node còn lại không đủ đa số, không ghi được.
import { execSync } from 'node:child_process'
const NODES = [1, 2, 3].map((i) => ({ name: `etcd${i}`, port: 23790 + i, container: `lab-etcd${i}` }))
const NET = 'lab-raft'
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const b64 = (s) => Buffer.from(s).toString('base64')
const pct = (a, p) => { const s = [...a].sort((x, y) => x - y); return s[Math.min(s.length - 1, Math.ceil(p * s.length) - 1)] }
const ms = (v) => (v < 10 ? v.toFixed(2) : Math.round(v).toString()).replace('.', ',') + ' ms'
async function call(port, path, body, timeout = 500) {
const r = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', body: JSON.stringify(body), signal: AbortSignal.timeout(timeout) })
const j = await r.json()
if (j.error || j.code) throw new Error(j.error || j.message)
return j
}
const put = (n, k, v, t) => call(n.port, '/v3/kv/put', { key: b64(k), value: b64(v) }, t)
const get = async (n, k, serializable = false) => {
const j = await call(n.port, '/v3/kv/range', { key: b64(k), serializable })
return j.kvs ? Buffer.from(j.kvs[0].value, 'base64').toString() : null
}
async function status(n) { try { return await call(n.port, '/v3/maintenance/status', {}, 300) } catch { return null } }
async function leaderOf(nodes = NODES) {
for (const n of nodes) {
const s = await status(n)
if (s?.leader && s.leader !== '0') {
for (const m of nodes) { const t = await status(m); if (t?.header.member_id === s.leader) return { node: m, term: Number(s.raftTerm) } }
}
}
return null
}
// Đọc trên một node bị cắt: chạy etcdctl ngay trong container đó
function ctlGet(n, k, consistency) {
try {
const out = execSync(`docker exec ${n.container} etcdctl --endpoints=http://127.0.0.1:2379 --command-timeout=2s get ${k} --consistency=${consistency} --print-value-only`, { stdio: ['ignore', 'pipe', 'pipe'] })
return out.toString().trim()
} catch (e) { return 'lỗi: ' + (e.stderr?.toString().trim().split('\n').pop() || e.message).slice(0, 80) }
}
function ctlPut(n, k, v) {
const t0 = performance.now()
try { execSync(`docker exec ${n.container} etcdctl --endpoints=http://127.0.0.1:2379 --command-timeout=2s put ${k} ${v}`, { stdio: ['ignore', 'pipe', 'pipe'] }); return `OK sau ${Math.round(performance.now() - t0)} ms` }
catch (e) { return `lỗi sau ${Math.round(performance.now() - t0)} ms: ` + (e.stderr?.toString().trim().split('\n').pop() || '').slice(0, 70) }
}
const cut = (n) => execSync(`docker network disconnect ${NET} ${n.container}`)
const heal = (n) => execSync(`docker network connect --alias ${n.name} ${NET} ${n.container}`)
async function waitLeader(nodes = NODES) { for (let i = 0; i < 100; i++) { const l = await leaderOf(nodes); if (l) return l; await sleep(100) } throw new Error('không có leader') }
// ---------- 0. sẵn sàng ----------
let L = await waitLeader()
console.log(`\nLeader hiện tại: ${L.node.name}, term ${L.term}`)
// ---------- 1. latency ----------
console.log('\n=== 1. Latency, 300 lần mỗi kiểu ===')
{
const F = NODES.find((n) => n !== L.node)
const run = async (name, fn) => {
const lat = []
for (let i = 0; i < 300; i++) { const t0 = performance.now(); await fn(i); lat.push(performance.now() - t0) }
console.log(`${name.padEnd(44)} p50 ${ms(pct(lat, 0.5))}, p99 ${ms(pct(lat, 0.99))}`)
}
await run(`ghi, gửi tới leader (${L.node.name})`, (i) => put(L.node, 'k', 'v' + i))
await run(`ghi, gửi tới follower (${F.name}, follower chuyển cho leader)`, (i) => put(F, 'k', 'v' + i))
await run('đọc linearizable trên follower (mặc định)', () => get(F, 'k'))
await run('đọc serializable trên follower', () => get(F, 'k', true))
}
// ---------- 2. cắt một follower ----------
console.log('\n=== 2. Cắt một follower khỏi mạng ===')
{
L = await waitLeader()
const F = NODES.find((n) => n !== L.node)
await put(L.node, 'gia', 'cu')
await sleep(300)
cut(F)
await put(L.node, 'gia', 'moi')
console.log(`ghi gia = moi qua leader ${L.node.name}: OK (leader và follower còn lại là đa số)`)
console.log(`${F.name} bị cắt, đọc serializable: ${ctlGet(F, 'gia', 's')}`)
console.log(`${F.name} bị cắt, đọc linearizable: ${ctlGet(F, 'gia', 'l')}`)
heal(F)
const t0 = performance.now()
let v
for (let i = 0; i < 100; i++) { v = ctlGet(F, 'gia', 's'); if (v === 'moi') break; await sleep(100) }
console.log(`nối lại mạng: ${F.name} đọc serializable ra '${v}' sau ${Math.round(performance.now() - t0)} ms`)
}
// ---------- 3. cắt leader ----------
console.log('\n=== 3. Cắt leader khỏi mạng (3 lần) ===')
for (let round = 1; round <= 3; round++) {
await sleep(1500)
L = await waitLeader()
const old = L.node, others = NODES.filter((n) => n !== old)
cut(old)
const t0 = performance.now()
let ok = null
for (let i = 0; i < 200 && !ok; i++) {
for (const n of others) { try { await put(n, 'k', 'sau-' + round, 300); ok = n; break } catch {} }
if (!ok) await sleep(50)
}
const tWrite = Math.round(performance.now() - t0)
const nl = await waitLeader(others)
const onOld = ctlPut(old, 'k', 'tu-leader-cu')
heal(old)
await sleep(1500)
const after = await status(old)
console.log(`lần ${round}: cắt ${old.name} (term ${L.term}) → phía đa số ghi được sau ${tWrite} ms, leader mới ${nl.node.name} term ${nl.term}; ghi vào leader cũ: ${onOld}; nối lại: ${old.name} là ${after?.leader === after?.header.member_id ? 'leader' : 'follower'}, term ${after?.raftTerm}`)
}
// ---------- 4. cắt hai node ----------
console.log('\n=== 4. Cắt hai node, node còn lại không đủ đa số ===')
{
await sleep(1500)
L = await waitLeader()
const keep = L.node, gone = NODES.filter((n) => n !== keep)
gone.forEach(cut)
await sleep(200)
const t0 = performance.now()
let msg
try { await put(keep, 'k', 'thieu-so', 3000); msg = 'OK (không đúng!)' } catch (e) { msg = `lỗi sau ${Math.round(performance.now() - t0)} ms (${e.name === 'TimeoutError' ? 'client chờ hết 3 s' : e.message})` }
console.log(`ghi vào ${keep.name} (leader cũ, giờ chỉ còn một mình): ${msg}`)
console.log(`đọc serializable trên ${keep.name}: ${ctlGet(keep, 'k', 's')}`)
console.log(`đọc linearizable trên ${keep.name}: ${ctlGet(keep, 'k', 'l')}`)
gone.forEach(heal)
const nl = await waitLeader()
console.log(`nối lại mạng: leader ${nl.node.name}, term ${nl.term}`)
}