Failover (P1/3): Heartbeat, term và một cuộc bầu cử; lab kill primary
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:
- P1 (Part này): Heartbeat, term và một cuộc bầu cử, kèm lab kill primary.
- P2: Majority commit point và rollback.
- P3: Ứng dụng nhìn thấy gì: read preference, timeout và retry.
Bài này nằm ở đâu
- Cần biết trước: Replica Set & Oplog (primary, secondary, oplog, replication lag), Durability & Consistency (
w: 1vàw: "majority"hứa gì với ứng dụng) - Giới thiệu: heartbeat và election timeout, term, trạng thái của member, priority/votes/arbiter, các bước của một election, thời gian failover khi
killprimary, khirs.stepDown()và khi network partition - Không dạy ở đây:
w,jvà read concern hứa gì (Durability & Consistency, phía đọc của Durability); oplog và replication lag (Replica Set & Oplog). Majority commit point và rollback nằm ở phần tiếp theo; read preference, server selection và retry ở phần cuối - Dẫn tới: Majority commit point và rollback
Môi trường lab: MongoDB 8.3.11 trong Docker (image
mongo:8), replica set 3 nodemongo-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 tronglab26/kèmINDEX.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ớiNế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? |
|---|---|---|
PRIMARY | Node duy nhất nhận lệnh ghi | Có |
SECONDARY | Sao chép dữ liệu, có thể nhận đọc | Có |
RECOVERING | Chư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 primary | Có |
ROLLBACK | Đang rollback; không đọc được | Có |
STARTUP2 | Đang initial sync | Có (trừ node vừa được thêm vào) |
ARBITER | Không giữ dữ liệu, chỉ bỏ phiếu | Có |
DOWN | Các member khác không liên lạc được | Khô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. Membervotes: 0phả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 clusterw: "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ự:
- 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.
- 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 đó. - 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òngAttempting to load local voted for document). - Kiểm tra dữ liệu mới nhất. Yêu cầu bỏ phiếu mang theo
lastWrittenOpTimevàlastAppliedOpTimecủ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. - Đủ đ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". - 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;catchUpTimeoutMillismặ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ần | Election 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) |
|---|---|---|---|---|
| 1 | 10.622 | 20.132 | 9.486 | 20.572 |
| 2 | 10.213 | 10.250 | 18 | 10.238 |
| 3 | 10.484 | 10.520 | 21 | 10.511 |
| 4 | 10.590 | 10.626 | 16 | 11.076 |
| 5 | 10.159 | 10.175 | 7 | 10.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ần | stepDown tới primary mới nhận ghi (ms) | Thao tác client dài nhất (ms) |
|---|---|---|
| 1 | 75 | 76 |
| 2 | 63 | 173 |
| 3 | 25 | 144 |
| 4 | 111 | 197 |
| 5 | 12 | 287 |
[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ần | Primary 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) |
|---|---|---|---|---|
| 1 | 10.035 | 10.135 | 10.156 | 36.372 |
| 2 | 10.048 | 10.083 | 10.091 | 36.255 |
| 3 | 10.038 | 11.460 | 11.491 | 36.308 |
| 4 | 10.047 | 10.072 | 10.186 | 36.423 |
| 5 | 10.087 | 10.184 | 10.195 | 36.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?
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?
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?