WiredTiger (P3/3): Prefix compression, chọn compressor

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

Ở phần trước: zstd cho file nhỏ nhất, 93,8 MiB so với 168,5 MiB của snappy, nhưng CPU lúc ghi gấp 2,1 lần snappy. Cái giá CPU đó đo được khi nạp 1 triệu document.

  • Cần đọc trước: Ba kiểu nén và bốn lab đo
  • Dẫn tới: Cache, Eviction & Working Set, bài tiếp theo. Phần này là phần cuối của bài WiredTiger & Compression.

Nén không làm cache nhỏ đi

Đây là hiểu nhầm đắt nhất, và lab đã có bằng chứng. Sau khi mọi thứ nằm trong cache, bytes currently in the cache của collection orders:

snappyzstdzlibnone
Trên đĩa (storageSize)168,5 MiB93,8 MiB109,1 MiB457,6 MiB
Trong WiredTiger cache494,1 MiB492,9 MiB492,9 MiB495,8 MiB

Cache của collection gần như không đổi: 492,9 đến 495,8 MiB, trong khi trên đĩa none (457,6 MiB) lớn gấp 4,9 lần zstd (93,8 MiB). Với zstd, một collection chiếm 93,8 MiB trên đĩa phải có 492,9 MiB trong cache, gấp 5,3 lần; với snappy là 2,9 lần, và cũng lớn hơn 12% so với dataSize BSON (494,1 so với 440,4 MiB, [suy luận] phần chênh là cấu trúc quản lý của page trong bộ nhớ). Cache 1 GB của lab chứa trọn collection này cộng index _id_ (khoảng 26,5 MiB trong cache) mà vẫn còn dư, nhưng chỉ vì collection nhỏ. [tài liệu] Manual MongoDB nói đúng điều này: dữ liệu collection trong WiredTiger cache không nén và có biểu diễn khác dạng trên đĩa.

Hệ quả: dung lượng trên đĩa, nén hay không, không cho biết cần bao nhiêu RAM. Compressor mạnh làm nhỏ đĩa, I/O và bản backup, nhưng không làm working set vừa cache dễ hơn; câu "working set có vừa cache không" là chủ đề của bài Cache, Eviction & Working Set.

Lab 5: prefix compression cho index

Hai collection refs_on và refs_off chứa cùng 1.000.000 document nhỏ (_id, ref, traceId, tenantId, n) với ba index giống nhau; ở refs_off mỗi index tạo với configString: "prefix_compression=false" (e5-prefix-build.out). Ba loại key cho ba đầu của phổ:

  • ref: "https://shop.example.vn/api/v2/orders/2026/10/" (46 ký tự giống nhau) cộng 8 chữ số tăng dần.
  • traceId: 32 ký tự hex ngẫu nhiên, gần như không có prefix chung.
  • tenantId: "t0000" đến "t0499", 500 giá trị lặp lại 2.000 lần mỗi giá trị.

Kích thước trên đĩa, và lượng cache index chiếm sau khi quét hết nó một lần từ cache trống (e5b-prefix-cache.out, container snappy vừa restart):

IndexĐĩa, prefix bậtĐĩa, prefix tắtTắt / bậtCache, bậtCache, tắt
ref_1 (prefix dài)12,28 MiB65,96 MiB5,4 lần20,28 MiB72,99 MiB
tenantId_1 (500 giá trị lặp)7,82 MiB13,57 MiB1,7 lần15,91 MiB21,56 MiB
traceId_1 (ngẫu nhiên)39,09 MiB42,34 MiB1,08 lần46,58 MiB49,78 MiB
  1. Tác dụng khác nhau rất xa tuỳ key. Key có tiền tố chung dài (URL, đường dẫn, mã có tiền tố, dãy tăng dần) co 5,4 lần; key lặp lại co 42%; key ngẫu nhiên chỉ co 8%.
  2. Nó làm nhỏ cả cache, đúng như tài liệu (ref_1: 20,3 so với 73,0 MiB), khác với block compression ở lab trước.
  3. Dạng trong cache lớn hơn dạng trên đĩa ngay cả khi có prefix compression (20,3 so với 12,3 MiB): hai dạng của page là hai dạng thật.

Tốc độ. Tài liệu WiredTiger cảnh báo prefix compression làm duyệt ngược và tra key ngẫu nhiên tốn thêm CPU và bộ nhớ. Lab không thấy khác biệt ổn định: quét covered toàn index khi cache warm, 9 lần xen kẽ, median 228 ms (bật) so với 226 ms (tắt) cho ref_1, 224 so với 224 ms cho traceId_1; quét ngược 227 so với 230 ms và 234 so với 223 ms (e5c-prefix-time.out, e5d-prefix-reverse.out). 20.000 lần tra ngẫu nhiên theo ref cho 2,998 s (bật) so với 3,789 s (tắt) nhưng dao động giữa các pass từ 2,7 đến 6,3 s (máy bận), nên không kết luận được gì. Tôi không có bằng chứng prefix compression chậm hơn ở quy mô này, và cũng không có bằng chứng ngược lại.

Lab 6: đổi compressor thì dữ liệu cũ thế nào

Hai thí nghiệm cho hai câu hỏi hay gặp.

Đổi cấu hình toàn cục có nén lại dữ liệu cũ không? Dừng container zlib, mở lại cùng volume bằng một mongod mới với --wiredTigerCollectionBlockCompressor none, rồi tạo collection mới và nạp 200.000 document (e7b-global-change.out):

setting now: {"blockCompressor":"none"}
{"coll":"orders","block_compressor_in_creationString":"zlib","count":1000000,"storageSizeBytes":114372608}
{"coll":"after_change","block_compressor_in_creationString":"none","count":200000,"storageSizeBytes":94892032}
old zlib blocks still readable: {...} count 99507     (countDocuments({ status: "pending" }) trên collection zlib)

orders vẫn mang block_compressor=zlib và vẫn 109,1 MiB; collection mới mang none. Dữ liệu cũ đọc được bình thường: [tài liệu] manual nói collection cũ "tiếp tục dùng compressor đã chọn khi được tạo"; tài liệu WiredTiger nói block trong file không ghi compressor nào đã nén nó, nên compressor phải được lấy từ cấu hình của table. [quan sát] đúng như vậy: creationString của orders vẫn là zlib.

Muốn nén lại dữ liệu cũ thì làm gì? Tạo collection mới với compressor mong muốn rồi copy dữ liệu sang. Từ collection snappy 200.000 document sang một collection tạo với configString: "block_compressor=zstd", bằng $merge (e7-override.out):

storageSize
c_regular (snappy)32,73 MiB
c_zstd (copy của nó, tạo với block_compressor=zstd)18,64 MiB

Copy 200.000 document mất khoảng 0,86 giây ở lab này. Với collection cỡ trăm GB đây là một việc vận hành lớn (thêm đĩa tạm, thêm ghi, chuyển ứng dụng sang tên mới), không phải "đổi một cờ". Đó là lý do nên đo trên mẫu trước: copy 1–5% dữ liệu thật sang bốn collection nhỏ, mỗi collection một compressor, so storageSize và CPU; ở quy mô nhỏ, đây là cách rẻ nhất để biết tỉ lệ nén của dữ liệu của bạn.

Chọn compressor thế nào

Từ lab 1 triệu đơn trên một máy (đều là [quan sát] ở bộ dữ liệu này):

  • snappy (mặc định) là điểm cân bằng: nén 2,6 lần với gần như không tốn CPU thêm so với không nén.
  • zstd cho đĩa nhỏ nhất ở đây, đổi lại CPU gấp đôi lúc ghi và COLLSCAN chậm gấp rưỡi khi page phải nạp từ file vào cache. Hợp với dữ liệu ít đọc, nơi đĩa hoặc backup là chi phí chính và CPU còn dư. [tài liệu] Manual đặt zstd làm mặc định cho time-series collection.
  • zlib ở đây vừa lớn hơn zstd vừa tốn CPU hơn. Lab này không cho lý do để chọn nó, nhưng đó là kết quả của dữ liệu này, đừng khái quát.
  • none chỉ hợp khi dữ liệu vốn đã nén hoặc đĩa cực nhanh và CPU cực hiếm; nhớ rằng nó lớn hơn dataSize.

Quy trình đáng tin: (1) copy mẫu dữ liệu thật vào bốn collection, mỗi collection một compressor; (2) so storageSize; (3) đo CPU khi nạp và khi quét cold; (4) tính lại với tốc độ đĩa của bạn; (5) chỉ khi đó đổi cấu hình, biết rằng nó chỉ áp cho collection mới.

So với PostgreSQL

Cùng bài toán: lưu 1 triệu đơn hàng. [tài liệu] PostgreSQL lưu mỗi bảng (gọi là heap) và mỗi index như một dãy page kích thước cố định (thường 8 kB, chọn lúc biên dịch server); một dòng có thể nằm ở bất kỳ page nào của bảng (mọi page của bảng tương đương nhau về logic), và index là cấu trúc riêng trỏ tới vị trí dòng. Giá trị lớn đi đường TOAST: khi một dòng vượt khoảng 2 kB, server nén và/hoặc đẩy giá trị lớn sang bảng phụ; kiểu nén chọn theo từng cột. Durability đến từ WAL: thay đổi vào log trước, vào file dữ liệu sau. Với index B-tree, tài liệu mô tả deduplication cho các key trùng nhau.

So với WiredTiger: collection không phải heap mà là một B-tree theo RecordId (hoặc theo _id nếu clustered), page không cố định kích thước và cấu hình được theo từng table, nén là cả block của page lúc ghi xuống (collection) cộng prefix compression (index). Hai thiết kế giải cùng một nhu cầu bằng những đường khác nhau: TOAST nén từng giá trị lớn, block compression nén từng page, deduplication và prefix compression làm nhỏ index bằng hai kỹ thuật không tương đương. Bài này không đo PostgreSQL, nên không có kết luận "bên nào nhỏ hơn hay nhanh hơn".

Bản đồ: bốn cơ chế còn lại của Phần này

                    page trong RAM
                         │
     ┌───────────────────┼───────────────────┬──────────────────────┐
     ▼                   ▼                   ▼                      ▼
 cache đầy,         ghi log trước,       nhiều phiên bản        nhiều thread
 page nào đi?       checkpoint sau       của cùng một dữ liệu   cùng chạm một page
 [bài về cache]     [bài về journal]     [bài về MVCC]          [bài về concurrency]
  • Cache, Eviction & Working Set: cache chứa gì, clean và dirty page, khi nào page bị evict, vì sao working set quan trọng hơn kích thước database.
  • Journal & Checkpoint: làm sao thay đổi chỉ nằm trong RAM mà sống sót qua mất điện: write-ahead log, checkpoint, phục hồi sau crash.
  • WiredTiger MVCC: update chain trên page, history store (WiredTigerHS.wt trong danh sách file), vì sao snapshot cũ giữ lại các phiên bản.
  • Concurrency: Locks, Latches & Tickets: những thứ chặn nhau thật sự ở từng tầng, và ticket là gì.

Những lỗi thường gặp

  • "Nén làm MongoDB chậm" hoặc "nhanh", không đo. Lab cho cả hai chiều: khi dữ liệu nằm trong cache, compressor không còn liên quan (129 đến 142 µs mỗi point read warm ở cả bốn); khi page phải vào từ file, zlib chậm gấp 2,4 lần none ở COLLSCAN cold (cache WiredTiger trống, file vẫn trong page cache của OS). Chiều thứ ba là đĩa thật, không đo ở đây. Câu trả lời đúng là "tuỳ working set có nằm trong cache không".
  • Nhìn dataSize hoặc storageSize để đoán RAM cần có. Page trong cache không nén: collection 93,8 MiB trên đĩa (zstd) chiếm 492,9 MiB trong cache.
  • Chỉ nhìn collection, quên index. Index (89,3 MiB) bằng 53% collection snappy, không compressor nào chạm vào, và prefix compression làm nó đổi nhiều (5,4 lần cho key có tiền tố dài) hoặc gần như không (8% cho key ngẫu nhiên).
  • Đổi blockCompressor rồi mong dữ liệu cũ nhỏ lại. Cấu hình toàn cục chỉ ảnh hưởng collection và index tạo sau; muốn nén lại phải copy sang collection mới.
  • Lấy tỉ lệ nén của người khác cho dữ liệu của mình. 2,61 lần của snappy là của bộ dữ liệu này (nhiều field lặp, một field hex ngẫu nhiên). Đo trên mẫu.
  • Coi none là "không tốn gì". Nó lớn hơn dataSize 3,9% và đọc 453,5 MiB từ file thay vì 168,5 MiB cho cùng một COLLSCAN.
  • Tin số đo trên Docker laptop như số production. Lab không có độ trễ đĩa thật, chạy chung máy với lab khác, và có lần container bị OOM. Cái đáng tin là hình dạng (CPU tăng theo thứ tự nào, byte từ file giảm bao nhiêu), không phải từng mili giây.

Cột mốc: Bạn đã có thể chọn compressor cho một collection, biết prefix compression giúp index nào, và hiểu vì sao nén không làm cache của collection nhỏ đi. Bài WiredTiger & Compression khép lại ở đây.

Hỏi & đáp

Cùng 1.000.000 đơn, nạp vào hai container: một dùng zstd (93,8 MiB trên đĩa), một dùng snappy (168,5 MiB). Sau khi quét hết, collection chiếm bao nhiêu trong WiredTiger cache?

  1. Gần như bằng nhau, khoảng 493 MiB, vì page trong cache không nén

    Lab đo 492,9 MiB (zstd) và 494,1 MiB (snappy), cả zlib và none cũng 493 đến 496 MiB. Block compression chỉ có trên đĩa. Xem mục "Nén không làm cache nhỏ đi".

  2. Khoảng 94 MiB với zstd và 169 MiB với snappy, vì cache lưu đúng thứ có trên đĩa

    Đó là hiểu lầm phổ biến nhất: cache của collection giữ dạng chưa nén, nên không nhỏ đi theo dung lượng đĩa. Xem bảng ở mục "Nén không làm cache nhỏ đi".

  3. Zstd chiếm ít hơn vì giải nén ít hơn, nhưng không đến mức bằng dung lượng đĩa

    Không có sự giảm nào ở cache: byte đưa vào cache là 448,2 (zstd) so với 448,6 MiB (snappy) trong cùng một COLLSCAN. Giải nén cho ra cùng một dạng. Xem phần Ba kiểu nén và bốn lab đo (Lab 4).

  4. Zstd chiếm nhiều hơn, vì phải giữ thêm bản nén để còn ghi lại

    Cache không giữ thêm bản nén nào; hai con số gần như trùng nhau (492,9 so với 494,1 MiB). Xem mục "Nén không làm cache nhỏ đi".

Bạn cần chọn compressor cho một collection 500 GB, và không biết dữ liệu của mình nén được bao nhiêu. Cách đáng tin cậy nhất?

  1. Lấy tỉ lệ nén từ bài benchmark phổ biến nhất, vì cùng WiredTiger thì cùng tỉ lệ

    Tỉ lệ nén là tính chất của dữ liệu: ở lab, một field hex ngẫu nhiên và nhiều field lặp cho 2,61 (snappy) đến 4,70 (zstd). Dữ liệu của bạn sẽ khác. Xem "Chọn compressor thế nào".

  2. Chọn compressor cho tỉ lệ nén cao nhất vì đĩa là chi phí duy nhất cần tối ưu

    CPU là chi phí thứ hai: zlib ngốn CPU gấp 3 snappy khi nạp và chậm gấp 2,4 lần none ở COLLSCAN cold (cache WiredTiger trống), mà ở lab này còn lớn hơn zstd. Xem phần Ba kiểu nén và bốn lab đo (Lab 3 và Lab 4).

  3. Đổi cấu hình toàn cục sang compressor mới, rồi chờ collection tự được nén lại

    Cấu hình toàn cục chỉ áp cho collection mới. Lab 6: orders vẫn zlib 109,1 MiB sau khi đổi sang none. Xem mục "Lab 6".

  4. Copy 1–5% dữ liệu thật vào bốn collection, mỗi cái một compressor, rồi so kết quả

    Đúng, và nó rẻ: ở lab, copy 200.000 document sang collection block_compressor=zstd mất 0,86 giây và cho 32,7 xuống 18,6 MiB. Sau đó tính lại với tốc độ đĩa của bạn. Xem mục "Chọn compressor thế nào".

Bạn đổi storage.wiredTiger.collectionConfig.blockCompressor từ snappy sang zstd trong file cấu hình rồi restart mongod. Collection orders 500 GB đã có thì sao?

  1. Được nén lại dần bằng zstd khi các page bị ghi lại

    Manual nói collection hiện có tiếp tục dùng compressor đã chọn lúc tạo. Lab 6 kiểm lại: orders vẫn mang block_compressor=zlib sau khi đổi. Xem mục "Lab 6".

  2. Giữ snappy; chỉ collection tạo sau đó dùng zstd

    Đúng với cả manual lẫn lab: collection cũ vẫn đọc được bình thường và giữ nguyên kích thước. Muốn nén lại phải copy sang collection mới tạo với compressor mong muốn. Xem mục "Lab 6: đổi compressor thì dữ liệu cũ thế nào".

  3. Không đọc được nữa vì mỗi block ghi compressor cũ, đến khi chạy lệnh convert

    Không phải: collection cũ vẫn đọc tốt, và cấu hình table của nó giữ compressor đã chọn. Lab 6 đọc count và findOne trên collection zlib sau khi đổi sang none. Xem mục "Lab 6".

Index { ref: 1 } trên 1.000.000 document, mọi key bắt đầu bằng cùng một URL dài 46 ký tự. Tạo nó với prefix_compression=false thay vì mặc định thì điều gì xảy ra ở lab?

  1. Index lớn gấp 5,4 lần trên đĩa (66,0 so với 12,3 MiB)

    Prefix compression lưu phần đầu chung một lần mỗi page. Lab: 12,28 so với 65,96 MiB trên đĩa, và cả trong cache: 20,28 so với 72,99 MiB. Xem "Lab 5: prefix compression cho index".

  2. Không đổi gì, vì index không được nén

    Index không có block compression, nhưng có prefix compression. Với key có tiền tố chung dài tác dụng rất lớn. Xem phần Ba kiểu nén và bốn lab đo (ba kiểu nén).

  3. Index nhỏ hơn, vì không phải lưu thông tin phụ của prefix

    Ngược lại, kích thước tăng 5,4 lần. Với key ngẫu nhiên như traceId chênh lệch chỉ 8% (39,09 so với 42,34 MiB), nhưng vẫn không nhỏ hơn. Xem bảng ở Lab 5.

Nếu bỏ hết thuật ngữ: một kho hàng cất đồ trong thùng, mỗi loại hàng một toà nhà, mỗi toà chia thành các ngăn kệ có bảng chỉ dẫn. Làm việc thì phải khiêng ngăn lên bàn và mở thùng ra; trả về thì đóng thùng lại và cất vào chỗ trống mới. Hút chân không chặt hơn thì kho chứa được nhiều hơn và tiền thuê kho rẻ hơn, nhưng bàn làm việc không rộng ra chút nào, và mỗi lần mở thùng tốn thêm công. Muốn biết nên chọn kiểu nén nào, đừng nghe lời khuyên chung: lấy một ít hàng thật của mình ra thử.

Bài tiếp theo

Bài này nói WiredTiger làm gì với một page, chưa nói chuyện gì xảy ra khi RAM không đủ. Ta đã thấy 493 MiB trong cache cho một collection chỉ 94 MiB trên đĩa; nếu working set to gấp ba cache thì sao? Page nào bị evict (bị bỏ khỏi cache để lấy chỗ)? Dirty page (page đã bị sửa trong cache nhưng chưa ghi xuống đĩa) khác clean page thế nào, và vì sao thread của chính ứng dụng đôi khi bị kéo vào làm việc eviction?

Cache, Eviction & Working Set trả lời: kích thước cache, clean và dirty page, ngưỡng eviction, thread nền và thread ứng dụng, và một lab với working set lớn hơn cache để thấy độ trễ tăng ra sao khi hit chuyển thành miss.

Tài liệu tham khảo