WiredTiger: движок хранения вживую

WiredTiger — не LSM, а B-tree с MVCC: снапшоты, checkpoint, journal и кеш с dirty/eviction вживую, с контрастом к PostgreSQL heap+btree и разбором, где заканчивается WAL и начинается специфика Mongo

Под капотом MongoDB — не самописный движок и не LSM-дерево, а WiredTiger: B-tree-хранилище документов с MVCC-снапшотами, которое по духу ближе к PostgreSQL, чем к Cassandra-подобным LSM-системам, но устроено иначе в деталях. Здесь — как WiredTiger версионирует данные без блокировок на чтение, что такое checkpoint и journal в его исполнении, и как ведёт себя кеш под нагрузкой: dirty-страницы, eviction, wiredTigerCacheSize. Флагманская статья «внутренностей» серии — метрики наблюдаются на живом стенде, а не берутся из документации.

Машинный зал WiredTiger: маскот-лист указывает на светящееся B-дерево (табличка «LSM-дерево — не мы» перечёркнута); панель CACHE с грязными (dirty) и чистыми (clean) страницами, клапан CHECKPOINT сбрасывает грязные страницы в дисковый барабан, лента JOURNAL; манометр «Латентность 9мс→40мс», табличка «WiredTiger приобретён в 2014, авторы Berkeley DB»

В статье

Document storage на B-tree, а не на LSM

Первая статья серии — документная модель и схема-дизайн — закончилась на том, что документ при обновлении переписывается целиком, а рост массива внутри документа упирается в жёсткий лимит 16 МиБ. Там это выглядело как свойство модели. На самом деле это следствие того, как физически устроен движок хранения. Пора спуститься на уровень ниже.

MongoDB с версии 3.2 использует WiredTiger — движок хранения, который Mongo не написала сама, а приобрела (компания WiredTiger, 2014) вместе с его авторами, теми же людьми, что стояли за Berkeley DB. Это важно для понимания родословной: WiredTiger — не эксперимент, а зрелая встраиваемая библиотека хранения общего назначения, и MongoDB — лишь один из её потребителей.

Ключевой факт, который ломает распространённое заблуждение: WiredTiger хранит документы в B-дереве, а не в LSM-дереве. MongoDB в массовом сознании стоит рядом с Cassandra и другими «NoSQL», а те почти поголовно на LSM. Но WiredTiger по умолчанию (type=file, конфигурация MongoDB) — это классическое B+-дерево на диске с кешем страниц в памяти, ровно по духу того, как устроены PostgreSQL, MySQL InnoDB или SQLite. У WiredTiger есть LSM-режим, но MongoDB его не использует — коллекции и индексы лежат в B-деревьях.

Что это меняет на практике:

  • Обновление — это read-modify-write страницы B-дерева, а не append нового значения в лог. Именно поэтому в первой статье рост документа давал плавно растущую латентность на батч (9 мс → 40 мс по мере разбухания документа): каждое обновление перечитывает и переписывает весь документ на его странице. У LSM-движка та же запись была бы дешёвым append’ом в memtable с отложенным слиянием.
  • Чтение по ключу — это спуск по дереву от корня к листу, обычно 2–4 обращения к страницам, большинство из которых уже в кеше. Нет «прочитать из N SST-файлов и слить», как у LSM.
  • Нет tombstone’ов и фонового compaction в LSM-смысле. Удаление помечает запись в дереве, место освобождается при последующих операциях и checkpoint’ах, а не отдельным процессом слияния уровней.

Каждая коллекция и каждый индекс — это отдельный B-дерево-файл WiredTiger (collection-*.wt, index-*.wt в dbPath). Документы внутри коллекции хранятся по возрастанию RecordId (внутренний 64-битный идентификатор записи), а _id-индекс и все прочие индексы — отдельные B-деревья, отображающие ключ → RecordId. Это ровно та же двухуровневая косвенность, что и «heap + btree» в PostgreSQL, только heap у WiredTiger — тоже B-дерево (по RecordId), а не куча в прямом смысле. К этому контрасту вернёмся ниже.

MVCC и снапшоты: как читатели не блокируют писателей

WiredTiger реализует MVCC (multi-version concurrency control) — многоверсионность, при которой читатель никогда не ждёт писателя и наоборот. Это то же семейство механизмов, что и в PostgreSQL, но реализованное иначе.

Каждая операция записи создаёт новую версию значения, привязанную к точке во времени. WiredTiger оперирует понятием snapshot: в момент старта транзакции (а любое чтение в MongoDB — это транзакция WiredTiger, даже одиночный find) движок фиксирует снимок — согласованное состояние всех данных на этот момент. Читатель видит ровно те версии, что были зафиксированы до его снапшота, и не видит незавершённых или более поздних записей. Писатель, меняющий ту же запись, создаёт новую версию в структуре update-цепочки, не трогая ту, которую читает снапшот.

Версии в WiredTiger живут не в самих страницах на диске, а в update-структурах в памяти — цепочках модификаций, привязанных к записи. Когда страница чистая (clean) на диске и приходит обновление, WiredTiger не переписывает диск немедленно — он строит в кеше update-цепочку поверх страницы, помечая её dirty. Эти незафиксированные на диск изменения и есть основной источник dirty-байтов, которые мы будем наблюдать на стенде.

Важное отличие от PostgreSQL: у PG старые версии строк (dead tuples) живут прямо в heap-странице и вычищаются VACUUM. У WiredTiger версии — в памяти, в update-цепочках; на диск через checkpoint попадает только актуальная (для durable history — с учётом history store, отдельного B-дерева WiredTigerHS.wt, где движок держит старые версии, нужные ещё живым снапшотам и для отката). Поэтому в MongoDB нет ничего похожего на VACUUM как ручную/фоновую операцию над bloat’ом версий — управление историей версий встроено в сам движок.

Практическое следствие MVCC для разработчика: длинная читающая операция (например, тяжёлый агрегейт или курсор, который клиент медленно вычерпывает) удерживает свой снапшот, а значит удерживает старые версии от очистки. На загруженной системе это давит на кеш и history store. Но это не блокировки — конкурентные записи проходят, просто история версий растёт, пока снапшот жив.

Compression: snappy против zstd

WiredTiger сжимает данные на нескольких уровнях, и это одно из самых заметных отличий от «наивного» хранения BSON один-в-один.

  • Сжатие блоков коллекций — по умолчанию snappy. Каждая страница данных (block) сжимается перед записью на диск. Snappy — быстрый алгоритм с умеренной степенью сжатия; выбран по умолчанию как компромисс «CPU против места».
  • Альтернатива — zstd (с MongoDB 4.2). Сжимает сильнее snappy при сопоставимой или чуть большей нагрузке на CPU; разумный выбор для «холодных», редко читаемых, но объёмных коллекций, где экономия диска важнее пары процентов CPU. Задаётся при создании коллекции: db.createCollection("logs", {storageEngine: {wiredTiger: {configString: "block_compressor=zstd"}}}).
  • Есть ещё zlib — сжимает примерно как zstd, но заметно медленнее; практически вытеснен zstd.
  • Индексы сжимаются отдельно — prefix compression (не блочные компрессоры). Соседние ключи в B-дереве индекса делят общий префикс, который хранится один раз. Для составных и строковых индексов это даёт существенную экономию, не трогая CPU-бюджет блочных компрессоров.

Тонкость, важная для интерпретации метрик кеша: сжатие работает на диске, но НЕ в кеше. Страницы в WiredTiger-кеше лежат в распакованном виде (плюс служебные структуры B-дерева и update-цепочки). Поэтому «100 МБ payload на вставку» превращаются в кеше в заметно больший объём — это мы прямо увидим на стенде. И наоборот: на диске (и в журнале) те же данные окажутся сжатыми. Логический размер BSON-документа ($bsonSize из первой статьи, где заказ в среднем 380 байт) — это ещё не то, что займёт диск после snappy, и не то, что займёт кеш до сжатия.

WiredTiger: кеш (dirty / clean) → checkpoint → диск, журнал сбокуwrite-нагрузка100k×800BWiredTiger cache (распакованные страницы)dirty bytes: 292 → 507 МБ под записьюcheckpointfsyncДиск: B-деревья (snappy)clean-страницы (на диске)checkpoints: 4 → 5tracked dirty bytes → 0резидентный объём кеша не падаеткаждая записьjournal (WAL): 34 → 40.5 МБрастёт при каждой записи, до всякого checkpointdirty — изменена, ещё не на дискеclean — совпадает с диском

Checkpoints и journal: механика фиксации на диск

Здесь сходятся два разных механизма долговечности, которые постоянно путают. У них разные роли.

Checkpoint — это согласованный снимок всех B-деревьев на диск. WiredTiger периодически (по умолчанию — раз в 60 секунд или при накоплении 2 ГБ журнала, что раньше) сбрасывает все накопленные в кеше dirty-страницы на диск и фиксирует новую «точку отсчёта», от которой при рестарте можно восстановиться, не проигрывая журнал с начала времён. Checkpoint атомарен: пока он строится, старая версия остаётся валидной; переключение на новую — одно атомарное действие. После успешного checkpoint’а все страницы, что были dirty, становятся clean — их содержимое теперь на диске.

Journal (WAL WiredTiger) — это write-ahead log для durability между checkpoint’ами. Checkpoint раз в минуту означает: если сервер упадёт через 59 секунд после checkpoint’а, минута записей потеряна — если бы не журнал. Каждая зафиксированная запись сначала попадает в журнал (последовательная дозапись в файл journal/WiredTigerLog.*), и только потом считается durable. При рестарте после сбоя WiredTiger берёт последний checkpoint и проигрывает журнал от него до конца — восстанавливая записи, не успевшие попасть в checkpoint. Журнал сжимается (snappy) и по умолчанию сбрасывается на диск группами (group commit) — по умолчанию MongoDB подтверждает журнал каждые 100 мс либо при j:true write concern немедленно.

Ключевое различие в одной фразе: journal растёт при КАЖДОЙ записи и обеспечивает durability до следующего checkpoint; checkpoint периодически материализует состояние на диск и позволяет обрезать журнал. На стенде мы увидим оба движения по отдельности: журнал прирастает под write-нагрузкой до всякого checkpoint’а, а форсированный checkpoint отдельным действием сбрасывает dirty-страницы.

Где journal WiredTiger — это уже частный случай общего паттерна write-ahead logging, а где — специфика Mongo, разбираем в отдельной границе ниже.

Кеш вживую: dirty, eviction, wiredTigerCacheSize

WiredTiger-кеш — это область памяти, где живут распакованные страницы B-деревьев, update-цепочки MVCC и служебные структуры. Размер по умолчанию: max(50% (RAM − 1 ГБ), 256 МБ) — то есть на машине с 16 ГБ RAM кеш возьмёт ~7.5 ГБ. Задаётся явно через storage.wiredTiger.engineConfig.cacheSizeGB (или --wiredTigerCacheSizeGB). Это не весь объём памяти процесса mongod — сверх кеша идут: filesystem cache ОС (сжатые страницы с диска), буферы соединений, служебная память агрегаций и сортировок. Типичная ошибка — отдать кешу почти всю RAM: тогда ОС нечем кешировать сжатые блоки, а сортировкам/соединениям не хватает памяти.

Внутри кеша страница живёт в одном из состояний:

  • clean — содержимое совпадает с диском (в последнем checkpoint’е). Можно выбросить из кеша без записи.
  • dirty — есть незафиксированные на диск изменения (update-цепочки). Прежде чем выбросить, нужно записать (reconcile) на диск.

Eviction — процесс вытеснения страниц из кеша, чтобы освободить место под новые. WiredTiger запускает eviction не «когда кеш полон», а по порогам занятости (в процентах от cache_size), с раздельными порогами для общей занятости и для доли dirty:

  • eviction_target (по умолчанию 80%) — фоновые eviction-потоки начинают мягко вытеснять, когда занятость кеша переходит эту черту.
  • eviction_trigger (по умолчанию 95%) — при достижении к eviction подключаются сами прикладные потоки (application threads начинают «платить» за место, это уже видно как рост латентности).
  • eviction_dirty_target (по умолчанию 5%) и eviction_dirty_trigger (по умолчанию 20%) — те же пороги, но по доле именно dirty-страниц: даже при незаполненном кеше слишком много грязи запускает вытеснение с записью на диск.

Различать eviction и checkpoint критично, потому что оба «пишут dirty-страницы на диск», но по-разному:

  • Checkpoint пишет dirty-страницы на диск и помечает их clean, оставляя в кеше. Резидентный объём кеша при этом почти не меняется.
  • Eviction пишет dirty-страницу (если она грязная) и удаляет её из кеша — резидентный объём падает, место освобождается под новые страницы.

Метрики обоих механизмов лежат в db.serverStatus().wiredTiger, секции cache, log, checkpoint. Именно их снимал стенд.

Что показал стенд: dirty падает в ноль, а eviction не сработал

Стенд mongodb/wiredtiger поднимает реальный replica set (mongo:8.2.11, 3 узла), импортирует сквозной датасет серии (seed=42: 50 000 пользователей, 5000 товаров, 200 000 заказов) и снимает serverStatus().wiredTiger в четырёх точках: до нагрузки → после read-нагрузки → после write-нагрузки → после форсированного checkpoint (db.adminCommand({fsync:1})). Каждый снимок снят явно с primary — кеш, журнал и checkpoint это ресурс конкретного узла, он не реплицируется.

Реальные строки стенда (Go stdout, дословно):

FIXTURE wiredtiger: phase=before bytes_in_cache=292397591 dirty_bytes=292365910 modified_evicted=33 unmodified_evicted=0 pages_requested=521039 log_bytes_written=34064256 checkpoints_succeed=4
FIXTURE wiredtiger: phase=after_read_load bytes_in_cache=292397591 dirty_bytes=292365910 modified_evicted=33 unmodified_evicted=0 pages_requested=521062 log_bytes_written=34064256 checkpoints_succeed=4
FIXTURE wiredtiger: read_load_docs=200000 read_load_latency=323.486838ms
FIXTURE wiredtiger: phase=after_write_load bytes_in_cache=507145699 dirty_bytes=507114017 modified_evicted=57 unmodified_evicted=0 pages_requested=725253 log_bytes_written=40548864 checkpoints_succeed=4
FIXTURE wiredtiger: write_load_docs=100000 write_load_payload_bytes=800 write_load_latency=946.328636ms
FIXTURE wiredtiger: phase=after_checkpoint bytes_in_cache=507524147 dirty_bytes=0 modified_evicted=57 unmodified_evicted=0 pages_requested=727126 log_bytes_written=40556160 checkpoints_succeed=5
FIXTURE wiredtiger: fsync_latency=689.971877ms

Те же числа по фазам, по метрикам serverStatus().wiredTiger:

Метрика (cache/log/checkpoint) before after_read after_write after_checkpoint
bytes currently in the cache 292 397 591 292 397 591 507 145 699 507 524 147
tracked dirty bytes in the cache 292 365 910 292 365 910 507 114 017 0
pages requested from the cache 521 039 521 062 725 253 727 126
log bytes written 34 064 256 34 064 256 40 548 864 40 556 160
total succeed number of checkpoints 4 4 4 5

Разберём по шагам, что каждое движение означает.

Read-нагрузка ничего не пачкает. Полный скан 200 000 документов orders (проекция _id+status, 323 мс) поднял только pages requested from the cache (521 039 → 521 062) — чтение всегда запрашивает страницы у кеша, даже когда кеш уже тёплый после импорта. Ни bytes in cache, ни dirty bytes не выросли ни на байт: read-only обращения не создают новых версий, только заново проходят через уже резидентные страницы. Это ровно то, что MVCC обещает — читатель не пишет.

Write-нагрузка растит и dirty, и журнал — причём журнал до всякого checkpoint. Bulk-вставка 100 000 документов (~800 байт payload каждый) в отдельную коллекцию подняла tracked dirty bytes с 292 365 910 до 507 114 017 (×1.7) и log bytes written с 34 064 256 до 40 548 864 (+6 484 608 байт). Обратите внимание: checkpoints_succeed при этом остался 4 — то есть журнал прирос на ~6.5 МБ в том же батче записи, без единого checkpoint. Это и есть write-ahead log в действии: durability обеспечивается журналом сразу при записи, checkpoint здесь ни при чём.

Ещё одна деталь для интерпретации: 100 000 × 800 байт ≈ 80 МБ полезной нагрузки, а dirty-байты выросли на ~215 МБ. Разница — BSON-оверхед, _id-индекс, служебные структуры B-дерева и то, что в кеше страницы распакованы (сжатие — только на диске, см. раздел про compression). А журнал прирос всего на ~6.5 МБ на те же данные — потому что журнал сжат (snappy) и пишет операции, а не распакованные страницы.

Форсированный checkpoint сбрасывает всю грязь в ноль — это флаш, а не eviction. db.adminCommand({fsync:1}) (689 мс) поднял total succeed number of checkpoints с 4 до 5 и уронил tracked dirty bytes с 507 114 017 до 0 — checkpoint записал все накопленные dirty-страницы на диск разом. Критично: bytes currently in the cache при этом почти не изменился (507.1M → 507.5M, даже чуть вырос). Это и есть отличие checkpoint от eviction: страницы записаны на диск и помечены clean, но остались в кеше. Резидентный объём кеша не упал — а упал бы, если бы страницы вытеснялись. Checkpoint флашит, eviction выселяет; здесь мы наблюдали именно флаш.

Честная находка: eviction на этом прогоне не сработал. Счётчики вытеснения почти не двигались: unmodified pages evicted весь прогон — 0, modified pages evicted вырос лишь с 33 до 57 за всю write-нагрузку (на 24 страницы — несопоставимо мало с объёмом записанных ~215 МБ dirty). Причина не в том, что «eviction не нужен», а в том, что нагрузка не задела пороги (eviction_target 80% / eviction_dirty_trigger 20%): на этом хосте (Docker Desktop, дефолтный cache_size от свободной памяти контейнера) ~215 МБ роста кеша за один прогон не хватило, чтобы перевалить пороги и запустить активное вытеснение. То есть eviction этот стенд не продемонстрировал — и это записано честно, как факт, а не как «eviction не важен». На большем объёме нагрузки или меньшем cacheSizeGB он стал бы заметным; здесь пороги просто не пройдены. Мы наблюдали жизненный цикл dirty-страницы (создание записью → флаш checkpoint’ом), но не её вытеснение под давлением памяти.

Стоит отметить и что WiredTiger вёл себя ровно по учебнику: dirty растёт под записью и падает в ноль после checkpoint, журнал растёт при записи независимо от checkpoint. Никаких расхождений с ожиданием — в отличие от демонстрации роста документа в первой статье, где всплыл миф про «перемещение записи» (у WiredTiger его нет: каждое обновление — read-modify-write всего документа в B-дереве).

Контраст: WiredTiger B-tree против PostgreSQL heap+btree

Сквозная нить серии — сравнение с PostgreSQL. На уровне движка контраст особенно предметный, потому что обе системы — B-tree-миры с MVCC, но с разной физикой.

Аспект WiredTiger (MongoDB) PostgreSQL
Первичное хранение B-дерево по RecordId (сам «heap» — тоже дерево) heap-файл (неупорядоченная куча строк по ctid)
Первичный индекс _id-индекс — отдельное B-дерево → RecordId нет «встроенного» PK; PK — обычный btree → ctid
Где живут старые версии в памяти (update-цепочки) + history store WiredTigerHS.wt прямо в heap-странице (dead tuples)
Очистка версий встроена в движок, нет ручного аналога VACUUM VACUUM/autovacuum вычищает dead tuples и bloat
Durability между checkpoint journal (WiredTiger WAL), snappy-сжат WAL, synchronous_commit
Материализация на диск checkpoint (по умолчанию ~60 с) checkpoint (checkpoint_timeout, обычно 5 мин)
Сжатие «из коробки» snappy на блоки + prefix на индексы нет блочного сжатия по умолчанию (только TOAST для крупных значений)
Обновление строки/документа read-modify-write записи в B-дереве новая версия строки (HOT-update при возможности), старая → dead

Два следствия, которые чаще всего кусают на практике:

  • «Куча» у Mongo упорядочена, у PG — нет. У WiredTiger записи лежат по RecordId в дереве; у PostgreSQL heap — куча, физический порядок строк произволен, а упорядоченность обеспечивают индексы (или CLUSTER разово). Это разные модели локальности данных.
  • Управление bloat’ом версий — принципиально разное. У PG dead tuples копятся в самих страницах и требуют VACUUM; забыл настроить autovacuum под нагрузку — получил раздувшиеся таблицы и индексы. У WiredTiger история версий живёт в памяти и history store, чистится движком, отдельной эксплуатационной ручки «провакуумить» нет. Это не «лучше/хуже» — это другой набор забот администратора.

Первая статья уже показала цифровой срез этого контраста на прикладном уровне: тот же документ заказа занял в среднем 380 байт BSON против 575 байт jsonb в PG, а единичное чтение по ключу оказалось быстрее в PostgreSQL (175.6 µs против 264.7 µs на одном хосте) — почему это «не всё так просто», разобрано в статье про документную модель.

Граница: journal WiredTiger против WAL в общем

Journal WiredTiger — это конкретная реализация общего паттерна write-ahead logging: сначала пиши намерение в последовательный лог, потом применяй к основным структурам; при сбое проигрывай лог с последней контрольной точки. Тот же паттерн — WAL в PostgreSQL, redo log в MySQL InnoDB, commit log в Cassandra/ScyllaDB.

Здесь проходит граница статьи. Всё, что общее для WAL как класса — зачем нужен последовательный лог, что такое group commit, как соотносятся долговечность и производительность, fsync и синхронность подтверждений, — разбирается в отдельной статье «WAL: что это и почему на нём держится надёжность» и её продолжениях про WAL в разных базах. Там же — прямой аналог synchronous_commit PostgreSQL и того, как разные СУБД торгуют латентность за durability.

Специфика именно Mongo/WiredTiger, которую держим здесь: журнал сжат snappy и пишется группами по умолчанию каждые 100 мс; per-write durability управляется write concern (j:true форсирует немедленный флаш журнала); checkpoint раз в ~60 с (против ~5 мин у PG) и его связка с обрезкой журнала. Всё остальное про WAL как таковой — по ссылке, чтобы не переписывать здесь целую отдельную статью.

Граница: B-tree против LSM в целом

Мы начали с того, что WiredTiger в конфигурации MongoDB — это B-дерево, а не LSM. Полное сравнение двух семейств движков — почему LSM выигрывает на write-heavy нагрузках, что такое write/read/space amplification, зачем нужен compaction и tombstone’ы, где B-tree отвечает предсказуемее — это тема отдельной статьи B-tree против LSM: как устроены движки храненияСкоро, чтобы не растекаться здесь.

Коротко, только чтобы поставить WiredTiger на карту: LSM-движки (RocksDB, движок Cassandra/ScyllaDB) превращают запись в дешёвый append в memtable с отложенным слиянием на диск, платя за это чтением из нескольких SST-файлов и фоновым compaction. B-tree (WiredTiger, InnoDB, PostgreSQL) платит за запись сразу — read-modify-write страницы, — но даёт предсказуемое чтение спуском по дереву и не требует compaction. То самое разбухание латентности при росте документа из первой статьи — прямое следствие B-tree-природы WiredTiger; на LSM тот же паттерн вёл бы себя иначе (и упирался бы в другие потолки).

Дальше в серии — индексы вглубь: как те самые отдельные B-деревья индексов участвуют в планах запросов, что такое multikey, ESR-порядок полей, covered queries и partial-индексы — и что показывает explain на живых данных.

Обсуждение в Telegram

Присоединиться →

Комментарии