Durability (P2/3): Lab majority và phía đọc

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

Ở phần trước: w: 1 chỉ đợi primary, nên acknowledged: true chưa đủ để coi dữ liệu là an toàn. Lệnh majority bị timeout vẫn có thể nằm trên primary: lab 2 đọc được đơn hàng ngay sau lỗi đó.

Lab 3: majority bảo vệ điều gì

Câu hỏi: w: 1 nhận acknowledged: true, rồi primary mất. Lệnh ghi đi đâu? Kịch bản (e3-rollback.sh, chạy 3 lần):

1. ghi T-M với w:"majority" khi cả ba node khoẻ                    → có trên ít nhất 2 node
2. "cắt dây": chuyển primary a sang một Docker network khác        → a không còn thấy b và c
3. ghi T-W1 với w:1 vào a                                          → acknowledged
4. ghi T-WM với w:"majority", wtimeout 1500 vào a                  → timeout, chưa có đa số
5. docker kill a
6. b và c bầu primary mới, ghi T-AFTER với w:"majority"
7. khởi động lại a, nối lại network: a trở về làm secondary

Lần thử đầu tôi dùng docker pause cho hai secondary thay vì cắt mạng, và kết quả sai bài toán: primary mới vẫn có T-W1 và T-WM (e3-pause-attempt.out). [suy luận] docker pause đóng băng tiến trình chứ không đóng băng mạng của kernel; nhiều khả năng dữ liệu đã nằm sẵn trong bộ đệm mạng của hai secondary, nên chúng đọc được sau khi tỉnh dậy, kể cả khi primary đã bị kill. Lab không kiểm tra riêng điều này. Muốn mô phỏng "chưa kịp chép" phải cắt đường mạng thật; mô phỏng sai là cách nhanh nhất để tin w: 1 an toàn hơn thực tế.

[quan sát] Trial 1 (e3-rollback.out; trial 2 và 3 trong e3-rollback-t2.out, e3-rollback-t3.out cho cùng kết quả):

3. w:1 insert  acknowledged: true after 5 ms
4. w:majority insert -> WriteConcernTimeout after 1515 ms (not acknowledged)
   old primary a, read local   : ["T1-M","T1-W1","T1-WM"]
   old primary a, read majority: ["T1-M"]
5. docker kill a
6. new primary elected among b,c after ~1 s: mongo-rs18-c:27017
   new primary read (local): ["T1-M"]
8. final read (majority): ["T1-AFTER","T1-M"]
   node a (direct, local read): ["T1-AFTER","T1-M"]

w: 1 trả acknowledged: true sau 5 ms (4–5 ms ở cả ba trial), nhưng node mới lên không có T1-W1; khi a quay lại, nó cũng không giữ: dữ liệu của nó bị kéo về cho khớp với lịch sử của đa số. Lệnh majority đã xác nhận lúc cả cụm khoẻ (T1-M) sống sót. Ngay cả lệnh w: "majority" bị timeout cũng mất.

Đây là rollback, và nó không xoá âm thầm. [quan sát] bsondump cho thấy mỗi trial để lại một file /data/db/rollback/<collectionUUID>/removed.<timestamp>.bson chứa đúng hai document bị cuốn đi (e3-bsondump.out); log của a ghi Operations reverted by rollback với insert: 2, rollback của trial 1 kéo dài 152 ms.

== .../removed.2026-10-09T07-25-13.1.bson
{"_id":"T1-W1","note":"w:1"}
{"_id":"T1-WM","note":"w:majority wtimeout"}

[tài liệu] File rollback mặc định được tạo (createRollbackDataFiles), nhưng không chứa thao tác xoá document hay drop collection. Tài liệu cũng khuyên dùng w: "majority" để tránh rollback dữ liệu đã xác nhận, và nói rõ dữ liệu của transaction commit bằng w: 1 cũng có thể bị rollback: lý do bài Transactions commit bằng majority.

w:1          acknowledged ✓ → mất ✗   (lab: 3/3 trial)
w:majority   timeout       → mất ✗   (chưa có đa số: vẫn có thể bị rollback)
w:majority   acknowledged ✓ → còn ✓   (lab: T-M, 3/3 trial)

Không phải "primary sập là mất dữ liệu": mất xảy ra với lệnh ghi chưa tới đa số lúc primary biến mất, và lab cố tình dựng khoảng hở đó. [tài liệu] Rollback không xảy ra nếu lệnh ghi đã kịp tới một member khác và member đó vẫn liên lạc được với đa số; một lệnh w: 1 thường được chép đi rất nhanh, nhưng w: 1 không hứa điều đó. Còn timeout vừa không phải thất bại (lab 2) vừa không phải an toàn. Thứ tự sự kiện bên trong cụm (ai bầu ai, điểm chung của rollback tìm ra sao, majority commit point nhích thế nào) là chuyện của bài Elections, Failover & Majority.

Phía đọc: đọc lại lệnh ghi của chính mình

Write concern nói về độ bền của lệnh ghi. Nhưng ứng dụng còn một câu hỏi khác: ghi xong, đọc lại có thấy không? Câu trả lời phụ thuộc vào hai thứ ghép lại: đọc ở đâu và đọc với mức nào.

readPreference    = đọc TỪ ĐÂU      (primary, secondary, nearest...)
read concern      = đọc ở MỨC AN TOÀN nào  (local, majority, snapshot...)
causal consistency = đọc theo THỨ TỰ nào    (sau lệnh ghi của tôi, sau lần đọc trước của tôi)

Lab 4a: read concern majority không đi một mình

[tài liệu] "majority" bảo đảm dữ liệu đọc ra đã được đa số xác nhận và không bị rollback, và hiệu năng ngang các mức khác (lab của bài Isolation cũng đo vậy). Trang tài liệu nói thêm: "dù ở mức nào, dữ liệu mới nhất trên một node không nhất thiết là phiên bản mới nhất của hệ thống". Trong transaction, majority chỉ có hiệu lực nếu commit bằng w: "majority".

Câu hỏi cho ứng dụng: nếu ghi bằng w: 1 rồi đọc majority ngay trên primary, có thấy lệnh ghi của mình không? [quan sát] e4b-majority-primary.out, 500 lần mỗi cách, đọc ngay sau khi nhận acknowledged:

primary: insertOne w:1        then find(readConcern majority) at once: not visible 498 / 500
primary: insertOne w:majority then find(readConcern majority) at once: not visible   0 / 500

Với w: 1, 498 trên 500 lần đọc majority ngay sau đó không thấy document vừa ghi, dù cùng một client, cùng một node. Với w: "majority" thì 0 trên 500. [suy luận] w: 1 trả lời trước khi majority commit point kịp nhích qua lệnh ghi (nhanh 0,2 ms so với 0,6 ms ở lab 1), còn w: "majority" chỉ trả lời khi nó đã nhích. Quy tắc dễ nhớ: muốn đọc majority thấy lệnh ghi của mình thì ghi cũng phải majority.

Lab 4b: đọc từ một secondary đã lệch

Replication bất đồng bộ, nên secondary luôn chậm hơn primary một chút. [tài liệu] Có một chi tiết theo phiên bản làm khe hở này tồn tại cả khi không có lag bất thường: từ MongoDB 8.0, w: "majority" trả lời khi đa số node đã ghi bền oplog entry, còn việc áp dụng vào collection diễn ra bất đồng bộ sau đó. Tài liệu viết thẳng: truy vấn trên secondary ngay sau một lệnh ghi majority có thể đọc trước khi secondary áp dụng, và nếu cần thấy ngay thì chạy trong session nhất quán nhân quả.

Để nhìn rõ khe hở đó, tôi phóng đại nó có chủ đích: đặt priority: 0 và secondaryDelaySecs: 1 cho c (e4-setup.js), tức c áp dụng mọi lệnh ghi trễ 1 giây. w: "majority" vẫn được a và b xác nhận ngay. Độ trễ này là tham số của lab, chọn để khe hở đủ lớn mà đo, không phải độ lệch điển hình của một secondary khoẻ.

Một phát hiện phụ quyết định cách dựng lab (e4a-hello.out):

hello on primary: hosts = ["mongo-rs18-a:27017","mongo-rs18-b:27017"] passives =
20 reads with readPreference secondary landed on 1 distinct host(s): ed9963a7df5d
hostname of b: ed9963a7df5d | hostname of c: eda8e0ee60d4

[quan sát] Primary không liệt kê c cho client, và 20 lần đọc readPreference: secondary chỉ rơi vào b. [suy luận] Driver không thấy member delayed nên không route tới nó. Vì vậy lab nối thẳng vào c (directConnection=true) để đọc, còn ghi đi qua replica set. Đó là điều kiện lab, không phải khuyến nghị.

Mỗi lần ghi một document với w: "majority" rồi ngay sau đó đọc đúng document ấy từ c, 30 lần mỗi cách (e4-causal.js, e4-causal.out):

Cách đọc từ cThấy lệnh ghi của mìnhThời gian đọc (p50)
Akhông session, read concern local0 / 300 ms
Bkhông session, read concern majority0 / 300 ms
Csession causalConsistency: false, majority0 / 300 ms
Dsession causalConsistency: true, majority30 / 301.003 ms (926–1.008)
Esession causalConsistency: true, local30 / 301.004 ms

Ba hàng đầu trả kết quả trống trong tối đa 1 ms: nhanh và sai. Ở D và E mỗi lần đọc chờ đúng khoảng c còn thiếu rồi trả kết quả đúng. Read concern majority không giúp (hàng B): nó hứa dữ liệu an toàn, không hứa dữ liệu mới tới mức bạn vừa ghi. Thứ giúp là causal consistency, và nó giúp bằng cách chờ.

Causal consistency

Causal consistency hứa: nếu thao tác B xảy ra sau thao tác A trong cùng một chuỗi nhân quả (cùng session), B không bao giờ thấy trạng thái cũ hơn A. Tài liệu liệt kê bốn lời hứa: read your writes, monotonic reads (lần đọc sau không cũ hơn lần đọc trước), monotonic writes và writes follow reads.

Cơ chế, hình dung bằng một "phiếu hẹn giờ": mỗi lệnh ghi trả về mốc operationTime; session nhớ nó; lần đọc sau mang mốc đó trong read concern dưới tên afterClusterTime, nghĩa là "chỉ trả lời khi dữ liệu đã tới mốc này". Node chưa tới thì chờ.

ghi (w: "majority")           đọc
   │ operationTime = T          │ readConcern: { level: "majority", afterClusterTime: T }
   ▼                            ▼
 primary ──────────────────► secondary c
                               ├── đã áp dụng tới T?  có   → trả ngay
                               └──                     chưa  → CHỜ tới khi tới T (hoặc hết maxTimeMS)

[quan sát] Làm thủ công, không qua session (e4-causal.out, e4c-maxtime.out):

find on c without afterClusterTime       : 0 docs in 4 ms
find on c with afterClusterTime = opTime : 1 docs in 999 ms
find on c, afterClusterTime, maxTimeMS 300: MaxTimeMSExpired code 50 after 304 ms

Không có afterClusterTime: 0 document sau 4 ms (nhanh và sai). Có: 1 document sau 999 ms (xấp xỉ khoảng lệch 1 giây). Cho ít thời gian hơn khoảng lệch thì MaxTimeMSExpired. Nên một lần đọc nhân quả ở secondary đang lag là một lần đọc chờ, và ứng dụng nên đặt maxTimeMS.

Lab dùng hai client nên phải báo mốc cho session người đọc bằng tay (advanceOperationTime, advanceClusterTime). Trong ứng dụng thường, một session trên một client lo việc đó: ghi và đọc qua cùng session có causalConsistency: true, driver tự đính afterClusterTime.

[tài liệu] Chỉ tổ hợp majority + majority bảo đảm cả bốn lời hứa, kể cả khi mạng chia và hai node cùng tưởng mình là primary; w: 1 cho tính nhân quả không có độ bền. Lab chỉ chạy khi không có sự cố, nên mọi hàng có session nhân quả, cả hai hàng w: 1 trong output (F, G), đều 30/30: đừng đọc đó là bảo đảm. Trường hợp failover xen giữa chuỗi đọc ghi lab không kiểm tra; nó dựa vào tài liệu và bài Elections, Failover & Majority.

Read concernWrite concernRead own writesGhi chú
majoritymajority✓ cả bốn lời hứa, kể cả khi mạng chiachậm nhất, đúng nhất
majorityw: 1không bảo đảm khi có sự cốlệnh ghi có thể rollback; không session (lab 4a): 498/500 không thấy
localmajoritychỉ khi không có sự cốđọc có thể thấy dữ liệu về sau bị rollback
localw: 1chỉ khi không có sự cốkhông bền, không an toàn

Cái giá và giới hạn

  • Đọc phải chờ. Chi phí bằng độ lệch của secondary lúc đọc: lab ép 1 giây nên mỗi lần đọc chờ khoảng 1 giây; ở cụm thật, lag tăng thì latency đọc tăng theo.
  • Mốc phải đi cùng người dùng. Ứng dụng nhiều instance, mỗi request tới một instance: operationTime phải đi theo request (cookie, header), nếu không instance thứ hai không biết phải chờ tới đâu. Session cũng không dùng song song từ nhiều luồng.
  • Đọc từ primary thì ít cần hơn. Khi không có failover, đọc local trên primary đã thấy lệnh ghi của mình. [tài liệu] Qua một cuộc bầu, lần đọc rơi vào primary cũ có thể không thấy lệnh ghi majority mà primary mới đã nhận, nên tài liệu khuyên ghi majority và đọc majority. Causal consistency đáng giá nhất khi bạn chủ động đọc từ secondary để giảm tải primary.
  • readPreference là bài khác. Chọn secondary nào, nearest, tag set và hành vi khi failover thuộc bài Elections, Failover & Majority. Ở đây chỉ cần nhớ ba núm: từ đâu, mức nào, thứ tự nào.

Cột mốc: Bạn đã có thể giải thích vì sao w: 1 có thể mất dữ liệu khi primary mất, và vì sao đọc lại lệnh ghi của mình cần majority ở cả hai phía. Tiếp theo: Retryable, chọn mức theo dữ liệu.

Hỏi & đáp

Primary bị tách khỏi mạng rồi bị kill; lúc đó nó vừa xác nhận một lệnh w: 1. Sau khi nó khởi động lại và nối mạng, document đó ở đâu?

  1. Bị rollback: chỉ còn trong file removed.*.bson trên node cũ

    Lab 3: node mới lên không có nó; a quay lại không giữ nó, và bsondump đọc được T1-W1 trong file rollback. Xem mục "Lab 3: majority bảo vệ điều gì".

  2. Vẫn còn ở cả ba node, vì primary cũ chép nó sang secondary khi khởi động lại và secondary nhận lại

    Chiều ngược lại mới đúng: node cũ phải khớp với lịch sử của đa số, không áp lịch sử của nó cho đa số. Lab: sau khi a quay lại, đọc majority và đọc thẳng trên a đều chỉ còn T1-AFTER và T1-M. Xem mục "Lab 3".

  3. Còn trên node cũ như một document bình thường, và hai node kia nhận nó khi đồng bộ

    Node cũ đã bị kéo về điểm chung với đa số; đọc local trực tiếp trên a sau khi rejoin ra T1-AFTER và T1-M, không có T1-W1. Xem mục "Lab 3".

  4. Còn trên node mới, vì node mới được bầu từ các secondary đã nhận nó

    Trong lab hai secondary bị tách khỏi primary trước khi lệnh ghi xảy ra, nên chúng chưa từng nhận nó; node mới lên không có T1-W1. Nếu nó đã kịp tới đa số thì mới không rollback, và đó là việc của majority. Xem mục "Lab 3".

Một trang chi tiết đơn hàng đọc từ secondary để giảm tải primary, nhưng đôi lúc khách vừa đặt xong không thấy đơn của mình. Thay đổi nào đúng nhất?

  1. Thêm j: true cho lệnh ghi để dữ liệu chắc chắn đã nằm trong journal trên đĩa

    j nói về độ bền trên node ghi, không nói secondary đã áp dụng tới đâu. Vấn đề ở đây là secondary chưa kịp áp dụng. Xem mục "Lab 4b: đọc từ một secondary đã lệch"; j nằm ở phần Write concern và ba lab đầu.

  2. Chỉ đặt read concern majority cho lần đọc, vì nó chỉ trả dữ liệu đã an toàn

    Lab 4b hàng B: majority trên secondary lệch trả 0/30. majority hứa dữ liệu an toàn, không hứa dữ liệu mới tới mức bạn vừa ghi. Xem mục "Lab 4b: đọc từ một secondary đã lệch".

  3. Ghi majority, đọc majority, cùng một session nhân quả cho cả hai

    Lab 4b hàng D: 30/30 thấy lệnh ghi của mình, đổi lại mỗi lần đọc chờ secondary bắt kịp (1.003 ms ở độ lệch 1 giây). Chỉ tổ hợp majority + majority bảo đảm cả khi có sự cố. Xem mục "Causal consistency".

  4. Tăng wtimeout để lệnh ghi chờ lâu hơn rồi mới trả lời cho khách

    wtimeout chỉ là giới hạn chờ khi lệnh ghi không đạt mức yêu cầu; nó không làm secondary đọc được dữ liệu mới. Xem mục "Lab 4b: đọc từ một secondary đã lệch"; wtimeout nằm ở phần Write concern và ba lab đầu.