8. MongoDB под капотом
Устройство WiredTiger, oplog и распределенный шардинг
Введение
В прошлой статье мы разобрали MongoDB с позиции разработчика: модель документа, CRUD-операции, транзакции и паттерны применения. Теперь настало время спуститься на этаж ниже и понять, как именно MongoDB хранит данные на диске, управляет памятью, обеспечивает атомарность и реплицирует состояние. Это знание необходимо Senior Go-инженеру, чтобы прогнозировать поведение системы под нагрузкой и избегать ситуаций, когда база «внезапно» упирается в диск или уходит в латентность.
Все современные версии MongoDB (начиная с 3.2) используют WiredTiger в качестве storage engine по умолчанию. Именно его внутреннее устройство мы и будем препарировать.
WiredTiger: B-Tree, страницы и сжатие
WiredTiger — это самостоятельный storage engine, разработанный ещё до поглощения MongoDB Inc. и оптимизированный для смешанных нагрузок OLTP. Он хранит данные в B+-деревьях, где каждый узел — это страница фиксированного размера (обычно 4–32 КБ).
flowchart TD
Doc["MongoDB Документ"] --> BsonRepr["BSON-представление"]
BsonRepr --> WtWrite["WiredTiger: запись в B-Tree"]
WtWrite --> RamPage["Страница данных в оперативной памяти"]
RamPage --> Compress["Сжатие Snappy/Zstd в памяти"]
Compress --> DiskWrite["Запись на диск в файл коллекции"]
DiskWrite --> WtFile["Файл .wt на диске"]
classDef default fill:#1e293b,stroke:#334155,color:#f8fafc;
classDef wtNode fill:#312e81,stroke:#6366f1,color:#ffffff;
class WtWrite,RamPage,WtFile wtNode;
Структура на диске
- Каждая коллекция и каждый индекс — это отдельное B-Tree в отдельном файле с суффиксом
.wt. - Файл делится на extents (непрерывные блоки), внутри которых лежат страницы.
- Страницы типов: leaf pages (собственно данные), internal pages (указатели на другие страницы) и overflow pages (для значений, не поместившихся в листовую страницу, например, крупные массивы).
Размер страницы и размер блока дискового ввода-вывода настраиваются (allocation_size, internal_page_max, leaf_page_max). Подбор этих параметров под рабочую нагрузку может дать десятки процентов прироста throughput.
Сжатие
Чтобы снизить дисковый I/O, WiredTiger по умолчанию сжимает страницы перед записью (snappy), а также опционально сжимает данные в памяти (in-memory compression). Это даёт выигрыш в объёме диска и в эффективности кэша: в тот же объём RAM помещается больше распакованных страниц при использовании in-memory сжатия. Однако ценой является расход CPU как на сервере MongoDB, так и косвенно на клиенте (меньше данных гоняется по сети).
Для Go-разработчика это означает: документ, полученный из MongoDB, уже распакован, и вы не видите разницы, но планирование ресурсов CPU на сервере должно учитывать нагрузку на сжатие, особенно при записи больших документов.
Управление памятью: WiredTiger Cache
Самое важное, что нужно понимать про производительность MongoDB — это её внутренний кэш, отделённый от page cache операционной системы.
flowchart TD
App["Запрос от Go-приложения"] --> WT["WiredTiger Storage Engine"]
WT --> Cache{"Данные в WT Cache?"}
Cache -- "Да (Hit)" --> Response["Вернуть документ<br>O(1) из RAM"]
Cache -- "Нет (Miss)" --> DiskRead["Прочитать страницу с диска<br>syscall pread"]
DiskRead --> Decompress["Распаковать Snappy"]
Decompress --> CacheInsert["Поместить в кэш"]
CacheInsert --> Response
classDef default fill:#1e293b,stroke:#334155,color:#f8fafc;
classDef hit fill:#1e293b,stroke:#10b981,color:#f8fafc;
classDef miss fill:#1e293b,stroke:#f43f5e,color:#f8fafc;
class Response hit;
class DiskRead,Decompress miss;
- По умолчанию размер кэша — 50% доступной RAM минус 1 ГБ, либо настраивается через
storage.wiredTiger.engineConfig.cacheSizeGB. - Кэш хранит несжатые страницы данных и индексов.
- Когда кэш заполняется, WiredTiger запускает eviction (вытеснение): фоновые потоки выталкивают старые страницы на диск, предварительно сжав их. Если запись грязных страниц не успевает за поступлением новых данных, операция записи может быть заблокирована до завершения вытеснения — это вызывает знаменитые «MongoDB лаги».
Journal и долговечность
WiredTiger пишет изменения сначала в journal (WAL, аналог WAL), а уже потом в основные файлы данных. Это гарантирует восстановление после краша.
- Journal — это отдельные файлы, по умолчанию сжатые snappy, записываются с настраиваемой периодичностью fsync (по умолчанию 100 мс). Для полной долговечности (
writeConcern: "majority",j: true) требуется дождаться fsync journal’а на большинстве узлов, что увеличивает latency. - При сбое питания journal проигрывается, возвращая данные к последней контрольной точке (checkpoint). Контрольные точки делаются каждые 60 секунд (или по объёму данных).
MVCC и транзакции под капотом
MongoDB с версии 4.0 добавила мультидокументные транзакции, базирующиеся на Snapshot Isolation внутри WiredTiger (подобно MVCC в PostgreSQL, см. 7. MVCC. Multi Version Concurrency Control). Однако есть важные нюансы.
- Snapshot Isolation: Каждый документ может иметь несколько версий в памяти/на диске, помеченных временной меткой транзакции. Читающая операция видит последнюю зафиксированную версию, которая была до начала транзакции.
- Хранение версий: WiredTiger использует update list в страницах: при обновлении документа старая версия остаётся в цепочке до тех пор, пока все читатели, которым она нужна, не завершатся. Это может приводить к увеличению размера кэша и дискового пространства, если транзакции долгие.
- Сборка мусора: Фоновый процесс удаляет старые версии, когда они больше не нужны. Это аналогично VACUUM в PostgreSQL, но встроено в движок.
Для Go-разработчика это означает: если ваш сервис держит долгую транзакцию (например, обновляет много документов в цикле с задержками), вы накапливаете мультиверсионный мусор и можете спровоцировать рост кэша и усиление eviction. Всегда стремитесь к коротким транзакциям и перемежайте их коммитами.
Oplog — сердце репликации
Oplog (operations log) — это ограниченная коллекция (capped collection), в которую Primary пишет все операции изменения данных в порядке их выполнения. Secondary ноды непрерывно читают oplog и применяют изменения к своим копиям.
- Записи в oplog идемпотентны (вставляются с полным состоянием документа или операцией
$setс конкретными значениями), чтобы повторное применение не испортило данные. - Размер oplog настраивается через
oplogSizeMB. Если Secondary отстаёт настолько, что нужная запись уже выпала из oplog, требуется полная ресинхронизация (initial sync) — дорогая операция, копирующая все данные. - В шардированном кластере каждая реплика-группа шарда имеет свой oplog.
mongosне участвует в репликации.
Mechanical Sympathy: чтение oplog Secondary нодами — это потоковая передача данных по сети, похожая на чтение Kafka-топика. Нагружает диск Primary (если данные не в кэше) и сеть. Для Go-драйвера важна настройка read preference: если вы шлёте чтение на Secondary, драйвер учитывает maxStalenessSeconds, чтобы не читать с сильно отставшей ноды.
Индексы под капотом: B-Tree в WiredTiger
Все индексы в MongoDB — это B-Tree в том же WiredTiger, аналогично B-Tree индексам в PostgreSQL, но с особенностями.
- Индексы хранят ключ (значения полей) + указатель на документ (RecordId). Для покрывающих индексов (covering index) значения запрошенных полей включаются прямо в индекс, экономя чтение документа.
- Multikey индексы для массивов: для каждого элемента массива создаётся отдельная запись в индексе, указывающая на тот же документ.
- Индексы сжаты (префиксное сжатие ключей), что уменьшает их размер.
- При вставке документа обновляются все индексы, что увеличивает write amplification. Поэтому важно не индексировать все поля бездумно — это замедлит запись и увеличит кэш.
Шардинг: как данные разлетаются по узлам
MongoDB реализует горизонтальное масштабирование через шардинг на основе диапазонов (range‑based) или хеша (hashed).
- Shard Key: поле (или поля), по которому распределяются документы. Диапазоны значений ключа делятся на чанки (chunks), которые равномерно распределяются между шардами.
- Chunk Migration: когда балансировщик видит дисбаланс, он перемещает чанки между шардами. Это происходит онлайн, но может создавать дополнительную нагрузку на сеть и диск.
- Config Servers: хранят метаданные о том, какой чанк на каком шарде лежит. Их три (replica set) для отказоустойчивости.
- Клиентская маршрутизация: Драйвер (в т.ч. Go-драйвер) кэширует карту чанков и направляет запросы прямо на нужный шард, минуя
mongos(после начального получения метаданных). Однако все операции записываются/читаются черезmongosв большинстве архитектур, упрощая логику.
Для Go-разработчика это значит: правильный выбор shard key критически важен, чтобы избежать hot shard (неравномерной нагрузки). Избегайте монотонно возрастающих полей (например, ObjectId) для range-шардинга, используйте hashed-шардинг.
Mechanical Sympathy: MongoDB и Go-рантайм
Когда ваш Go-сервис выполняет collection.FindOne(ctx, filter), происходит следующая физическая картина:
- Драйвер берёт соединение из пула (управляемого внутри
mongo.Client). Это TCP-сокет, подключённый кmongodилиmongos. - Формируется BSON-запрос (сериализация фильтра в бинарный формат) — аллокация в куче Go. Использование тегов
bsonи кодогенерации снижает рефлексию и аллокации по сравнению сbson.M{}. - Системный вызов
writeотправляет запрос в сокет. Горутина паркуется (планировщик Go передаёт управление другой горутине). - На сервере MongoDB
epoll_waitобнаруживает событие, поток обрабатывает запрос: поиск в WiredTiger Cache. Если промах —preadдля чтения страницы с диска, распаковка snappy. Затем формируется BSON-ответ. - На стороне Go
epoll_waitпробуждает горутину, драйвер читает ответ, десериализует в структуру (аллокация памяти под строки, слайсы). - GC в Go будет собирать временные буферы запроса/ответа и промежуточные объекты десериализации. При большом потоке ответов рекомендуется переиспользовать структуры (например, через
sync.Pool), чтобы снизить давление.
Если MongoDB начинает читать с диска, драйвер может столкнуться с увеличением времени ответа до десятков миллисекунд вместо микросекунд. Горутины зависают в ожидании сети, но так как это сетевой ввод-вывод, поток ОС не блокируется (благодаря netpoller). Однако планировщик Go может создать дополнительные потоки ОС для других готовых горутин, что нужно учитывать при настройке GOMAXPROCS.
Ловушки и оптимальные практики
Итог
MongoDB под капотом — это гармоничное сочетание B-Tree storage engine (WiredTiger), сжатия на лету, MVCC и распределённых механизмов репликации и шардинга. Понимание этих механизмов позволяет Go-разработчику осознанно выбирать размер кэша, конфигурацию write/read concern, проектировать схему данных, минимизирующую дисковые операции, и писать код, который не перегружает GC лишними аллокациями.
Теперь, освоив документную модель, мы переходим к следующему крупному классу NoSQL — колоночным (wide-column) базам, которые оптимизированы для записи и хранения временных рядов и событий. 9. Column базы. Cassandra