Thuật toán Ứng dụng — DP, String, Big Data & hơn nữa
35/38
Bài 35 / 38~25 phútThuật toán phân tánMiễn phí lượt xem

Raft — đồng thuận leader & log replication

Thuật toán consensus dễ hiểu: bầu leader, nhân bản log, commit khi đa số xác nhận. Nền của etcd, Consul, TiKV.

TL;DR: Raft giải quyết bài toán consensus — nhiều node thống nhất một chuỗi lệnh (replicated log) dù có node chết hay mạng phân vùng. Raft chia thành 3 phần tách biệt: (1) leader election — dùng randomized timeout + term để bầu một leader duy nhất; (2) log replication — leader nhận lệnh từ client, ghi vào log rồi replicate sang follower, commit khi đa số (quorum) xác nhận; (3) safety — chỉ bầu node có log mới nhất, đảm bảo không bao giờ mất entry đã commit. Raft được thiết kế để dễ hiểu hơn Paxos — tách rõ từng phần, không có trường hợp đặc biệt ẩn. Nền tảng của etcd, Consul, TiKV, CockroachDB.

Năm 2013, Ongaro và Ousterhout đặt câu hỏi: tại sao không ai thật sự hiểu Paxos đủ để implement đúng mà không có bug? Họ thiết kế Raft từ đầu với nguyên tắc understandability: mọi quyết định thiết kế đều ưu tiên dễ hiểu hơn hiệu suất tối đa. Kết quả là một thuật toán có thể giải thích đầy đủ trong 30 trang — và đã được implement thành công trong hàng trăm hệ thống production.

1. Bài toán consensus — tại sao cần

Replicated state machine: giả sử bạn muốn một database chịu được node failure. Cách tiếp cận: chạy cùng database trên 5 node, mọi lệnh ghi (write) phải được áp dụng theo cùng thứ tự trên tất cả node. Nếu node 1 áp dụng lệnh A rồi B, node 2 áp dụng B rồi A → state khác nhau → inconsistent.

Consensus là đảm bảo tất cả node thống nhất về cùng một chuỗi lệnh (replicated log), dù có node chết giữa chừng hay mạng bị phân vùng tạm thời. Yêu cầu cốt lõi:

  • Safety: không bao giờ trả về kết quả sai — nếu hai node quyết định thì quyết định phải giống nhau.
  • Liveness: cuối cùng sẽ tiến được (không block mãi), miễn là đa số node còn hoạt động.

Raft đảm bảo safety tuyệt đối và liveness khi mạng ổn định (không có split-vote liên tục).

2. Các khái niệm cốt lõi

Term: đơn vị thời gian logic trong Raft — mỗi election tăng term lên 1. Term đóng vai trò như "thế hệ leader". Mọi message đều mang term; node nhận message từ term cũ hơn → bỏ qua.

Trạng thái node: mỗi node trong Raft luôn ở một trong ba trạng thái:

Ba trạng thái Raft và các cửa chuyển: Follower, Candidate, Leader

Follower và Leader đều là trạng thái ổn định; Candidate là trạng thái tạm, và là chỗ duy nhất có thể quay về tay trắng. Không ai đủ đa số (split vote) thì phải làm lại với term mới — đó là lý do election timeout phải ngẫu nhiên chứ không phải một hằng số.

  • Follower: trạng thái mặc định. Nhận AppendEntries từ leader, vote cho candidate. Nếu không nhận heartbeat trong election_timeout → chuyển sang Candidate.
  • Candidate: đang xin vote. Gửi RequestVote tới tất cả node. Nếu nhận đa số → thành Leader.
  • Leader: nhận lệnh từ client, replicate sang follower, quyết định commit. Gửi heartbeat (AppendEntries rỗng) định kỳ để follower không timeout.

Log entry: mỗi entry gồm (term, index, command). index là vị trí trong log (1-based). term là term khi entry được tạo.

Commit index: vị trí log cao nhất đã được commit (đa số xác nhận). Chỉ entry đã commit mới được apply vào state machine.

3. Leader election

3.1 Randomized timeout — tránh split vote

Mỗi follower có election_timeout ngẫu nhiên trong khoảng [150ms, 300ms]. Node nào hết timeout trước → khởi động election, tăng term, tự vote cho mình, gửi RequestVote.

Tại sao ngẫu nhiên? Nếu timeout cố định, tất cả follower timeout cùng lúc → tất cả đều thành candidate → split vote (không ai đủ đa số). Timeout ngẫu nhiên đảm bảo trong phần lớn trường hợp chỉ một node timeout trước, kịp thu đủ vote trước khi các node khác timeout.

Plain Text
-- Tại mỗi follower, chạy liên tục
function election_timer(self):
    while true:
        đặt timeout T <- ngẫu nhiên trong [150ms, 300ms]
        chờ T milliseconds
        if không nhận heartbeat trong T:
            khởi_động_election(self)

function khởi_động_election(self):
    self.currentTerm <- self.currentTerm + 1
    self.state <- CANDIDATE
    self.votedFor <- self.nodeId        -- tự vote cho mình
    votes_received <- 1
    for each peer trong cluster:
        gửi RequestVote(term=self.currentTerm,
                        candidateId=self.nodeId,
                        lastLogIndex=self.log.lastIndex(),
                        lastLogTerm=self.log.lastTerm())

3.2 RequestVote — election restriction

Plain Text
function on_RequestVote(self, req):
    -- Từ chối nếu term cũ hơn
    if req.term < self.currentTerm:
        return VoteResponse(granted=false, term=self.currentTerm)

    -- Cập nhật term nếu nhận term mới hơn
    if req.term > self.currentTerm:
        self.currentTerm <- req.term
        self.state <- FOLLOWER
        self.votedFor <- null

    -- Chỉ vote nếu chưa vote trong term này VÀ log của candidate ít nhất mới bằng mình
    already_voted <- self.votedFor != null and self.votedFor != req.candidateId
    log_ok <- (req.lastLogTerm > self.log.lastTerm()) or
              (req.lastLogTerm = self.log.lastTerm() and
               req.lastLogIndex >= self.log.lastIndex())

    if not already_voted and log_ok:
        self.votedFor <- req.candidateId
        reset_election_timer(self)
        return VoteResponse(granted=true, term=self.currentTerm)
    else:
        return VoteResponse(granted=false, term=self.currentTerm)
// Time: O(n) message per election  Space: O(1) per node

Election restriction là điểm mấu chốt của safety: node chỉ vote cho candidate có log "ít nhất mới bằng" mình. "Mới hơn" nghĩa là: lastLogTerm lớn hơn, hoặc bằng term nhưng lastLogIndex lớn hơn hoặc bằng. Điều này đảm bảo leader mới luôn có tất cả entry đã committed — node nào có log cũ hơn sẽ không bao giờ được bầu.

Thử ngẫmsplit vote xảy ra ở term 5, không ai thắng đa số. Cluster có tự phục hồi nhờ timeout ngẫu nhiên ở term tiếp theo, hay cần can thiệp thủ công?

4. Log replication

Sau khi được bầu, leader nhận lệnh từ client và replicate sang follower theo giao thức AppendEntries.

Leader và bốn follower: hai follower trả lời success, cộng với leader là ba trên năm nên đủ đa số để commit

Ngưỡng là đa số, không phải toàn bộ — và leader tự tính mình vào đa số đó. Hai follower còn lại chưa trả lời cũng không cản được commit; đó là lý do cụm 5 node chịu được 2 node chết mà vẫn phục vụ bình thường.

Plain Text
-- Leader nhận lệnh từ client
function on_client_request(self, command):
    entry <- { term: self.currentTerm, index: self.log.nextIndex(), command: command }
    self.log.append(entry)
    broadcast AppendEntries(entry) tới tất cả follower
    chờ đến khi đa số follower xác nhận entry.index
    self.commitIndex <- entry.index
    apply command vào state machine
    trả lời client

-- AppendEntries handler tại follower
function on_AppendEntries(self, req):
    if req.term < self.currentTerm:
        return AppendResponse(success=false, term=self.currentTerm)

    -- term lớn hơn → cập nhật term và lùi về FOLLOWER (đặc tả an toàn Raft)
    if req.term > self.currentTerm:
        self.currentTerm <- req.term
        self.state <- FOLLOWER
        self.votedFor <- null

    reset_election_timer(self)   -- nhận heartbeat từ leader hợp lệ

    -- Log consistency check: entry trước phải khớp
    if req.prevLogIndex > 0:
        if self.log.length < req.prevLogIndex or
           self.log[req.prevLogIndex].term != req.prevLogTerm:
            return AppendResponse(success=false, term=self.currentTerm)

    -- Ghi entries vào log (xóa conflict nếu có)
    for each entry trong req.entries:
        if self.log[entry.index] exists and self.log[entry.index].term != entry.term:
            self.log.truncate(entry.index)  -- xóa từ đây về sau
        self.log.append(entry)

    -- Cập nhật commitIndex
    if req.leaderCommit > self.commitIndex:
        self.commitIndex <- min(req.leaderCommit, self.log.lastIndex())
        apply tất cả entry tới commitIndex vào state machine

    return AppendResponse(success=true, term=self.currentTerm)
// Time: O(n * log_entries) per round  Space: O(log_size) per node

4.1 Log matching property

Raft duy trì bất biến quan trọng: nếu hai log có cùng index và term tại một entry, thì mọi entry trước đó cũng giống nhau. Lý do: leader chỉ tạo một entry duy nhất tại mỗi (index, term), và entries không bao giờ được sửa sau khi ghi (chỉ truncate khi follower phát hiện conflict với leader).

prevLogIndex + prevLogTerm trong AppendEntries là "chứng minh" entry trước khớp — follower từ chối nếu không khớp, leader tự động giảm nextIndex[follower] để thử lại từ entry trước đó.

5. Safety — election restriction phòng mất data

Tình huống nguy hiểm: leader cũ (term 1) commit entry tại index 5. Sau đó leader cũ crash. Node nào được bầu làm leader mới?

Nếu không có election restriction: node với log ngắn hơn có thể được bầu → log của nó không có entry index 5 → leader mới có thể ghi đè index 5 bằng entry khác → mất data đã committed.

Election restriction ngăn điều này: node muốn được bầu phải có log "ít nhất mới bằng" đa số. Entry đã committed được đa số xác nhận → đa số có entry đó. Muốn được đa số vote → phải có log mới bằng đa số → phải có entry đó. Vậy leader mới luôn có mọi entry đã committed.

5.1 Không commit entry từ term cũ trực tiếp

Một subtlety quan trọng: leader mới không commit entry từ term cũ bằng cách đếm replica. Thay vào đó, leader append một no-op entry (log entry rỗng) ở term hiện tại vào đầu nhiệm kỳ, commit entry đó → tự động kéo theo commit mọi entry trước đó. Cơ chế này tránh một edge case phức tạp trong Raft paper (Figure 8) nơi entry từ term cũ có thể bị overwrite dù đã có đa số replica. Bản chất an toàn (Raft §5.4.2): leader KHÔNG commit entry term cũ chỉ bằng đếm replica; chỉ khi một entry term HIỆN TẠI trên cùng log được commit thì prefix (gồm entry term cũ) mới commit gián tiếp. No-op chỉ là tối ưu để commit prefix sớm, không bắt buộc.

Thử ngẫmleader mới vừa thắng election ở term 4 nhưng chưa có client request nào tới. Nó có cần tự tạo no-op entry ngay lập tức không, hay chờ request đầu tiên mới làm?

6. Xử lý failure scenarios

Kịch bảnRaft xử lý thế nào
Follower crashLeader tiếp tục retry AppendEntries vô hạn. Khi follower restart, log sẽ được đồng bộ lại từ nextIndex[follower] mà leader duy trì.
Leader crash trước khi commitEntry chưa commit sẽ không được apply. Leader mới có thể không có entry đó → entry bị mất (đúng — chưa commit nên OK). Client retry sẽ gửi lại lệnh.
Leader crash sau khi commitEntry đã committed → đa số có entry đó → leader mới (phải thắng election tức là log đủ mới) sẽ có entry đó → không mất.
Network partitionMinority partition (bao gồm old leader) không thể commit gì (không đủ quorum). Majority partition bầu leader mới, tiếp tục hoạt động. Khi partition heal: old leader thấy term cao hơn → tự chuyển thành follower, log của nó bị overwrite.

7. Pitfall

Pitfall 1 — Bỏ qua election restriction khi vote

Plain Text
-- SAI: vote cho bất kỳ candidate nào có term cao hơn
function on_RequestVote_wrong(self, req):
    if req.term > self.currentTerm and self.votedFor = null:
        return VoteResponse(granted=true)
Plain Text
-- DUNG: kiem tra log cua candidate phai moi bang hoac moi hon log cua minh
function on_RequestVote_correct(self, req):
    log_ok <- (req.lastLogTerm > self.log.lastTerm()) or
              (req.lastLogTerm = self.log.lastTerm() and
               req.lastLogIndex >= self.log.lastIndex())
    if not already_voted and log_ok:
        return VoteResponse(granted=true)

Bỏ election restriction cho phép node với log cũ trở thành leader → ghi đè entry đã committed → mất data. Đây là nguồn gốc của nhiều Raft implementation bug trong thực tế.

Pitfall 2 — Commit entry của term cũ trực tiếp

Plain Text
-- SAI: dem replica cua entry tu term cu, commit khi dat da so
-- Vi du: entry index=5, term=1, da co 3/5 replica sau khi leader term=2 restart
-- Leader term=2 thay "a, 3 replica roi, commit luon"
-- Raft paper Figure 8 cho thay day co the sai
Plain Text
-- DUNG: leader moi append no-op entry o term hien tai truoc
-- Khi no-op commit (dat da so o term hien tai), moi entry cu cung duoc commit
-- No-op lan truyen commitIndex xuong follower trong AppendEntries tiep theo

Commit entry của term cũ trực tiếp là sai vì đa số replica tại thời điểm hiện tại không đủ bảo đảm an toàn cho entry cũ — có thể có leader khác (cùng lúc đó) đã ghi đè slot đó ở term khác. Commit qua no-op ở term hiện tại loại bỏ trường hợp này.

Pitfall 3 — Không reset election timer khi nhận RequestVote hợp lệ

Plain Text
-- SAI: chi reset timer khi nhan AppendEntries, khong reset khi grant vote
function on_RequestVote_bad(self, req):
    -- grant vote...
    return VoteResponse(granted=true)
    -- Quen: khong reset election_timer
Plain Text
-- DUNG: reset timer khi grant vote de candidate co du thoi gian thu thap vote
function on_RequestVote_correct(self, req):
    -- grant vote...
    reset_election_timer(self)   -- quan trong!
    return VoteResponse(granted=true)

Không reset timer khi grant vote khiến follower có thể timeout và khởi động election mới ngay khi candidate đang thu thập vote — gây split vote lặp đi lặp lại, cluster không bao giờ bầu được leader.

8. So sánh với Paxos

Thuộc tínhPaxos (Multi-Paxos)Raft
Độ phức tạpRất phức tạp, nhiều trường hợp đặc biệtĐơn giản hơn, tách biệt 3 phần rõ ràng
LeaderKhông bắt buộc có leader duy nhất rõ ràngLuôn có 1 leader duy nhất tại một thời điểm
Log gapsCho phép holes trong log, phức tạp xử lýKhông có holes — log liên tục
Membership changeKhông được mô tả trong bài báo gốcJoint consensus hoặc single-server changes được đặc tả
Implementation difficultyCao — Chubby (Google), ZooKeeper mất nhiều nămThấp hơn đáng kể — nhiều correct implementation trong 1-2 ngàn dòng code
Ứng dụngZooKeeper (ZAB, variant), Chubbyetcd, Consul, TiKV, CockroachDB, RethinkDB

Raft không nhanh hơn Paxos — cùng số round-trip cần thiết. Ưu điểm là correctness dễ đạt hơn: người implement ít có cơ hội bỏ sót edge case vì thuật toán không có edge case ẩn.

Thử ngẫmbảng so sánh nói Raft "không có holes" trong log còn Paxos cho phép. Một log có gap (thiếu entry ở giữa) cản trở gì khi apply các entry phía sau gap đó vào state machine?

9. Ứng dụng thực tế

etcd: key-value store Raft-based, nền tảng của Kubernetes (lưu cluster state). Mọi kubectl apply đều qua etcd Raft cluster. 3 hoặc 5 node etcd là tiêu chuẩn — đủ chịu 1 hoặc 2 node failure.

Consul (HashiCorp): service discovery + configuration. Dùng Raft cho consistent catalog, gossip (SWIM) cho membership — hai lớp kết hợp: gossip scalable, Raft consistent.

TiKV (PingCAP): distributed key-value store nền của TiDB. Raft group per region — mỗi region (96 MB data) có 3-replica Raft group riêng → scale horizontal bằng cách tăng số region.

CockroachDB: distributed SQL. Raft per range (chunk of data). Mọi transaction đều qua Raft consensus → serializable isolation trên nhiều node.

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

  • Quorum — đọc/ghi đa số: Raft commit khi đa số follower xác nhận — đây chính là quorum (majority quorum). Bài quorum giải thích tại sao đa số (n/2 + 1) đủ để đảm bảo overlap, Raft áp dụng trực tiếp nguyên tắc đó.
  • Gossip protocol: Gossip đạt eventual consistency trong O(log N) vòng — không đủ mạnh cho consensus. Raft đảm bảo strong consistency nhưng cần round-trip đồng bộ. Consul kết hợp cả hai: gossip cho membership (fast, scalable), Raft cho configuration (consistent).
  • Vector clock — thứ tự nhân quả: Raft dùng term như một vector clock đơn giản (scalar clock) để phát hiện stale messages. Khác vector clock đa chiều, term trong Raft chỉ cần so sánh số nguyên vì chỉ có 1 leader tại một thời điểm.
  • Merkle tree — kiểm tra nhất quán: Sau khi log được replicated qua Raft, snapshot của state machine có thể được verify bằng Merkle tree — đặc biệt khi follower cần download snapshot từ leader thay vì replay toàn bộ log.
  • Case study Cassandra & etcd: Thấy Raft trong production: etcd log compaction, snapshot transfer, cluster membership change (add/remove node) và cách Kubernetes dựa vào etcd cho cluster state.

📚 Deep Dive

📚 Deep Dive — Nguồn gốc & tham khảo

Bài báo gốc:

  • Ongaro, D. & Ousterhout, J. (2014) — "In Search of an Understandable Consensus Algorithm", USENIX ATC. Bài báo Raft. Extended version (Ongaro PhD thesis) tại raft.github.io — đặc tả đầy đủ nhất bao gồm membership change và log compaction.

Visualization:

  • Raft Visualization — animation tương tác cho phép crash node, partition network và xem leader election + log replication hoạt động. Bắt buộc xem trước khi implement.

Paxos gốc để so sánh:

  • Lamport, L. (1998) — "The Part-Time Parliament", ACM TOCS. Khét tiếng khó đọc — Lamport sau đó viết "Paxos Made Simple" (2001) ngắn hơn.
  • Lamport, L. (2001) — "Paxos Made Simple" — 14 trang, dễ đọc hơn nhiều.

Triển khai tham khảo:

  • etcd/raft — Go implementation được dùng trong production, code sạch: github.com/etcd-io/raft.
  • MIT 6.824 Distributed Systems Lab 2 — implement Raft từ đầu bằng Go, bài tập chuẩn để hiểu sâu.

Tóm tắt

  • Consensus = nhiều node thống nhất cùng chuỗi lệnh (replicated log), chịu được node failure.
  • Raft tách thành 3 phần độc lập: leader election, log replication, safety.
  • Leader election: randomized timeout tránh split vote; mỗi term có tối đa 1 leader; election restriction (log mới nhất) đảm bảo leader không mất data.
  • Log replication: leader append rồi gửi AppendEntries; commit khi đa số xác nhận; commitIndex lan xuống follower trong round tiếp theo.
  • Safety: chỉ node có log mới nhất mới được bầu; leader không commit entry term cũ trực tiếp mà qua no-op ở term hiện tại.
  • Raft không nhanh hơn Paxos — ưu điểm là correctness dễ đạt hơn, ít edge case ẩn.
  • Nền tảng của etcd (Kubernetes), Consul, TiKV, CockroachDB.

Tự kiểm tra

Tự kiểm tra
0/7 câu đã trả lời
  1. Q1
    Tại sao election timeout phải ngẫu nhiên thay vì cố định? Điều gì xảy ra nếu timeout cố định?
  2. Q2
    Election restriction hoạt động như thế nào và tại sao nó đảm bảo không mất entry đã committed?
  3. Q3
    Mô tả quá trình AppendEntries từ khi client gửi lệnh đến khi client nhận phản hồi. Đa số ở đây nghĩa là gì?
  4. Q4
    Điều gì xảy ra với old leader khi network partition được khắc phục? Tại sao old leader không gây corruption?
  5. Q5
    Tại sao leader không commit entry của term cũ trực tiếp? Mô tả Figure 8 của Raft paper.
  6. Q6
    So sánh Raft và Paxos: Raft có ưu điểm gì và đánh đổi gì? Khi nào vẫn nên dùng Paxos?
  7. Q7
    Với cụm 5 node Raft, chịu được tối đa bao nhiêu node failure đồng thời? Tại sao không thể chịu được 3 node failure?

Bài tiếp theo: Mini-challenge — consistent hashing ring

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

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

Bài tiếp theo

Mini-challenge — consistent hashing ring có virtual node