Под капотом MongoDB — не самописный движок и не LSM-дерево, а WiredTiger: B-tree-хранилище документов с MVCC-снапшотами, которое по духу ближе к PostgreSQL, чем к Cassandra-подобным LSM-системам, но устроено иначе в деталях. Здесь — как WiredTiger версионирует данные без блокировок на чтение, что такое checkpoint и journal в его исполнении, и как ведёт себя кеш под нагрузкой: dirty-страницы, eviction, wiredTigerCacheSize. Флагманская статья «внутренностей» серии — метрики наблюдаются на живом стенде, а не берутся из документации.
В статье
- Document storage на B-tree, а не на LSM
- MVCC и снапшоты: как читатели не блокируют писателей
- Compression: snappy против zstd
- Checkpoints и journal: механика фиксации на диск
- Кеш вживую: dirty, eviction,
wiredTigerCacheSize - Что показал стенд: dirty падает в ноль, а eviction не сработал
- Контраст: WiredTiger B-tree против PostgreSQL heap+btree
- Граница: journal WiredTiger против WAL в общем
- Граница: B-tree против LSM в целом
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, и не то, что займёт кеш до сжатия.
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 на живых данных.
Комментарии