WiredTiger (P2/3): Ba kiểu nén và bốn lab đo

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

Ở phần trước: mỗi collection và index là một file .wt chứa một B-tree, và page trong RAM khác page trên đĩa. Block compression chỉ làm nhỏ phần trên đĩa, không làm nhỏ RAM; prefix compression của index là ngoại lệ.

Ba kiểu nén, ba chỗ khác nhau

[tài liệu] Tài liệu WiredTiger liệt kê ba cơ chế và nói rõ cái nào làm nhỏ cả RAM, cái nào chỉ làm nhỏ đĩa:

Cơ chếNén cái gìNhỏ hơn trên đĩaNhỏ hơn trong RAMTrong MongoDB
Prefix compressionphần đầu giống nhau của các key liền nhau, lưu một lần mỗi pagecócóbật mặc định cho index, tắt cho collection
Dictionarygiá trị giống hệt nhau, lưu một lần mỗi pagecócókhông dùng: dictionary=0 [quan sát]
Block compressioncả block nhị phân (một page ở dạng lưu trên đĩa) khi ghi xuống đĩacókhôngsnappy mặc định cho collection; không có cho index

Manual MongoDB nói cùng ý: collection trong WiredTiger cache không nén; index trong cache có biểu diễn khác dạng đĩa nhưng vẫn hưởng lợi của prefix compression để giảm RAM. Hai câu này sẽ được thử bằng lab. Tài liệu WiredTiger cũng cảnh báo block compression "có thể tốn CPU đáng kể".

Cấu hình ở đâu

[tài liệu] Compressor mặc định của collection là snappy, đặt bằng storage.wiredTiger.collectionConfig.blockCompressor (hoặc cờ --wiredTigerCollectionBlockCompressor), giá trị none, snappy, zlib, zstd; với zstd còn có engineConfig.zstdCompressionLevel (mặc định 6, khoảng giá trị đổi ở 8.2: -7 đến 22). Prefix compression của index là storage.wiredTiger.indexConfig.prefixCompression (mặc định true). Journal có compressor riêng (engineConfig.journalCompressor, mặc định snappy), không liên quan tới compressor của collection; journal là chuyện của bài Journal & Checkpoint. Cả hai ghi đè được từng collection lúc createCollection và từng index lúc createIndex qua storageEngine:

db.createCollection("users", { storageEngine: { wiredTiger: { configString: "block_compressor=zlib" } } })
db.orders.createIndex({ ref: 1 }, { storageEngine: { wiredTiger: { configString: "prefix_compression=false" } } })

Điều tài liệu nhấn mạnh, và lab 6 kiểm: đổi giá trị toàn cục chỉ ảnh hưởng collection và index tạo sau đó; cái đã có "tiếp tục dùng compressor đã chọn khi được tạo".

Setup và dataset

MongoDB version : 8.3.11 (mongo:8), WiredTiger 12.0.0 (theo WiredTiger.turtle), mongosh 2.12.0
Hardware        : Apple M4 host, Docker VM ~7,7 GiB; mỗi container --memory=2g --cpus=2
Configuration   : mongod --wiredTigerCacheSizeGB 1 --wiredTigerCollectionBlockCompressor <snappy|zstd|zlib|none>
                  bốn container standalone, mỗi container một volume mới
Dataset         : lab20.orders, 1.000.000 document, trung bình 461 byte (440,4 MiB BSON)
Indexes         : _id_, { tenantId: 1, createdAt: -1 }, { userId: 1 }, { traceId: 1 }, { ref: 1 }

Schema là đơn hàng quen thuộc của series, thêm vài field để dữ liệu có cả phần lặp lại lẫn phần ngẫu nhiên:

{
  _id,                                   // ObjectId tăng dần (12 byte)
  tenantId: "t0042", userId: "u0007",    // 500 và 200 giá trị khác nhau
  status: "completed", createdAt, total, // status: 4 giá trị
  tags: ["gift", "cod"], items: [{ sku, qty, price }, ...],
  shipping: { city: "Đà Nẵng", street: "123 Lê Lợi 4" },   // 12 thành phố, 60 tên đường
  note: "Giao giờ hành chính. Gọi trước khi giao.",         // ghép từ 12 câu cố định
  traceId: "9f3a...",                    // 32 ký tự hex NGẪU NHIÊN (không nén được nhiều)
  ref: "orders/vn/2026/10/00000183"      // tăng dần, tiền tố chung dài
}

Tỉ lệ nén là tính chất của dữ liệu, không phải của compressor. Bộ dữ liệu này nằm giữa hai thái cực: phần lớn field lặp lại (dễ nén), traceId thì ngẫu nhiên (khó nén). Dữ liệu của bạn sẽ cho tỉ lệ khác, ở cuối bài có cách đo trên dữ liệu thật. Cả bốn container nạp đúng cùng một dãy document (sinh bằng seed cố định, _id cũng cố định), 100 lần insertMany mỗi lần 10.000 document, để thời gian chỉ cộng phần insertMany, không cộng thời gian sinh dữ liệu ở mongosh. Index tạo sau khi nạp xong.

Lab 2: dung lượng trên đĩa

Sau khi nạp và fsync (ép checkpoint), đọc $collStats với storageStats (e3-sizes-*.out; MiB = 1.048.576 byte):

CompressordataSize (BSON)storageSize (file)dataSize / storageSizeTổng index
snappy (mặc định)440,4 MiB168,5 MiB2,6189,3 MiB
zstd440,4 MiB93,8 MiB4,7089,3 MiB
zlib440,4 MiB109,1 MiB4,0489,7 MiB
none440,4 MiB457,6 MiB0,9689,3 MiB
  • dataSize là tổng kích thước BSON, giống hệt ở cả bốn container (461.796.477 byte). Chênh lệch ở storageSize là toàn bộ tác dụng của compressor: zstd nhỏ hơn snappy 44%. [quan sát] Ở bộ dữ liệu này zstd (mức mặc định 6) vừa nhỏ hơn zlib vừa rẻ hơn về CPU (lab 3); đừng khái quát sang dữ liệu khác.
  • none lớn hơn cả dataSize (thêm 3,9%): page vẫn có header, căn theo block 4 KB và có chỗ trống chờ tái sử dụng (4,6 MiB). "Không nén" không có nghĩa là "đĩa bằng dữ liệu".
  • Index không đổi: bốn index phụ có kích thước trùng từng byte giữa bốn container (13,87 / 6,27 / 39,09 / 12,24 MiB); chỉ _id_ dao động (17,86 đến 18,24 MiB). Đúng như creationString: compressor của collection không chạm vào index. Và với snappy, index (89,3 MiB) bằng 53% collection (168,5 MiB): tối ưu collection mà quên index là tối ưu một nửa bức tranh.

[quan sát] Bộ đếm nén của WiredTiger: snappy ghi 9.208 lần page, tất cả ở nhóm tỉ lệ nén "smaller than 4"; zstd và zlib ghi khoảng 4.040 lần, tất cả ở nhóm "smaller than 8"; không page nào nén thất bại. Số lần ghi chỉ bằng nửa không phải ngẫu nhiên: xem lab 4.

Lab 3: cái giá lúc ghi

Nạp 1.000.000 document ba lần liên tiếp mỗi container (drop rồi nạp lại), ghi median của tổng thời gian các lệnh insertMany (e2-load-*.out). Rồi ba lần nạp nữa vào một collection tạm chỉ có index _id_, đo CPU của mongod (user_time_us cộng system_time_us trong serverStatus) và ghi median (e2b-cpu.out):

CompressorThời gian insert 1 triệu (median 3 lần)CPU của mongod cho lần nạp + checkpoint (median 3 lần)
snappy9,1 s (khoảng 110.000 doc/s)2,76 s
zstd9,3 s5,86 s (2,1 lần snappy)
zlib9,5 s8,35 s (3,0 lần snappy)
none9,2 s2,82 s

Thời gian insert gần như không đổi (9,1 đến 9,5 s). Ba lần nạp thêm cho khoảng rộng hơn (median 9,2 / 11,3 / 10,1 / 9,6 s, máy bận), nên chỉ kết luận được: trên máy này, ở workload này, compressor không làm latency insert đổi một cách ổn định. CPU thì khác rõ: zlib gấp ba snappy, zstd gấp đôi, snappy và none ngang nhau. Chỉ có 3 lần đo, nhưng khoảng của chúng không chồng lên nhau giữa các nhóm: snappy 2,6–3,0 s, none 2,3–2,9 s, zstd 5,0–6,9 s, zlib 8,2–9,8 s.

[suy luận] Cách giải thích hợp lý: nén diễn ra khi page được ghi xuống (thread nền của WiredTiger, lúc eviction hoặc checkpoint), và cache 1 GB chưa bị áp lực nên thread của client không phải tự đi nén; chi phí hiện ở CPU chứ không ở latency. Tôi không kiểm tra riêng giả thuyết này, và bài về cache cũng không đo chi phí của compressor khi cache bị áp lực. Nó hàm ý rằng khi máy thiếu CPU hoặc cache bị ép, compressor đắt có thể lộ ra thành latency, và đó là chuyện của bài về cache. Bộ đếm bytes written for leaf pages cho thấy lượng việc nén: snappy 470,5 MiB thành 172,5; zstd 470,1 thành 106,6; zlib 468,5 thành 111,6; none 471,6 thành 471,6.

Lab 4: cái giá lúc đọc

  • COLLSCAN với filter không khớp gì ({ "shipping.city": "Nowhere" }, hint({ $natural: 1 })): mọi document phải được giải nén và đọc, không có gì gửi về client.
  • Point read: findOne({ _id }) với _id ngẫu nhiên (5.000 lần trên cold cache, 20.000 lần × 5 pass trên warm cache; thời gian gồm cả vòng đi về của mongosh).
  • Cold cache = ngay sau docker restart: cache WiredTiger còn trống, chưa nạp dữ liệu, nhưng page cache của OS không bị xoá (xem giới hạn bên dưới). Warm cache = dữ liệu đã nằm trong WiredTiger cache.

Giới hạn quan trọng: tôi không xoá page cache của hệ điều hành (nó ảnh hưởng cả máy ảo Docker mà các lab khác đang dùng), nên "cold" chỉ nghĩa là cache WiredTiger trống. File dữ liệu vẫn nằm trong RAM của VM, "đọc đĩa" thực chất là sao chép từ RAM. Lab thấy được chi phí giải nén và số byte phải lấy từ file, nhưng không thấy thời gian chờ đĩa thật.

Median của 3 lần (cold) hoặc 7 lần và 5 pass (warm) (e4-*.out):

snappyzstdzlibnone
COLLSCAN cold: thời gian455 ms684 ms1.098 ms452 ms
COLLSCAN cold: byte đọc từ file168,5 MiB93,3 MiB108,5 MiB453,5 MiB
COLLSCAN cold: byte đưa vào cache448,6 MiB448,2 MiB448,2 MiB449,1 MiB
5.000 point read cold: µs mỗi lần190240349168
cùng 5.000 _id, lần 2 (warm): µs134138147135
COLLSCAN warm: thời gian193 ms204 ms191 ms222 ms
20.000 point read warm: µs mỗi lần136142140129

Dao động của COLLSCAN cold (3 lần): snappy 401–661 ms, none 386–860 ms, zlib 1.081–1.175 ms.

1. Khi dữ liệu đã ở trong cache, compressor không còn liên quan. COLLSCAN warm 191 đến 222 ms, point read warm 129 đến 142 µs: nằm trong dao động của máy (con số 222 ms của none tôi không giải thích được, nên không gán cho none chậm hơn). Page trong cache không nén, nên đọc nó giống nhau bất kể từng được nén bằng gì.

2. Khi page phải vào cache từ file, compressor trả giá bằng CPU theo thứ tự nhẹ đến nặng: none ≈ snappy (455 so với 452 ms) < zstd (1,5 lần none) < zlib (2,4 lần none). Point read cũng vậy: zlib 349 µs, none 168 µs. Byte đưa vào cache gần như bằng nhau (448 đến 449 MiB): giải nén cho ra cùng một dạng.

3. Cái được là số byte lấy từ file: 168,5 / 93,3 / 108,5 / 453,5 MiB. Trên máy này chúng gần như miễn phí vì đến từ RAM của VM; trên đĩa thật thì không. Ghép số byte đo được với hai tốc độ đĩa giả định (phép tính minh hoạ, không đo trên đĩa thật: cộng thời gian I/O với thời gian scan đã đo, coi như không chồng lên nhau):

Tốc độ đọc giả địnhsnappyzstdzlibnone
200 MiB/s (đĩa chậm hoặc nhiều tải)1,30 s1,15 s1,64 s2,72 s
2.000 MiB/s (NVMe nhanh)0,54 s0,73 s1,15 s0,68 s

Bài học nằm ở hình dạng chứ không ở từng ô: đĩa càng chậm thì nén mạnh càng có lợi, đĩa càng nhanh thì CPU giải nén càng lộ ra.

Nén mạnh hơn thì page trong cache to hơn. Chia byte đưa vào cache cho số page đọc: trung bình khoảng 27 KiB mỗi page với none (16.842 page), 50 KiB với snappy (9.274), và 114 KiB với zstd và zlib (khoảng 4.040). Trên đĩa, mỗi page chỉ khoảng 19 KiB (snappy) đến 28 KiB (none, zlib). [tài liệu] Khi bật block compression, "kích thước page đã cấu hình sẽ không khớp với kích thước thật trên đĩa", và lượng dữ liệu đưa vào nén dựa trên kết quả nén trước đó. [quan sát] leaf_page_max=32KB vẫn nằm trong creationString; page trên đĩa nằm dưới mức đó, nhưng một page với zstd hay zlib mở ra trong cache lớn gấp khoảng bốn lần none. Hệ quả thấy được ở point read cold: cùng 5.000 lần đọc đưa vào cache 134 MiB với none, 246 MiB với snappy, 336 và 340 MiB với zstd và zlib. Mỗi point read ngẫu nhiên vào page nén mạnh phải giải nén cả page lớn để lấy một document, và [suy luận] đó là một phần lý do zlib 349 µs, zstd 240 µs, none 168 µs (tôi không tách riêng yếu tố này).

Cột mốc: Bạn đã có thể đọc dung lượng trên đĩa của snappy, zstd, zlib và none, và nói cái giá CPU lúc ghi và lúc đọc của từng compressor. Tiếp theo: Prefix compression, chọn compressor.

Hỏi & đáp

Lab nạp 1 triệu document: thời gian insert chỉ 9,1 đến 9,5 s ở cả snappy, zstd, zlib và none, nhưng CPU của mongod là 2,8 / 5,9 / 8,4 / 2,8 s. Cách giải thích hợp lý nhất?

  1. Compressor chạy trong driver ở client nên insert không đổi, còn server chỉ phải giải nén khi đọc

    Việc nén là của WiredTiger bên trong mongod (CPU mongod tăng theo compressor); driver gửi BSON, không nén page. Xem "Lab 3: cái giá lúc ghi".

  2. Zlib thực ra không tốn CPU hơn; số khác nhau chỉ vì máy bận và bộ đếm CPU không đáng tin

    Ba lần đo cho cùng thứ tự (zlib 8,2 đến 9,8 s, snappy 2,6 đến 3,0 s), khoảng chênh lớn hơn nhiều so với dao động. Xem "Lab 3".

  3. Nén chạy khi page được ghi xuống; cache chưa bị ép nên client không đợi, chi phí hiện ở CPU

    Đây là giải thích [suy luận] trong bài, chưa kiểm tra riêng: nó khớp với dữ liệu và hàm ý rằng khi thiếu CPU hoặc cache bị ép, compressor đắt có thể lộ ra thành latency. Xem "Lab 3".

  4. Nén chỉ chạy một lần ở fsync cuối cùng, nên không nằm trong thời gian insert của client

    fsync sau lần nạp chỉ mất 55 đến 314 ms (rất nhỏ so với 9 s nạp), gợi ý rằng phần lớn dữ liệu đã được ghi xuống, và nén, trong lúc nạp. Xem "Lab 3: cái giá lúc ghi".

Bạn đo lại COLLSCAN khi mọi page đã nằm trong WiredTiger cache (warm), trên bốn collection nén bằng snappy, zstd, zlib và none. Kết quả nào hợp lý nhất theo Lab 4?

  1. zlib vẫn chậm nhất, gấp 2,4 lần none, vì mỗi lần quét phải giải nén lại page

    2,4 lần là số của COLLSCAN cold (1.098 so với 452 ms), khi page phải vào cache từ file. Khi warm, cả bốn đều 191 đến 222 ms. Xem mục "Lab 4: cái giá lúc đọc".

  2. zstd nhanh nhất, vì collection của nó nhỏ nhất trên đĩa (93,8 MiB) nên ít phải đọc hơn cả

    Dung lượng đĩa chỉ quan trọng khi page phải đọc từ file. Khi warm, zstd 204 ms, snappy 193 ms, none 222 ms: nằm trong dao động của máy. Xem mục "Lab 2: dung lượng trên đĩa" và "Lab 4: cái giá lúc đọc".

  3. none nhanh nhất, vì không có gì để giải nén, còn các compressor khác đều trả thêm CPU

    Page trong cache đều không nén, nên none không có lợi thế khi warm; con số 222 ms của none còn là cao nhất và bài không giải thích được nó. Xem mục "Lab 4: cái giá lúc đọc".

  4. Gần như bằng nhau (191 đến 222 ms): page trong cache không nén nên compressor không còn liên quan

    Warm: 193 / 204 / 191 / 222 ms cho snappy / zstd / zlib / none, point read 129 đến 142 µs. Compressor chỉ lộ ra khi page phải vào cache từ file (cold). Xem mục "Lab 4: cái giá lúc đọc".