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 — collectionlocal.oplog.rsghi 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.0src/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 ($incthà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 writew:1đã ack,majoritymấ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
- Một log có thứ tự được sao chép. Mọi thay đổi trên primary thành một entry oplog có
tstă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. - 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ượng | Vì sao (hệ quả của 2 ý trên) |
|---|---|
w:1 đã ack vẫn mất khi failover | Ack 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ình | Commit point đứng yên → mọi thứ sau nó đều "tạm", WiredTiger phải giữ để còn rollback được |
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
- Kéo, không đẩy. Secondary mở một exhaust cursor tailable trên oplog của sync source — sau lệnh đầu, nguồn tự gửi lô tiếp. Entry đầu tiên trả về phải trùng entry cuối của mình: entry cũ nhất của nguồn mới hơn entry cuối của mình → too stale (mục 5.6); khác nhánh → rollback (mục 3.5).
- Ghi trước, áp sau. OplogWriter ghi lô vào oplog và flush journal → cập nhật
lastWritten/lastDurable→ đây là cái commit point dùng. OplogApplier áp song song (replWriterThreadCount16;replBatchLimitOperations5 000,replBatchLimitBytes100 MB): các entry cùng document vào cùng thread để giữ thứ tự, DDL đi lô riêng;lastAppliedchỉ tiến khi xong cả lô — đọclocaltrên secondary đọc tạilastAppliednên không thấy nửa lô. - Chọn nguồn (chaining). Mặc định
chainingAllowed: true: secondary chọn node gần nhất theo ping, bỏ node trễ hơn 30 s (maxSyncSourceLagSecs) và node "không đi trước mình". Hệ quả đo được (07-wcphần A): r2 đang kéo từ r3, khoá r3 → writemajorityhết giờ dù r1 + r2 khoẻ; lần khác (07-chain.txt) chờ 35,0 và 45,4 s mới được ack — tới khi r2 bỏ nguồn trễ quá 30 s. Rủi ro "thỉnh thoảng", khó tái hiện. - Flow control: primary tự giảm ticket ghi để majority lag ≤
flowControlTargetLagSeconds10 s — hãm ghi thay vì để secondary rớt xa (mặt trái: mục 5.7).
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) | p50 | p99 | Chờ gì |
|---|---|---|---|
w:1 | 1,40 ms | 7,01 ms | Primary đã ghi vào cache + log buffer (chưa fsync — bài 6) |
w:1, j:true | 3,70 ms | 17,99 ms | Journal của primary đã fsync |
w:"majority" (mặc định) | 4,82 ms | 29,49 ms | 2/3 node đã ghi + journal entry oplog |
w:2 / w:3 / w:3, j:true | 4,47 / 6,52 / 7,83 ms | 17,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
- Heartbeat 2 s giữa mọi cặp node (N² gói → tối đa 50 member, 7 có quyền bầu). Không thấy primary trong
electionTimeoutMillis10 s (+ độ lệch ngẫu nhiên) → đứng ra bầu. - Dry run trước, không tăng term — primary thấy term cao hơn là tự step down, nên không làm đổ primary đang khoẻ khi mình không thắng nổi. Thắng dry run → tăng term,
tự bầu, ghi
LastVotebền vàolocal.replset.election(restart cũng không bầu 2 lần trong 1 term) → xin phiếu thật. - Voter từ chối khi: term cũ, khác config/tên set, đã bầu trong term này, oplog ứng viên cũ hơn mình — luật giữ write đã commit sống qua failover — hoặc voter là arbiter đang thấy primary khoẻ có priority ≥ ứng viên (README repl).
- Thắng → catch-up (kéo nốt entry từ node mới hơn;
catchUpTimeoutMillis−1 = không giới hạn; node mới hơn chờ 30 s rồi giành — catchup takeover) → drain bộ đệm → ghi entry "new primary" ở term mới (Raft: entry term cũ chỉ được commit gián tiếp) → nhận ghi. - Priority takeover: node priority cao hơn, đã bắt kịp trong
priorityTakeoverFreshnessWindowSeconds2 s, chờelectionTimeout × (hạng + 1)rồi bầu. Stepdown có điều kiện (rs.stepDown(): chờ secondary bắt kịp rồi election handoff — gọireplSetStepUpcho nó) và vô điều kiện (thấy term cao hơn; 10 s không liên lạc được đa số).
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 đâu | Có 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 321 | p50 0,98 ms |
available | Như local; trên shard không chờ làm mới metadata | Có, thêm orphan (bài 9) | — |
majority | Snapshot tại commit point | Không — nhưng có thể cũ | p50 0,79 ms |
linearizable | Như majority + ghi một noop và chờ nó majority → chắc mình vẫn là primary | Không, và không cũ hơn write majority đã ack trước đó | p50 3,94 ms (×4); chỉ 1 document, chỉ primary |
snapshot | Một thời điểm cho cả câu lệnh/transaction | Không | bà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
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 vi | Nguồn |
|---|---|---|
w:1 | Ack 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 s | lab 07-partition |
w:1, j:true | Sống qua crash của primary, vẫn bị rollback khi failover | manual write concern |
w:"majority" + journal | Không bị rollback trừ khi đa số mất đĩa cùng lúc; lab: 0 mất | manual pv1, lab |
| Sẵn sàng ghi | Cầ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ạn | 50 member, 7 có quyền bầu; oplog 990 MB – 50 GB mặc định; maxStalenessSeconds ≥ 90; rollback ≤ 24 h | manual, 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
- 3 lần
w:1: mất 441, 359, 439 write đã ack — đúng bằng số write A gửi trong ~10 s đầu: r1 chỉ tự step down khi 10 s không nghe được đa số (liveness timeout). Toàn bộ nằm trong file rollback; A chưa từng nhận một lỗi nào cho chúng. - Không lần nào có 2 primary cùng lúc: r1 xuống ở +10,05–10,81 s, primary mới lên ở +10,47–21,26 s (lần 2: r2 và r3 cùng đứng ra, mỗi node tự bầu mình — "already voted for
another candidate … this term (82)" — split vote, thêm 10 s). Cái nguy hiểm là 10 s primary cô lập vẫn ack
w:1và cho đọclocal. majoritykhông mất write nào, nhưng lỗi ≠ thất bại: 4 write "lỗi" bị rollback, 1 write "lỗi" vẫn nằm trong DB (đã tới r2/r3 trước lúc cắt). App phải coiWriteConcernTimeoutlà "không biết" và đối soát theo khoá nghiệp vụ.- Client B — phía khoẻ — treo 30,3–31,4 s dù primary mới nhận ghi từ +10,5 s. Lab
07-detect, cắt 70 s:
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.)
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ần | stepDown, retryWrites=false | stepDown, true | kill, false | kill, true |
|---|---|---|---|---|
| 1 | 0 / 1 | 0 / 0 | 0 / 8 | 0 / 7 |
| 2 | 3 / 7 | 0 / 5 | 5 / 8 | 8 / 8 |
| 3 | 2 / 8 | 0 / 0 | 2 / 8 | 5 / 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)
- Commit point đứng yên → flow control hãm cả ghi
w:1: từ ~680 xuống 100 update/s (flowControlMinTicketsPerSecond100), p99 từ 14 ms lên 880 ms. README flow control: cơ chế "chỉ chạy khi read concern majority bật" để tránh treo PSA — nhưng từ 5.0enableMajorityReadConcernluôn bật (manual), nên PSA mất một node giờ bị hãm tới sàn (suy ra từ README + lab). - Tắt flow control thì thấy đúng cái giá mà manual cảnh báo (phần bài 6 để lại): oplog vượt kích thước cấu hình (49 → 147 MB trong 130 s, ~0,75 MB/s
— ví dụ minh hoạ: ~2,7 GB/giờ, cả cuối tuần là hàng trăm GB), history store và cache tăng đều (cache 98 → 169 MB trên 256 MB), và
oldest_timestampbị ghim lùi theo commit point — mọi phiên bản sau đó phải giữ để còn rollback được. - Chữa theo manual:
votes: 0, priority: 0cho p2 (reconfig thường, không cầnforce) → commit point bắt kịp tức thì, oplog về 49 MB,majorityack lại. Cái giá: lúc này "đa số" = 1 node,majoritychỉ còn bền nhưw:1, j:true. p2 quay lại thì dùngrs.reconfigForPSASet().
6. Vận hành trong production
| Theo dõi | Lấy ở đâu | Báo động khi |
|---|---|---|
| Replication lag từng secondary | rs.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.lastCommittedWallTime | Tăng đều = đa số không ghi được (PSA, mất node, chaining kẹt) — mục 5.7 |
| Cửa sổ oplog | db.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 member | rs.status().members[].stateStr | RECOVERING, ROLLBACK, STARTUP2 kéo dài, "(not reachable/healthy)" |
| Bầu chọn, rollback | serverStatus().electionMetrics; log 21612 "Rollback summary"; thư mục rollback/ | Mỗi lần có — rollback = có write cần đối soát |
| Flow control | serverStatus().flowControl.isLagged, timeAcquiringMicros | isLagged: true → ghi bị hãm, có thể tới 100 ticket/s (PSA mất node, mục 5.7) |
| Phía app | Lỗi NotWritablePrimary, lỗi mạng, server selection timeout, WriteConcernTimeout | WriteConcernTimeout phải log kèm id nghiệp vụ |
- Cấu hình nặng ký:
priority(lệch nhau là có takeover — mục 5.2),votes,hidden+priority: 0cho node backup/analytics,secondaryDelaySecs(node trễ phải hidden và votes 0, không thì kéo commit point theo),tags+ write concern tự đặt (settings.getLastErrorModes) để buộc ghi tới 2 datacenter,chainingAllowed. HạelectionTimeoutMillisthì failover nhanh hơn nhưng dễ bầu nhầm khi mạng chập chờn (chưa đo). - Capacity: oplog cần ≥ (tốc độ ghi oplog × cửa sổ muốn giữ). Ví dụ minh hoạ: 2 MB/s oplog × 24 h ≈ 170 GB. Nhớ
updateMany/backfill tạo 1 entry mỗi document (mục 3.1) nên một job migration có thể "ăn" cả cửa sổ trong vài phút — lab 5.6. - Bảo trì không downtime: rolling — restart/upgrade từng secondary, rồi
rs.stepDown()primary (lab: khoảng không ghi được 75–171 ms nhờ election handoff) và làm nốt node cũ. FCV và upgrade ở bài 11. - Chi phí: 3 bản dữ liệu, 3 bộ đĩa; mỗi write đi 2 lần qua mạng tới secondary — đặt ở 3 AZ thì có phí traffic liên AZ (tuỳ cloud, chưa verify con số).
- Runbook trực đêm: không có primary → còn đa số không (đừng vội
force); "too stale" → initial sync; có file rollback → đối soát; PSA mất node →votes: 0; lag tăng → index build, lệnh dài, đĩa chậm, chaining.
7. So sánh và ma trận quyết định
| Trục | MongoDB replica set | PostgreSQL streaming replication (+ Patroni) | Redis + Sentinel |
|---|---|---|---|
| Failover tự động | Có 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ỗng | Chỉ primary |
| Mất write đã ack khi failover (mặc định) | Không; có nếu hạ w:1 | Có thể; cần synchronous mode | Có thể |
| Chọn độ bền / độ mới theo từng lệnh | Có (w, readConcern, read preference) | Theo session/transaction (synchronous_commit) | Lệnh WAIT |
| Độ phức tạp vận hành | Thấp–vừa: 1 binary, cấu hình trong rs.conf() | Vừa–cao: Patroni + DCS + replication slot | Thấ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
- Topology (ADR): 3 node có dữ liệu ở 3 AZ, priority bằng nhau trừ khi có lý do. Hai datacenter thì node thứ ba (dù chỉ bỏ phiếu) phải ở site thứ ba — 2 node ở DC chính thì mất DC chính là mất đa số. Không PSA cho dữ liệu nghiệp vụ.
- Độ bền: giữ mặc định
majority;w:1chỉ cho dữ liệu chấp nhận mất. Tiền, đơn, ghi danh:majority+ khoá idempotency (unique index trên mã nghiệp vụ). Ghi vào ADR: "WriteConcernTimeout= không biết", app phải đối soát. - Đọc: luồng đọc-sau-ghi đọc primary; báo cáo đọc hidden member có tag; cần đọc secondary mà thấy write của mình thì causal session (bài 8).
- Chuẩn driver:
timeoutMScho request web, heartbeat/connect timeout ngắn hơn mặc định (mục 5.3); PHP-FPM chọn có chủ ýserverSelectionTryOnce(5.5); retry tầng app chỉ cho lệnh idempotent (5.4). - Tốc độ team: failover drill định kỳ (stepDown giờ thấp điểm, cắt mạng ở staging), đo p99 + số lỗi — lỗi retry chỉ lộ khi có failover thật.
- Lock-in, exit: ngữ nghĩa write/read concern theo từng lệnh không có bản 1-1 ở PostgreSQL. Lối ra: CDC từ change stream (bài 10) sang hệ mới, chạy song song, đối soát rồi chuyển; Atlas thêm lock-in vận hành — giữ được đường mongodump/restore và CDC là vẫn ra được.
9. Anti-patterns và bài học thực tế
- PSA ở production. Manual khuyên tránh; lab 5.7 cho thấy vì sao. Nếu buộc phải có:
votes: 0cho secondary hỏng, theo dõi commit point. w:1cho đơ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.- Coi
WriteConcernTimeoutlà thất bại rồi thử lại. Write vẫn nằm đó (mục 3.3) → E11000 hoặc đơn trùng. - Đọc secondary ngay sau khi ghi (giỏ hàng, trạng thái thanh toán) — lab ryw hụt cả khi ghi
majority;maxStalenessSecondssàn 90 s không cứu. - 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). - 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.
- 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.
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ònw:1thì 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:1ack 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;majoritythì không bao giờ được ack ở primary cô lập. - Ghi
majorityxong, đọ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.
wtimeouthế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ớ trongconfig.transactions(đi theo oplog). Vô dụng vớiupdateMany, 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).