Failover (P1/3): Heartbeat, term và một cuộc bầu cử; lab kill primary

15 phút đọcSeries: MongoDB: từ gốc đến internals

Bài này trả lời một câu hỏi: khi primary của một replica set biến mất, ai quyết định node nào lên thay, mất bao lâu, và dữ liệu cùng ứng dụng chịu hậu quả gì?

Bài có 3 Part:

Bài này nằm ở đâu

Môi trường lab: MongoDB 8.3.11 trong Docker (image mongo:8), replica set 3 node mongo-fo26-a/-b/-c, mỗi node 1 CPU và 1 GB RAM, WiredTiger cache 0,25 GB, priority 1 cho cả ba. Client là Node.js driver 7.7.0 chạy trong container riêng, cùng Docker network. Mốc thời gian là mili giây tính từ lúc gây sự cố (T0), lấy từ log của mongod và từ client; đồng hồ host và máy ảo Docker lệch nhau 30 đến 44 ms, nên các số cỡ chục ms là xấp xỉ. Host là Docker Desktop trên Mac, chạy cùng lúc với lab khác, nên mỗi kịch bản chỉ có 5 lần thử (mẫu nhỏ), báo khoảng min đến max. Script và output thô nằm trong lab26/ kèm INDEX.md.

Nhãn: [tài liệu] theo manual MongoDB (trang "current" lúc viết ghi 9.0, lab là 8.3.11); [quan sát] đo trong lab này; [suy luận] rút ra từ quan sát, chưa kiểm riêng; [chi tiết cài đặt] cách server đang làm, không phải cam kết; [hình dung] mô hình để dễ nhớ.

Hình dung trước: ca trực đêm không có trưởng ca

Ba người trực một đường dây nóng, và chỉ một người cầm điện thoại chung (trưởng ca) trả lời khách. Hai người còn lại ngồi nghe, ghi lại mọi cuộc gọi trưởng ca nhận. Cứ vài giây họ nhắn nhau một chữ "còn đó". Một lần, tin nhắn từ trưởng ca không tới trong 10 giây: trưởng ca mất điện, hay chỉ đứt liên lạc? Nếu một người tự cầm điện thoại trong khi trưởng ca thật vẫn trả lời khách ở phòng bên, sẽ có hai người hứa với khách những điều khác nhau.

Nên họ đặt luật: ai muốn làm trưởng ca phải xin phiếu, và phải có phiếu của đa số cả nhóm (2 trên 3, tính cả phiếu của chính mình). Hai nhóm tách rời nhau không thể cùng có đa số, nên không bao giờ có hai trưởng ca cùng được công nhận. Thêm một luật: người ghi chép ít hơn người khác trong nhóm không được làm trưởng ca, vì trưởng ca mới phải biết mọi thứ nhóm đã đồng ý.

trưởng ca                     = primary
người trực còn lại            = secondary
nhắn "còn đó" mỗi vài giây    = heartbeat
10 giây không nghe trưởng ca  = election timeout
xin phiếu, cần 2 trên 3       = election, majority
số lần đổi trưởng ca          = term
sổ ghi các cuộc gọi           = oplog

Đây chỉ là cách hình dung. MongoDB thực tế không "xin phiếu" trước mặt nhau như người: nó là các lệnh gửi giữa các tiến trình, và có thêm bước thử trước khi đếm phiếu thật (mục "Một cuộc bầu cử đi qua những bước nào").

Ý chính

Replica set có đúng một primary nhận ghi. Các member gửi heartbeat cho nhau mỗi 2 giây. Nếu một secondary không thấy primary trong 10 giây (electionTimeoutMillis), nó đứng ra làm candidate, xin phiếu của các member khác, và chỉ trở thành primary khi có đa số phiếu (majority: hơn một nửa số member có quyền bỏ phiếu) và có optime (vị trí trong oplog) cao nhất trong số các member nó nhìn thấy. Mỗi cuộc bầu có một số thứ tự gọi là term; member thấy một term cao hơn thì theo term đó. Trong lúc chưa có primary, replica set không nhận ghi; đọc trên secondary vẫn chạy được nếu client cho phép.

Vì sao phải là đa số

Đây là lý do replica set nên có số member lẻ. Giả sử ba node, mạng chia làm hai:

  Mạng bị chia:   [ a (primary) ]   │   [ b ]  [ c ]
                                    │
  Phía a:  thấy 1 trên 3 member  →  không đủ đa số (cần 2)
  Phía b,c: thấy 2 trên 3 member →  đủ đa số, được phép bầu primary mới

Nếu luật chỉ là "không thấy primary thì tự lên", cả hai phía đều có primary, và hai primary nhận hai chuỗi lệnh ghi khác nhau mà không nhìn thấy nhau. Tình huống đó gọi là split brain, và không có cách nào ghép lại hai lịch sử mà không mất dữ liệu. Yêu cầu đa số chặn chuyện này ngay từ đầu: phía thiểu số không có primary được công nhận.

[tài liệu] Khi primary chỉ còn thấy thiểu số member có quyền bỏ phiếu, nó step down (thôi làm primary, trở về secondary); còn phía có đa số tự tổ chức election. Hệ quả: 3 node chịu mất 1; 4 node cũng chỉ chịu mất 1 (đa số của 4 là 3), nên node thứ tư không mua thêm khả năng chịu lỗi.

Heartbeat, term và trạng thái của member

[tài liệu] Các member gửi heartbeat cho nhau mỗi 2 giây; một member không trả lời trong 10 giây bị đánh dấu là không liên lạc được. [quan sát] rs.conf().settings của lab:

heartbeatIntervalMillis: 2000
heartbeatTimeoutSecs:    10
electionTimeoutMillis:   10000
catchUpTimeoutMillis:    -1        (không giới hạn: xem Lab 1)

[quan sát] Lệnh heartbeat trong log (replSetHeartbeat) mang theo tên replica set, phiên bản cấu hình (configVersion, configTerm) và term hiện tại của người gửi; [tài liệu] trạng thái và optime (vị trí trong oplog) mà member đã áp dụng cũng được trao đổi qua đây (xem replSetGetStatus). Term là con số tăng 1 mỗi lần có election. [quan sát] Trong đoạn log ở mục "Một cuộc bầu cử đi qua những bước nào", b trả lời yêu cầu bầu thật bằng term: 7: nó đã theo term mới của candidate. [suy luận] Một primary thấy term lớn hơn thì biết đã có người được bầu thay và phải step down. [quan sát] Term cũng nằm trong optime của từng dòng oplog, dưới dạng { ts: Timestamp(1791598424, 17), t: 1 } với t là term của primary đã ghi dòng đó; [suy luận] nhờ vậy sau một lần failover hai node vẫn so được "ai có nhiều hơn".

Mỗi member ở một trạng thái tại một thời điểm. Những trạng thái cần biết:

Trạng tháiÝ nghĩa [tài liệu]Có vote?
PRIMARYNode duy nhất nhận lệnh ghiCó
SECONDARYSao chép dữ liệu, có thể nhận đọcCó
RECOVERINGChưa sẵn sàng cho đọc (tự kiểm tra lúc khởi động, hoặc vừa xong rollback/resync); không đủ điều kiện làm primaryCó
ROLLBACKĐang rollback; không đọc đượcCó
STARTUP2Đang initial syncCó (trừ node vừa được thêm vào)
ARBITERKhông giữ dữ liệu, chỉ bỏ phiếuCó
DOWNCác member khác không liên lạc đượcKhông

Priority, votes và arbiter

  • priority (mặc định 1): member có priority cao hơn bắt đầu election sớm hơn và dễ được bầu hơn; [tài liệu] priority 0 nghĩa là không bao giờ làm primary và không tự bắt đầu election. Đây là núm đúng để nói "node này ở datacenter xa, đừng bầu nó".
  • votes (mặc định 1): manual dặn đừng đổi để điều khiển ai làm primary; chỉ đổi trong trường hợp đặc biệt như vượt giới hạn 7 member có vote. Member votes: 0 phải có priority 0.
  • Arbiter là member chỉ bỏ phiếu, không giữ dữ liệu. Nó cho một replica set hai node dữ liệu có số phiếu lẻ. [tài liệu] Cái giá: nó không xác nhận được lệnh ghi. Manual cảnh báo kiến trúc primary + secondary + arbiter có vấn đề hiệu năng khi secondary mất hoặc tụt lại (w: "majority" phải chờ), và ở sharded cluster w: "majority" có thể không hoàn thành. Từ 5.3 server mặc định không cho thêm nhiều hơn một arbiter, vì nhiều arbiter có thể bầu ra một node đã tụt lại phía sau.

Không có arbiter nào trong lab này: cả ba node đều giữ dữ liệu.

Một cuộc bầu cử đi qua những bước nào

Đây là log thật của một election sau khi tôi docker kill primary (e-kill-t5.out; hai dòng catch-up lấy từ log của node a trong summary-catchup-kill.txt; rút gọn; số bên trái là mili giây sau lệnh kill):

+10159  a  "Conducting a dry run election to see if we could be elected"  (currentTerm: 6)
+10159  a  "Starting an election, since we've seen no PRIMARY in election timeout period"
+10159  b  "Responding to vote request"  dryRun: true,  term: 6, ... voteGranted: true
+10160  a  "Dry election run succeeded, running for election"  (newTerm: 7)
+10160  a  "Storing last vote document in local storage for my election"
+10161  b  "Responding to vote request"  dryRun: false, term: 7, ... voteGranted: true
+10162  a  "Election succeeded, assuming primary role"  (term: 7)
+10163  a  "Entering primary catch-up mode"
+10170  a  "Exited primary catch-up mode"
+10175  a  "Transition to primary complete; database writes are now permitted"

Đọc theo thứ tự:

  1. Hết election timeout. Secondary không thấy primary trong 10 giây thì bắt đầu. Vẫn chưa tăng term.
  2. Dry run (thử trước). Candidate hỏi các member "nếu tôi xin phiếu thật thì bạn có bầu không", nhưng chưa tăng term. [quan sát] [chi tiết cài đặt] Log gọi nó là dry run election; manual trang Elections không mô tả bước này. [suy luận] Có vẻ đây là ý tưởng "pre-vote" của các giao thức đồng thuận: không để một node bị cô lập tăng term vô ích rồi làm gián đoạn cả cụm khi nối mạng lại; tôi không kiểm chứng điều đó.
  3. Election thật. Candidate tăng term (6 lên 7 ở trên), ghi phiếu của chính nó xuống đĩa (Storing last vote document), rồi gửi yêu cầu bỏ phiếu. [suy luận] Một member chỉ bỏ một phiếu mỗi term; việc ghi phiếu xuống đĩa là để khởi động lại không bầu hai lần (log lúc khởi động có dòng Attempting to load local voted for document).
  4. Kiểm tra dữ liệu mới nhất. Yêu cầu bỏ phiếu mang theo lastWrittenOpTime và lastAppliedOpTime của candidate ([quan sát] có trong log: lastWrittenOpTime: { ts: Timestamp(1791598885, 146), t: 6 }). [tài liệu] "Một member không thể trở thành primary nếu nó không có optime cao nhất trong số các member nhìn thấy được." Đó là cái chặn việc bầu một node đang tụt lại. [quan sát] Lab này không tái hiện một lần bị từ chối vì dữ liệu cũ: cả 54 câu trả lời trong log đều là voteGranted: true.
  5. Đủ đa số thì thành primary. Ở lab này cần 2 phiếu (numVotesNeeded: 2); phiếu của candidate cộng một phiếu "yes".
  6. Catch-up rồi mới nhận ghi. Primary mới vào primary catch-up mode: nó catch up, tức lấy nốt các lệnh ghi mới hơn mà member khác có thể có. [tài liệu] Trong lúc catch-up, primary mới chưa nhận ghi từ client; catchUpTimeoutMillis mặc định -1 (không giới hạn). Ở lần chạy trên, catch-up mất 7 ms.

rs.status() của primary cho thấy kết quả của một election. [quan sát] Các trường liên quan, lấy ở election đầu tiên của replica set vừa khởi tạo (term 1, e0-config.out, đã rút gọn), không phải ở lần kill ở trên:

set: 'fo26',  term: 1,  myState: 1 (PRIMARY)
majorityVoteCount: 2,  writeMajorityCount: 2,  votingMembersCount: 3
electionCandidateMetrics: {
  lastElectionReason: 'electionTimeout',
  lastElectionDate:  2026-10-10T02:13:44.673Z,
  electionTerm: 1,  numVotesNeeded: 2,  priorityAtElection: 1,
  numCatchUpOps: 0,
  newTermStartDate:  2026-10-10T02:13:44.686Z,
  wMajorityWriteAvailabilityDate: 2026-10-10T02:13:45.181Z }

Hai ngày tháng cuối đáng chú ý. [tài liệu] newTermStartDate là lúc primary mới ghi dòng oplog mở đầu term của nó (manual gọi là new term oplog entry), còn wMajorityWriteAvailabilityDate là lúc dòng đó được majority commit. [quan sát] Ở đây dòng đó được ghi 13 ms sau election, nhưng phải tới 508 ms lệnh ghi w: "majority" mới dùng được (một set vừa khởi tạo; tôi không đo số này ở các lần failover). Đây là lần đầu tiên majority commit point xuất hiện trong bài; phần tiếp theo nói nó là gì.

Lab 1: kill primary, stepDown và network partition

Câu hỏi: từ lúc primary biến mất tới lúc ghi lại được mất bao lâu, và ứng dụng thấy gì?

Cách đo: một client Node.js (driver 7.7.0, mặc định retryWrites: true) ghi tuần tự mỗi lần một insertOne với w: "majority" suốt 45 giây, ghi lại mọi thao tác kéo dài từ 300 ms trở lên và mọi lỗi. Song song, một client khác hỏi hello từng node mỗi 100 ms. Tôi lấy mốc T0 ngay trước khi gây sự cố; mỗi kịch bản chạy 5 lần, mỗi lần primary là node khác nhau (e-kill-t1.out đến e-partition-t5.out, bảng tóm tắt trong summary-e-*.txt).

Kịch bản 1: docker kill primary (SIGKILL: tiến trình chết, không báo ai).

LầnElection bắt đầu sau (ms)Primary mới nhận ghi sau (ms)Catch-up (ms)Thao tác client dài nhất (ms)
110.62220.1329.48620.572
210.21310.2501810.238
310.48410.5202110.511
410.59010.6261611.076
510.15910.175710.140

[quan sát] Election chỉ bắt đầu sau 10,16 đến 10,62 giây, tức ngay sau electionTimeoutMillis; [tài liệu] manual nói median thời gian tới khi có primary mới thường không quá 12 giây với cấu hình mặc định. Không lỗi nào lọt ra ứng dụng: lệnh ghi đang chạy khi primary chết chờ 10,1 đến 11,1 giây rồi hoàn thành trên primary mới (retryable write, xem Durability & Consistency), như một lệnh ghi chậm. Lần thứ nhất là ngoại lệ: election vẫn mất 10,6 giây, nhưng primary mới ở trong catch-up mode 9,5 giây rồi mới nhận ghi, nên lệnh ghi chờ 20,6 giây. Tôi không giải thích được lần đó: log mặc định không nói primary mới chờ gì (một giả thuyết chưa kiểm: nó chờ một optime mà node vừa chết đã báo qua heartbeat). Năm lần thử là mẫu nhỏ; đừng coi 10 giây là con số chắc chắn.

Sau docker kill, tên container biến khỏi DNS của Docker, nên heartbeat tới nó thất bại gần như tức thì (HostUnreachable, mỗi ~0,5 giây; summary-heartbeat-after-kill.txt), vậy mà không ai bắt đầu election trước khi hết 10 giây. [suy luận] Election timeout cố ý chờ đủ lâu, để một lần mạng chập chờn không kéo theo một lần đổi primary.

Kịch bản 2: rs.stepDown() (primary tự nhường).

LầnstepDown tới primary mới nhận ghi (ms)Thao tác client dài nhất (ms)
17576
263173
325144
4111197
512287

[quan sát] Failover có kế hoạch mất 12 đến 111 ms kể từ lúc lệnh được nhận, và không thao tác nào của client lâu hơn 287 ms. [tài liệu] Primary đang step down chỉ định một secondary bắt đầu election ngay, không đợi hết election timeout; [quan sát] log ghi Starting an election due to step up request rồi Skipping dry run and running for election. [tài liệu] replSetStepDown chờ tối đa secondaryCatchUpPeriodSecs (mặc định 10 giây) cho một secondary electable catch up trước khi step down, và node đã nhường không được làm primary lại trong stepDownSecs (mặc định 60 giây). Đây là cách bảo trì đúng: nâng cấp từng node thì step down trước khi tắt nó.

Kịch bản 3: network partition (primary bị cô lập khỏi hai node kia bằng cách chuyển nó sang một Docker network riêng; tiến trình vẫn chạy; client ở phía hai node còn lại). Mốc T0 ở đây nằm trước lệnh chuyển mạng nên các số gồm cả 120 đến 190 ms chạy lệnh đó.

LầnPrimary cũ step down (ms)Election bắt đầu (ms)Primary mới nhận ghi (ms)Thao tác client dài nhất (ms)
110.03510.13510.15636.372
210.04810.08310.09136.255
310.03811.46011.49136.308
410.04710.07210.18636.423
510.08710.18410.19536.432

[quan sát] Hai phía làm đúng như yêu cầu đa số. Phía hai node bầu primary mới sau 10,1 đến 11,5 giây; primary cũ, vẫn chạy ở phía một node, step down sau 10,04 đến 10,09 giây (log: Stepping down from primary in response to heartbeat) và từ đó chỉ ghi Not starting an election, since we are not electable ... I cannot see a majority. Ở cả 5 lần, primary cũ step down trước khi election bên kia bắt đầu, nên lab không lúc nào thấy hai primary. Nhưng trước mốc đó, primary cũ vẫn nhận ghi w: 1: lab của majority commit point và rollback đếm được hơn 1.100 lệnh được xác nhận trong khoảng 9 giây, và tất cả bị rollback.

Cột cuối: thao tác của client lâu 36,3 đến 36,4 giây dù primary mới có sau khoảng 10 giây, và không lỗi nào lọt ra. Lệnh đang chạy nằm trên một kết nối tới primary cũ, và phía bên kia chỉ im lặng. [quan sát] Ở cả 5 lần, thao tác kết thúc 40,5 giây sau lúc client kết nối, còn client kết nối khoảng 4,1 giây trước T0. [chi tiết cài đặt] Mã nguồn driver 7.7.0 đặt timeout cho kết nối monitor là connectTimeoutMS (mặc định 30 giây) cộng heartbeatFrequencyMS (10 giây); khi timeout đó chạm, driver đóng cả các kết nối đang dùng tới node đó. [suy luận] Lệnh ghi bị đóng theo rồi được retry trên primary mới; 36,3 giây là 40 giây trừ phần client đã chạy trước partition, nên con số phụ thuộc thời điểm. Phần cuối đo lại với timeoutMS. Cần nhớ: partition đắt hơn crash cho ứng dụng, vì crash làm kết nối lỗi ngay còn partition thì không.

Tóm lại: crash ≈ 10 đến 11 giây (một lần 20 giây), stepDown < 0,3 giây, partition ≈ 10 giây ở phía server và ≈ 36 giây ở client với timeout mặc định. Đây là cận dưới của một máy ảo bận.

Cột mốc: Bạn đã có thể kể một election từ lúc hết election timeout tới lúc primary mới nhận ghi, nói vì sao yêu cầu đa số chặn split brain, và đọc thời gian failover của ba kịch bản (crash, stepDown, partition). Phần sau giải thích majority commit point và rollback.

Hỏi & đáp

Replica set 3 node bị chia mạng: primary a ở một phía một mình, b và c ở phía kia. Điều gì xảy ra theo lab?

  1. Cả hai phía đều có primary cho tới khi mạng nối lại, rồi mới gộp lịch sử

    Đó chính là split brain mà yêu cầu đa số chặn: phía một node không có đa số phiếu nên không thể tự giữ vai trò primary. Xem mục "Vì sao phải là đa số".

  2. Không ai làm primary cho tới khi mạng nối lại, vì không phân biệt được crash với partition

    Phía có đa số vẫn bầu được: lab thấy primary mới sau khoảng 10 giây. Xem mục "Lab 1: kill primary, stepDown và network partition".

  3. Phía b, c bầu primary mới, primary cũ step down

    Primary cũ step down sau 10,04 đến 10,09 giây ở cả 5 lần, còn primary mới nhận ghi sau 10,1 đến 11,5 giây. Xem mục "Lab 1: kill primary, stepDown và network partition".

  4. Primary cũ ở lại làm primary vì nó có dữ liệu mới nhất cụm

    Dữ liệu mới nhất chỉ quan trọng khi bầu candidate; primary cũ không thấy đa số nên phải nhường. Xem mục "Vì sao phải là đa số".

Vì sao với rs.stepDown() primary mới nhận ghi sau chưa tới 0,2 giây, trong khi kill primary mất hơn 10 giây?

  1. Vì stepDown dùng heartbeat nhanh hơn nên phát hiện lỗi sớm hơn hẳn kill

    Heartbeat vẫn 2 giây. Ở trường hợp kill, các heartbeat cũng thất bại gần tức thì mà election vẫn đợi 10 giây. Xem mục "Lab 1: kill primary, stepDown và network partition".

  2. Vì stepDown bỏ qua yêu cầu đa số, chỉ cần một phiếu

    Vẫn cần đa số phiếu (numVotesNeeded: 2); điều khác là thời điểm bắt đầu. Xem mục "Một cuộc bầu cử đi qua những bước nào".

  3. Vì primary mới không phải catch-up khi primary cũ tự nhường chỗ

    Primary mới vẫn vào catch-up mode; ở kịch bản kill nó chỉ mất 7 đến 21 ms (trừ lần 1), nên catch-up không phải thứ chiếm 10 giây. Xem mục "Lab 1: kill primary, stepDown và network partition".

  4. Vì một secondary được chỉ định bắt đầu election ngay

    Primary đang step down chỉ định một secondary bắt đầu election ngay, không đợi hết election timeout; log ghi Starting an election due to step up request. Lab: primary mới nhận ghi 12 đến 111 ms sau lệnh stepDown. Xem mục "Lab 1: kill primary, stepDown và network partition".

Một đội trực 4 người, mạng chia 2 và 2. Ai muốn làm trưởng ca phải có phiếu của đa số cả đội. Điều gì xảy ra?

  1. Không nhóm nào bầu được trưởng ca cho tới khi hai nhóm liên lạc lại

    Mỗi nhóm có 2 trên 4 phiếu, chưa tới đa số (3), nên không ai được công nhận. Vì vậy node thứ tư không tăng khả năng chịu lỗi so với ba node. Xem mục "Vì sao phải là đa số".

  2. Mỗi nhóm tự bầu một trưởng ca, vì đã có đủ một nửa số phiếu

    Một nửa không phải đa số: nhóm 4 người cần ít nhất 3 phiếu. Xem mục "Vì sao phải là đa số".

  3. Nhóm có trưởng ca cũ giữ nguyên trưởng ca đó

    Người đó chỉ thấy 2 trên 4 member, nên cũng phải nhường. Xem mục "Vì sao phải là đa số".

  4. Nhóm có người ghi chép nhiều nhất được quyền làm trưởng ca

    Dữ liệu mới nhất là điều kiện cần để nhận phiếu, không thay được đa số phiếu. Xem mục "Một cuộc bầu cử đi qua những bước nào".