7. Document базы. MongoDB

Документные базы данных: архитектура MongoDB, BSON и разработка на Go

Введение

Документные базы данных — это второй по популярности класс NoSQL-систем после key-value хранилищ. Если в key-value вы оперируете непрозрачным blob’ом по ключу, а в реляционных базах раскладываете данные по строго типизированным строкам и столбцам, то документная модель предлагает промежуточный подход: вы храните самодостаточные документы — обычно JSON-подобные структуры, которые база данных понимает и может индексировать.

MongoDB — безусловный лидер среди документных СУБД, и именно с ним чаще всего сталкивается Go-разработчик, когда проект требует гибкой схемы, горизонтального масштабирования и высокой скорости разработки. В этой статье мы разберём архитектуру MongoDB, модель данных, ключевые механизмы и то, как он взаимодействует с Go-рантаймом на уровне процессора и операционной системы.


Модель данных: документы и коллекции

В отличие от реляционной модели, где данные нормализованы (см. 9. Нормализация. Введение) и распределены по нескольким таблицам, в MongoDB вы храните связанные данные вместе — в одном документе.

  • Документ — это иерархическая структура, аналог JSON-объекта, но хранимая в бинарном формате BSON (Binary JSON). Документ содержит поля и значения, включая вложенные документы и массивы.
  • Коллекция — аналог таблицы, но без обязательной схемы. Документы в одной коллекции могут иметь разные наборы полей.
  • База данных — контейнер коллекций.
Go
// Пример документа в представлении Go
type User struct {
    ID        primitive.ObjectID `bson:"_id,omitempty"`
    Name      string             `bson:"name"`
    Email     string             `bson:"email"`
    Addresses []Address          `bson:"addresses,omitempty"`
    // Можно иметь разный набор полей в разных документах
}

type Address struct {
    Street string `bson:"street"`
    City   string `bson:"city"`
}

Денормализация как философия

В реляционных базах вы бы разнесли пользователя, адреса и заказы в три таблицы и собрали бы их JOIN’ами. В MongoDB типичный паттерн — встроить адреса прямо в документ пользователя. Это предвосхищает паттерн денормализации, оправданный для оптимизации чтения ценой усложнения обновлений и увеличения размера документа.

Однако MongoDB поддерживает и ссылки между коллекциями (manual references, DBRef), что позволяет в некоторых случаях возвращаться к «реляционному» стилю. Выбор между встраиванием и ссылками — одна из главных проектировочных дилемм.


BSON: как документы хранятся и передаются

BSON — это бинарная запись JSON-подобных структур. Формат спроектирован для быстрого сканирования: строгая типизация (различает int32, int64, double, string, datetime, ObjectId и т.д.), length-префиксы, отсутствие необходимости полного парсинга для извлечения одного поля.

flowchart LR
    A["Документ JSON<br>{ name: 'Alice' }"] --> B["BSON документ<br>длина + элементы + \x00"]
    B --> C["Элемент 1: тип string (0x02), имя name, длина 6, значение Alice\x00"]
    C --> D["Хранилище WiredTiger<br>или In-Memory Cache"]

    classDef default fill:#1e293b,stroke:#334155,color:#f8fafc;
    classDef highlight fill:#312e81,stroke:#6366f1,color:#ffffff;
    class B,C highlight;
  

С точки зрения Go-драйвера mongo-go-driver, при выполнении collection.InsertOne(ctx, doc) сериализация в BSON происходит на стороне клиента, используя рефлексию (или кодогенерацию с тегами bson). Это важнейший момент для производительности: BSON-энкодинг может создавать множество временных слайсов и объектов, увеличивая давление на GC. Рекомендуется переиспользовать структуры и по возможности применять bson.M{} только для прототипирования.


Архитектура MongoDB: от процесса до кластера

Процесс mongod — это основной демон, обслуживающий запросы, управляющий хранением и индексами. Он работает как многопоточный сервер с собственным менеджментом памяти и журналированием.

flowchart TD
    App["Go-приложение<br>(mongo-go-driver)"] --> Router["mongos<br>Маршрутизатор запросов"]
    Router --> Shard1["Shard 1<br>(Replica Set)"]
    Router --> Shard2["Shard 2<br>(Replica Set)"]

    Shard1 --> Primary1["Primary 1 (Leader)"]
    Shard1 --> Secondary1["Secondary 1 (Follower)"]

    Shard2 --> Primary2["Primary 2 (Leader)"]
    Shard2 --> Secondary2["Secondary 2 (Follower)"]

    Router --> ConfigSrv["Config Servers (Replica Set)<br>Метаданные кластера и карты чанков"]

    classDef default fill:#1e293b,stroke:#334155,color:#f8fafc;
    classDef nodeAct fill:#312e81,stroke:#6366f1,color:#ffffff;
    class Router,ConfigSrv nodeAct;
  
  • Replica Set — группа из нескольких mongod-процессов, обеспечивающая отказоустойчивость. Данные реплицируются на все узлы с помощью oplog (журнал операций). Выборы Primary происходят по протоколу Raft (в адаптации MongoDB). Клиент может читать с Secondary для распределения нагрузки.
  • Sharding — горизонтальное масштабирование: коллекции разбиваются на диапазоны по shard key. Компонент mongos выступает прозрачным прокси, направляя запросы в нужные шарды. Config-серверы хранят карту распределения данных.
  • Многопоточность: начиная с версии 4.0, MongoDB использует пул потоков для выполнения операций, а WiredTiger внутри использует свои потоки для сжатия и контрольных точек.

Go-драйвер знает топологию кластера, обнаруживает Primary через isMaster (в новых версиях hello) и автоматически переключается при сбоях. Пул соединений настраивается через options.ClientOptions.


Индексы в MongoDB

Без индексов MongoDB сканирует всю коллекцию (collection scan). Но в отличие от реляционных БД, здесь вы можете индексировать не только скалярные поля, но и вложенные поля, элементы массивов (multikey индексы), гео-координаты и текст.

Основные типы индексов:

  • Single Field / Compound — аналоги B-Tree индексам в PostgreSQL (см. B Tree индекс под капотом).
  • Multikey — создаётся автоматически, когда поле — массив. Каждый элемент массива индексируется отдельно.
  • Text — для полнотекстового поиска, основан на инвертированном индексе.
  • Wildcard — индексирует все поля документа динамически, полезен для гетерогенных коллекций.
  • TTL — автоматическое удаление документов по времени.
  • Hashed — хеш-индекс для равномерного распределения при шардинге.

С точки зрения производительности индексы в MongoDB хранятся в том же storage engine (WiredTiger) и работают через B-Tree с оптимизациями для сжатия. Как и в реляционных БД, индексы ускоряют чтение, но замедляют запись и потребляют RAM для кэша.


Транзакции и ACID

Долгое время MongoDB поддерживала атомарность только в рамках одного документа — и это было достаточным для многих сценариев. С версии 4.0 появились мультидокументные транзакции с гарантиями ACID (ACID), распространяющиеся на целые replica sets, а с 4.2 — и на шардированные кластеры.

Транзакции в MongoDB работают на основе snapshot isolation, аналогично MVCC в PostgreSQL (MVCC). Однако они имеют ограничения: максимальное время выполнения (по умолчанию 60 секунд), и они не заменяют грамотного моделирования данных — к транзакциям прибегают только тогда, когда встраивание документов не решает проблему.

Go
session, err := client.StartSession()
if err != nil {
    return err
}
defer session.EndSession(ctx)

err = mongo.WithSession(ctx, session, func(sc mongo.SessionContext) error {
    if err := session.StartTransaction(); err != nil {
        return err
    }
    // операции с разными коллекциями
    if _, err := collection1.InsertOne(sc, doc1); err != nil {
        session.AbortTransaction(sc)
        return err
    }
    if _, err := collection2.UpdateOne(sc, filter, update); err != nil {
        session.AbortTransaction(sc)
        return err
    }
    return session.CommitTransaction(sc)
})

Mechanical Sympathy: как MongoDB работает с железом

В основе MongoDB лежит storage engine WiredTiger (опционально — In-Memory). Понимание его работы важно для прогнозирования производительности Go-сервиса.

  • Формат хранения: документы и индексы хранятся в B-Tree файлах. WiredTiger кэширует часто используемые страницы в RAM, используя собственный cache (не полагаясь полностью на page cache ОС). При чтении, если данные не в кэше, происходит syscall pread для загрузки страницы с диска. Это дорого, как и в случае PostgreSQL, поэтому размер кэша WiredTiger должен быть соизмерим с рабочим набором данных.
  • Сжатие: WiredTiger по умолчанию сжимает данные алгоритмом snappy — это уменьшает дисковый I/O ценой CPU. Влияние на Go-рантайм: при извлечении документов Mongo-драйвер получает уже распакованные BSON-данные, так что на стороне клиента экономится сетевой трафик, но CPU на сервере MongoDB тратится на распаковку.
  • Journal (WAL): Все операции записи сначала попадают в journal (аналог WAL). fsync журнала происходит с настраиваемым интервалом. Это критично для долговечности: по умолчанию данные в journal сбрасываются каждые 100 мс, но можно настроить более агрессивный flush.
  • Влияние на аллокации в Go: BSON парсинг — это CPU-интенсивная задача. Каждый полученный документ преобразуется в структуру или bson.M, что аллоцирует память в куче. Чтобы не перегружать GC, используйте потоковую обработку (cursor.All() вычитывает сразу все документы, что может привести к огромному слайсу). Лучше итерироваться курсором: for cursor.Next(ctx) { cursor.Decode(&doc) }.

Пример с курсором и снижением аллокаций

Go
cursor, err := collection.Find(ctx, bson.D{{"status", "active"}})
if err != nil {
    return err
}
defer cursor.Close(ctx)

for cursor.Next(ctx) {
    var user User
    if err := cursor.Decode(&user); err != nil {
        // log
        continue
    }
    // обрабатываем user, не накапливая все в памяти
}
if err := cursor.Err(); err != nil {
    return err
}

Паттерны использования в Go

CRUD операции

Основные методы драйвера:

  • InsertOne, InsertMany
  • Find, FindOne
  • UpdateOne, UpdateMany
  • DeleteOne, DeleteMany
  • Aggregate — мощный конвейер обработки данных на стороне сервера.
Go
filter := bson.D{{"email", "alice@example.com"}}
update := bson.D{{"$set", bson.D{{"status", "verified"}}}}
result, err := collection.UpdateOne(ctx, filter, update)
if err != nil {
    // ...
}
_ = result

Агрегация

Агрегационный конвейер — это цепочка стадий ($match, $group, $sort, $project и др.), выполняемых на сервере. Это очень эффективно, так как данные не гоняются по сети. В Go это выглядит как слайс bson.D:

Go
pipeline := mongo.Pipeline{
    {{"$match", bson.D{{"status", "active"}}}},
    {{"$group", bson.D{{"_id", "$city"}, {"count", bson.D{{"$sum", 1}}}}}},
    {{"$sort", bson.D{{"count", -1}}}},
}
cursor, err := collection.Aggregate(ctx, pipeline)
if err != nil {
    return err
}
defer cursor.Close(ctx)
// обработка курсора

Смена схемы и миграции

Из-за отсутствия строгой схемы миграции в MongoDB менее формализованы, чем в реляционном мире (4. Миграции базы данных). Но это не значит, что их нет: вы пишете скрипты (на Go или JS), которые проходят по документам и обновляют их. Популярны библиотеки migrate для MongoDB, которые отслеживают применённые миграции в специальной коллекции.


Ловушки и собеседование


MongoDB против PostgreSQL против Key-Value

Сценарий MongoDB PostgreSQL Redis / Valkey
Гибкая, часто меняющаяся схема ✅ ❌ (нужны миграции) ✅ (opaque blob)
Сложные ACID-транзакции через несколько таблиц/коллекций ⚠️ (возможно, но дорого) ✅ ❌ (кроме Lua-скриптов)
Скорость чтения по ключу без JOIN ✅ ✅ (с индексом) ✅✅ (микросекунды)
Аналитические ad-hoc запросы ⚠️ (агрегации гибкие, но без полноценного SQL) ✅ (SQL, оконные функции, CTE) ❌
Полнотекстовый поиск ✅ (Text Index) ✅ (GIN, tsvector) ❌ (требует Redis Stack)
Хранение огромного количества простых ключей ⚠️ ⚠️ ✅✅
Поддержка в Go Отличная (драйвер mongo-go-driver) Отличная (database/sql, pgx) Отличная (go-redis)

Итог

MongoDB — это зрелая документная СУБД, которая позволяет быстро разрабатывать приложения за счёт гибкой схемы, богатых возможностей вложенных документов и горизонтального масштабирования. Для Go-разработчика она предоставляет зрелый драйвер с поддержкой пула соединений, сессий и конвейерной обработки. Однако выбор MongoDB должен быть осознанным: вместе с гибкостью приходит ответственность за правильное моделирование данных, управление индексами и аккуратное использование транзакций.

В следующей статье мы залезем под капот MongoDB и разберём его внутреннее устройство: как работает WiredTiger, oplog, шардинг и MVCC на уровне исходного кода и структур на диске.

8. MongoDB под капотом