Durability & Consistency: lời hứa của `acknowledged: true`
Ba bài đã qua để lại cùng một câu hỏi, mỗi bài hỏi từ một phía. Bài CRUD & Query Model dừng ở { acknowledged: true }: dữ liệu đã bền tới đâu, đã vào journal chưa, đã sang secondary chưa? Bài Transactions & Atomicity commit bằng w: "majority" mà chưa nói từng mức write concern hứa gì. Bài Isolation & Snapshot cho thấy read concern majority có thể cũ hơn những gì primary đã áp dụng, và để ngỏ chuyện đọc lại lệnh ghi của chính mình qua secondary hay qua failover.
Bài này trả lời cả ba bằng thí nghiệm: đo cái giá của từng mức, làm mất một lệnh ghi đã được xác nhận rồi tìm nó trong file rollback, đọc từ một secondary chậm 1 giây để thấy lệnh ghi của mình biến mất, và bắt một lệnh ghi bị timeout rồi được driver gửi lại mà vẫn chỉ chạy một lần.
Bài này nằm ở đâu
- Cần biết trước: Transactions & Atomicity (replica set 3 node, majority là 2, session,
w: "majority"khi commit), Isolation & Snapshot (read concernlocal/majority/snapshot,majoritykhông phải snapshot), CRUD & Query Model (kết quảacknowledged) - Giới thiệu: write concern (
w,j,wtimeout) và mặc định của nó, lỗi timeout, rollback nhìn từ phía ứng dụng, read concernmajorityđi cùng write concern, causal consistency qua session (afterClusterTime), retryable writes và reads,synchronous_commitcủa PostgreSQL, chọn mức theo loại dữ liệu - Không dạy ở đây: journal hoạt động thế nào để giữ lời hứa
j: true(bài Journal & Checkpoint); majority commit point di chuyển ra sao khi failover (bài Elections, Failover & Majority). Bài này chỉ nói chúng hứa gì với ứng dụng và trỏ sang đó - Dẫn tới: Write Conflicts, Retry & Idempotency
Môi trường lab: MongoDB 8.3.11 trong Docker (image
mongo:8), replica set 3 node tênrs18: ba containermongo-rs18-a/-b/-ctrên một máy host Apple M4, mỗi container 1 CPU, 1 GB RAM, WiredTiger cache 0,25 GB, databaselab18;acó priority 2 nên là primary lúc bắt đầu. Client là mongosh 2.12.0 trong các container ngắn hạn cùng network. Ba node chung một máy nên mạng giữa chúng gần như bằng không: mọi số đo có chờ majority là cận dưới, và ổ đĩa là ổ Docker trên macOS, không phải ổ production. Mọi số trong bài đều đo thật và có file output thô kèm theo.
Nhãn: [tài liệu] theo tài liệu chính thức; [quan sát] đo trong lab này (MongoDB 8.3.11); [suy luận] rút ra từ quan sát, không kiểm tra riêng; [chi tiết cài đặt] cách server đang làm, không phải cam kết API; [hình dung] mô hình để dễ nhớ.
Giải thích trong 30 giây
acknowledged: true chỉ nói rằng server đã làm xong phần việc mà bạn yêu cầu trước khi trả lời. Yêu cầu càng ít thì lời hứa càng yếu. Với w: 1, phần việc đó chỉ là "primary đã áp dụng lệnh ghi"; nếu primary mất ngay sau đó, lệnh ghi có thể biến mất dù ứng dụng đã nhận acknowledged: true.
Chữ D (durability) trong ACID là một lời hứa nguyên khối ở database một máy. MongoDB tách nó thành ba núm bạn tự chọn: bao nhiêu node đã có dữ liệu (w), node đó đã ghi xuống journal trên đĩa chưa (j), và chờ tối đa bao lâu (wtimeout). Phía đọc có núm riêng (read concern), và ở giữa là causal consistency để "đọc lại điều mình vừa ghi" không phụ thuộc may rủi. Cuối cùng, mạng có thể đứt đúng lúc lệnh ghi đã chạy mà câu trả lời chưa tới: retryable writes giải quyết đúng chỗ đó.
Hình dung trước: gửi một lá thư quan trọng
Bạn gửi một lá thư quan trọng. Công ty có ba bưu cục: một bưu cục chính nhận thư, hai chi nhánh nhận bản sao sau đó. Bạn có thể dặn nhân viên ở quầy:
- "Báo tôi khi thư đã nằm trên bàn anh." Bưu cục chính cháy ngay lúc đó thì thư mất.
- "Ghi thư vào sổ nhật ký rồi hãy báo." Mất điện rồi bật lại vẫn tra ra thư, nhưng cháy bưu cục thì sổ cũng cháy.
- "Chỉ báo khi thư đã tới ít nhất hai trong ba bưu cục." Chậm hơn chút, nhưng một bưu cục cháy thì thư vẫn còn nơi khác.
- "Chỉ báo khi cả ba đã có thư." An toàn nhất, nhưng một chi nhánh đóng cửa là bạn đứng chờ mãi.
Bưu điện MongoDB
───────────────────────────────── ───────────────────────────────────────
nhân viên nói "xong rồi" acknowledged: true
thư nằm trên bàn nhân viên primary đã áp dụng (w: 1)
ghi vào sổ nhật ký journal trên đĩa (j: true)
thư tới 2 trong 3 bưu cục w: "majority" (3 node: primary + 1 secondary)
thư tới cả 3 bưu cục w: 3
chờ tối đa 30 phút rồi báo "chưa kịp" wtimeout
bưu cục chính cháy primary mất, secondary lên thay[hình dung] Đây chỉ là cách hình dung. Thư thật chỉ ở một nơi; primary giữ bản của nó và chép tiếp sang secondary. "Báo chưa kịp" không huỷ thư: lệnh ghi vẫn nằm đó và có thể tới nơi sau (lab 2). Còn khi bưu cục chính cháy rồi quay lại, thư chỉ có ở đó bị dọn khỏi dữ liệu chính và đặt vào một file riêng (lab 3). Ghi sổ nhật ký ở MongoDB là một cơ chế riêng với giá riêng, nằm ở bài Journal & Checkpoint.
Write concern: ba núm vặn
Write concern là tham số của lệnh ghi: "hãy chỉ trả lời khi đã làm được ngần này việc". Dạng đầy đủ:
{ w: <số node | "majority">, j: <true | false>, wtimeout: <ms> }w: bao nhiêu node
[tài liệu]
w | Server trả lời khi | Ghi chú |
|---|---|---|
0 | không chờ xác nhận | lỗi chỉ lộ ra nếu là lỗi mạng; không retry được (mục "Retryable writes và retryable reads") |
1 | primary đã áp dụng lệnh ghi | dữ liệu có thể bị rollback nếu primary mất trước khi chép sang secondary nào |
2, 3, ... | primary và đủ secondary mang dữ liệu để đạt con số đó | w: 3 trên 3 node: mất một node là lệnh ghi không bao giờ xong |
"majority" | đa số các member có quyền bầu và mang dữ liệu đã có lệnh ghi | 3 node: 2; không bị rollback khi failover |
Hai điều hay bị bỏ sót. Một: member ẩn, delayed hay priority 0 (có quyền bầu) vẫn xác nhận được majority; delayed secondary chỉ xác nhận được sau ít nhất secondaryDelaySecs. Hai: ở P-S-A (primary, secondary, arbiter), majority bằng đúng số node mang dữ liệu, nên khi một node dữ liệu mất, w: "majority" có thể timeout hoặc không bao giờ xong vì arbiter không giữ dữ liệu.
j: đã ghi journal chưa
[tài liệu] j: true yêu cầu các node được đếm trong w đã ghi lệnh ghi xuống journal trên đĩa trước khi xác nhận. Bản cũ chỉ đòi primary; bản hiện tại đòi mọi node được đếm trong w. Tài liệu viết rõ: j: true một mình không bảo đảm lệnh ghi không bị rollback khi primary failover. Journal bảo vệ trước việc một node tắt đột ngột; majority bảo vệ trước việc một node biến mất. Journal hoạt động ra sao: bài Journal & Checkpoint. Hệ quả với j, theo bảng của tài liệu:
w | j không ghi | j: true | j: false |
|---|---|---|---|
1 | ghi vào bộ nhớ rồi xác nhận | journal trên đĩa | bộ nhớ |
"majority" | journal trên đĩa (nếu writeConcernMajorityJournalDefault: true, là mặc định) | journal trên đĩa | bộ nhớ; nhưng nếu writeConcernMajorityJournalDefault: true thì lệnh ghi majority vẫn chỉ được xác nhận sau khi journal đã flush |
Tức là với cấu hình mặc định, w: "majority" đã ngầm có j: true. Nếu ai đó đặt writeConcernMajorityJournalDefault: false, lệnh ghi majority có thể bị rollback khi đa số node cùng crash rồi khởi động lại.
wtimeout: chờ bao lâu
[tài liệu] wtimeout là thời gian tối đa (ms) để lệnh ghi lan tới đủ node, tính sau khi lệnh ghi đã thành công trên primary. Không đặt wtimeout mà mức write concern không thể đạt thì lệnh ghi chờ vô hạn. wtimeout không áp dụng cho w <= 1. Điều tài liệu nhấn mạnh và lab 2 sẽ đo: khi hết giờ, MongoDB trả lỗi write concern nhưng không huỷ những gì đã ghi.
Mặc định, và những ngoại lệ phụ thuộc phiên bản
[tài liệu] Từ MongoDB 5.0, write concern mặc định ngầm của replica set và sharded cluster là { w: "majority" }. Ngoại lệ: nếu có arbiter và số node mang dữ liệu không lớn hơn majority của các node có quyền bầu, mặc định rơi về { w: 1 }. Ví dụ tài liệu đưa ra: 2 node dữ liệu + 1 arbiter → w: 1; 4 node dữ liệu + 1 arbiter → "majority". Có thể thay bằng setDefaultRWConcern; nếu đặt thì bắt buộc phải có w.
[quan sát] Lab đọc đúng như vậy (e0-config.out):
version 8.3.11
writeConcernMajorityJournalDefault true | settings.getLastErrorDefaults {"w":1,"wtimeout":0}
{
w: 'majority',
wtimeout: 0
}
defaultWriteConcernSource implicit
writeMajorityCount 2 votingMembersCount 3
w:1 -> {"acknowledged":true,"insertedId":1}
w:majority -> {"acknowledged":true,"insertedId":2}
j:true w:1 -> {"acknowledged":true,"insertedId":3}
default -> {"acknowledged":true,"insertedId":4}Bốn lệnh ghi, bốn lần acknowledged: true, giống hệt nhau. Ứng dụng không thể phân biệt lời hứa từ phản hồi; lời hứa nằm ở tham số bạn đã truyền. Nguồn của mặc định hiện hành xem bằng getDefaultRWConcern (implicit khi chưa ai đặt, global sau khi đặt bằng setDefaultRWConcern).
Bảng lời hứa tóm tắt, để các bài sau quay lại:
| Cấu hình | Khi acknowledged: true thì | Còn có thể mất khi |
|---|---|---|
w: 1 | primary đã áp dụng (trong bộ nhớ) | primary tắt đột ngột (chưa journal) hoặc failover trước khi chép sang secondary |
w: 1, j: true | primary đã ghi journal trên đĩa | failover trước khi chép sang secondary (lab 3) |
w: "majority" (mặc định) | đa số node đã có và đã ghi journal | gần như không: phải mất cả đa số node cùng lúc và cả đĩa của chúng |
w: 3 (3 node) | cả ba node đã áp dụng (không đặt j thì chưa chắc đã journal) | hiếm, nhưng chỉ cần một node mất là lệnh ghi treo |
Setup và dataset
MongoDB version : 8.3.11 (mongo:8), replica set 3 node rs18, primary lúc đầu = mongo-rs18-a, FCV 8.3
Hardware : Apple M4 host; mỗi node 1 CPU, 1 GB RAM
Configuration : --replSet rs18 --wiredTigerCacheSizeGB 0.25; write concern mặc định ngầm w: "majority"
Dataset : lab18.lat insert 1 document nhỏ mỗi lần (tenantId, userId, status, createdAt, amount), index { tenantId: 1, createdAt: -1 }
lab18.wt, rb, cc, rcm, sd, rt: vài document cho từng kịch bản
Client : mongosh 2.12.0, một client tuần tự, trừ khi ghi khácMọi script và output thô của bài nằm cạnh nhau trong thư mục lab (lab18/); tên file được ghi ở từng mục.
Lab 1: cái giá của từng mức
Một client tuần tự insertOne một document nhỏ, mỗi cấu hình 200 lần làm nóng rồi 1.000 lần đo, 5 lượt xen kẽ các cấu hình, bảng ghi median của p50 và p95 qua 5 lượt (e1-latency.js, e1-latency.out):
| Write concern | p50 | p95 |
|---|---|---|
w: 1 | 0,215 ms | 0,525 ms |
w: 1, j: true | 0,495 ms | 0,981 ms |
w: 2 | 0,558 ms | 0,846 ms |
w: 3 | 0,579 ms | 0,710 ms |
w: "majority", j: false | 0,605 ms | 0,877 ms |
w: "majority" (không ghi j) | 0,618 ms | 0,955 ms |
w: "majority", j: true | 0,668 ms | 1,189 ms |
| không đặt write concern (mặc định) | 0,627 ms | 0,908 ms |
[quan sát]
w: 1rẻ nhất: 0,215 ms. Thêmj: truecộng khoảng 0,28 ms ở p50: lệnh ghi chờ một lần ghi journal xuống đĩa của máy này.- Chờ thêm một secondary (
w: 2) hoặc majority cộng khoảng 0,35–0,40 ms so vớiw: 1ở đây, nơi ba node chung một máy và mạng gần bằng không. Trên production mỗi lần chờ majority còn cộng ít nhất một vòng mạng giữa các node. - Không đặt write concern cho kết quả như
majority(0,627 so với 0,605–0,668): đúng như tài liệu nói về mặc định ngầm. - Không rút ra được
jkhác nhau thế nào trong majority. Hiệu số 0,605 / 0,618 / 0,668 nhỏ hơn dao động giữa các lượt của chính một cấu hình (p50 củamajoritykhông ghijdao động 0,605–0,824), vàj: falsecòn bịwriteConcernMajorityJournalDefault: trueghi đè. Đừng đọc bảng thành "j: truecộng 0,05 ms cho majority". Thứ tự giữaw: 2,w: 3và majority (0,558–0,668) cũng nằm trong dao động đó.
[suy luận] Chi phí của j: true phụ thuộc hoàn toàn vào đĩa: đây là Docker trên SSD macOS, không đại diện cho đĩa của bạn. Một client tuần tự cũng không cho thấy throughput khi nhiều client cùng ghi (journal có thể gộp nhiều lệnh vào một lần flush, chuyện của bài Journal & Checkpoint). Bảng này nói về latency của một lệnh ghi, không phải throughput.
Lab 2: lỗi timeout không có nghĩa là lệnh ghi thất bại
Chặn cả hai secondary (docker pause), rồi ghi { w: "majority", wtimeout: 2000 } vào primary (e2-wtimeout.sh, e2-wtimeout.js, e2-wtimeout.out):
after 2016 ms ERROR name=MongoWriteConcernError code=64 codeName=WriteConcernTimeout
message: waiting for replication timed out
errorLabels: []
same client, read local : [{"_id":"order-1","status":"paid"}]
same client, read majority: []
-- docker unpause b c
after unpause, read majority: [{"_id":"order-1","status":"paid"}]
b (secondary) has it: [{"_id":"order-1","status":"paid"}][quan sát] Lỗi trả về sau 2.016 ms với WriteConcernTimeout (code 64) và không có error label nào (errorLabels: []; error label là chuỗi server gắn kèm lỗi để cho biết nên thử lại hay không). Document thì đang nằm trên primary (đọc local thấy), chưa an toàn (đọc majority chưa thấy). Khi secondary hoạt động lại, nó được chép sang và majority thấy nó. Đúng như tài liệu: "MongoDB không huỷ thay đổi đã thành công trước khi hết wtimeout".
Hệ quả: gặp WriteConcernTimeout, trạng thái lệnh ghi là chưa biết: có thể sẽ tới nơi (lab này), có thể bị rollback (lab 3), và không phải "đã thất bại". Retry bằng _id mới là cách chắc chắn tạo bản trùng (lab 5b).
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 secondaryLầ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 / 500Vớ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ừ c | Thấy lệnh ghi của mình | Thời gian đọc (p50) | |
|---|---|---|---|
| A | không session, read concern local | 0 / 30 | 0 ms |
| B | không session, read concern majority | 0 / 30 | 0 ms |
| C | session causalConsistency: false, majority | 0 / 30 | 0 ms |
| D | session causalConsistency: true, majority | 30 / 30 | 1.003 ms (926–1.008) |
| E | session causalConsistency: true, local | 30 / 30 | 1.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 msKhô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 concern | Write concern | Read own writes | Ghi chú |
|---|---|---|---|
majority | majority | ✓ cả bốn lời hứa, kể cả khi mạng chia | chậm nhất, đúng nhất |
majority | w: 1 | khô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 |
local | majority | chỉ khi không có sự cố | đọc có thể thấy dữ liệu về sau bị rollback |
local | w: 1 | chỉ 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:
operationTimephả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
localtrê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 ghimajorityvà đọcmajority. Causal consistency đáng giá nhất khi bạn chủ động đọc từ secondary để giảm tải primary. readPreferencelà 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.
Retryable writes và retryable reads
Mạng có một kiểu lỗi đặc biệt khó xử lý: lệnh ghi đã chạy nhưng câu trả lời không về. Ứng dụng nhận lỗi timeout, và không biết có nên gửi lại. Gửi lại thì có thể ghi hai lần; không gửi thì có thể mất.
Cách MongoDB giải bài toán này
[tài liệu] Retryable writes để driver thử lại đúng một lần một số lệnh ghi sau lỗi mạng hoặc khi không tìm thấy primary khoẻ. Cần replica set hoặc sharded cluster (không phải standalone) với WiredTiger; driver tương thích MongoDB 4.2 trở lên và mongosh bật sẵn (retryWrites=true). Được retry (khi write concern có ack, tức không phải w: 0): insertOne, insertMany, updateOne, replaceOne, deleteOne, họ findAndModify, và bulkWrite chỉ gồm các thao tác trên một document. Không được retry: updateMany, deleteMany, w: 0, và các lệnh ghi bên trong transaction (riêng commitTransaction và abortTransaction thì driver retry một lần). Driver chờ tối đa serverSelectionTimeoutMS để tìm primary mới; failover lâu hơn thì lệnh ghi lỗi. Đặt timeoutMS thì driver có thể retry nhiều lần cho tới khi hết giờ.
Làm sao khỏi ghi hai lần? [chi tiết cài đặt] (mức khái niệm) Mỗi lệnh ghi retryable mang ID session (lsid) và số thứ tự của lệnh trong session. Server nhớ những lệnh nào của session đã thực thi; gặp lại đúng lệnh đó, nó không chạy lại mà trả kết quả lần đầu. Bằng chứng gián tiếp ở lab 5b: bộ đếm serverStatus().transactions.retriedCommandsCount tăng đúng 1 mỗi lần.
[tài liệu] Từ MongoDB 6.1, nếu cả lần đầu và lần retry đều lỗi mà không có lệnh ghi nào được thực hiện, lỗi mang error label NoWritesPerformed. Nếu client không phản hồi lâu hơn localLogicalSessionTimeoutMinutes, lệnh ghi có thể được áp dụng lại khi client tỉnh: hiếm nhưng có thật.
Retryable reads đơn giản hơn vì đọc không đổi dữ liệu. [tài liệu] Driver tương thích server 6.0 trở lên bật sẵn (retryReads=false để tắt) và retry một lần các lệnh như find, aggregate (không có $out/$merge), count, countDocuments, estimatedDocumentCount, distinct, listDatabases, listCollections, listIndexes và change stream; không retry getMore, mapReduce và lệnh gọi qua runCommand chung. mongosh không hỗ trợ retryable reads, nên bài này không thí nghiệm được chúng.
Lab 5a: step down giữa dòng lệnh ghi
Một client ghi liên tục 8 giây (mỗi vòng: insertOne và updateOne { $inc } lên một bộ đếm, cả hai w: "majority"), trong khi một client khác chạy rs.stepDown(30) trên primary khoảng giây thứ ba. Hai lần chạy cho mỗi cấu hình (e5a-stepdown.sh, e5a-stepdown.out, e5a-stepdown-run2.out):
retryWrites | Số vòng | Lỗi ứng dụng nhìn thấy | Document / bộ đếm so với số lần được xác nhận |
|---|---|---|---|
true (lần 1) | 4.936 | 0 | 4.936 document, bộ đếm 4.936: khớp |
true (lần 2) | 4.906 | 0 | 4.906 document, bộ đếm 4.906: khớp |
false (lần 1) | 4.493 | 1: InterruptedDueToReplStateChange (code 11602) ở một $inc | bộ đếm 4.493 trong khi chỉ 4.492 lần $inc được xác nhận: lệnh báo lỗi vẫn được áp dụng |
false (lần 2) | 4.065 | 1: PrimarySteppedDown (code 189) ở một insertOne | 4.064 document: lệnh báo lỗi không có trong dữ liệu |
[quan sát] Khi tắt retry, mỗi lần chạy ứng dụng thấy đúng một lỗi, cả hai đều tới dưới dạng MongoWriteConcernError không có error label, nhưng hậu quả ngược nhau: một lệnh ghi báo lỗi mà vẫn được áp dụng, một lệnh ghi báo lỗi mà không có trong dữ liệu. Chỉ nhìn lỗi, ứng dụng không phân biệt được. Khi bật retry (mặc định), 0 lỗi lọt ra ứng dụng ở cả hai lần, và số document, bộ đếm đều khớp số lần được xác nhận.
Lab không nhìn thấy trực tiếp lần retry nào ở 5a. retriedCommandsCount bằng 0 trên cả ba node (e5a2-counters.out), nhưng bộ đếm này chỉ tăng khi server gặp lại một lệnh đã chạy; một lần retry chạy lệnh lần đầu thì không được đếm. [suy luận] Hai lần tắt retry đều có một lệnh trúng lúc step down, nên nhiều khả năng ở hai lần bật retry driver cũng đã retry âm thầm, và lần retry đó không gặp lại lệnh nào đã chạy. Mỗi cấu hình chỉ 2 lần chạy: đủ để thấy hình dạng, chưa đủ để nói tần suất.
Lab 5b: lệnh đã chạy, câu trả lời không về
Đây là trường hợp khó thật. Cách dựng: chặn hai secondary, ghi w: "majority" với socketTimeoutMS=1500. Primary áp dụng lệnh ghi ngay rồi chờ đa số; client hết giờ socket sau 1,5 giây; 1,8 giây sau khi client bắt đầu gửi thì tôi mở lại hai secondary. Mỗi trường hợp reset collection trước (e5b.sh, e5b-timeout-retry.js, e5b-timeout-retry.out):
| Lệnh | retryWrites | Ứng dụng nhìn thấy | Dữ liệu sau đó | Nếu ứng dụng tự gửi lại |
|---|---|---|---|---|
insertOne (đơn mới) | true | thành công, sau 1.945 ms | 1 đơn; retriedCommandsCount +1 | không cần |
insertOne | false | MongoNetworkTimeoutError sau 1.509 ms | 1 đơn (đã có) | 2 đơn paid |
updateOne { $inc: { n: 1 } } | true | thành công, sau 1.949 ms | n = 1; retriedCommandsCount +1 | không cần |
updateOne { $inc: { n: 1 } } | false | MongoNetworkTimeoutError sau 1.506 ms | n = 1 (đã cộng) | n = 2 |
[quan sát] Khi retryWrites=false, ứng dụng nhận lỗi timeout dù lệnh ghi đã có trong dữ liệu. Ứng dụng tự "thử lại cho chắc" bằng _id mới thì ra hai đơn hàng; với $inc thì bộ đếm cộng hai lần. Khi retryWrites=true, ứng dụng nhận thành công sau ~1,95 giây; dữ liệu có 1 đơn, n = 1, và retriedCommandsCount trên primary tăng 1: server đã gặp lại một lệnh đã chạy và không thực thi lần hai. [suy luận] Driver gửi lại khi socket hết giờ ở 1,5 giây, và lần retry chờ majority tới khi secondary mở lại (1,8 giây); lab không ghi thời điểm từng bước.
[suy luận] Retryable writes giảm chứ không loại bỏ rủi ro: chúng chỉ phủ lỗi trong một lần retry, chỉ cho các lệnh retry được (không có updateMany, deleteMany), và chỉ khi driver còn tìm được primary trong serverSelectionTimeoutMS. Khi cả lần retry cũng lỗi, ứng dụng quay về tình huống "không biết lệnh ghi đã chạy chưa" của lab 2. Ở đó mới cần idempotency: thiết kế lệnh ghi sao cho chạy hai lần cho kết quả như một lần (_id do client sinh ra, khoá idempotency, $set thay vì $inc, unique index). Phần đó là của bài Write Conflicts, Retry & Idempotency, nơi cũng so sánh retry của lệnh ghi lẻ với retry cả transaction (TransientTransactionError).
So với PostgreSQL
PostgreSQL gom phần lớn những gì bài này gọi là write concern vào một tham số synchronous_commit, đi kèm danh sách standby synchronous_standby_names. Phần này không chạy PostgreSQL: mọi nhận định lấy từ trang cấu hình WAL và replication của tài liệu PostgreSQL hiện hành, mang nhãn [tài liệu PostgreSQL], không phải số đo.
synchronous_commit | Commit chờ | Gần nhất trong MongoDB (lỏng, không tương đương) |
|---|---|---|
off | không chờ gì; trễ tới 3 lần wal_writer_delay (mặc định 200 ms). Crash có thể mất vài giao dịch cuối nhưng database vẫn nhất quán | w: 1 |
local | WAL đã flush xuống đĩa cục bộ | w: 1, j: true |
remote_write | standby đã nhận và ghi vào file system (chưa chắc xuống đĩa) | w: 2 không đặt j (bản sao mới ở bộ nhớ) |
on (mặc định) | flush cục bộ và, nếu có standby đồng bộ, standby đã nhận và flush | w: "majority" |
remote_apply | standby đã áp dụng, nên truy vấn trên standby thấy ngay | không có; MongoDB giải bài toán đó ở phía đọc bằng causal consistency |
[tài liệu PostgreSQL] Nếu synchronous_standby_names rỗng, remote_apply, remote_write và local đều chỉ còn là flush cục bộ như on. Danh sách standby có thể theo ưu tiên FIRST n (...) hoặc theo quorum ANY n (...); ánh xạ sang majority chỉ lỏng vì MongoDB đếm member của replica set, PostgreSQL đếm standby có tên trong danh sách.
Hai khác biệt cần nhớ. Một: mặc định. PostgreSQL mặc định on nhưng chưa cấu hình standby đồng bộ thì chỉ đợi đĩa cục bộ, không đợi bản sao nào; MongoDB từ 5.0 đợi đa số (trừ ngoại lệ arbiter). Hai: PostgreSQL không có tham số tương đương read concern cho standby, nên "đọc thấy gì" ở standby là việc của remote_apply và của ứng dụng. Cả hai đều cho đổi mức theo từng giao dịch (SET LOCAL synchronous_commit TO OFF; write concern theo từng lệnh ghi). Đây là so sánh khái niệm, không phải so sánh tốc độ.
Chọn mức theo loại dữ liệu
Câu hỏi quyết định: nếu lệnh ghi này đã được xác nhận mà vẫn biến mất sau một failover, hậu quả là gì?
| Loại dữ liệu | Gợi ý | Giá | Phải đi kèm |
|---|---|---|---|
| Sổ cái, thanh toán, đơn hàng | w: "majority" (mặc định), retryWrites=true, có wtimeout hoặc timeoutMS | latency cao hơn (lab: 0,62 so với 0,22 ms ở mạng gần 0); timeout hoặc treo khi mất đa số | idempotency key để retry an toàn; WriteConcernTimeout xử lý như "chưa biết" |
| Cần đọc lại lệnh ghi của mình qua secondary | ghi majority, đọc majority trong session causalConsistency: true | đọc chờ secondary đuổi kịp | maxTimeMS; mang mốc thời gian theo request |
| Event log, click, metric: mất vài giây cuối chấp nhận được | w: 1 | mất lệnh chưa tới secondary khi failover (lab 3) | chấp nhận rõ ràng; vẫn giữ retryWrites=true |
| Dữ liệu giống cache, dựng lại được | w: 1 | như trên | có đường dựng lại; tránh w: 0 vì mất luôn thông báo lỗi và không retry được |
Hai nguyên tắc. Đừng hạ mức để "nhanh hơn" khi chưa đo: ở mạng gần bằng 0, majority chỉ đắt hơn w: 1 khoảng 0,4 ms (lab 1); số thật của bạn nằm ở RTT giữa các node. Đừng nâng thành w: 3: nó đổi khả dụng lấy độ bền (mất một node là không ghi được), trong khi majority chịu được mất một node mà vẫn ghi. Còn w: 1, j: true chỉ chống tắt đột ngột một node, vẫn mất khi failover.
Những lỗi thường gặp
- Tin
acknowledged: truelà "đã an toàn". Lab 3:w: 1xác nhận sau 4–5 ms và mất ở 3/3 lần chạy. Lời hứa nằm ở write concern, không ở phản hồi. - Coi
WriteConcernTimeoutlà thất bại. Lab 2: lệnh ghi nằm trên primary rồi tới nơi; lab 3: cũng có thể bị rollback. Trạng thái là chưa biết. - Retry bằng
_idmới hoặc$incmà không có idempotency. Lab 5b: một timeout, hai đơn hàng, bộ đếm cộng hai lần. - Nghĩ
j: truelà majority. Journal chống tắt đột ngột một node, không chống failover. - Đọc
majorityngay sauw: 1rồi tưởng sẽ thấy. Lab 4a: 498/500 lần không thấy. Muốn đọc lại lệnh ghi của mình, ghi và đọc phải cùngmajority. - Đọc secondary mà không có session nhân quả. Lab 4b: 0/30 thấy lệnh ghi của mình; session nhân quả chờ ~1 giây và thấy 30/30.
- Mô phỏng mất mạng bằng
docker pause. Trong lab 3, lệnh ghi vẫn tới secondary dù chúng bị đóng băng, nên suýt kết luận sai. Hãy cắt mạng thật. - Đặt
w: 3để "chắc ăn", hoặc quên arbiter. Mất một node là lệnh ghi treo. Với P-S-A, mặc định ngầm rơi vềw: 1vàmajoritycó thể không bao giờ xong khi mất node mang dữ liệu.
Tóm tắt
acknowledged: truechỉ cam kết phần việc bạn yêu cầu.w= bao nhiêu node,j= đã ghi journal chưa,wtimeout= chờ bao lâu. Mặc định từ 5.0 làw: "majority"(trừ ngoại lệ arbiter), và majority ngầm có journal.- Cái giá (lab 1, mạng gần bằng 0):
w: 10,215 ms;w: 1, j: true0,495 ms;w: "majority"0,618 ms; các biến thểjcủa majority không phân biệt được trong dao động. WriteConcernTimeout(lab 2): lỗi sau 2.016 ms, không có error label, lệnh ghi vẫn nằm trên primary rồi tới nơi. Timeout là "chưa biết".- Rollback (lab 3):
w: 1xác nhận sau 4–5 ms rồi mất ở 3/3 trial khi primary bị tách và kill, lệnhmajoritytimeout cũng mất, lệnhmajorityđã xác nhận còn. Dữ liệu bị cuốn đi nằm trong fileremoved.*.bson. - Đọc: ghi
w: 1rồi đọcmajorityngay trên primary không thấy ở 498/500 lần. Đọc từ secondary lệch 1 giây: 0/30 không session, 30/30 với session nhân quả, đổi lại mỗi lần đọc chờ ~1 giây. - Retryable writes: driver retry một lần, server nhận ra lệnh đã chạy nên không áp dụng hai lần (1 đơn,
n = 1); tắt retry rồi tự gửi lại thì 2 đơn,n = 2.mongoshkhông hỗ trợ retryable reads. - PostgreSQL gom vào
synchronous_commitcộng danh sách standby; mặc định PostgreSQL không chờ bản sao nào, mặc định MongoDB chờ đa số. Chọn mức theo hậu quả của việc mất dữ liệu đó.
Hỏi & đáp
Một service ghi đơn hàng với { w: 1 } và nhận { acknowledged: true } sau 5 ms. Điều nào đúng về đơn hàng đó ngay lúc này?
Hai secondary tạm mất. Bạn ghi { w: "majority", wtimeout: 2000 } và sau 2 giây nhận WriteConcernTimeout. Điều nào mô tả đúng trạng thái của lệnh ghi?
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?
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?
Một lệnh insertOne bị timeout socket sau khi primary đã áp dụng nó (secondary tạm chậm), retryWrites=true. Điều gì xảy ra?
Chọn write concern cho hai bảng: nhật ký click của người dùng (hàng chục nghìn bản ghi mỗi giây) và sổ cái thanh toán. Cách nào hợp lý?
Bạn nhờ một nhân viên bưu điện gửi thư quan trọng. Anh ta nói "xong rồi" ngay khi đặt thư lên bàn mình, trước khi thư đi đâu. Điều nào hợp lý nhất để bạn làm?
Nếu bỏ hết thuật ngữ: khi bạn nhờ người khác lưu một thứ quan trọng, câu "xong rồi" chỉ có nghĩa là họ đã làm đúng việc bạn nhờ, không hơn. Nhờ "nhận thư" thì họ nhận; muốn thư không mất khi một nơi cháy, phải nhờ "đưa tới ít nhất hai nơi". Nếu người đưa tin mất liên lạc ngay lúc bạn không biết thư đã đi chưa, thì đừng viết lại một lá thư mới: hãy hỏi lại đúng lá thư đó, hoặc viết sao cho đọc hai lần cũng chỉ có một việc xảy ra. Và nếu bạn muốn đọc lại chính điều mình vừa gửi từ một chi nhánh, hãy mang theo biên nhận: chi nhánh nào chưa nhận tới số biên nhận đó thì phải đợi.
Bài tiếp theo
Bài này trả lời "lệnh ghi đã bền tới đâu" và "đọc lại lệnh ghi của mình bằng cách nào". Nhưng nó cũng để lại một vòng lặp mở: khi một lệnh ghi lỗi, ứng dụng làm gì? WriteConflict trong transaction, WriteConcernTimeout, timeout mạng, PrimarySteppedDown: lỗi nào retry được, retry bằng cách nào, và làm sao để chạy hai lần cũng không gây hại?
Write Conflicts, Retry & Idempotency trả lời những câu đó: vòng WriteConflict → abort → retry, sự khác nhau giữa retryable write (driver lo) và transaction retry (ứng dụng lo), cái gì phải idempotent, và cách giảm tranh chấp trên một document nóng. Còn journal giữ lời hứa j: true thế nào, và majority commit point nhích ra sao khi failover, là chuyện của bài Journal & Checkpoint và bài Elections, Failover & Majority.
Tài liệu tham khảo
- Write Concern (w, j, wtimeout, implicit default and the arbiter exception, acknowledgment behavior, writeConcernMajorityJournalDefault)
- Read Concern "majority" (guarantee, performance, majority commit point example, read your own writes)
- Causal Consistency and Read and Write Concerns (four guarantees, table of combinations, network partition scenarios)
- Retryable Writes (operations, retryWrites default, once-only retry, NoWritesPerformed, failover period)
- Retryable Reads (operations, unsupported operations, mongosh does not support them)
- Replica Set Configuration (writeConcernMajorityJournalDefault, settings.getLastErrorDefaults, members[n].secondaryDelaySecs)
- Rollbacks During Replica Set Failover (rollback data files, avoiding rollbacks, w: 1 and journaling)
- Read Isolation, Consistency, and Recency (read uncommitted, causal consistency)
- PostgreSQL: Write Ahead Log settings (synchronous_commit, wal_writer_delay)
- PostgreSQL: Replication settings (synchronous_standby_names, FIRST and ANY)