← назад к разделу

MongoDB изначально проектировалась под распределённость: replica set появился раньше транзакций, шардинг — стандартный режим работы крупных кластеров. В этой статье — как считать, сколько уже занимает кластер, как устроена репликация, и как выбирать shard key, чтобы не пришлось делать resharding кварталами.

Примеры — на тех же двух коллекциях, что и в статье про ACID: category (3 документа) и product (7 документов, в реальности — миллионы).

Как считать размер кластера

Три уровня детализации.

База целиком:

db.stats(1024 * 1024 * 1024);  // вывод в GB
// {
//   db: "shop",
//   collections: 8,
//   objects: 142_530_891,
//   dataSize: 41.2,        // логический размер документов
//   storageSize: 18.7,     // сжатый, на диске (WiredTiger ~50% сжатие на JSON-подобных данных)
//   indexSize: 6.4,
//   totalSize: 25.1
// }

Одна коллекция:

db.product.stats(1024 * 1024 * 1024);
// {
//   size: 12.3,           // логический объём документов
//   storageSize: 5.8,     // на диске после WiredTiger-сжатия
//   nindexes: 4,
//   totalIndexSize: 2.1,
//   avgObjSize: 132       // байт на документ — важно для прогноза
// }

По индексам:

db.product.aggregate([{ $indexStats: {} }]).forEach(s => {
    print(s.name, s.accesses.ops);
});
// _id_ 12000000
// categoryId_1 8500000
// price_-1 145         ← редко используется, кандидат на удаление

«storageSize меньше size в 2× и более» — нормально для WiredTiger со snappy/zstd. «totalSize вашей базы догоняет RAM сервера» — пора задумываться о шардинге. Грубо: на одной машине комфортно живёт active dataset ≤ 70% от RAM. Когда индексы перестают помещаться — производительность падает на порядок.

Replica set — как устроена репликация

Минимум — три узла: один primary (принимает записи) и два secondary (реплицируют). Альтернатива — primary + secondary + arbiter (узел-голосователь без данных), но для production чаще берут три полноценных узла.

        ┌──────────┐
write → │ primary  │ ───┐
        └──────────┘    │ репликация через oplog
              ▲         │
              │         ▼
        ┌──────────┐ ┌──────────┐
        │secondary │ │secondary │
        └──────────┘ └──────────┘

Oplog — capped collection в системной базе local, куда primary пишет каждую успешную операцию. Secondaries читают oplog и применяют у себя. Это аналог WAL в PostgreSQL, но логический (операции, а не страницы) — поэтому MongoDB не требует одинаковых версий или одинаковой компоновки данных между репликами.

Размер oplog'а определяет, как долго реплика может отстать. По умолчанию — 5% от свободного места на диске (но не больше 50 GB). Если secondary отстал больше, чем длина oplog'а — приходится делать initial sync с нуля.

Выбор primary

При сбое primary узлы голосуют. Побеждает кандидат с самым свежим oplog'ом и большинством голосов. Выбор занимает 10–30 секунд — на это время кластер недоступен на запись.

Что важно знать:

  • Кворум нужен для записи: если из трёх узлов два пропали — оставшийся становится readonly. Это правильное поведение: иначе при разрыве сети получили бы split brain.
  • Запись с w: "majority" гарантированно переживёт failover. С w: 1 — нет.
  • priority в конфигурации replica set позволяет указать предпочтительный primary (например, в основном дата-центре).

Read preference

Куда драйвер посылает чтение. Пять режимов:

РежимГде читаемКогда брать
primary (default)Только primaryКогда нужны самые свежие данные (баланс, заказ перед оплатой)
primaryPreferredPrimary, если он жив; иначе secondaryRead-after-write критичные операции с fallback
secondaryТолько secondaryАналитические запросы, отчёты — не нагружать primary
secondaryPreferredSecondary, primary только если все secondary упалиRead-heavy load, толерантный к stale данным
nearestРеплика с минимальной сетевой задержкойGeo-distributed clusters
db.product.find({ categoryId: 1 })
    .readPref("secondaryPreferred", [{ region: "eu-west" }]);
// читаем из ближайшей реплики в Европе

Подводный камень: secondary может отставать. С readConcern: "majority" это в основном решается (читаем то, что подтверждено большинством), но stale-данные всё равно возможны. Для критичных read-after-write — использовать causal consistency.

Когда переходить на sharded cluster

Так же как и в PostgreSQL: когда одной машины уже не хватает. Конкретные триггеры:

  • активный dataset не помещается в RAM, тяжёлые запросы вынуждают читать с диска;
  • write throughput упирается в IOPS одной ноды;
  • общий объём приближается к лимиту одного хранилища;
  • нужно держать данные географически рядом с пользователями.

Партиционирования в стиле PostgreSQL внутри одной БД у MongoDB нет: следующий шаг масштабирования за пределы одной replica set — сразу sharded cluster.

Sharded cluster — что внутри

Четыре типа узлов:

        ┌─────────────────────┐
client→ │       mongos        │  ←── 1+ роутера, stateless
        │   (query router)    │
        └─────────────────────┘
            │           │
            ▼           ▼
     ┌──────────┐ ┌──────────┐
     │ Shard A  │ │ Shard B  │  ←── каждый шард = свой replica set
     │(rs of 3) │ │(rs of 3) │
     └──────────┘ └──────────┘
            │           │
            ▼           ▼
        ┌───────────────────┐
        │  Config Servers   │  ←── 3 узла, хранят metadata
        │ (replica set)     │      (какой chunk на каком shard'е)
        └───────────────────┘
  • mongos — точка входа клиента. Принимает запросы, по shard key решает, на какой шард их отправить, агрегирует ответы. Stateless — масштабируется горизонтально.
  • Config servers — replica set из 3 узлов с метаданными кластера (маршруты chunk → shard, версии). Без них кластер не работает.
  • Shards — обычные replica set'ы с куском данных. Данные внутри коллекции разделены на chunks (по умолчанию 128 MB), которые распределены по шардам.
  • Balancer — фоновый процесс, перераспределяющий chunks между шардами при дисбалансе. Запускается автоматически.

Стоимость минимального production-кластера: 3 (config) + 6 (два шарда × 3 реплики) + 2 (mongos с резервом) = 11 узлов. Это сильно больше одной replica set — поэтому шардинг включают только когда альтернатив нет.

Стратегии shard key

Ranged sharding

Документы распределены по диапазонам значений shard key. Если shard key — _id, и значения идут по возрастанию (ObjectId или BIGSERIAL), то новые документы концентрируются на последнем шарде — типичный антипаттерн «hot shard».

sh.shardCollection("shop.product", { _id: 1 });  // BAD на монотонном _id

Ranged подходит, когда:

  • shard key равномерно распределён (например, categoryId при равном объёме категорий);
  • запросы часто фильтруют по диапазону этого ключа (геолокация, время в исторических данных).

Hashed sharding

MongoDB сама хеширует значение shard key. Распределение равномерное, hot shard невозможен. Минус: запросы по диапазону значений идут на все шарды (scatter-gather).

sh.shardCollection("shop.product", { _id: "hashed" });
// _id монотонный — но после хеша распределение равномерно

Подходит для:

  • ключей с монотонным ростом (_id, timestamp);
  • запросов по точному значению (find({ _id: ... }));
  • write-heavy нагрузки, когда главное — равномерно нагрузить шарды.

Zoned sharding

Прикрепляем диапазоны shard key к зонам (тегам шардов). Используется для geo-распределения и tenant-изоляции.

// Каждый шард получает тег региона
sh.addShardTag("shardEU", "EU");
sh.addShardTag("shardUS", "US");

// Документы с region="EU" идут в EU-шард
sh.addTagRange(
    "shop.product",
    { region: "EU", productId: MinKey },
    { region: "EU", productId: MaxKey },
    "EU"
);

Полезно, когда регуляторика требует физически держать данные клиента в его юрисдикции.

Compound shard key

Часто оптимум — составной ключ: первая часть даёт равномерность, вторая — locality для типичных запросов.

Для нашей product, если запросы обычно идут по categoryId и _id, а товаров по категориям сильно разное количество:

sh.shardCollection(
    "shop.product",
    { categoryId: 1, _id: "hashed" }
);

В одной категории документы хешируются по _id (равномерно по шардам), но запросы find({ categoryId: 1 }) могут читать чуть меньше шардов, чем чистый hash по _id.

Как выбирать shard key

Пять критериев — выполнение всех обязательно:

  1. Высокая кардинальность — много разных значений. Shard key gender с двумя значениями физически не позволит распределить по 10 шардам.
  2. Равномерное распределение — иначе горячий шард. userId обычно ок, country для российской компании — плохо.
  3. Ключ есть в большинстве запросов — иначе каждый find() идёт на все шарды.
  4. Низкая частота изменений — shard key менять можно с 4.4 (refineCollectionShardKey, reshardCollection с 5.0), но это операция уровня кварталов работы.
  5. Targeted writes возможны — каждая запись попадает на один конкретный шард, без распределённой транзакции.

Антипример из нашего домена: шардировать product по _id (ObjectId), а category оставить нешардированной. Запросы с $lookup product → category бьют по всем шардам.

Лучше: шардируем обе коллекции по categoryId (с compound key для product). Тогда product и его category физически рядом — $lookup остаётся локальным.

Сравнение PostgreSQL и MongoDB

PostgreSQLMongoDB
Партиционирование внутри инстансаЕсть (RANGE, LIST, HASH)Нет — только sharded cluster
Минимальная репликацияStream replication + 1 standbyReplica set из 3 узлов
FailoverНе автоматический (Patroni, Stolon)Автоматический, 10–30 секунд
ШардингЧерез расширения (Citus)Встроенный
Cross-shard JOINЧерез FDW, сложно$lookup с автоматическим scatter-gather
Изменение ключа распределенияОчень дорого, обычно через миграциюreshardCollection (5.0+), часы-дни

Главное концептуальное отличие: в PostgreSQL партиционирование и шардирование — разные инструменты с разной стоимостью. В MongoDB шардинг сразу горизонтальный (между серверами) и сразу автоматический.

Что почитать дальше

  • MongoDB: Replication и Sharding — официальная документация.
  • ACID и согласованность в MongoDB — какие гарантии работают в sharded cluster.
  • Моделирование документов — почему правильный schema design снижает потребность в шардинге.
  • Партиционирование и шардирование в PostgreSQL — сравнение подходов.