WiredTiger — cache, MVCC, eviction, checkpoint và journal: một lần ghi đi qua những đâu

Một client ghi w:1 liên tục, rồi docker kill mongod: 9–302 write đã được trả "OK" biến mất sau khi khởi động lại (9 lần kill, không lần nào 0). Cùng tải với j:true hoặc w:"majority": 0 mất. 32 client update ngẫu nhiên làm findOne của một collection không liên quan chậm từ p99 32 ms lên 75 ms — tăng cache từ 256 MB lên 1 GB không chữa được. Xoá nửa collection làm file to thêm 52 %. Bài này đi theo một lần ghi qua từng tầng của WiredTiger (cache, update chain, eviction, checkpoint, journal), đo từng tầng, và giải thích các hiện tượng đã gặp ở bài 1, 2, 3, 5. Transaction ở mức API để bài 8; write concern nhiều node để bài 7.

Thuật ngữ dùng trong bài
page (trang — đơn vị đọc/ghi của B-tree) · update chain (các phiên bản của một key trong RAM, mới nhất đứng đầu) · MVCC (giữ nhiều phiên bản để người đọc không chặn người ghi) · snapshot (ảnh dữ liệu tại một thời điểm) · stable / oldest timestamp (mốc đã majority commit / mốc cũ nhất còn đọc được) · history store (file WiredTigerHS.wt chứa phiên bản cũ) · dirty (trang đã sửa, chưa ghi đĩa) · eviction (đẩy trang ra khỏi cache) · reconcile (chuyển trang từ dạng RAM sang dạng đĩa) · checkpoint (ghi một bản nhất quán của mọi trang bẩn xuống đĩa) · journal / WAL (write-ahead log — ghi log trước để khôi phục sau crash) · copy-on-write (sửa thì viết bản mới ở chỗ khác, không ghi đè) · ticket (vé vào storage engine, giới hạn số thao tác chạy cùng lúc).
Kiểm chứng: Context7 (/mongodb/mongo, /websites/mongodb_manual); source nhánh v9.0 (README của storage/, storage/wiredtiger/, repl/, admission/execution_control/; wiredtiger_kv_engine.cpp, wiredtiger_util.cpp, wiredtiger_global_options.idl); WiredTiger architecture guide và config_def.c; manual + release notes 9.0. Output: labs/mongodb/out/06-*.txt. MongoDB 9.0.2, container --memory 1g, cache 0,25 GB, máy 4 vCPU dùng chung — số ms chỉ để so tỉ lệ.

Trả lời 30 giây

WiredTiger là B-tree có phiên bản: trang trong cache đã giải nén, mỗi key sửa có update chain mang timestamp (MVCC); trên đĩa là ảnh nén viết theo copy-on-write, không ghi đè. Một write: thêm phiên bản vào update chain → chép entry oplog vào log buffer → ack (w:1 ack ngay lúc này) → journal fsync mỗi 100 ms (j:true/majority chờ tới đây) → checkpoint 60 s ghi trang xuống file, bản cũ còn cần đọc sang history store. Trên replica set journal chỉ chứa oplog; crash = mở stable checkpoint + áp lại oplog. Cache mặc định max(50 % (RAM − 1 GB), 256 MB) theo cgroup; dirty chạm 20 % thì thread của request phải tự evict → p99 vọt, và tăng cache không chữa được nghẽn ghi. Snapshot/transaction dài ghim phiên bản cũ; transaction phải vừa cache (no-steal); xoá không trả đĩa (cần compact).

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

MongoDB 1.x–2.x chỉ có một storage engine, sau này gọi là MMAPv1: file dữ liệu được mmap vào bộ nhớ, hệ điều hành tự quyết trang nào ở RAM, trang nào xuống đĩa. Đơn giản, nhưng có ba giới hạn cứng (manual MMAPv1, bản 3.4, FAQ concurrency 3.4):

WiredTiger do Keith Bostic và Michael Cahill (hai kiến trúc sư của Berkeley DB / Sleepycat) viết thành một storage engine độc lập. MongoDB mua công ty WiredTiger ngày 16/12/2014 (press release), đưa vào 3.0 (2015) dạng tuỳ chọn qua pluggable storage engine API, đặt làm mặc định từ 3.2, deprecated MMAPv1 ở 4.0 và xoá hẳn ở 4.2. 4.4 thay bảng tràn cache WiredTigerLAS.wt (lookaside) bằng history store bền (WiredTigerHS.wt) (F. Pachot, 2025). Những thứ MongoDB có về sau dựa thẳng vào nó: document-level concurrency, nén, read concern majority (đọc tại một timestamp), transaction nhiều document (bài 8).

Tối ưu cho: nhiều luồng cùng đọc/ghi các document khác nhau (cache gần như không khoá, xung đột phát hiện lạc quan), đọc theo snapshot, tiết kiệm đĩa nhờ nén. Không tối ưu cho: update nhỏ ngẫu nhiên trên dữ liệu lớn hơn RAM (ghi trang là ghi cả trang — lab đo 34–43 KB ghi đĩa cho một update vài byte), transaction lớn (phải nằm trọn trong cache tới lúc commit), trả lại dung lượng sau khi xoá, và document nóng chịu hàng nghìn lần ghi mỗi giây.

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

Hai ý trung tâm:

  1. B-tree có phiên bản trong RAM, ảnh copy-on-write trên đĩa. Mỗi collection và mỗi index là một bảng B-tree key → value (bài 3). Trang trong cache là bản làm việc: đã giải nén, có mảng để binary search, và mỗi key bị sửa có một update chain (danh sách phiên bản, mới nhất đứng đầu, mỗi phiên bản mang transaction id + timestamp). Trang trên đĩa chỉ là ảnh nén, được viết ra ở chỗ mới mỗi khi trang được reconcile (chuyển từ dạng RAM sang dạng đĩa) — không bao giờ ghi đè khối cũ.
  2. Checkpoint là bản nhất quán trên đĩa; journal lấp khoảng giữa hai checkpoint. Mỗi 60 s, checkpoint ghi mọi trang bẩn thành một snapshot nhất quán tại stable timestamp. Thay đổi sau checkpoint chỉ sống qua crash nếu nằm trong journal (WAL). Trên replica set, journal chỉ chứa oplog — dữ liệu collection được dựng lại bằng cách áp lại oplog từ checkpoint.

Các hiện tượng ở bài trước đều là hệ quả của hai ý này:

Hiện tượngVì sao
Tên field dài tốn RAM dù đĩa nén gần hết (bài 1), index 9,4 MB chiếm 25,7 MB cache (bài 3)Cache giữ trang đã giải nén; nén chỉ có trên đĩa
History store +80 MB khi sửa mảng to (bài 2)Phiên bản cũ phải giữ 300 s cho đọc snapshot; trang được reconcile thì bản cũ chuyển sang WiredTigerHS.wt
Xoá dữ liệu mà file không nhỏ điCopy-on-write: khối trống chỉ vào danh sách "dùng lại được"; file chỉ co khi khối trống nằm ở cuối
p99 vọt khi ghi nhiềuTrang bẩn phải reconcile + ghi đĩa rồi mới bỏ khỏi RAM; quá ngưỡng thì chính thread của request phải làm
Transaction lớn bị huỷ, transaction dài làm cache nặngUpdate chưa commit không evict được; snapshot cũ ghim mọi phiên bản mới hơn nó
Ghi w:1 đã ack vẫn mất khi crashAck trả về khi entry oplog còn nằm trong log buffer ở RAM; journal fsync mỗi 100 ms
Đường đi của một lần updateOne trong WiredTiger Update thêm phiên bản mới vào update chain trong cache, chép entry oplog vào log buffer, trả ack w:1 khi tất cả còn ở RAM; journal fsync sau tối đa 100 ms; checkpoint ghi trang xuống file .wt sau tối đa 60 s, bản cũ sang history store. Một updateOne trên replica set, w:1 — ack trước, bền sau ứng dụng driver cache WiredTiger — RAM, bản chưa nén trang lá (leaf page) _id 5_id 6_id 7_id 8… update chain, mới nhất đứng đầu v3 @103 v2 @102 v1 đĩa mỗi lần sửa thêm 1 phiên bản (txn id + timestamp) log buffer (slot) — RAM: entry oplog của lần ghi journal — WiredTigerLog.* replica set: chỉ oplog được ghi vào collection-….wt (ảnh trang đã nén) A A′ checkpoint viết khối mới, khối cũ chờ dùng lại WiredTigerHS.wt — phiên bản cũ ① ack w:1 khi phiên bản mới và entry oplog mới chỉ nằm trong RAM ② ≤ 100 ms: fsync journal (commitIntervalMs) — j:true và w:"majority" chờ tới bước này ③ ≤ 60 s: checkpoint (syncdelay) ghi trang vào file .wt, bản cũ còn cần đọc sang history store
Hình 1 — Request được trả lời ở ①, khi phiên bản mới và entry oplog còn chỉ ở RAM; độ bền đến sau, ở ② (journal, ≤ 100 ms) và ③ (checkpoint, ≤ 60 s). Crash giữa ① và ② là mất write đã ack (mục 5.1); crash sau ② thì mongod mở checkpoint gần nhất rồi áp lại oplog (mục 3.6).

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

3.1 Cache: trang trong RAM không phải trang trên đĩa

Đọc một document mà trang chưa có trong cache: WiredTiger đi từ root page của B-tree xuống, gặp trang chưa nạp thì nhờ block manager đọc khối từ file, kiểm checksum, giải nén, rồi dựng mảng chỉ mục cho từng key để binary search. Lần đầu trang bị sửa, nó được gắn thêm cấu trúc theo dõi thay đổi: mỗi key có một update chain, key mới chèn vào skip list nằm giữa các key cũ (arch-cache). Hệ quả:

Kích thước mặc định = max(50 % × (RAM − 1 GB), 256 MB) (WiredTigerUtil::getMainCacheSizeMB), "RAM" là RAM mongod thấy — giới hạn cgroup nếu có (06-cachedef.txt, mongod rỗng, không truyền --wiredTigerCacheSizeGB):

ContainermemLimitMB mongod thấycache
9.0.2 --memory 1g1 024256 MB (sàn)
9.0.2 và 8.0.32 --memory 2g2 048512 MB
9.0.2 --memory 4g4 0961 536 MB
9.0.2 không giới hạn (máy 7,9 GB)7 9413 458 MB
Bẫy: manual vẫn ghi "chạy trong container thì phải đặt --wiredTigerCacheSizeGB" — có cgroup limit thì 8.0/9.0 tự tính đúng. Nguy hiểm là container không có --memory: mỗi mongod lấy nửa RAM của cả máy (3,4 GB ở đây). Cache cũng chỉ là một phần: --memory 1g + cache 256 MB vẫn bị OOM-kill ở bài 5 vì heap truy vấn. Cache > 80 % RAM chỉ bị cảnh báo lúc khởi động (log id 22300).

3.2 MVCC: update chain, timestamp và history store

Mỗi update tạo một phiên bản mới ở đầu update chain của key, mang transaction id (cho isolation giữa các storage transaction) và timestamp (MongoDB gắn timestamp của entry oplog). Đọc một key, WiredTiger đi theo thứ tự: update chain trong RAM → bản trên ảnh đĩa → history store, lấy bản đầu tiên mà snapshot của mình "thấy" (arch-transaction). MongoDB điều khiển hai mốc toàn cục:

Khi trang bẩn được reconcile, bản mới nhất đã commit thành giá trị trên đĩa; các bản cũ hơn mà còn có thể được đọc đi vào WiredTigerHS.wt — một B-tree duy nhất cho mọi bảng, key = (id của B-tree, key gốc, start timestamp, bộ đếm), value = (stop timestamp, durable timestamp, kiểu, giá trị) (arch-hs). Lab 06-mvcc.txt: 20 000 document ~1 KB, 4 worker $set một chuỗi 1 000 ký tự mới (~1 700 update/s), node mới:

MVCC: update chain có timestamp, reader ghim phiên bản cũ, history store Mỗi update thêm một phiên bản có timestamp vào update chain. Một transaction snapshot đọc tại ts 101 ghim phiên bản v2. Khi trang được reconcile, bản mới nhất v4 ghi vào file collection, các bản cũ còn cần đọc ghi vào WiredTigerHS.wt. Số đo: A. cửa sổ 300 s (mặc định): 3200/s; B. cửa sổ 0: 160/s; C. cửa sổ 0 + 1 transaction mở: 3600/s; D. sau khi transaction bị huỷ: 120/s. Một key, nhiều phiên bản: ai ghim bản cũ, bản cũ đi đâu _id 7, mỗiupdate 1 bản v1 @100 v2 @101 v3 @102 v4 @103 txn snapshot đọc tại ts 101 oldest_timestamp Bản nào mới hơn oldest_timestamp (= stable − 300 s) hoặc còn reader cần thì chưa bỏ được. file .wt: bản mới nhất (v4) WiredTigerHS.wt: v1, v2, v3 ↑ reconcile (eviction / checkpoint) Số đo: bản cũ đẩy sang history store mỗi giây (lab 06-mvcc, cùng tải update) A. cửa sổ 300 s (mặc định) 3 200/s B. cửa sổ 0 160/s C. cửa sổ 0 + 1 transaction mở 3 600/s D. sau khi transaction bị huỷ 120/s Trung vị mỗi pha. WiredTigerHS.wt trên đĩa: 0 → 87 MB (pha A), 91 → 243 MB (pha C); transaction bị huỷ sau ~65–69 s.
Hình 2 — Trên: cơ chế (tượng trưng). Dưới: số đo thật (out/06-mvcc.txt). Cửa sổ 300 s mặc định giữ mọi bản cũ 5 phút; cửa sổ 0 thì history store gần như đứng yên — cho tới khi một transaction snapshot mở và để đó.

3.3 Eviction: ai dọn cache, và khi nào thread của request phải tự dọn

Eviction (đẩy trang ra khỏi cache) do một eviction server dò từng B-tree, chọn khoảng 1/3 trang lâu không dùng nhất (LRU xấp xỉ) cho vào hàng đợi, và 4 eviction worker thread (MongoDB truyền eviction=(threads_min=4,threads_max=4), 06-params.txt) lấy ra xử lý. Trang sạch thì chỉ việc free. Trang bẩn phải reconcile: chọn phiên bản mới nhất đã commit làm giá trị trên đĩa, đưa các phiên bản cũ còn cần đọc vào history store, nén, ghi khối mới (arch-eviction). Ngưỡng đọc từ thống kê WiredTiger của node lab:

Ngưỡng% cacheỞ cache 256 MBVượt ngưỡng thì
eviction_target / eviction_trigger80 / 95204,8 / 243,2 MBworker bắt đầu dọn / thread của request bị kéo vào dọn
eviction_dirty_target / _trigger5 / 2012,8 / 51,2 MBnhư trên, chỉ tính trang bẩn
eviction_updates_target / _trigger2,5 / 106,4 / 25,6 MBnhư trên, tính byte của update chain

Lab 06-evict.txt: client updateOne ngẫu nhiên (w:1) lên 2 triệu đơn (682 MB BSON, gấp 2,7 lần cache), song song probe findOne theo _id trên shop.products (nhỏ, luôn trong cache) và probe insertOne vào collection khác, 50 ms một lần; mỗi biến thể sau docker restart, tải 60 s:

Biến thểupdate/sghi đĩa / updatethread request tự evictprobe đọc p50 / p99 / maxprobe ghi p99
pha nghỉ (cả 3 lượt)——02,8–4,0 / 17–41 / 77–119 ms18–26 ms
cache 0,25 GB, 32 client2 28537,4 KB231 s8,7 / 74,8 / 347 ms92,5 ms
cache 0,25 GB, 4 client1 74643,2 KB0,5 s2,9 / 45,6 / 304 ms32,6 ms
cache 1 GB (container 2 GB), 32 client2 19334,2 KB132 s10,6 / 156,6 / 936 ms212,1 ms
Dirty chạm 20 % thì app thread phải tự evict và p99 của truy vấn không liên quan tăng Lab 06-evict, cache 0,25 GB, 32 client: dirty tăng lên 20 % sau 7 giây tải và dính ở đó; từ lúc ấy thread của request phải tự evict (tới 15 000 ms mỗi giây cộng dồn) và p99 của probe đọc tăng từ 32 lên 75 ms, max 347 ms. Lab 06-evict: cache 0,25 GB, 32 client update ngẫu nhiên — 1 mẫu mỗi giây 60 s tải: 32 client updateOne ngẫu nhiên lên 2 triệu đơn dirty trigger 20 % dirty target 5 % dirty% cache app threadtự evictms/giây thang 0–15 000 ms/s (nhiều thread cộng lại) 100 probe đọcp99 mỗigiây (ms) 0 s 10 s 20 s 30 s 40 s 50 s 60 s 70 s checkpoint 913ms giây 17: dirty chạm 20 % → thread của request bắt đầu tự evict
Hình 3 — Số đo thật (out/06-evict.txt, lượt đầu). Dirty chạm eviction_dirty_trigger 20 % thì 4 eviction worker không ghi kịp: thread phục vụ request bị kéo đi reconcile + ghi trang (cột đỏ). Probe findOne trên collection khác, nằm sẵn trong cache, vẫn chậm theo: p99 32 → 75 ms, max 347 ms (máy dùng chung — so tỉ lệ).
  1. Nghẽn ở băng thông ghi đĩa, không phải RAM. Một update vài byte ($inc + $set ngày) làm bẩn cả trang lá; reconcile ghi lại cả trang vào khối mới: 34–43 KB ghi đĩa mỗi update (~85 MB/s). Cache gấp 4 không giúp: ngưỡng dirty là phần trăm cache nên cache 1 GB vẫn chạm 20 % (ở 205 MB thay vì 51 MB), p99 còn tệ hơn.
  2. Thêm client chỉ dời hàng đợi vào storage engine: 4 client đạt 76 % throughput của 32 client, gần như không thread request nào phải evict.
  3. Truy vấn không liên quan cũng chậm: thread nào cần chỗ trong cache đều có thể bị bắt evict hoặc phải chờ ("application thread time waiting for cache" cộng dồn 358 s trong 60 s), thêm tranh CPU và đĩa — findOne trên trang nằm sẵn trong cache chậm 2–4 lần.

3.4 Checkpoint và block manager: không bao giờ ghi đè

Checkpoint (mỗi syncdelay = 60 s) chạy trong một transaction snapshot tại stable timestamp (mốc đã majority commit — bài 7): nhờ eviction giảm bớt trang bẩn, reconcile mọi trang bẩn, checkpoint history store sau cùng (reconcile dữ liệu sinh thêm bản cũ cho nó), fsync, rồi ghi metadata và file "turtle" trỏ tới checkpoint mới (arch-checkpoint). Block manager không ghi đè: mỗi file có danh sách extent alloc / avail / discard, khối cũ chỉ được dùng lại khi checkpoint cũ tham chiếu nó bị xoá (arch-block).

3.5 Journal: WAL có group commit — và trên replica set nó chỉ chứa oplog

WiredTiger ghi log không cần khoá: transaction lúc commit giành một phần của log slot (vùng đệm trong RAM, tổng 32 MB) bằng phép toán atomic rồi chép bản ghi vào — chưa có syscall nào. Slot được đẩy xuống file khi đầy, khi có ai yêu cầu flush, hoặc theo nhịp của thread log_server (docs ghi 50 ms; lab mục 5.1 cho thấy write vẫn nằm trong RAM tới lần flush của MongoDB) (arch-logging). MongoDB không bật sync khi commit; thread JournalFlusher gọi log_flush(sync=on) mỗi commitIntervalMs = 100 ms, hoặc ngay khi có write j:true đang chờ — nhiều commit chung một lần fsync (group commit). File log tối đa 100 MB, nén snappy, bản ghi ≤ 128 byte không nén (manual journaling).

Điểm hay bị hiểu sai: trên replica set, bảng của collection và index không được journal. Chỉ oplog (và vài bảng của DB local) đi vào journal; document lên đĩa nhờ checkpoint, crash thì được dựng lại từ oplog (storage README). Lab 06-jbytes.txt, 20 000 insert (~270 byte) và 5 000 lần $inc:

Byte journal mỗi thao tácReplica set 1 nodeStandalone (không --replSet)
insert, 0 index phụ273266
insert, 4 index phụ273 — không đổi304 — index cũng được journal
$inc 1 field trên document 10 KB387 (entry oplog 314 byte: {"$v":2,"diff":{"u":{"n":5}}})128 (WiredTiger chỉ log phần sửa)

Vì vậy j:true trên replica set nghĩa là "entry oplog của write đã fsync", không phải "trang dữ liệu đã fsync" — đủ để dựng lại write sau crash. Và journal tỉ lệ với số oplog, không với kích thước document hay số index.

3.6 Crash recovery: mở checkpoint, replay journal, áp lại oplog

sequenceDiagram
  participant M as mongod khởi động
  participant WT as WiredTiger
  participant R as replication recovery
  M->>WT: mở checkpoint gần nhất (recoveryTimestamp = stable timestamp lúc đó)
  WT->>WT: replay journal từ LSN của checkpoint, oplog đầy tới lần fsync cuối
  M->>R: Recovering from stable timestamp, topOfOplog
  R->>R: cắt oplog ở oplogTruncateAfterPoint (bỏ lỗ ở cuối)
  R->>WT: áp lại entry oplog từ stable timestamp tới đỉnh oplog vào collection và index
  R-->>M: Completed oplog application for recovery, rồi SECONDARY, PRIMARY

Hai tầng khôi phục: WiredTiger chỉ replay journal (tức dựng lại oplog), còn replication recovery mới đưa dữ liệu collection từ stable checkpoint lên kịp oplog (repl README: Startup Recovery). Trên primary, entry oplog có thể commit không theo thứ tự (oplog hole); điểm "không còn lỗ" được ghi riêng (oplogTruncateAfterPoint, có journal) để lúc khởi động cắt phần đuôi có lỗ. Lab 06-recovery.txt: fsync tạo checkpoint, đặt syncdelay 3 600 lúc chạy, ghi w:1 T giây (~3 400 insert/s) rồi docker kill; số đọc từ log JSON của lần khởi động. Lượt 60 s và 180 s vẫn dính một checkpoint ở giây ~54 (đặt syncdelay lúc chạy chỉ có hiệu lực sau lần checkpoint đang chờ) — nên đọc theo cột khoảng cách tới checkpoint:

Ghi trước killstable checkpoint cách đỉnh oplogentry oplog áp lạiWiredTiger openedáp oplog (startUpReplCoord)starting → PRIMARY
10 s (28 970 insert)19 s28 889766 ms293 ms1 216 ms
60 s (205 990 insert)6 s17 626616 ms147 ms929 ms
180 s (615 709 insert)127 s441 1544 415 ms5 957 ms10 537 ms

Cả hai bước đều tăng theo lượng ghi kể từ stable checkpoint cuối: 441 nghìn entry (127 s ghi) cần 4,4 s replay journal + 6,0 s áp oplog, tổng 10,5 s mới nhận ghi. Bình thường khoảng cách này ≤ 60 s; nó dài ra khi checkpoint chậm, stable timestamp đứng (mục 3.4) hoặc node chết lặp lại (mục 5.4).

3.7 Nén: chỉ có trên đĩa, trả giá bằng CPU lúc đọc/ghi trang

Collection nén theo khối (block_compressor, mặc định snappy; time series mặc định zstd), index chỉ có prefix compression (bài 3), journal nén snappy. Nén xảy ra lúc reconcile, giải nén lúc đọc trang vào cache — trang trong cache luôn là bản chưa nén. Đặt riêng từng collection: createCollection(name, {storageEngine: {wiredTiger: {configString: "block_compressor=zstd"}}}). Lab 06-compress.txt: cùng 500 000 đơn (174 MB BSON) chép sang 4 collection, lượt 2 (lượt 1 còn trả giá đọc nguồn từ đĩa):

Nénfile (storageSize)tỉ lệchép + fsyncquét trọn khi cache rỗng (3 vòng)vào cache khi quét
snappy (mặc định)60,0 MB2,90×4,8 s0,8–1,4 s185 MB
zstd (mức 6)31,9 MB5,46×4,8 s1,1–1,9 s185 MB
zlib38,1 MB4,57×6,7 s1,8–6,8 s188 MB
không nén185,8 MB0,94×4,5 s1,7–2,5 s184 MB

zstd cho file bằng nửa snappy mà thời gian ghi ngang nhau; quét chậm hơn một chút (giải nén tốn CPU hơn). Không nén không nhanh hơn: phải đọc gấp 3 lần byte (từ page cache của OS — lab không xoá được cache của OS trên máy dùng chung, đĩa lạnh thật sẽ còn nghiêng về bản nén). Số ms nhiễu (zlib có vòng 6,8 s) — so tỉ lệ. Hai điều chắc chắn: cache không lợi gì từ nén (185 MB dù file 32 hay 186 MB), và đổi compressor chỉ áp dụng cho collection tạo mới — dữ liệu cũ phải chép sang (hoặc initial sync node mới với --wiredTigerCollectionBlockCompressor).

3.8 Concurrency: khoá ý định, xung đột lạc quan, và ticket

MongoDB chỉ lấy intent lock (khoá ý định IS/IX) ở mức global, database, collection — chúng không chặn nhau. "Ai được sửa document nào" do WiredTiger quyết theo kiểu lạc quan: key đã có update chưa commit của transaction khác, hoặc update mà snapshot của mình không thấy, thì trả WT_ROLLBACK; server đổi thành WriteConflictException, huỷ storage transaction và tự chạy lại (writeConflictRetry, chờ tăng dần ~1,1×) — client không thấy, trừ trong transaction nhiều document (bài 8). Trước khi vào storage engine, mỗi thao tác còn phải lấy một ticket đọc hoặc ghi (execution control). Từ 7.0, số ticket tự điều chỉnh bằng throughput probing: bắt đầu thấp (min 4), mỗi 100 ms nếu ticket đang cạn mà tăng 10 % làm throughput tăng thì giữ, không thì lùi; trần 128 (execution control README, 06-params.txt). Lab 06-tickets.txt (w:1, kèm 1 probe insertOne vào collection khác mỗi 20 ms):

Tải 30 supdate/swriteConflictstotalTickets (ghi)tổng thời gian chờ ticketprobe p50 / p99
64 client, update rải rác 2 triệu đơn2 59904 → 19590 s22,8 / 152 ms
50 client $inc cùng 1 document2 253121 35417 → 3654 s18,4 / 245 ms
50 client, mỗi client 1 document riêng3 284017 → 3623 s21,8 / 167 ms

Đọc ra: (1) ticket giờ không mặc định 128 — bắt đầu từ 4 và chỉ tăng khi tăng thật sự giúp; 64 client phần lớn thời gian nằm chờ ticket (590 s cộng dồn trong 30 s) thay vì chen nhau trong cache — "available thấp" không có nghĩa quá tải, hãy xem hàng đợi. (2) Document nóng: mỗi update thành công kèm ~1,8 WriteConflict, throughput thấp hơn 31 % so với ghi document riêng, kết quả vẫn đúng (tổng views = số update thành công); lúc ngủ chờ retry thao tác vẫn giữ ticket (internalQueryEnableWriteConflictBackoffWithoutTicket mặc định false). 9.0 thêm metric writeConflictRetryWaiters/writeConflictRetryLimitHit và giới hạn retry thích ứng (lỗi 512 WriteConflictRetryLimitExceeded) — mặc định tắt (internalQueryWriteConflictRetryLimitMax: 0), server vẫn retry vô hạn.

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

ThứGiá trị / hành viNguồn
AtomicMột document cùng mọi index của nó trong một storage transactionstorage README
Isolation ở tầng storageSnapshot isolation (transaction id + timestamp); xung đột ghi phát hiện lạc quan → WriteConflictarch-transaction
Độ bền khi được ackw:1: chưa bền — mất 9–302 write đã ack mỗi lần docker kill (9 lần), cửa sổ ≤ commitIntervalMs. j:true / majority: 0 mất qua 6 lần killlab 06-journal
CheckpointMỗi 60 s, tại stable timestamp, luôn nhất quánarch-checkpoint
Đọc về quá khứ≤ minSnapshotHistoryWindowInSeconds (300 s); xa hơn → SnapshotTooOld (239)lab 06-mvcc
Transaction nhiều documentSống ≤ 60 s (thực tế tới ~90 s, lab bị huỷ ở ~65–69 s); dirty của nó ≤ 75 % dirty trong cache, không thì TransactionTooLargeForCache (388) — lab: ~20 MB với cache 256 MBlab 06-mvcc, 06-toolarge
Cache≥ 256 MB; ngưỡng evict 80/95 %, dirty 5/20 %, update 2,5/10 %06-params
Ticket4–128 mỗi loại, tự điều chỉnh mỗi 100 msexecution control README, lab 06-tickets
JournalFile 100 MB, xoá sau checkpoint; fsync ≤ 100 ms; trên replica set chỉ chứa oplogmanual, lab 06-jbytes

Khung lý thuyết: theo thuật ngữ ARIES, WiredTiger là no-force (commit không ép trang xuống đĩa, chỉ journal) và no-steal (dữ liệu chưa commit không bao giờ vào file dữ liệu) — chỉ cần redo log, checkpoint luôn sạch, đổi lại transaction bị chặn bởi cache. PostgreSQL, InnoDB là steal. CAP/PACELC thuộc replica set (bài 7); ở một node, câu hỏi chỉ là "ack trước hay sau fsync".

5. Failure modes

5.1 docker kill giữa lúc ghi: write w:1 đã ack biến mất

Lab 06-journal.txt (node tmp-mdb-j): 16 worker insert tuần tự _id = w·10⁷ + n, client ghi lại thời điểm nhận ack của từng write; sau ~8 s docker kill (SIGKILL), docker start, đếm write đã ack mà không có trong DB:

Write concernsố lần killwrite đã ack mà MẤTchúng được ack trong … cuốivào DB mà chưa kịp ack
w:1 (commitIntervalMs 100)3171, 71, 1244 ms, 14 ms, 6 ms0, 0, 0
w:1, commitIntervalMs 5002307, 81080 ms, 262 ms0
w:1, commitIntervalMs 126, 15 ms, 1 ms0
j:true30, 0, 0—, —, —5, 2, 10
w:"majority"30, 0, 0—, —, —3, 3, 7

Gộp cả 6 lần kill w:1 ở lab crash recovery (mục 3.6): 9 lần, mất 9–302 write đã ack, không lần nào 0, tất cả được ack trong ≤ 71 ms cuối.

docker kill giữa lúc ghi: w:1 mất write đã ack, j:true và majority thì không Hai làn thời gian. Làn w:1: mọi write được ack ngay, log được đẩy xuống OS theo nhịp; khi docker kill, các write đã ack sau lần đẩy cuối còn nằm trong bộ nhớ của mongod nên mất (9–302 write mỗi lần, được ack trong ≤ 71 ms cuối). Làn j:true và majority: write chỉ được ack sau khi journal đã fsync, nên không mất write đã ack nào; vài write đã vào DB nhưng client chưa nhận ack. docker kill (SIGKILL) giữa lúc 16 worker đang insert — ai được ack, ai mất w:1 ack ngay khi entry vào log buffer flush + fsync journal (mỗi commitIntervalMs) j:true, majority ack sau khi journal đã fsync docker kill mất 9–302write đã ack(ack ≤ 71 ms cuối) mất 02–10 write vào DBmà chưa kịp ack Ô cam = đã ack; ô xanh = ack sau fsync (nhiều write chung 1 lần fsync); ô xám = chưa ack lúc mongod chết. Viền đỏ: đã ack nhưng còn trong log buffer của process → mất. Lab: 9 lần kill với w:1, 3 với j:true, 3 với majority.
Hình 4 — Ack của w:1 đến trước độ bền, ack của j:true/majority đến sau fsync journal (out/06-journal.txt, 06-recovery.txt). SIGKILL không xoá page cache của OS; mất điện thật còn mất thêm phần chưa fsync (chưa verify).

5.2 Transaction hoặc snapshot để mở: history store phình, rồi bị huỷ

Phần C và D của 06-mvcc.txt (mục 3.2): transaction readConcern: "snapshot" đọc 1 document rồi không làm gì. Trong lúc nó mở, history store nhận ~3 600 bản/giây thay vì ~160; reaper huỷ nó sau ~65–69 s, lệnh kế tiếp của client nhận:

251 NoSuchTransaction — Transaction with { txnNumber: 1 } has been aborted.

Đọc tại một atClusterTime cũ hơn cửa sổ (đặt cửa sổ 0, đọc mốc 5 s trước):

239 SnapshotTooOld — Read timestamp Timestamp(1791570625, 530) is older than the oldest available timestamp.

Phát hiện: metric "pinned by the oldest active read timestamp" tăng đều; db.currentOp({"transaction": {$exists: true}}) cho timeOpenMicros. Hay gặp: transaction mở rồi gọi API ngoài; cursor snapshot qua nhiều getMore; ETL đọc một snapshot nhiều phút. Ngoài reaper còn có cache pressure monitor huỷ transaction cũ nhất khi cache kẹt lâu (README; lab chưa tái hiện).

5.3 Transaction quá lớn cho cache: lỗi 388

Update chưa commit không được evict (no-steal). Lab 06-toolarge.txt, cache 256 MB, insert document 1 KB trong một transaction:

transaction, ngưỡng mặc định 0.75: lỗi sau 20400 document (~20.0 MB) trong 2422 ms, dirty cao nhất đo được 34.0 MB
388 — WiredTigerRecordStore::insertRecord -31800: transaction is too large and will not fit in the storage engine cache (Transaction has the oldest pinned transaction ID)
errorLabels: []
ngoài transaction: 200000 document (~196.5 MB) insert xong trong 7854 ms

Chưa tới 1/10 cache đã chết: giới hạn thật là ngưỡng dirty/update (20 % / 10 % cache), transaction bị huỷ khi phần dirty của nó vượt 75 % tổng dirty (transactionTooLargeForCacheThreshold). Lỗi không có nhãn TransientTransactionError nên withTransaction không retry; đặt ngưỡng 1.0 thì thành WriteConflict mà README cảnh báo "may result in drivers retrying an operation indefinitely". Chữa: chia lô.

5.4 Crash loop: lần khởi động sau lâu hơn lần trước

Node chết trước checkpoint đầu tiên sau khi khởi động thì stable checkpoint vẫn là cái cũ: lần sau phải áp lại cả phần oplog của lần chạy trước. Lab phần B (syncdelay mặc định 60 s, mỗi lần sống ~25 s):

Lần chếtstable checkpoint cách đỉnh oplogentry oplog áp lạistarting → PRIMARY
130 s73 0642 379 ms
264 s127 4753 089 ms
396 s209 2113 407 ms

Một mongod bị OOM-kill lặp lại mỗi dưới 60 s (kiểu bài 5) vì thế ngày càng lâu mới lên PRIMARY. Phát hiện bằng log: Recovering from stable timestamp + numOpsApplied của Completed oplog application for recovery.

5.5 Xoá không trả dung lượng — và còn làm file to ra

Lab 06-compact.txt: lab6.o_compact 1 000 000 đơn (snappy):

BướccountstorageSizefreeStorageSizeindex
sau khi chép1 000 000118,8 MB033,3 MB
xoá nửa cũ (3,8 s)500 000180,5 MB58,9 MB58,3 MB
chèn 250 000 đơn mới750 000180,5 MB28,4 MB58,3 MB
compact, force: true (0,2 s, bytesFreed 18,0 MB)750 083162,3 MB10,2 MB58,3 MB

Xoá làm file to thêm 52 %: mỗi trang bị xoá bớt được viết lại ở chỗ mới (copy-on-write) trong khi khối cũ còn bị checkpoint trước giữ, index cũng vậy. Chỗ trống được dùng lại (chèn 250 000 đơn không làm file lớn thêm). compact trên primary bị từ chối nếu không có force:

20 IllegalOperation — will not run compact on an active replica set primary as this will slow down other running operations. use force:true to force

dryRun: true ước đúng 18,0 MB — chỉ 18 trên 28,4 MB trống: compact dời khối từ 10–20 % cuối file lên đầu rồi cắt đuôi, best-effort (arch-compact); probe insert + find vào chính collection đó: 0 lỗi. autoCompact (8.0+, runOnce: true, freeSpaceTargetMB: 1): lab6.o_auto (xoá nửa) 89,0 → 64,1 MB sau 5 s — nhưng nó chạy trên mọi collection của node: 41 file compact, 341 MB lấy lại. Tắt trước khi backup (manual).

6. Vận hành trong production

Metric cần theo dõi (db.serverStatus(), FTDC lưu sẵn mỗi giây; tên field đúng bản 9.0.2):

MetricĐường dẫnĐọc thế nào
Cache dùng / dirty / updatewiredTiger.cache["bytes currently in the cache"], ["tracked dirty bytes in the cache"], ["bytes allocated for updates"] ÷ ["maximum bytes configured"]~80 % dùng là bình thường; dirty dính 20 % / update 10 % = thread request đang bị kéo đi dọn
Thread request tự evict / chờ cachecache["application thread time evicting (usecs)"], ["thread-yield"]["application thread time waiting for cache (usecs)"]Tăng liên tục = p99 xấu
Checkpointcheckpoint["most recent time (msecs)"]Vài giây trở lên = đĩa không kịp
History store, snapshot bị ghimcache["history store table on-disk size"], transaction["transaction range of timestamps pinned by the oldest active read timestamp"]Tăng đều = reader ghim hoặc commit point đứng
Ticket, xung độtqueues.execution.write.normalPriority.queueLength, metrics.operation.writeConflictsHàng đợi dài kéo dài; conflict vọt = document nóng

Tham số ảnh hưởng lớn (giá trị thật ở 06-params.txt): cacheSizeGB/cacheSizePct (đổi lúc chạy được qua wiredTigerEngineRuntimeConfig: "cache_size=…"); wiredTigerEvictionThreadsMin/Max 4/4 (chỉ giúp khi còn CPU và đĩa); syncdelay 60 s (manual khuyên không đổi); commitIntervalMs 100 (1–500, chính là cửa sổ mất của w:1); minSnapshotHistoryWindowInSeconds 300; transactionLifetimeLimitSeconds 60; blockCompressor snappy (chỉ cho collection mới); executionControlConcurrencyAdjustmentAlgorithm throughputProbing (đặt cứng số ticket là chuyển sang fixed).

Capacity planning: RAM ≈ 2 × (working set chưa nén + index) + 1 GB, vì cache chỉ là 50 % (RAM − 1 GB); đĩa = dữ liệu nén + history store (tới ≈ số update/s × 300 s × cỡ document) + journal (xoá sau mỗi checkpoint) + chỗ trống cho compact; băng thông ghi = số update ngẫu nhiên/s × 35–45 KB. Upgrade: định dạng file theo FCV, đổi compressor hay cấu hình bảng thì phải tạo lại collection hoặc initial sync node mới. Lúc 3 giờ sáng, p99 vọt: (1) dirty % và app thread evict (mục 3.3) → tìm job ghi hàng loạt, giảm luồng của nó; (2) checkpoint time và đĩa (iostat); (3) history store + pinned timestamp → tìm transaction/cursor dài trong $currentOp, hoặc node secondary chết (commit point đứng); (4) hàng đợi ticket + writeConflicts → document nóng.

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

MongoDB Community không còn engine nào khác để chọn (in-memory chỉ có ở Enterprise/Percona); câu hỏi thật là "MVCC của MongoDB khác PostgreSQL chỗ nào" (PostgreSQL: dead tuple và bloat, wraparound):

TrụcWiredTiger (MongoDB 9.0)PostgreSQL 18 (heap)
Phiên bản cũ nằm đâuUpdate chain trong RAM; khi reconcile → WiredTigerHS.wt riêngNgay trong heap, cạnh bản mới (dead tuple)
Ai dọn bản cũTự động: hết cửa sổ snapshot + không reader nào cần thì reconcile bỏ; trang HS rỗng bị checkpoint dọnVACUUM / autovacuum; chậm thì bloat, wraparound
Một update nhỏ ghi gì xuống đĩaJournal: entry oplog (~0,3–0,4 KB); file: cả trang khi reconcile (lab 34–43 KB/update ngẫu nhiên)WAL + tuple mới; full page image lần đầu sau checkpoint; index mới trừ HOT update
Dữ liệu chưa commit ra đĩa?Không (no-steal) → transaction phải vừa cache (lab: lỗi 388 ở ~20 MB với cache 256 MB)Có (steal) → transaction lớn được, rollback chỉ là đánh dấu
Trả dung lượng sau xoácompact / autoCompact (8.0+), không chặn CRUD, best-effortVACUUM FULL (khoá bảng) / pg_repack
Hai writer cùng một bản ghiLạc quan: WriteConflict, server tự retry (giữ ticket)Bi quan: writer sau chờ row lock

Dùng (và để mặc định) khi: working set ở dạng chưa nén vừa cache, tốc độ ghi trang dưới băng thông ghi của đĩa, transaction ngắn và nhỏ, ghi phân tán trên nhiều document. Tránh hoặc phải thiết kế lại khi: update nhỏ ngẫu nhiên trên dữ liệu lớn gấp nhiều lần RAM mà vẫn đòi p99 thấp; một transaction gom hàng chục MB; cần đọc snapshot kéo dài hàng phút; xoá hàng loạt định kỳ mà ngân sách đĩa chật; counter nóng hàng nghìn lần ghi/giây trên một document.

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

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

  1. "Tăng cache là hết chậm." Chỉ đúng khi nghẽn là đọc; ghi ngẫu nhiên thì ngưỡng dirty tăng theo cache còn đĩa thì không — lab 1 GB p99 còn tệ hơn 256 MB.
  2. w:1 cho đơn hàng, tiền. Ack ≠ bền (mục 5.1); failover còn rollback (bài 7).
  3. Hạ minSnapshotHistoryWindowInSeconds về 0 cho history store khỏi phình. Đọc snapshot về quá khứ hỏng (SnapshotTooOld), transaction dài vẫn ghim như cũ. Chữa bằng mô hình dữ liệu (bài 2) và transaction ngắn.
  4. Bọc import/migration lớn trong một transaction — chết ở vài chục MB (mục 5.3).
  5. Xoá rồi đợi đĩa tự nhỏ; compact giờ cao điểm trên primary; bật autoCompact rồi quên (nó quét cả node).
  6. Counter nóng trong một document — tách nhiều document con rồi cộng, hoặc đệm ở Redis.
  7. Coi "available tickets thấp" là quá tải — từ 7.0 ticket bắt đầu ở 4; nhìn hàng đợi.

Sự cố công khai: CircleCI (26/03–10/04/2019): service khắp fleet gọi lại createIndex lúc khởi động (tưởng rẻ) làm cạn ticket WiredTiger, cộng áp lực bộ nhớ — đồ thị có "application evicted pages", eviction thất bại, phải failover; khắc phục gồm bỏ lệnh tạo index thừa, chỉnh tcmalloc, nâng bản (postmortem). Manual tự ghi: PSA mất một node có dữ liệu thì "the storage engine keeps all changes that happen after the commit point on disk", cache áp lực, oplog vượt cỡ (manual). Trước 4.4, bảng tràn WiredTigerLAS.wt từng phình tới mức có tham số giới hạn riêng maxCacheOverflowFileSizeGB — nay ghi "Deprecated. Has no effect" (wiredtiger_global_options.idl).

Docs không nói rõ: trên replica set journal chỉ là oplog, j:true chỉ cam kết entry oplog; node chết lặp lại trước checkpoint đầu tiên thì lần khởi động sau lâu hơn lần trước.

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

Vì sao cache WiredTiger mặc định chỉ khoảng 50 % RAM?
Phần còn lại cho page cache của OS (giữ file đã nén — đọc lại không cần đĩa), heap truy vấn, connection, data handle. Công thức max(50 % × (RAM − 1 GB), 256 MB), "RAM" là giới hạn cgroup nếu có.
Ở tầng storage, w:1, j:true và w:"majority" khác nhau thế nào?
w:1: ack khi entry oplog vào log buffer trong RAM. j:true: ack sau fsync journal (group commit, ≤ 100 ms). majority: ack khi đa số node đã ghi và — do writeConcernMajorityJournalDefault: true — đã journal. Lab docker kill: chỉ w:1 mất write đã ack.
Xoá nửa collection sao file không nhỏ đi?
Copy-on-write: khối giải phóng chỉ vào danh sách dùng lại; file co khi khối trống nằm cuối file. compact/autoCompact dời khối từ cuối lên đầu rồi cắt đuôi, best-effort.
Transaction (hay cursor snapshot) dài ảnh hưởng gì nếu nó chỉ đọc?
Nó ghim snapshot: mọi phiên bản mới hơn không bỏ được, khi trang được reconcile chúng dồn vào history store, cache nặng thêm. Transaction bị huỷ sau 60 s (thực tế tới ~90 s, reaper chạy mỗi 30 s).
Sao query đọc collection khác cũng chậm khi batch job ghi?
Dirty chạm 20 % cache → thread của request (bất kỳ) bị kéo đi evict/chờ cache; thêm tranh ticket, CPU, đĩa. Lab: p99 của findOne không liên quan tăng 2–4 lần.
Lỗi TransactionTooLargeForCache (388) là gì, chữa sao?
No-steal: update chưa commit không evict được; dirty của transaction vượt 75 % dirty cache thì bị huỷ thay vì retry mãi (lab: ~20 MB ở cache 256 MB). Chia lô.

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

Môi trường: MongoDB 9.0.2, replica set 1 node, cache 0,25 GB, --memory 1g: tmp-mdb-1 (bộ dữ liệu chung, js/seed.js 138 s) cho eviction, nén, compact, ticket; tmp-mdb-j (collection nhỏ, bị docker kill nhiều lần) cho journal, recovery, MVCC, transaction lớn; tmp-mdb-sa standalone; mongod rỗng tạm (9.0.2, 8.0.32) đọc cache mặc định. Client tải: Node 22 + driver mongodb 7.7.0 (node/06-*.mjs); còn lại mongosh (js/06-*.js).

cd labs/mongodb
./06-wiredtiger.sh              # chạy hết (~35 phút): seed params cachedef evict jbytes journal recovery mvcc toolarge compress compact tickets
./06-wiredtiger.sh journal      # 1 phần
(source lib.sh && mdb_down tmp-mdb-1 tmp-mdb-j)   # dọn container

Bẫy khi chạy lại: setParameter: {syncdelay: 3600} lúc chạy chỉ có hiệu lực sau lần checkpoint đang chờ (muốn tắt hẳn: khởi động với --syncdelay); journalCommitInterval, wiredTigerEngineRuntimeConfig đặt lúc chạy mất sau restart; lệnh đầu tiên của driver Node 7.7 từng chết với MongoTopologyClosedError: Topology is closed khi serverSelectionTimeoutMS: 1500 — đã nâng lên 5 000 ms; timestamp 64 bit trong serverStatus về Node là number: giây = Math.floor(x / 2**32); "history store table on-disk size" chỉ cập nhật sau checkpoint; biến thể cache 1 GB cần docker update --memory 2g rồi trả về 1g.

Chưa verify: mất điện thật (SIGKILL không xoá page cache của OS; đĩa có cache ghi không chống mất điện còn làm hỏng cả j:true); history store và checkpoint khi majority commit point đứng yên (PSA — bài 7); cache pressure monitor tự huỷ transaction (PeriodicThreadToRollbackUnderCachePressure, chỉ đọc README); vì sao cửa sổ mất của w:1 vượt 50 ms dù docs WiredTiger nói log_server đẩy slot xuống OS mỗi 50 ms (đo được, chưa đọc tới source); đọc đĩa lạnh thật cho phần nén (không xoá được page cache trên máy dùng chung); số đo ở RAM, đĩa và số core lớn hơn; Atlas.