וואלהתאונת דרכים בנתיבות: שמונה נפצעו, ארבעה מהם במצב בינוניThe Jerusalem PostIsraeli agriculture at risk ahead of High Holy Days as desalination systems remain nonfunctionalBollywood HungamaSonu Sood reaches Nepal with relief supplies for flood victims: ‘We just want to be with the people and will try to bring their lives back”RTP DesportoJogos do Mediterrâneo. Duplo ouro para PortugalESPN DeportesZverev coqueteó con una derrota histórica pero reaccionó a tiempo y ganó un partido de casi cinco horas en el US Open 2026النهارأعداء الداخل أخطـر من أعداء الخارجInquirerDepEd eyes consultations on students’ use of transparent bagsThe South AfricanSoweto R93.5 million PowerBall winner collects prizeMalay MailUS embassy shifts Sabah travel status to ‘relaxed’ after security improvementsBBC SportBalogun abandoned Everton move after having medicalVilaWebParlon desmenteix que els menors detinguts pel crim de Manresa siguin estrangersObservador DesportoAmadora-Sintra. Doentes urgentes esperam 12 horas
The Daily Newsstand · Free, Always
Wednesday, September 2, 2026

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

Translate

Разворачивать новый кластер базы данных каждую неделю — так себе удовольствие, особенно когда обслуживание десятков кластеров съедает время дежурных. Когда кластеров стало много, локальные аномалии стало сложнее замечать на общих дашбордах.

С этой проблемой мы столкнулись в Яндекс Трекере: за год количество активных организаций и кластеров выросло вдвое, а нагрузка на сервис поднялась всего на 13%. Получилось парадоксально: потребление ресурсов и сложность эксплуатации росли вместе с числом организаций, хотя реальная нагрузка увеличивалась значительно медленнее.

Меня зовут Олег Нуриманов, я старший разработчик в Яндекс 360. В статье расскажу о том, как мы ушли от модели «одна организация — одна база», сократили число бэкенд‑кластеров с 70 до 7 и снизили потребление ресурсов в 2 раза. 

Бывшая архитектура Яндекс Трекера

Для хранения данных в Яндекс Трекере мы используем Yandex StoreDoc — наш внутренний аналог MongoDB® Atlas (дальше я буду писать просто MongoDB). Исторически модель хранения строилась по схеме «одна организация — одна база данных».

Почему эта модель была удобна: 

  • Естественная изоляция: данные организаций разделены по разным базам. 

  • Крупной организации можно легко выделить персональный кластер.

  • Разработчик работает с базой организации, как будто она одна, и ему не нужно учитывать мультитенантность. 

  • Простое управление данными организации: легко делать полный бэкап данных организации и изолированно восстанавливать отдельные организации.

Предыдущая модель разделяла данные организаций по разным базам; при необходимости организации можно было дать собственный кластер. В модель входили следующие ключевые компоненты:

  • Фронтенд и Public API принимали внешние запросы пользователей и интеграций. 

  • Балансер запрашивал у Координатора маппинг организации на нужный бэкенд и маршрутизировал туда входящие запросы от Frontend и Public API.

  • Координатор выбирал наиболее свободный кластер, чтобы разместить новые организации, и управлял жизненным циклом организации на кластере. 

  • Кластеры бэкенда обрабатывали бизнес‑логику; при этом под каждый отдельный кластер MongoDB заводился свой кластер бэкенда. 

  • Хранилище (MongoDB / Yandex StoreDoc) — каждая организация жила в отдельной базе данных, примерно по 110 коллекций на организацию, а один кластер Mongo содержал несколько сотен таких баз.

Схема бывшей архитектуры Трекера

Схема бывшей архитектуры Трекера

Но у этой схемы была неприятная обратная сторона — ограничение MongoDB на количество коллекций.

Лимиты MongoDB и рост, который сломал модель

В документации к Mongo Atlas рекомендуется держать не более ~100K коллекций и индексов на кластер. В нашем внутреннем аналоге Yandex StoreDoc эти рамки не были описаны, и на Mongo 4.4 у нас было 1400 организаций на кластер.

Учитывая, что на одну организацию приходилось ~110 коллекций и ~340 индексов (~450 объектов суммарно), кластер нёс нагрузку в 585K–630K объектов. И это был предел: уже после 1300 организаций (~140K коллекций) создание новых индексов и коллекций начинало тормозить кластер, приводя к тайм‑аутам DML‑операций и ошибкам при обращении к бэкенду.

В пиковые недели приток мог достигать 4К новых организаций. С этим удавалось справляться благодаря запасу ёмкости имеющихся кластеров и тому, что владельцы удаляли больше не нужные им организации: на старых кластерах регулярно освобождалось место.

Однако в среднем органический рост составлял несколько тысяч организаций в неделю, и лимиты всё равно неумолимо приближались. 

Прирост организаций в неделю

Прирост организаций в неделю

При нашем лимите в 1400 баз на кластер это означало два новых кластера в неделю. А новый кластер — это ведь не просто базы данных + реплики, это ещё и бэкенды, мониторинг, усложнение релизов и дополнительная нагрузка на SRE.

В итоге мы получили такую картину за год:

  • Активные организации: +111% (выросли больше чем вдвое).

  • Количество кластеров: +106% (подскочило с 33 до ~70).

  • Реальный RPS: всего +13%.

Нагрузка почти не менялась, но инфраструктура и сложность эксплуатации росли вместе с числом организаций. 

Это вылилось в три большие проблемы:

  • Заблокированные обновления. Мы не могли перейти на новые версии MongoDB: на свежих версиях лимит организаций падал в разы, и нам пришлось бы развернуть ещё сотню кластеров сверху.

  • Сложности с observability. Алерты размазались по 70 кластерам. На общем дашборде всё было зелёным, а на конкретном кластере в это время уже начался сбой, который мы ловили только постфактум.

  • Бюджет и инфраструктура. При сохранении такой динамики к середине года мы улетели бы далеко за 100 кластеров.

Требования к целевой архитектуре Трекера

Стало понятно: просто добавлять новые кластеры больше нельзя, надо менять саму модель хранения. Схема «одна организация = одна база» неминуемо упиралась в лимиты хранилища. Поэтому мы решили перейти к общей мультитенантной базе, где данные многих организаций хранятся вместе и разделяются по идентификатору тенанта.

Функциональные требования:

  • Выделенные кластеры для крупных организаций. У некоторых организаций большие объёмы данных или жёсткие требования к изоляции, и нам нужно держать их на отдельном железе.

  • Горизонтальное масштабирование. Для нас было принципиально важно сохранить архитектуру горизонтального масштабирования, чтобы иметь возможность гибко добавлять новые бэкенды и кластеры, а не уйти в архитектуру с одним мегакластером. 

  • Защита от утечек на уровне архитектуры. Как только данные разных организаций оказываются в одной базе, появляется риск: один неверный запрос — и пользователь одной организации видит данные другой. Защита от этого должна быть зашита в систему. 

  • Никаких обязательных миграций с даунтаймом. Система должна одновременно работать и со старой архитектурой, и с новой. У нас терабайты живых данных — вариант «выключить сервис на ночь и всё перелить» мы даже не рассматривали.

Нефункциональные требования:

  • Минимум изменений вне слоя хранения. Часть запросов к базе Трекера формировалась в сервисном слое, потому что не было разделения на сервисную модель и модель хранения. И если бы при переходе на мультитенантность нужно было вручную модифицировать запросы, нам пришлось бы переписать все DAO и углубляться в бизнес‑логику.

  • Рациональное использование железа: новые кластеры и серверные ресурсы должны выделяться, только когда растёт RPS, а не просто из‑за появления новых организаций.

  • Запас роста на несколько лет: важно ликвидировать проблему системно и с заделом на будущее, чтобы мы не упёрлись в аналогичные лимиты уже через год.

На схеме целевой архитектуры Трекера мы сохранили привычное взаимодействие сервисов, и Frontend, Public API, Балансировщик и Координатор остались на своих местах.

Что изменилось: 

  • Выделенные кластеры (слева) продолжают работать для крупных и ещё не релоцированных организаций в режиме «одна база — одна организация».

  • Мультитенантные кластеры (справа) принимают новые организации в одну общую базу данных.

Целевая архитектура Трекера

Целевая архитектура Трекера

Так как организации перестали создавать сотни отдельных коллекций, приток новых организаций не исчерпывает лимиты MongoDB. В результате на один мультитенантный кластер удаётся разместить десятки тысяч организаций, а новые кластеры подключаются только при реальном росте нагрузки.

Возможные пути перехода к целевой архитектуре Трекера 

Было три способа перейти к новой архитектуре Трекера:

  • Разделить сервисные модели и DAO. Написать интерфейсы, сделать две имплементации под выделенную и мультитенантную базы и прокидывать нужную.

    Но у нас больше 140 DAO, и нам пришлось бы переписывать бизнес‑логику, полгода жить в рисках что‑то сломать и потом постоянно поддерживать двойной код для каждой новой фичи.

  • Перекроить модель хранения. Перевести первичные ключи на ObjectId, убрать старые _id в отдельные поля и везде вставить orgId.

    От этого варианта мы отказались сразу: у нас 93 сущности с неуникальными ключами, и переделка структуры документов в таком масштабе заставила бы одномоментно переливать терабайты данных с огромным риском уронить сервис.

Модели хранения

Модели хранения

  • Вклиниться в слой доступа к данным. Сделать Декоратор поверх MongoCollection, который превращает обычные запросы в мультитенантные.

Мы выбрали Декоратор. Это был единственный способ решить проблему, не переписывая бизнес‑логику, без рискованных миграций и раздувания стоимости разработки новых фич.

Механика работы MongoCollection Decorator

Декоратор берёт на себя трансформацию запросов и данных:

  • Формирует PK: декоратор перехватывает сохранение документа и формирует из исходного PK составной id: { orgId: <tenant>, id: <originalpk> }.

Трансформация данных на лету

Трансформация данных на лету

  • Автоматически добавляет условие по тенанту. К любому фильтру декоратор сам подшивает контекст текущей организации, и благодаря этому запрос не может утечь за границы тенанта.

Трансформация запросов к БД

Трансформация запросов к БД 

Поиск по неключевому полю

Поиск по неключевому полю

  • Модифицирует индексы: делает orgId автоматическим префиксом во всех создаваемых индексах коллекции, чтобы выборки работали быстро и использовали индекс.

Трансформация индексов

Трансформация индексов

  • Валидирует и преобразует обратно: при чтении восстанавливает исходную структуру _id для DAO и сверяет orgId документа с сессионным контекстом, а при рассинхроне выкидывает Exception.

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

Выбор ключа шардирования в MongoDB

Чтобы равномерно распределить данные мультитенантного кластера по шардам и не получить перекосов, нужно было правильно выбрать ключ шардирования. 

Мы рассмотрели три гипотезы:

  • Шардирование только по _id.orgId.

    • Плюс: все данные одной организации на одном шарде. Запросы выполняются точечно, без SHARD_MERGE, не нужно опрашивать весь кластер.

    • Минус: крупные организации быстро раздувают свои данные, чанки вылезают за допустимый размер, и появляются огромные Jumbo chunks, которые MongoDB не может разместить на нескольких шардах.

  • Шардирование по всему _id целиком (Hashed).

    • Плюс: равномерное распределение по шардам, никаких Jumbo chunks.

    • Минус: документы одной организации, которые должны быть в одной коллекции, хранятся в экземплярах этой же коллекции на всех шардах. Любая выборка по организации превращается в веерный запрос по всем шардам кластера (SHARD_MERGE).

  • Вариант, который мы в итоге выбрали, — составной ключ: { "_id.orgId": 1, "_id.id": "hashed" }

    • Плюсы: первое поле (_id.orgId, Range) работает префиксом — запросы конкретной организации направляются строго на её шарды; второе поле (_id.id, Hashed) равномерно размазывает документы гигантов по чанкам, спасая от Jumbo chunks. 

    • Минус: монотонно возрастающий orgId всегда записывается в последний диапазонный чанк. 

Запасной вариант, если это станет проблемой, — фолбэк — генерация случайных равномерно распределённых 8-байтовых ID. Вероятность коллизии тут мала, и даже если она случится, то мы назначим организации следующий случайный ID.

Как только мы определились со схемой шардирования, вылезли грабли: уникальный индекс гарантирует уникальность только в пределах одного шарда, если в него не входит ключ шардирования.

То есть стандартный unique index по какому‑нибудь логическому полю (например, по slug или email) в шардированном кластере просто не будет работать глобально.

Мы разрулили это разделением на два кейса:

  • Небольшие коллекции. Их шардируем только по orgId, а уникальный индекс делаем составным: (orgId + localKey). Уникальность проверяется локально внутри шарда организации, и этого вполне достаточно.

  • Большие коллекции. А вот если нужна глобальная уникальность по всему кластеру, мы проверяем её во вспомогательной нешардированной коллекции‑реестре, которая всегда лежит на одном шарде.

Онлайн‑релокация организаций без простоя

Мультитенантные кластеры решили проблему масштабирования новых организаций, но оставался главный вызов: как высвободить ресурсы и перевезти десятки тысяч уже существующих организаций со старых кластеров без даунтаймов?

Было три сценария релокации:

  • Выделенный → мультитенантный: оборачиваем старый PK в композитный ключ.

  • Мультитенантный → выделенный: выносим организацию, которая переросла общие мощности, на отдельное железо.

  • Выделенный → выделенный или мультитенантный → мультитенантный: точечно балансируем нагрузку между кластерами.

Обычные mongodump и mongorestore не подходят. Переносом занимается внешний процесс, на который мы почти не влияем. Его тяжело восстановить после сбоя. Для конвертации данных пришлось бы создавать временные view для каждой коллекции с новым первичным ключом.

Вместо них мы взяли Yandex Data Transfer с кастомным трансформером. Миграция запускается по схеме Snapshot + Replication, а её жизненный цикл проходит через статусы:

Статусы трансфера

Статусы трансфера

  • CREATED → CREATING. Проверяются подключения и, если нужно, создаются ресурсы. 

  • SNAPSHOTTING. Снятие и полный перенос текущего слепка данных. В начале этапа фиксируется стартовая позиция в Oplog источника (T0​).

  • RUNNING. Data Transfer на лету дочитывает изменения из Oplog от точки T0​ и стримит их в целевой кластер с сохранением resumeToken на случай сбоев.

  • DONE/ERROR. В DONE трансфер переходит после успешного переключения трафика, а при сбоях на любом из этапов проваливается в ERROR.

Рулит процессом Координатор — конечный автомат (FSM), который через фоновые воркеры пошагово ведёт организацию по всем этапам релокации.

Безопасное переключение трафика Rollback

Чтобы переезжать без простоя и риска потерять документы, мы разработали пятиэтапный сценарий переключения с гарантией быстрого отката Rollback.

Процесс релокации

Процесс релокации

  • CREATED. Координатор даёт бэкендам команду приготовить конфигурации для исходного (source) и целевого (target) подключений. 

  • COPY. Запускается основной трансфер (Snapshot + Replication). Параллельно регистрируется, но пока не активируется обратный трансфер (Replication). Соответствует статусам CREATING→SNAPSHOTTING→RUNNING трансфера. 

  • READY_TO_SWITCH. Следим за лагом репликации: как только отставание от реального времени падает до нескольких секунд, система готова к переключению. Соответствует статусу RUNNING трансфера. 

  • Переключение трафика (Switching). Самый критичный момент релокации — разведение потоков записи и чтения между кластерами.

    Как видно на схеме ниже, Балансировщик постоянно сверяется с Координатором и управляет маршрутизацией запросов Org1:

    • Переводим Org1 на исходном кластере в ReadOnly (розовый поток обновлений замораживается).

    • Ждём пару секунд, пока Data Transfer дочитает остаточный Oplog.

    • Запускаем обратный трансфер (Target → Source).

    • Обновляем таблицу маршрутизации в Балансировщике. С этого момента все новые запросы (зелёный поток) уходят на Backend N в Multitenant DB.

    • Снимаем ReadOnly — организация уже работает в новой мультитенантной базе.

Разведение потоков записи и чтения между кластерами

Разведение потоков записи и чтения между кластерами

  • COMPLETING → COMPLETED. Организация уже на новом кластере, но обратная репликация остаётся активной ещё 24 часа. Если за сутки аномалий не обнаружилось, статус меняется на COMPLETED, обратный трансфер гасится, а старая БД отправляется на утилизацию. Соответствует статусу DONE трансфера.

Если на этапе COMPLETING что‑то пошло не так — например, вылез неявный баг или просела производительность, — мы переключаем роутинг в Балансировщике обратно на старый кластер. Благодаря работающему обратному трансферу старая база содержит 100% свежих данных — пользователи продолжают работу, даже не заметив отката.

Так мы получили надёжную релокацию, за которую нестрашно в продакшене. Давайте посмотрим, к чему все эти перестройки привели, в цифрах. 

Результаты проекта

Спустя год после перехода на мультитенантную архитектуру мы сравнили ключевые метрики:

  • Кластеры бэкенда. Было ~70 — стало 7: 5 мультитенантных + 2 выделенных.

  • Количество организаций выросло почти в 2 раза. 

Экономия ресурсов на 1000 организаций:

  • CPU: −49%

  • RAM: −60%

  • Диск: −36%

Инженерные результаты: 

  • Разблокировали обновления MongoDB: полностью сняли ограничение по количеству коллекций на кластер.

  • Восстановили наблюдаемость: обслуживание 7 кластеров вместо 70 вернуло предсказуемость мониторингу и дежурствам.

  • Сняли барьеры роста: инфраструктура теперь масштабируется от реального RPS и объёма данных, а не от количества зарегистрированных организаций.

Архитектурные результаты и уроки, которые мы извлекли: 

  1. Прагматизм выше идеализма. Рефакторинг с полным разделением сервисных моделей потребовал бы месяцев разработки, а паттерн «Декоратор» над слоем хранения решил бизнес‑задачу гораздо быстрее, с минимальными затратами и без риска для бизнес‑логики.

  2. Лучше сразу делить приложение на слои. Главное — не допускать утечек работы с базой в верхние слои. Благодаря тому что работа с MongoDB была изолирована в DAO‑слое, мы переписали модель хранения прозрачно для остального кода.

  3. Любая миграция должна быть обратимой. Запуск обратного трансфера и 24-часовая фаза репликации спасли нас от потери данных при возможных сбоях.

  4. Безопасность должна быть зашита в архитектуру. Проверки принадлежности данных конкретному тенанту должны происходить автоматически в пайплайне запроса, а не зависеть от внимательности разработчика.

  5. Внимательно выбирайте ключ шардирования. Составной ключ спас нас от SHARD_MERGE и неделимых Jumbo chunks. Правда, ради этого пришлось пойти на компромисс и отказаться от привычных уникальных индексов (UK) в MongoDB.

  6. Проверяйте гипотезы и делайте прототипы. Только бенчмарки на реальных объёмах и нагрузке дают реальный ответ, выдержит ли база выбранную схему.

Если вы тоже сталкивались с масштабированием коллекций в MongoDB или мигрировали активные организации между базами — делитесь вашим опытом в комментариях!

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.