MongoDB sharding для 100 миллионов игровых профилей: как мы масштабировали базу данных для гейм-студии

Ситуация: MongoDB на пределе вертикального масштабирования

«ГеймСтудия» — разработчик мобильных MMO-игр с аудиторией в 100 миллионов зарегистрированных игроков. MongoDB 7.0 на одном мощном сервере (96 vCPU, 512 GB RAM, NVMe RAID) — 2.8 TB данных, 120 коллекций. Основная коллекция — player_profiles с 100M документов, средний размер документа 4 KB.

Проблемы, с которыми студия пришла к нам в itfresh.ru:

  • Latency росла — p99 чтения профиля увеличилась с 5 мс до 45 мс за последние 6 месяцев. Working set перестал помещаться в RAM.
  • Запись упиралась в I/O — 80 000 writes/sec при пиковой активности (вечерний прайм-тайм). WiredTiger cache eviction не успевал.
  • Географическая задержка — игроки из России, Европы и Юго-Восточной Азии. Все читают из одного сервера в Москве. Для SEA — 200 мс RTT.
  • Бэкапы — mongodump на 2.8 TB занимал 6 часов и деградировал производительность.

Решение очевидно — sharding. Но настроить его правильно для gaming workload — отдельная инженерная задача.

Архитектура sharded кластера

MongoDB sharding состоит из трёх компонентов:

  • mongos — роутеры запросов, stateless, можно ставить сколько угодно
  • config servers — хранят метаданные о распределении данных (replica set из 3 нод)
  • shards — хранят данные, каждый шард — отдельный replica set

Наша архитектура для «ГеймСтудии»:

# Топология кластера

# Config Server Replica Set (CSRS)
config-rs:
  - config1.msk.internal:27019
  - config2.msk.internal:27019
  - config3.msk.internal:27019

# Shard 1 (Россия/СНГ) — Москва
shard-ru-rs:
  - shard-ru1.msk.internal:27018   # primary
  - shard-ru2.msk.internal:27018   # secondary
  - shard-ru3.msk.internal:27018   # secondary

# Shard 2 (Европа) — Франкфурт
shard-eu-rs:
  - shard-eu1.fra.internal:27018   # primary
  - shard-eu2.fra.internal:27018   # secondary
  - shard-eu3.fra.internal:27018   # secondary

# Shard 3 (Юго-Восточная Азия) — Сингапур
shard-sea-rs:
  - shard-sea1.sgp.internal:27018  # primary
  - shard-sea2.sgp.internal:27018  # secondary
  - shard-sea3.sgp.internal:27018  # secondary

# Mongos роутеры (по одному в каждом регионе)
mongos:
  - mongos-msk.internal:27017
  - mongos-fra.internal:27017
  - mongos-sgp.internal:27017

Каждый шард — replica set из 3 нод для отказоустойчивости. Потеря одной ноды шарда не влияет на доступность. Потеря целого шарда делает недоступными только данные этого шарда (своего региона).

Выбор shard key: самое важное решение

Shard key определяет, как данные распределяются между шардами. Неправильный выбор — и вы получите hotspot (один шард обрабатывает 90% запросов) или scatter-gather (каждый запрос идёт на все шарды).

Мы рассмотрели три варианта для коллекции player_profiles:

Shard keyПлюсыМинусы
_id (ObjectId)Монотонно растущий → хорошо для диапазонных запросовВсе новые документы идут на один шард (hotspot на insert)
player_id (UUID)Равномерное распределениеНет локальности — запросы по региону идут на все шарды
{region, player_id} (compound)Локальность по региону + равномерность внутриЗапросы без region идут на все шарды

Мы выбрали compound shard key {region: 1, player_id: "hashed"}:

# Включаем sharding для базы данных
mongosh --host mongos-msk.internal

use admin
sh.enableSharding("gamedb")

# Создаём индекс для shard key
use gamedb
db.player_profiles.createIndex({region: 1, player_id: "hashed"})

# Шардируем коллекцию
sh.shardCollection("gamedb.player_profiles", {
  region: 1,
  player_id: "hashed"
})

# Пример документа
db.player_profiles.insertOne({
  player_id: UUID("550e8400-e29b-41d4-a716-446655440000"),
  region: "RU",
  username: "DragonSlayer",
  level: 85,
  guild_id: ObjectId("...."),
  inventory: [...],
  stats: {
    total_playtime_hours: 1240,
    pvp_rating: 2150,
    achievements: [...]
  },
  last_login: ISODate("2026-04-04T18:30:00Z"),
  created_at: ISODate("2024-11-15T10:00:00Z")
})

Почему compound: region обеспечивает, что все игроки одного региона находятся на одном шарде (zone sharding), а player_id: "hashed" обеспечивает равномерное распределение внутри региона. Запрос профиля по {region, player_id} — targeted query на один шард.

Zone sharding: данные ближе к игрокам

Zone sharding привязывает диапазоны shard key к конкретным шардам. Мы настроили зоны по регионам:

# Добавляем шарды в зоны
sh.addShardTag("shard-ru-rs", "RU")
sh.addShardTag("shard-eu-rs", "EU")
sh.addShardTag("shard-sea-rs", "SEA")

# Определяем диапазоны shard key для каждой зоны
sh.addTagRange(
  "gamedb.player_profiles",
  {region: "RU", player_id: MinKey},
  {region: "RU", player_id: MaxKey},
  "RU"
)

sh.addTagRange(
  "gamedb.player_profiles",
  {region: "EU", player_id: MinKey},
  {region: "EU", player_id: MaxKey},
  "EU"
)

sh.addTagRange(
  "gamedb.player_profiles",
  {region: "SEA", player_id: MinKey},
  {region: "SEA", player_id: MaxKey},
  "SEA"
)

# Проверяем распределение
sh.status()
# gamedb.player_profiles
#   shard key: { region: 1, player_id: "hashed" }
#   chunks:
#     shard-ru-rs:  1847  (42M documents)
#     shard-eu-rs:  1523  (35M documents)
#     shard-sea-rs: 998   (23M documents)
#   tag: RU -> shard-ru-rs
#   tag: EU -> shard-eu-rs
#   tag: SEA -> shard-sea-rs

Теперь когда game-сервер в Сингапуре запрашивает профиль игрока из SEA, запрос идёт через локальный mongos к локальному шарду — латентность 2-3 мс вместо 200 мс до Москвы.

Настройка балансировщика:

# Балансировщик мигрирует chunks между шардами для равномерности
# Но в zone sharding миграция происходит только внутри зоны

# Настраиваем окно для балансировки (только ночью, чтобы не мешать игрокам)
use config
db.settings.updateOne(
  {_id: "balancer"},
  {$set: {
    activeWindow: {start: "03:00", stop: "06:00"},
    _secondaryThrottle: true,
    waitForDelete: true
  }},
  {upsert: true}
)

# Мониторинг миграций
sh.getBalancerState()   // true
sh.isBalancerRunning()  // false (вне окна)

# Посмотреть активные миграции
db.adminCommand({currentOp: true, desc: /moveChunk/})

Read preference, write concern и oplog

В gaming workload важен баланс между консистентностью и производительностью. Мы настроили разные политики для разных операций:

// Connection string для game-сервера
const uri = "mongodb://mongos-sgp.internal:27017/gamedb" +
  "?readPreference=secondaryPreferred" +
  "&readPreferenceTags=region:SEA" +
  "&w=majority" +
  "&journal=true" +
  "&retryWrites=true" +
  "&maxPoolSize=200" +
  "&minPoolSize=20";

// Для критичных операций (покупки, PvP результаты) — строгая консистентность
db.player_profiles.updateOne(
  {player_id: playerId, region: "SEA"},
  {$inc: {"wallet.gems": -100}, $push: {"inventory": newItem}},
  {
    writeConcern: {w: "majority", j: true, wtimeout: 5000}
  }
);

// Для некритичных операций (логирование, аналитика) — быстрая запись
db.player_events.insertOne(
  {player_id: playerId, event: "level_up", level: 86, ts: new Date()},
  {
    writeConcern: {w: 1, j: false}
  }
);

Oplog sizing — критичный параметр для sharded кластера. Oplog должен быть достаточно большим, чтобы выдерживать временную недоступность secondary:

# Проверяем текущий размер oplog
mongosh --host shard-ru1.msk.internal:27018

rs.printReplicationInfo()
# configured oplog size:   50000MB
# log length start to end: 172800secs (48hrs)
# oplog first event time:  Wed Apr 03 2026 10:00:00
# oplog last event time:   Fri Apr 05 2026 10:00:00

# 48 часов — достаточно для maintenance window
# Если secondary будет недоступна > 48 часов — потребуется initial sync

# Увеличиваем oplog до 100 GB если нужно
db.adminCommand({replSetResizeOplog: 1, size: 102400})

Настройка read preference для разных типов запросов:

// Профиль игрока — читаем с primary (свежие данные)
db.player_profiles.findOne(
  {player_id: playerId, region: "RU"},
  {readPreference: "primary"}
);

// Рейтинговая таблица — можно с secondary (данные могут отставать на 1-2 сек)
db.player_profiles
  .find({region: "RU"})
  .sort({"stats.pvp_rating": -1})
  .limit(100)
  .readPref("secondaryPreferred");

// Аналитические запросы — обязательно secondary (не нагружаем primary)
db.player_events
  .aggregate([
    {$match: {ts: {$gte: dayAgo}}},
    {$group: {_id: "$event", count: {$sum: 1}}}
  ])
  .readPref("secondary");

Мониторинг и бэкапы шардированного кластера

Мониторинг sharded кластера — сложнее, чем одиночного сервера. Нужно следить за каждым шардом, балансировщиком и маршрутизаторами.

# mongostat — быстрый обзор производительности всех шардов
mongostat --host mongos-msk.internal:27017 --discover
#                    insert query update delete getmore command dirty  used
# shard-ru1:27018      2.4k  8.1k   5.2k    120     340    *1.8k  2.1% 78.3%
# shard-eu1:27018      1.8k  6.4k   4.1k     98     280    *1.4k  1.8% 72.1%
# shard-sea1:27018     1.1k  4.2k   2.8k     67     190    *0.9k  1.4% 65.7%

# mongotop — какие коллекции потребляют больше всего I/O
mongotop --host shard-ru1.msk.internal:27018 5
#                        ns    total    read    write
# gamedb.player_profiles       42ms    28ms     14ms
# gamedb.player_events         18ms     3ms     15ms
# gamedb.guild_data             7ms     5ms      2ms

Ключевые метрики для Prometheus (через mongodb_exporter):

# prometheus alerts для MongoDB sharded cluster
groups:
  - name: mongodb_sharding
    rules:
      - alert: MongoDBShardReplicationLag
        expr: mongodb_rs_members_replicationLag > 10
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "Replication lag >10s on {{ $labels.instance }}"

      - alert: MongoDBChunkImbalance
        expr: |
          max(mongodb_shards_chunks_total) -
          min(mongodb_shards_chunks_total) > 200
        for: 30m
        labels:
          severity: warning
        annotations:
          summary: "Chunk imbalance between shards: {{ $value }} chunks"

      - alert: MongoDBCacheEvictionHigh
        expr: rate(mongodb_wiredtiger_cache_eviction_pages_total[5m]) > 1000
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "WiredTiger cache eviction rate high on {{ $labels.instance }}"

Бэкапы шардированного кластера — нетривиальная задача. Нужно обеспечить консистентность между шардами:

# Вариант 1: mongodump с --oplog (для небольших кластеров)
mongodump \
  --host mongos-msk.internal:27017 \
  --oplog \
  --gzip \
  --out /backup/$(date +%Y%m%d)

# Вариант 2: Percona Backup for MongoDB (рекомендуем для продакшена)
# Обеспечивает consistent point-in-time snapshot всех шардов
pbm backup --type=logical --compression=zstd
pbm list
# 2026-04-05T03:00:00Z  logical  zstd  420.3GB  done
# 2026-04-04T03:00:00Z  logical  zstd  419.8GB  done

# Восстановление
pbm restore 2026-04-05T03:00:00Z

Результаты и выводы

Миграция на sharded кластер заняла 3 недели: неделя на подготовку инфраструктуры, неделя на миграцию данных (initial sharding) и неделя на тестирование под нагрузкой.

МетрикаДо (один сервер)После (3 шарда)
Чтение профиля p9945 мс3-5 мс (локальный шард)
Запись p9912 мс (с периодическими спайками до 200 мс)4 мс (стабильно)
Макс. writes/sec80 000 (потолок)240 000 (линейно масштабируется)
Latency для SEA игроков200 мс3 мс
Время бэкапа6 часов2 часа (параллельно по шардам)
Working set в RAMНе помещался (512 GB)Помещается на каждом шарде

Главные уроки:

  • Shard key — навсегда. В MongoDB нельзя изменить shard key после шардирования коллекции (без пересоздания). Тщательно продумывайте его до миграции.
  • Zone sharding — мощный инструмент для геораспределённых приложений, но требует, чтобы приложение знало регион пользователя и передавало его в каждом запросе.
  • Mongos stateless — размещайте mongos на каждом app-сервере как sidecar. Это убирает лишний сетевой hop и не создаёт единой точки отказа.
  • Мониторинг обязателен — без наблюдения за балансировщиком, oplog-ом и chunk distribution кластер может деградировать незаметно.

Если у вас MongoDB упирается в потолок одного сервера — обращайтесь к нам в itfresh.ru, мы спроектируем и настроим sharded кластер под вашу нагрузку.

Часто задаваемые вопросы

Replica set решает проблемы отказоустойчивости и масштабирования чтения (read from secondary). Sharding нужен, когда данные не помещаются в RAM одного сервера (working set > доступной памяти), или когда write throughput упирается в I/O одного сервера. Типичный порог — 500 GB-1 TB данных или 50 000+ writes/sec.
Хороший shard key обладает тремя свойствами: высокая кардинальность (много уникальных значений), равномерное распределение (нет hotspots), направленность запросов (большинство запросов содержат shard key). Избегайте монотонно растущих ключей (timestamp, ObjectId) — все новые записи пойдут на один шард. Используйте hashed shard key или compound key с hashed компонентом.
Да, это штатная операция: sh.addShard("new-shard-rs/host1:27018,host2:27018,host3:27018"). Балансировщик автоматически начнёт мигрировать chunks на новый шард. Миграция происходит в фоне, без даунтайма, но создаёт дополнительную нагрузку на I/O. Рекомендуем добавлять шарды в период минимальной нагрузки.
Запросы, направленные на данные этого шарда, будут возвращать ошибку. Запросы к данным на других шардах продолжат работать нормально. Если запрос не содержит shard key (scatter-gather), он вернёт частичные результаты или ошибку, в зависимости от настройки. Каждый шард — replica set из 3 нод, поэтому потеря одной ноды прозрачна.
Минимум два для отказоустойчивости. Оптимально — по одному на каждый app-сервер (как sidecar или localhost). Mongos stateless и потребляет мало ресурсов (200-500 MB RAM). Размещение mongos локально убирает сетевую задержку между приложением и роутером и исключает единую точку отказа.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#MongoDB#sharding#mongos#config server#shard key#chunk migration#balancer#zone sharding
Комментарии 0

Оставить комментарий

загрузка...