Replica set — oplog, bầu primary, write concern, read concern và những write đã ack vẫn mất

Cắt mạng primary 30 s trong lúc một client đứng cùng phía với nó ghi w:1: primary cũ vẫn trả "OK" thêm ~10 s, rồi 359–441 write đã ack bị dời ra file rollback/…/removed.bson khi mạng nối lại — client không nhận một lỗi nào. Cùng kịch bản với w:"majority": 0 mất. docker kill primary: 10,4–11,3 s không ghi được, gần hết là chờ timeout; primary cũ (priority 2) quay lại giành quyền và 3/42 lần thua phiếu → thêm ~10,7 s không có primary. PSA mất secondary: flow control hãm cả ghi w:1 từ ~680 xuống 100 update/s. Bài này đi theo một write qua oplog, commit point, bầu chọn, rollback và đo từng chỗ hỏng. Transaction, causal consistency: bài 8; change stream: bài 10; CAP/PACELC: bài 1; journal, crash một node: bài 6.

Thuật ngữ dùng trong bài
replica set (nhóm mongod giữ cùng dữ liệu, 1 primary nhận ghi) · oplog (operation log — collection local.oplog.rs ghi mọi thay đổi theo thứ tự) · optime (vị trí trong oplog = ts + term) · term (số nhiệm kỳ, tăng mỗi lần bầu) · heartbeat (gói "còn sống" giữa các node) · sync source (node mà secondary kéo oplog về) · commit point (optime mà đa số node có quyền bầu đã ghi — không bao giờ bị rollback) · write concern (chờ bao nhiêu node xác nhận rồi mới trả lời ghi) · read concern (đọc ở mức nhất quán nào) · read preference (đọc ở node nào) · rollback (node cắt bỏ phần oplog rẽ nhánh) · initial sync (chép toàn bộ dữ liệu cho node mới/hỏng) · stepdown (primary tự xuống secondary) · retryable write (lệnh ghi mang id phiên + số thứ tự để thử lại không bị chạy 2 lần).
Kiểm chứng: Context7 (/mongodb/mongo, /websites/mongodb_manual); source v9.0 src/mongo/db/repl/README.md ("README repl"); manual (write/read concern, rollback, PSA, protocol version, staleness), 8.0 compatibility, Jepsen. Output: labs/mongodb/out/07-*.txt. MongoDB 9.0.2, 3 node trên 1 máy (--memory 1g, cache 0,25 GB), driver Node 7.7.0, ext-mongodb 2.5.3 — số ms chỉ để so tỉ lệ.

Trả lời 30 giây

Replica set là một log có thứ tự (oplog) được sao chép. Primary ghi document và entry oplog trong cùng một storage transaction; entry được viết lại cho idempotent ($inc thành "đặt giá trị cuối"). Secondary kéo oplog từ sync source, ghi vào oplog của nó, áp song song theo lô, báo vị trí ngược lên. Commit point = optime đa số đã ghi: w:"majority" chờ nó (8.0+: chờ ghi, không chờ áp), readConcern: "majority" đọc tại nó. Bầu chọn pv1 dựa trên Raft: heartbeat 2 s, 10 s không thấy primary thì dry run → tăng term → xin phiếu; voter từ chối ứng viên có oplog cũ hơn mình, nên primary mới luôn có mọi write đã commit. Write chưa tới đa số chỉ nằm trên primary cũ và bị rollback ra file khi nó quay lại. Lab: failover ~10–11 s; cắt mạng 30 s mất 359–441 write w:1 đã ack, majority mất 0; PSA mất một node có dữ liệu → commit point đứng yên: flow control hãm ghi xuống 100 update/s, tắt nó thì oplog và history store phình.

1. Bài toán gốc và bối cảnh ra đời

MongoDB 1.x chỉ có master-slave: master chết thì người vận hành đổi tay. Bản 1.6 (2010) đưa vào replica set — các secondary tự bầu primary mới (A. J. J. Davis, InfoQ). Cơ chế bầu đời đầu (protocol version 0) so độ mới bằng timestamp đồng hồ thật, không có term. Hệ quả được Jepsen đo: 2015 (2.6.7) thấy đọc bẩn và đọc cũ từ primary bị cô lập (aphyr 2015); 2017 thấy pv0 mất 93 write đã ack khi đồng hồ lệch (Jepsen 3.4.0-rc3).

Câu trả lời của MongoDB là đi theo Raft: pv1 (mặc định từ 3.2) thêm term, chặn bầu 2 lần trong 1 term, chặn ứng viên có log cũ (manual); 3.4.1 qua mọi test Jepsen với majority + linearizable. Các mốc sau: readConcern majority (3.2), linearizable (3.4); retryable writes, causal consistency (3.6); rollback recover to timestamp nhờ timestamp của WiredTiger (4.0, bài 6); streaming replication (4.4); mặc định w:"majority" (5.0); 8.0: majority ack khi đa số đã ghi, không chờ áp; 9.0: số đo cho initial sync.

Tối ưu cho: tự failover trong một region (~10 s, không cần người), không mất write đã majority-ack, đọc thêm ở secondary, backup/phân tích trên node phụ. Không tối ưu cho: ghi ở nhiều nơi cùng lúc (mỗi term chỉ 1 primary), failover dưới 1 s, đọc-ngay-sau-ghi ở secondary, quá 7 node có quyền bầu (tối đa 50 member). Giá: dữ liệu ×3, mỗi write majority thêm ít nhất 1 vòng mạng.

2. Mô hình tư duy cốt lõi

  1. Một log có thứ tự được sao chép. Mọi thay đổi trên primary thành một entry oplog có ts tăng dần. Secondary chỉ là máy phát lại log đó. Hai node có cùng đoạn đầu (prefix) của log thì có cùng dữ liệu.
  2. Commit point chia log làm hai. Phần đến commit point đã nằm trên đa số → mọi primary tương lai đều có (vì voter từ chối ứng viên cũ hơn). Phần sau commit point là "lịch sử tạm" của một primary — có thể bị cắt bỏ.
Hiện tượngVì sao (hệ quả của 2 ý trên)
w:1 đã ack vẫn mất khi failoverAck khi entry mới ở phần "tạm"; primary mới không có nó → nhánh đó bị rollback
w:"majority" không mấtĐã qua commit point; luật bầu bảo đảm primary mới có nó
Đọc secondary thấy dữ liệu cũSecondary đứng sau trong cùng một log
readConcern: "majority" cũ hơn nhưng không bao giờ "biến mất"Đọc tại commit point, không đọc phần tạm
PSA mất một node: history store, oplog phìnhCommit point đứng yên → mọi thứ sau nó đều "tạm", WiredTiger phải giữ để còn rollback được
Đường đi của một insert w:majority trên replica set 3 node Ứng dụng gửi insert tới r1. r1 ghi document và entry oplog 106. r2 kéo entry về, ghi vào oplog của nó rồi báo vị trí ngược lên r1. Đủ đa số (r1 và r2) nên commit point tiến tới 106 và r1 trả ack. r3 nhận entry muộn hơn; r2 áp entry vào dữ liệu sau khi đã báo. Một insert w:"majority" — ack khi đa số đã GHI entry oplog (8.0+), chưa chắc đã ÁP ứng dụng driver r1 · PRIMARY oplog (local.oplog.rs) 101 102 103 104 105 106 commit point document + entry oplog trong 1 storage transaction ← w:1 trả ack ngay lúc này đa số = 2/3 node có quyền bầu commit point: optime đa số đã ghi kéo (pull) báo vị trí kéo r2 · SECONDARY 102 103 104 105 106 đã ghi đã áp r3 · SECONDARY (chậm hơn) 102 103 104 105 106 đã ghi + áp ack majority
Hình 1 — Write đi theo log: r1 ghi document và entry oplog trong một storage transaction (ack w:1 ở đây), secondary kéo entry về, ghi vào oplog của mình rồi báo vị trí ngược lên (replSetUpdatePosition); khi đa số đã ghi, commit point tiến và w:"majority" được ack. Từ 8.0 ack không chờ secondary áp entry — lab đo ghi majority rồi đọc ngay ở r2 vẫn hụt 10/500 lần (mục 3.3).

3. Internals và quyết định thiết kế

3.1 Oplog: collection capped, mỗi entry idempotent

README repl: một OpObserver chèn entry vào local.oplog.rs trong cùng storage transaction với thay đổi dữ liệu; entry được viết lại cho idempotent. Lab 07-oplog.txt đọc entry thật:

updateOne $inc {qty: 3}           {"op":"u","o":{"$v":2,"diff":{"u":{"qty":8}}},"o2":{"_id":1},"txnNumber":2,…}   ← 5 + 3: ghi GIÁ TRỊ CUỐI
updateOne $push "b" (mảng có 1 pt) {"op":"u","o":{"$v":2,"diff":{"stags":{"a":true,"u1":"b"}}},…}           ← diff: phần tử 1 của mảng tags
updateOne không đổi gì            0 entry
insertMany 4 / 1 000 document     1 / 2 entry applyOps (gom lô) — 1 000 document: 114,5 KB
updateMany 1 000 document         1 000 entry, 162,1 KB          deleteMany 1 000: 100 entry applyOps (10 lệnh/entry), 86,4 KB
createIndex {qty: 1}              7 entry (config.system.indexBuilds, startIndexBuild, commitIndexBuild…)
-- áp lại entry "u: {qty: 8}" 2 lần bằng applyOps → qty = 8, 8 ; chạy lại lệnh $inc 3 gốc 2 lần → 11, 14

Vì sao phải idempotent: initial sync áp oplog chồng lên dữ liệu đang chép dở, recovery sau crash áp lại từ checkpoint (README repl, "Idempotency concerns") — một entry có thể được áp hơn một lần. Đổi lại: entry update mang giá trị cuối nên không biết "ý định" (+3); entry insert/delete tách theo document. Trường của một entry: ts (Timestamp(giây, thứ tự)), t (term), ui (UUID collection), wall, và lsid/txnNumber/stmtId/prevOpTime khi là retryable write (mục 3.8). Khi không có tải, primary ghi entry {"op":"n","o":{"msg":"periodic noop"}} mỗi 10 s (periodicNoopIntervalSecs) để optime vẫn tiến — secondary và driver dựa vào nó để đo độ trễ.

Kích thước: mặc định 5 % đĩa trống, kẹp trong 990 MB – 50 GB (lab: 60 GB trống → maxSize 3025 MB). Cửa sổ oplog (oplog window) = khoảng thời gian từ entry cũ nhất tới mới nhất — node nào tắt lâu hơn cửa sổ thì không bắt kịp được nữa (mục 5.6). Hai bẫy đo được:

replSetResizeOplog {size: 500}  → BadValue: BSON field 'size' value must be >= 990, actual value '500'
mongod --oplogSize 5 (lúc tạo oplog lần đầu) → maxSize 5 MB — không có sàn 990 MB

minRetentionHours (hay --oplogMinRetentionHours): chỉ xoá entry khi oplog đầy và entry cũ hơn N giờ → oplog được phép to hơn maxSize; đặt bằng replSetResizeOplog thì mất khi restart nếu không ghi vào file cấu hình (manual).

3.2 Secondary: kéo, ghi, áp — ba luồng, hai bộ đệm

flowchart LR
  P["sync source
(primary hoặc secondary khác)"] -- "exhaust cursor: find ts ≥ entry cuối" --> F["OplogFetcher"] F --> B1["bộ đệm ghi"] --> W["OplogWriter
ghi oplog, flush journal
→ lastWritten / lastDurable"] W --> B2["bộ đệm áp"] --> A["OplogApplier
16 thread, lô ≤ 5 000 op / 100 MB
→ lastApplied (cuối lô)"] W -. "replSetUpdatePosition" .-> P

3.3 Commit point và write concern

Primary nhận replSetUpdatePosition (optime đã ghi/đã bền/đã áp của từng node), lấy optime lớn nhất mà đa số node có quyền bầu đã bền (lastDurable, vì writeConcernMajorityJournalDefault: true) làm commit point, đánh thức các write đang chờ. "Đa số" của write concern = min(đa số mọi node có quyền bầu kể cả arbiter, số node có quyền bầu có dữ liệu) (manual). Mỗi lần commit point tiến, stable_timestamp được tính lại theo nó — checkpoint của WiredTiger và rollback dựa vào mốc này (bài 6).

Write concern (lab 07-wc, 900 insert/mức)p50p99Chờ gì
w:11,40 ms7,01 msPrimary đã ghi vào cache + log buffer (chưa fsync — bài 6)
w:1, j:true3,70 ms17,99 msJournal của primary đã fsync
w:"majority" (mặc định)4,82 ms29,49 ms2/3 node đã ghi + journal entry oplog
w:2 / w:3 / w:3, j:true4,47 / 6,52 / 7,83 ms17,6 / 30,0 / 82,7 msĐủ số node đã ghi

Cùng máy nên vòng mạng gần 0; giữa hai datacenter cách 10 ms thì majority ≥ 10 ms mỗi write (chưa verify ở mạng thật). Mặc định đọc được bằng getDefaultRWConcern: {"w":"majority","wtimeout":0}, nguồn implicit. Công thức mặc định ngầm (README repl): có arbiter và số node có dữ liệu ≤ đa số phiếu → {w:1}, ngược lại majority (PSA → w:1, mục 5.7). Đã đặt cluster-wide bằng setDefaultRWConcern thì không gỡ được nữa.

8.0 đổi nghĩa ack (dịch): "write với majority trả ack khi đa số đã ghi entry oplog … trước đây chờ đa số áp" (8.0 compatibility). Lab 07-read ryw: ghi rồi đọc ngay ở từng secondary (nối thẳng), 500 lần mỗi kiểu:

ghi w:1          → đọc ngay readConcern local    ở r2: 377/500 không thấy · r3: 116/500 không thấy
ghi w:1          → đọc ngay readConcern majority ở r2: 497/500 không thấy · r3: 396/500 không thấy
ghi w:"majority" → đọc ngay readConcern local    ở r2:  10/500 không thấy · r3:   1/500 không thấy
ghi w:"majority" → đọc ngay readConcern majority ở r2:  55/500 không thấy · r3:  10/500 không thấy

Kể cả node đã "xác nhận" write vẫn có thể chưa áp, và snapshot majority của secondary đi sau commit point một nhịp heartbeat. Muốn đọc lại write của mình ở secondary: causal consistency (session + afterClusterTime, bài 8) — không phải majority.

wtimeout không huỷ write — chỉ là hết giờ chờ. Lab 07-wc, khoá ghi secondary bằng fsyncLock:

-- A. nguồn đồng bộ: r2 kéo từ r3, r3 kéo từ r1                 ← chaining
   fsyncLock r3; insert {_id: 2}, w:"majority", wtimeout 1000 → WriteConcernTimeout: waiting for replication timed out (1015 ms)
   insert {_id: 4}, w:3, wtimeout 1000 → WriteConcernTimeout …; ngay sau đó: r1:CÓ r2:không r3:không
-- B. fsyncLock thêm r2 (không còn đa số nào ghi được)
   insert {_id: 3}, w:"majority", wtimeout 1000 → WriteConcernTimeout: waiting for replication timed out (1013 ms)
   ngay sau đó: r1:CÓ r2:không r3:không · đọc majority trên r1: 0 document · đọc local: 1
   app "thử lại" cùng _id 3 → WriteConcernTimeout … + writeError 11000 : E11000 duplicate key error … dup key: { _id: 3 }
   app "thử lại" đơn không có _id cố định (2 lần) → WriteConcernTimeout · WriteConcernTimeout → trên r1 có 2 đơn A-100
-- fsyncUnlock r2, r3 → sau 3 s: _id 3 → r1:CÓ r2:CÓ r3:CÓ · đọc majority trên r1: 1 document · đơn A-100: r1:2 r2:2 r3:2

Hệ quả: lỗi WriteConcernTimeout nghĩa là không biết, không phải thất bại. Document có thể được commit sau đó (như trên), hoặc bị rollback nếu primary đổi (mục 5.3). App "thử lại" insert có _id cố định thì gặp E11000 — dấu hiệu write lần trước đã vào; không có khoá tự nhiên thì đơn bị nhân đôi.

3.4 Bầu chọn pv1: Raft cộng priority

Bầu primary mới sau khi primary bị docker kill Ba node trao heartbeat mỗi 2 giây. r1 bị kill. r2 và r3 chờ hết electionTimeoutMillis 10 giây không thấy primary. r2 hết giờ trước, chạy dry run, tăng term từ 46 lên 47, xin phiếu r3, r3 đồng ý, r2 thành primary. Primary chết: ~10 s chờ timeout, rồi bầu xong trong vài chục ms r1 · priority 2 PRIMARY · term 46 ✕ docker kill r2 SECONDARY PRIMARY · term 47 r3 SECONDARY heartbeat 2 s electionTimeoutMillis 10 000 (+ độ lệch ngẫu nhiên) — không thấy primary thì đứng ra bầu dry run (không tăng term) → term 47 → tự bầu mình, ghi LastVote xuống đĩa → xin phiếu −3 s 0: kill +10,1–11,3 s: bầu + vài chục ms: nhận ghi
Hình 2 — Bầu chọn pv1 (Raft). Lab 07-failover: từ lúc docker kill tới lúc node mới "bắt đầu bầu" là 10,1–11,3 s (10 lần); từ đó tới "database writes are now permitted" chỉ 19–137 ms. Gần hết thời gian failover là chờ timeout, không phải bầu.
docker kill r1 (lần 1) → KHÔNG GHI ĐƯỢC 10 751 ms | 0 lỗi | 1 insert chờ 10 731 ms
   log r2 +10 748 ms bắt đầu bầu (hết electionTimeout) → +10 750 ms dry run thắng, bầu thật (term 47)
   log r2 +10 754 ms THẮNG → catch-up → +10 767 ms NHẬN GHI (transition to primary complete)
   log r1 +26 082 ms SECONDARY → +36 420 ms bầu do priority takeover (term 48) → r2 stepdown vì thấy term cao hơn
8 lần kill: 10 362 – 11 287 ms không ghi được; "bắt đầu bầu" ở +10 090 – 11 280 ms (10 lần), tới "nhận ghi" thêm 19 – 137 ms
rs.stepDown(60): 75 – 171 ms không ghi được (election handoff, 0 lỗi)

3.5 Rollback: recover to timestamp

Node quay lại thấy optime cuối mình có lớn hơn optime của nguồn nhưng thuộc term cũ hơn → OplogStartMissing → trạng thái ROLLBACK (không phục vụ đọc). Các bước (README repl, log thật của r1 ở mục 5.3): tìm common point (entry cuối hai bên còn giống nhau) → tăng Rollback ID → ghi các document bị đổi sau common point ra file BSON (createRollbackDataFiles mặc định true) → bảo WiredTiger quay về stable_timestamp (bài 6) → đánh dấu cắt oplog sau common point → áp oplog của nguồn → huỷ mọi session → SECONDARY. Không có "merge": dữ liệu trong file rollback chỉ con người mới đưa lại được (đọc bằng bsondump, nạp bằng mongorestore sau khi xem xét). 9.0.2 ghi hai loại file:

/data/db/rollback/4bdcf7aa-…/removed.2026-10-10T14-12-12.5.bson      ← UUID của lab.part: 439 document (bsondump)
/data/db/rollback/local.oplog.rs/removed.2026-10-10T14-12-12.4.bson   ← các entry oplog bị cắt: {"lsid":…,"txnNumber":…,"op":"i","ns":"lab.part",…}

Rollback chỉ tới rollbackTimeLimitSecs (86 400 s) — nhánh rẽ dài hơn thì rollback thất bại, node phải initial sync lại.

3.6 Read concern và read preference

Read concernĐọc tại đâuCó thể thấy dữ liệu sau này bị rollback?Giá (lab, findOne theo _id)
local (mặc định)Snapshot mới nhất (secondary: tại lastApplied)Có — lab 5.3: r1 thấy 760 document, về sau còn 321p50 0,98 ms
availableNhư local; trên shard không chờ làm mới metadataCó, thêm orphan (bài 9)—
majoritySnapshot tại commit pointKhông — nhưng có thể cũp50 0,79 ms
linearizableNhư majority + ghi một noop và chờ nó majority → chắc mình vẫn là primaryKhông, và không cũ hơn write majority đã ack trước đóp50 3,94 ms (×4); chỉ 1 document, chỉ primary
snapshotMột thời điểm cho cả câu lệnh/transactionKhôngbài 8

linearizable trên secondary: NotWritablePrimary: cannot satisfy linearizable read concern on non-primary node. Read preference (primary, primaryPreferred, secondary, secondaryPreferred, nearest) chỉ chọn node; driver ước lượng độ trễ của secondary qua heartbeat (10 s) nên maxStalenessSeconds có hai sàn — lab 07-read maxstale:

maxStalenessSeconds=10: MongoInvalidArgumentError: Option "maxStalenessSeconds" must be at least 20 seconds   (heartbeat 10 s + idleWritePeriod 10 s)
maxStalenessSeconds=89: MongoInvalidArgumentError: Option "maxStalenessSeconds" must be at least 90 seconds
Đọc ở secondary đang trễ với maxStalenessSeconds 90 Ứng dụng đọc với readPreference secondary và maxStalenessSeconds 90. r2 bắt kịp primary, trả giá trị mới. r3 bị khoá ghi (fsyncLock) nên trễ tới 25 giây, vẫn dưới ngưỡng 90 giây nên vẫn được chọn và trả giá trị cũ. readPreference secondary + maxStalenessSeconds 90: trễ 25 s vẫn "đủ tươi" ứng dụng (driver Node) secondary, maxStaleness 90 ước lượng trễ r2 ≈ 0 s, r3 ≈ 25 s → cả hai < 90 s, chọn ngẫu nhiên r1 PRIMARY · v = 207 r2 · v = 207 (bắt kịp) r3 · v = 0 fsyncLock — trễ tới 24,8 s đọc ở r2: v = 207 ✓ đọc ở r3: v = 0 ✗ (cũ 24,8 s)
Hình 3 — Lab 07-read: khoá r3 25 s, ghi w:"majority" liên tục (r1 + r2 đủ), đọc readPreference: secondary, maxStalenessSeconds: 90: 105/105 lần đọc rơi vào r3 trả dữ liệu cũ (cũ nhất 24,8 s), 102 lần rơi vào r2 thì đúng. maxStalenessSeconds chỉ loại node trễ quá 90 s — không phải cơ chế read-your-writes.

3.7 Initial sync

Node mới hoặc đã xoá dữ liệu vào STARTUP2: xoá mọi thứ trừ local, ghi nhận optime bắt đầu của nguồn, mở OplogFetcher đệm oplog vào một collection (không giới hạn bởi RAM), chép từng collection bằng exhaust cursor (index dựng song song), rồi áp oplog đã đệm tới stopTimestamp — lỗi kiểu DuplicateKey ở giai đoạn này bị bỏ qua vì entry có thể đã nằm trong dữ liệu vừa chép. Trong lúc đó node không bỏ phiếu và không được tính vào majority (newlyAdded). Kiểu mặc định là logical; file copy based chỉ có ở Enterprise. Lab: 2 triệu đơn + 3 triệu event (~1 000 MB chưa nén) chép xong trong 12 s, SECONDARY sau 15 s; lần chạy khác log "Initial sync done" {"durationSeconds": 17}, 59,6 MB/s — cùng máy, đĩa và mạng thật chậm hơn nhiều (chưa verify ở quy mô TB).

3.8 Retryable writes: chạy đúng một lần qua failover

Driver (mặc định retryWrites=true) gắn lsid + txnNumber vào mỗi lệnh ghi một document (insert, updateOne/replaceOne/deleteOne, findAndModify, bulkWrite không có lệnh multi). Server ghi kết quả của câu lệnh vào config.transactions — bảng này đi cùng oplog sang secondary, nên primary mới biết câu lệnh đã chạy và trả lại kết quả cũ thay vì chạy lần hai. Driver chỉ thử lại một lần, chỉ với lỗi mạng và lỗi có nhãn RetryableWriteError (NotWritablePrimary, InterruptedDueToReplStateChange…); updateMany/deleteMany không được thử lại. Lab 07-retry — 8 worker $inc, ép đúng ca "đã chạy trên đa số mà mất ack" (khoá r3, ghi w:3, stepDown r1):

-- ép ca: fsyncLock r3 → $inc w:3 đã chạy trên r1 + r2 (đủ đa số) nhưng còn chờ r3 → rs.stepDown() r1
   retryWrites=false: app tin đã tăng 3078 lần · giá trị trong DB 3086 · CỘNG TRÙNG 8 · app thử lại 8 lần
      8 × MongoWriteConcernError InterruptedDueToReplStateChange: operation was interrupted
   retryWrites=true:  app tin đã tăng 2940 lần · giá trị trong DB 2940 · CỘNG TRÙNG 0 · app thử lại 0 lần

Đây là write concern error: lệnh đã chạy xong, chỉ phần chờ bị ngắt; driver gửi lại cùng txnNumber, primary mới trả lại kết quả cũ.

4. Đảm bảo và giới hạn

ThứGiá trị / hành viNguồn
w:1Ack trước khi bền và trước khi tới đa số: mất khi crash (bài 6) và khi failover — 359–441 write đã ack mỗi lần cắt mạng 30 slab 07-partition
w:1, j:trueSống qua crash của primary, vẫn bị rollback khi failovermanual write concern
w:"majority" + journalKhông bị rollback trừ khi đa số mất đĩa cùng lúc; lab: 0 mấtmanual pv1, lab
Sẵn sàng ghiCần đa số phiếu; mất đa số → không có primary (đọc secondary vẫn được nếu read preference cho phép)manual
Thời gian failover≥ electionTimeout (10 s) + bầu (vài chục ms) + driver nhận ra (0,03 s – hàng chục s, mục 5.3)lab 07-failover, 07-detect
Giới hạn50 member, 7 có quyền bầu; oplog 990 MB – 50 GB mặc định; maxStalenessSeconds ≥ 90; rollback ≤ 24 hmanual, lab

Khung lý thuyết: trong CAP, phía thiểu số không ghi được → với w:"majority" + readConcern majority/linearizable là CP. Trong PACELC (bài 1): mặc định PC/EC; hạ xuống w:1 + đọc secondary thì thành PA/EL — nhanh hơn, đổi lấy mất write và đọc cũ. Bầu chọn là Raft (term, vote theo log mới nhất), nhưng commit là "đa số đã ghi" chứ không phải "đa số đã áp".

5. Failure modes

5.1 Primary chết: ~10 s không ghi được, driver giấu bằng cách bắt request chờ

Lab 07-failover: client Node ghi liên tục (insert tuần tự, nghỉ 20 ms), docker kill r1. 8 lần: 10,36–11,29 s không có ack nào; node mới "bắt đầu bầu" ở +10,1–11,3 s và "database writes are now permitted" sau đó 19–137 ms (07-failover.txt, -run1.txt). Với retryWrites=true (mặc định) app không thấy lỗi — insert đang bay bị đứt kết nối, driver chờ server selection (tối đa serverSelectionTimeoutMS 30 s) rồi gửi lại cho primary mới: một request treo 10,3–11,0 s. Tắt retryWrites: đúng 1 lỗi MongoNetworkError: read ECONNRESET mỗi lần (insert đang bay), các insert sau cũng chỉ chờ. Bẫy nổ trễ: timeout của tầng HTTP/queue ngắn hơn 11 s thì người dùng thấy lỗi dù DB tự hồi phục; và request treo 10 s giữ connection pool → nghẽn dây chuyền.

rs.stepDown() (bảo trì có kế hoạch) thì khác hẳn: primary chờ một secondary bắt kịp rồi gọi replSetStepUp cho nó (election handoff) — lab 3 lần chỉ 75–171 ms không ghi được, 0 lỗi.

5.2 Primary cũ quay lại: priority takeover có thể thua phiếu

r1 (priority 2) khởi động lại, về SECONDARY trong ~1 s, bắt kịp, ~10 s sau đòi lại primary. Qua mọi lab của bài: 42 lần đòi, 3 lần thua phiếu (out/07-takeover.txt):

13:12:06.472 [4615660] Starting an election for a priority takeover
13:12:06.543 [21402] Stepping down from primary, because a new term has begun term 10        ← primary đang chạy (r2) tự đổ
13:12:06.618 [51799] … from tmp-mdb-r3: candidate's data is staler than mine. candidate's last written OpTime:
                     { ts: Timestamp(1791637926, 2), t: 9 }, my last written OpTime: { ts: Timestamp(1791637926, 3), t: 9 }
13:12:06.854 [21434] Lost election
13:12:17.259 [21450] Election succeeded, assuming primary role term 11                       ← 10,7 s không có primary

Dry run thắng, nhưng giữa dry run và bầu thật client ghi thêm một entry (i:3) mà r1 chưa có → cả hai voter nói "không"; còn primary đang chạy đã thấy term 10 nên tự step down. Một lần đổi primary không cần thiết gây thêm ~10,7 s gián đoạn. Tránh: priority bằng nhau, hoặc chỉ đổi primary bằng rs.stepDown() có kế hoạch; tải ghi càng đều càng dễ dính.

5.3 Cắt mạng primary: write w:1 đã ack bị rollback, client phía đa số bị treo

Lab trọng tâm 07-partition: r1 là primary, docker network disconnect r1 trong 30 s. Client A chạy trong network namespace của r1 — như app server nằm cùng datacenter với primary cũ; client B ở phía r2/r3 dùng chuỗi replicaSet. Cả hai insert tuần tự. Lần 3 (mốc 0 = lúc cắt):

-- w:1
   A: sau lúc cắt vẫn được ack tới +10 048 ms; lỗi đầu tiên +10 071 ms: MongoServerError NotWritablePrimary: not primary
   log r1 +10 054 ms [21475] Stepping down from primary in response to heartbeat
   log r2 +10 466 ms [21450] Election succeeded, assuming primary role {"term": 85}
   đọc trên r1   +9 221 ms: local 724 | majority 321        ← A đọc thấy write của chính nó (đọc bẩn)
                +10 224 ms: local 760 | majority 321
   nối mạng → r1 +177 ms Transition to ROLLBACK · +541 ms Marking to truncate all oplog entries … greater than common point
             · +548 ms Rollback summary → SECONDARY;   đọc trên r1 +31 009 ms: local 321 | majority 321
   WRITE ĐÃ ACK MÀ MẤT: 439 (được ack từ +69 đến +10 048 ms) · file rollback: 439 document · write báo lỗi mà vẫn có: 0
   B (phía đa số): KHÔNG GHI ĐƯỢC 30 288 ms — insert đang bay tới r1 treo tới lúc nối mạng
-- w:"majority", wtimeout 2000
   A: lỗi đầu tiên +2 058 ms: MongoWriteConcernError WriteConcernTimeout: waiting for replication timed out
   WRITE ĐÃ ACK MÀ MẤT: 0 · file rollback: 4 document (đều là write đã báo lỗi) · write báo LỖI mà vẫn có trong DB sau cùng: 1
dA mặc định                                   KHÔNG GHI ĐƯỢC 33 443 ms | 0 lỗi | 1 insert chờ 33 422 ms
dB timeoutMS=2000                             KHÔNG GHI ĐƯỢC 11 462 ms | 25 lỗi (24 × getaddrinfo EAI_AGAIN tmp-mdb-r1,
                                                                           1 × MongoOperationTimeoutError: Timed out during socket read (1999ms))
dC heartbeatFrequencyMS=2000&connectTimeoutMS=2000  KHÔNG GHI ĐƯỢC 11 430 ms | 0 lỗi

Kill thì kernel gửi RST, driver biết ngay; cắt mạng thì gói tin rơi im lặng, socketTimeoutMS mặc định 0 — lệnh đang bay chờ tới khi thread giám sát kết luận r1 chết (heartbeat 10 s + connectTimeoutMS 30 s) rồi ngắt kết nối: ~33 s. timeoutMS đổi chờ thành lỗi nhanh (app phải retry); rút heartbeat/connect timeout thì driver tự chuyển sau ~11 s. (EAI_AGAIN: DNS của Docker xoá tên container bị cắt — mạng thật sẽ là timeout kết nối.)

Cắt mạng primary: write w:1 được ack rồi bị rollback Mạng giữa r1 và r2, r3 bị cắt. Client A cùng phía r1 tiếp tục ghi w:1, r1 trả ack cho từng write. Sau khoảng 10 giây r1 tự step down; r2 được bầu primary ở term mới và nhận write của client B. Khi mạng nối lại, các entry r1 ghi trong lúc bị cắt được chuyển ra file rollback, r1 nhận lịch sử của r3. Cắt mạng primary 30 s — client A cùng phía r1, ghi w:1 rollback/<uuid>/removed.….bson client A ✓ ack r1 PRIMARY · term 84 SECONDARY sau 10,1 s c c B B w1 w1 w1 … c = entry đã commit (đa số có) 439 write w:1 đã trả "OK" cho A mạng bị cắt r2, r3 (đa số) SECONDARY, không thấy primary r2 PRIMARY · term 85 (+10,5 s) c c B B B = write của client B (phía đa số), term 85 +30 s nối mạng → r1 ROLLBACK (~0,5 s) → SECONDARY
Hình 4 — Write w:1 chỉ nằm trên r1 là nhánh lịch sử không có trong commit point. Khi mạng nối lại, r1 tìm điểm chung (common point) với r2, đưa document đã ghi sau đó ra file BSON, cắt oplog, rồi chép lịch sử term 85. Client A không hề nhận lỗi cho các write này. Số đo: lab 07-partition (mục 5.3).

5.4 App thử lại sau lỗi: cộng trùng

Cùng lab, failover "thường" (stepDown hoặc docker kill ở giây 10, w:"majority"), 3 lần chạy — ô = cộng trùng / số lần app phải tự gọi lại (out/07-retry-run1.txt, -run2.txt, 07-retry.txt):

LầnstepDown, retryWrites=falsestepDown, truekill, falsekill, true
10 / 10 / 00 / 80 / 7
23 / 70 / 55 / 88 / 8
32 / 80 / 02 / 85 / 7

Hai điều đáng nhớ. (1) Tắt retryable writes: 4/6 lần failover cộng trùng 2–5 lần — các lệnh đã chạy xong mà mất ack. (2) Bật retryable writes vẫn cộng trùng khi primary bị kill (2/3 lần chạy): lỗi vẫn lộ ra app (MongoNetworkError: connection 4 to … closed — địa chỉ của r1 đã chết; chưa xác định lần thử lại của driver hỏng ở bước nào), app gửi một lệnh mới (txnNumber mới) → server không nhận ra là trùng. Retryable writes chỉ khử trùng cho lần thử lại của driver. Ở tầng app, chỉ thử lại lệnh idempotent ($set giá trị tuyệt đối, upsert theo khoá) hoặc lệnh mang khoá chống trùng (unique index trên mã giao dịch); $inc, $push, insert không _id cố định thì không.

5.5 Driver PHP và Node nhìn failover khác nhau

Lab 07-php: một process PHP (giống một worker PHP-FPM — ext-mongodb giữ topology theo URI trong process) insert w:"majority" mỗi 100 ms, docker kill r1:

URI mặc định:           KHÔNG GHI ĐƯỢC 15 660 ms | 11 lỗi | 1 insert chờ lâu nhất 5 413 ms
   9 × ConnectionTimeoutException: No suitable servers found (`serverSelectionTryOnce` set): [connection closed calling hello
       on 'tmp-mdb-r1:27017']. Topology type: ReplicaSetNoPrimary
   1 × ConnectionTimeoutException: … [Failed to resolve 'tmp-mdb-r1']. Topology type: ReplicaSetNoPrimary
serverSelectionTryOnce=false: KHÔNG GHI ĐƯỢC 10 126 ms | 0 lỗi | 1 insert chờ lâu nhất 10 026 ms

Driver PHP chạy đơn luồng: mặc định serverSelectionTryOnce=true — không có primary thì quét một lượt rồi lỗi ngay; node vừa lỗi bị bỏ qua 5 s (cooldownMS 5 000, cố định theo spec SDAM), nên ghi lại được muộn hơn primary mới tới ~5 s. Driver Node có thread giám sát riêng, chờ trong server selection, hầu như không lộ lỗi (5.1). PHP-FPM: mỗi worker tự dò topology — 200 worker là 200 lần dò, mỗi worker tự gặp lỗi. Chọn có chủ ý: serverSelectionTryOnce=false + serverSelectionTimeoutMS nhỏ hơn timeout của FPM, hoặc để tầng trên retry. Laravel đọc đúng DSN …?replicaSet=rs0&w=majority&readPreference=secondaryPreferred (writeConcern {"w":"majority"}) — nhưng secondaryPreferred cho cả connection là đọc cũ khắp nơi; đặt read preference theo từng truy vấn.

5.6 Secondary rớt khỏi cửa sổ oplog: phải initial sync lại

Lab 07-stale: rs0 dựng mới với --oplogSize 990, nạp shop.orders + lms.events — riêng việc nạp đã lấp oplog (979,69 / 990 MB, cửa sổ 0,02 h). docker stop r3, rồi 6 lần updateMany $inc trên 2 triệu đơn (mỗi document 1 entry):

lần 1: 73 510 ms · oplog dùng 975.55 / 990 MB, cửa sổ 142 s
lần 6: 79 842 ms · oplog dùng 983.64 / 990 MB, cửa sổ 249 s
-- docker start r3 → tmp-mdb-r3:27017 RECOVERING
[21126] "Too stale to catch up. Entering maintenance mode. See http://dochub.mongodb.org/core/resyncingaverystalereplicasetmember"
        lastOpTimeFetched t:1791642954 · earliestOpTimeSeen t:1791643188      ← entry cuối của r3 cũ hơn entry cũ nhất còn lại 234 s
[21350] "Going into maintenance mode"

Node kẹt ở RECOVERING mãi mãi — không tự chữa. Cách chữa duy nhất: xoá dữ liệu của nó và để initial sync chạy lại (lab: 15 s cho 1 GB; production nhiều TB là nhiều giờ, trong lúc đó replica set thiếu một bản sao và node nguồn bị đọc toàn bộ dữ liệu). Bài học về số: oplog 990 MB chỉ giữ được 2,4–4,2 phút khi có job backfill — trong khi cảm giác "1 GB oplog là nhiều" đến từ lúc tải nhẹ. Tính cửa sổ ở giờ ghi nặng nhất (hoặc lúc chạy migration), đặt oplogMinRetentionHours nếu đĩa cho phép, và hãm tốc độ backfill (bài 2: theo lô + nghỉ) khi có node đang bảo trì.

5.7 PSA mất một node: majority treo, commit point đứng yên

Lab 07-psa. Ngay bước đầu đã có bẫy: thêm arbiter vào PSS đang chạy bị từ chối, vì nó làm đổi write concern mặc định ngầm:

rs.addArb("tmp-mdb-arb:27017") → NewReplicaSetConfigurationIncompatible: Reconfig attempted to install a config that would change the implicit
  default write concern. Use the setDefaultRWConcern command to set a cluster-wide write concern and try the reconfig again.

PSA dựng mới (p1 priority 2, p2, arbiter; --oplogSize 50): getDefaultRWConcern không còn defaultWriteConcern (nguồn implicit) — theo công thức ở mục 3.3 mặc định là w:1, và lab xác nhận: insert không ghi write concern vẫn "ok sau 3 ms" khi p2 đã chết. Đổi PSS sang PSA = hạ độ bền mặc định xuống w:1, không có cảnh báo. Tải nền 4 worker updateOne ~1 KB, w:1; docker stop p2 → p1 vẫn là primary (p1 + arbiter = 2/3 phiếu):

w:"majority", wtimeout 3000 → sau 3088 ms: WriteConcernTimeout: waiting for replication timed out
w:"majority" (wtimeout 0 = chờ mãi), timeoutMS 8000 phía driver → sau 7994 ms: MongoOperationTimeoutError: Server reported a timeout error
   t    pha          | commit point trễ | HS đĩa | cache  | oplog (cấu hình 50 MB) | update/s | p99      | flow control
  35 s  đủ 3         |    0 s           |  0 MB  |  84 MB |  79 MB*                |  668     |  13,8 ms | không hãm
  79 s  mất p2       |   34 s           |  4 MB  |  88 MB |  46 MB                 |  178     | 872 ms   | HÃM 100 ticket/s
 189 s  mất p2       |  144 s           |  9 MB  | 105 MB |  50 MB                 |  100     | 878 ms   | HÃM 100 ticket/s
 202 s  + tắt flow control (enableFlowControl: false)
 332 s  mất p2       |  287 s           | 25 MB  | 169 MB | 147 MB                 |  767     |   8,0 ms | tắt
 354 s  p2 votes:0, priority:0 (reconfig ok sau 43 ms) |    0 s | 37 MB | 112 MB | 49 MB | 746 | 8,7 ms
   * lúc khoẻ oplog vẫn dao động 47–88 MB: chỉ xoá được từng khối (truncate marker) đã cũ hơn mốc cần cho recovery (WiredTiger README)

6. Vận hành trong production

Theo dõiLấy ở đâuBáo động khi
Replication lag từng secondaryrs.printSecondaryReplicationInfo(), replSetGetStatus.members[].optimeDate so với primary; 8.3+: metrics.repl.network.oplogFetcherLagSeconds> vài giây kéo dài; gần flowControlTargetLagSeconds (10 s) là primary bắt đầu bị hãm
Commit point trễnow − replSetGetStatus.optimes.lastCommittedWallTimeTăng đều = đa số không ghi được (PSA, mất node, chaining kẹt) — mục 5.7
Cửa sổ oplogdb.getReplicationInfo().timeDiffHours (đo ở giờ ghi nhiều nhất)< thời gian bảo trì dài nhất + thời gian initial sync (mục 5.6)
Trạng thái memberrs.status().members[].stateStrRECOVERING, ROLLBACK, STARTUP2 kéo dài, "(not reachable/healthy)"
Bầu chọn, rollbackserverStatus().electionMetrics; log 21612 "Rollback summary"; thư mục rollback/Mỗi lần có — rollback = có write cần đối soát
Flow controlserverStatus().flowControl.isLagged, timeAcquiringMicrosisLagged: true → ghi bị hãm, có thể tới 100 ticket/s (PSA mất node, mục 5.7)
Phía appLỗi NotWritablePrimary, lỗi mạng, server selection timeout, WriteConcernTimeoutWriteConcernTimeout phải log kèm id nghiệp vụ

7. So sánh và ma trận quyết định

TrụcMongoDB replica setPostgreSQL streaming replication (+ Patroni)Redis + Sentinel
Failover tự độngCó sẵn trong server (Raft-like)Không; cần Patroni/repmgr + DCS (etcd, Consul)Sentinel (process riêng)
Ack mặc địnhĐa số đã ghi (từ 5.0)Chỉ primary — lab 07-pg: standby sync_state = async, synchronous_standby_names rỗngChỉ primary
Mất write đã ack khi failover (mặc định)Không; có nếu hạ w:1Có thể; cần synchronous modeCó thể
Chọn độ bền / độ mới theo từng lệnhCó (w, readConcern, read preference)Theo session/transaction (synchronous_commit)Lệnh WAIT
Độ phức tạp vận hànhThấp–vừa: 1 binary, cấu hình trong rs.conf()Vừa–cao: Patroni + DCS + replication slotThấp

Dùng khi: cần HA tự động trong một region mà không muốn tự ráp công cụ; muốn chọn độ bền theo từng lệnh (w, readConcern); đọc thêm ở secondary chấp nhận được độ cũ. Tránh / cân nhắc khi: cần ghi active-active nhiều region; cần failover dưới 1–2 s; chỉ có 2 máy (PSA là cái bẫy, mục 5.7); team chưa có kỷ luật idempotency (retry, wtimeout) — replica set đúng chỉ khi app xử lý được "không biết write đã vào chưa".

8. Góc nhìn tech lead: tác động tổ chức

9. Anti-patterns và bài học thực tế

  1. PSA ở production. Manual khuyên tránh; lab 5.7 cho thấy vì sao. Nếu buộc phải có: votes: 0 cho secondary hỏng, theo dõi commit point.
  2. w:1 cho đơn hàng vì "nhanh hơn". Lab: p50 1,4 ms so với 4,8 ms — đổi lại 359–441 write đã ack biến mất mỗi lần cắt mạng 30 s, app không biết.
  3. Coi WriteConcernTimeout là thất bại rồi thử lại. Write vẫn nằm đó (mục 3.3) → E11000 hoặc đơn trùng.
  4. Đọc secondary ngay sau khi ghi (giỏ hàng, trạng thái thanh toán) — lab ryw hụt cả khi ghi majority; maxStalenessSeconds sàn 90 s không cứu.
  5. App tự retry lệnh không idempotent — kể cả khi bật retryWrites: lỗi đã lộ ra app nghĩa là lần thử lại của driver đã hỏng; lab 5.4: kill primary → cộng trùng 5 và 8 lần (2/3 lần chạy).
  6. Số node chẵn / arbiter chung máy với data node. 2 node có dữ liệu không arbiter: chết 1 là hết primary; 4 node: chết 2 là hết primary — node thứ 4 không thêm khả năng chịu lỗi.
  7. Priority lệch → mỗi lần node ưu tiên restart là thêm một lần đổi primary; lab: 3/42 lần thua phiếu, mất thêm ~10,7 s.
  8. rs.reconfig(cfg, {force: true}) khi chỉ mất liên lạc tạm thời — bỏ qua kiểm tra đa số, có thể sinh hai cấu hình song song (manual; chưa dựng lab).

Bài học công khai: Jepsen 2013 (2.4.1) — cắt mạng mất hàng nghìn write đã ack với "safe"/"replicas-safe", và 2 write cả với majority do bug trả OK quá sớm (aphyr 2013); 2015 — đọc bẩn/đọc cũ từ primary bị cô lập; 2017 — pv0 phụ thuộc đồng hồ, 3.4.1 qua test (mục 1). Điều docs không nhấn: rollback không báo cho app — chỉ có dòng log và một thư mục file BSON; không ai xem thì dữ liệu đó mất hẳn.

10. Câu hỏi phụ interviewer hay hỏi tiếp

Vì sao w:"majority" không bị rollback còn w:1 thì có?
Primary mới phải nhận phiếu đa số, voter từ chối ứng viên có oplog cũ hơn mình, nên primary mới chắc chắn có mọi entry đã nằm trên đa số. w:1 ack khi entry chỉ ở primary; primary đó mất vai trò thì nhánh của nó bị cắt.
Có thể có 2 primary cùng lúc?
Có thể trong khoảng ngắn (khác term), nhất là khi mạng cắt không đối xứng. Cắt đối xứng thì primary cũ thường xuống trước: nó chỉ chờ 10 s mất đa số, ứng viên chờ 10 s + độ lệch ngẫu nhiên — lab 4/4 lần (xuống ở +10,05–10,81 s). Nhưng trong ~10 s đó nó vẫn ack w:1; majority thì không bao giờ được ack ở primary cô lập.
Ghi majority xong, đọc secondary có thấy không?
Không chắc. 8.0+ ack khi đa số đã ghi oplog; lab hụt 10/500 lần. Dùng causal session hoặc đọc primary.
wtimeout hết thì dữ liệu ở đâu?
Vẫn ở primary (và node đã nhận). Có thể được commit sau đó hoặc bị rollback. Không có cơ chế huỷ.
Retryable writes chống trùng thế nào? Khi nào vô dụng?
lsid + txnNumber, server nhớ trong config.transactions (đi theo oplog). Vô dụng với updateMany, khi app tự retry ngoài driver bằng lệnh mới, hoặc khi retry lần 2 của driver cũng lỗi — app vẫn phải xử lý.

Phụ lục: lab đã chạy

Môi trường: MongoDB 9.0.2 (mongo:9.0.2), rs0 = tmp-mdb-r1 (priority 2), r2, r3, mỗi node --memory 1g, cache 0,25 GB, cùng một máy 4 vCPU dùng chung; Node 22 + driver mongodb 7.7.0; PHP 8.3 + ext-mongodb 2.5.3 + laravel-mongodb 5.11; PostgreSQL 18.4 (my-test-pgdb) để so chế độ replication. Chạy lại:

cd labs/mongodb
./07-replica-set.sh                 # tất cả (~2 giờ), hoặc từng phần:
./07-replica-set.sh params|oplog|oplogmin|failover|partition|detect|wc|read|retry|php|psa|stale|pg
source ./lib.sh && mdb_down $RS_NODES tmp-mdb-arb tmp-mdb-p1 tmp-mdb-p2   # dọn

Output: out/07-params.txt, 07-oplog.txt, 07-failover.txt (+ 07-takeover.txt: mọi lần bầu thua — takeover thua phiếu, split vote), 07-partition.txt, 07-detect.txt, 07-wc.txt, 07-read.txt, 07-retry.txt, 07-php.txt, 07-psa.txt, 07-stale.txt, 07-chain.txt, 07-pg.txt, 07-oplogmin.txt (lần chạy trước: 07-failover-run1, 07-psa-run1, 07-retry-run1/2). Script: 07-replica-set.sh, js/07-*.js, node/07-*.mjs, laravel/07-failover.php.

Bẫy khi dựng lab: client "cùng phía" primary cũ phải chạy trong network namespace của nó (--network container:tmp-mdb-r1, nối 127.0.0.1); sửa .sh đang chạy → ghi file mới rồi mv đè (bash giữ inode cũ); chạy lại writer cùng tag mà không drop collection → toàn E11000; replSetSyncFrom chỉ ăn khi nguồn "đi trước"; mongosh: st.members?.find(…) → TypeError: "this" is null or not defined; driver Node 7.7.0 một lần sập process khi primary bị kill (MongoNetworkError: read ECONNRESET, nhãn HandshakeError/SystemOverloadedError, không ai bắt) — script gắn process.on('unhandledRejection').

Chưa verify: mạng thật nhiều datacenter (độ trễ, cắt mạng không đối xứng — chưa tái hiện được 2 primary cùng lúc); electionTimeoutMillis nhỏ hơn; file copy based initial sync (Enterprise) và initial sync quy mô TB; flow control hãm PSA tới sàn — suy ra từ README + lab, chưa đọc source; Atlas; reconfig force; traffic liên AZ; 8.0.32 (chỉ chạy 9.0.2; thay đổi ack của 8.0 lấy từ release notes).