
Всем привет! Меня зовут Андрей, я бэкенд‑разработчик в команде Яндекс 360. В индустрии я уже около 15 лет и повидал некоторое ПО. Последние три года занимаюсь ядром Яндекс Диска — всем, что связано с метаданными о файлах.
Однажды мы в Диске переносили общие данные из шардированного MongoDB в шардированный же PostgreSQL. После переноса пользовательских данных у нас осталась часть данных про общие папки. Их было сложно изолировать внутри шарда пользователя, и они остались в общей БД на MongoDB, которую мы так и назвали — CommonDB. Спустя время мы заметили, что общая БД не справляется с нагрузкой: все запросы перед выполнением должны были сначала получить информацию об общих папках, и только после этого они начинали работать. Поэтому надо было дублировать информацию ближе к другим данным пользователей — на их шарды.
Однако при дублировании важно было избежать распределённых транзакций, так как они снижают общую производительность. Также проблемой был сам процесс перехода: у нас сотни миллионов пользователей, которые не должны были ощущать процесс перехода и потерять доступ к своим данным. При этом надо было выкатывать изменения не сразу на 100%, а частично, с возможностью в любой момент отключить функциональность. При выкатке также нельзя было допустить даунтайм.
В статье я хочу поделиться опытом этой масштабной миграции. Под катом покажу, как вообще устроены сложные миграции и как к ним подходить. А также перечислю те пункты, на которые нужно обратить внимание, если вам предстоит миграция под нагрузкой.
Как была устроена архитектура
Основные элементы архитектуры Яндекс Диска
Начнём с того, что это не один сервис, обрабатывающий все файлы, а около 60 подсервисов. Одни выполняют отложенные задачи, другие отвечают, например, за рендеринг документов. Схема всего этого хозяйства довольно большая. Я покажу её просто для иллюстрации масштаба. Сразу скажу, что система хранения бинарных данных выделена особо, и о ней мы сегодня говорить не будем.

В статье мы сосредоточимся на ядре Диска — той части, что отвечает за метаданные: как файлы организованы в папки, как папки вложены друг в друга и т. д. Основные компоненты, о которых пойдёт речь:
MPFS/DJFS — файловая система;
Sharpei — координатор шардов;
MPFS DB — БД метаданных о файлах и папках;
CommonDB — вспомогательная БД, с которой мы сегодня как раз и будем разбираться.
Ядро Диска хранит данные в шардированной структуре — у нас около 150 шардов. Когда пользователь делает запрос к своей файловой системе, она сначала обращается к Sharpei — координатору, который знает текущее состояние всех шардов. Про схему шардирования и её эволюцию я подробно рассказывал в одной из своих статей.

Файловая система спрашивает у Sharpei, на каком шарде «живёт» пользователь. Координатор отвечает адресами реплик и указывает, какой нужен мастер. После этого файловая система отправляет запрос уже в нужный шард. Схема понятная и рабочая: нагрузка распределяется по пользователям равномерно. Но всё портят общие папки.
Проблема общих папок

Общие папки — это, по сути, «окна» в диск другого пользователя. Но вся метаинформация по этим папкам хранится только у владельца. Мы не дублируем данные на шардах гостей и не синхронизируем между ними десятки шардов: это и сложно, и чревато расхождениями в данных. Вместо этого вся логика сосредоточена у владельца: он хранит не только структуру папки, но и информацию о правах доступа других пользователей.
Из‑за этого, когда гость делает любой запрос к файловой системе, она сначала должна получить список всех общих папок этого пользователя, чтобы понять: будет ли он работать со своими личными файлами или же с общими. Получив полный список общих папок и зная, в какой папке хочет работать пользователь, мы с помощью координатора шардов выясняем, где «живёт» владелец, и отправляем запрос уже в его шард.

Теперь — подробнее о CommonDB, которая хранит информацию об общих папках. Нам нужно уметь получать список общих папок, которыми владеет текущий пользователь, и находить ссылки на чужие общие папки, где он выступает в роли гостя.
Фактически это две коллекции:
shares
— собственные общие папки пользователя;share_links
— ссылки на чужие папки, к которым у пользователя есть доступ.
Коллекции связаны через ID, но это не настоящие внешние ключи и никакой жёсткой связи между ними в MongoDB нет. С точки зрения этой базы данных такая схема неидеальна: она не гарантирует консистентности.
Но у нас были свои требования: актуальная информация об общих папках должна быть одновременно доступна всем — и владельцу, и каждому гостю. Если владелец меняет, например, права доступа, эти изменения должны сразу же отразиться у всех участников. При этом сама CommonDB — это точка, к которой идут все такие запросы. Из‑за этого у неё:
высокий rate чтения — каждый раз при проверке пути надо понять, не относится ли он к общей папке;
низкий rate записи, что несколько облегчило нам процесс миграции.
Однако по мере роста сервиса количество соединений к MongoDB, где живёт CommonDB, стало приближаться к пределам её возможностей. Возникли перебои. Более того, ядро диска использует шардированный PostgreSQL, а тут — Mongo. В результате приходится поддерживать два типа БД в одном проекте, что неудобно и усложняет эксплуатацию.
А ещё, если с CommonDB случалась беда, страдал весь сервис. Условный енот‑дежурный Диска в такие моменты буквально сидел в огне. Но вида не подавал.

С учётом всех вводных созрел план
Во‑первых, мы хотели, чтобы любой запрос пользователя обслуживался его собственным шардом, включая запросы на общие папки. Это позволило бы отказаться от обращения к общей базе.
Во‑вторых, мы стремились избавиться от единой точки отказа. При этом переход должен быть незаметен для пользователя. Да, мы могли себе позволить короткие окна read‑only, но никакого даунтайма, особенно массового, быть не должно.
С учётом этого мы решили задействовать уже имеющуюся шардированную БД, чтобы переложить данные ближе к самим пользователям и каким‑то образом согласовать данные, если они нужны сразу на двух шардах.
Как мы реорганизовали данные
Мы решили перенести данные из MongoDB в PostgreSQL. Структура осталась почти той же: появились две таблицы — shares и share_links, аналогичные коллекциям в MongoDB. Главное отличие — у каждой записи появилось поле state
со статусом синхронизации.
Кроме того, гостям тоже нужно видеть общие папки. Поэтому информацию о них мы начали сохранять прямо в таблицу папок. Это, в общем, логично: если есть папка, то пусть она будет прямо в файловой системе. Для этого мы добавили в схему таблицы папок специальные поля: share_id
, owner_id
, rights
. Таким образом, гость получает доступ к общей папке через привычную файловую структуру, без похода в другой шард или общую БД.

Нам нужно было не просто создать новые таблицы, но и консистентно изменять их у владельца и гостя. Для этого мы использовали паттерн саги с оркестратором. Роль оркестратора выполняло наше ядро.
Рассмотрим пример: пользователь принимает приглашение в общую папку.
Вот как это происходило шаг за шагом:
Сначала создавался линк в PostgreSQL с состоянием
syncing
(синхронизация в процессе).Далее на шарде гостя создавали новую общую папку с указанием
share_id
,owner_id
и прав доступа.Опционально добавляли соответствующую запись в CommonDB. На момент переезда это было необходимо, чтобы обеспечить обратную совместимость.
После завершения всех шагов саги мы меняли состояние линка у владельца на ready.

Только после этого гость видел, что приглашение принято, и получал доступ к папке. Если на каком‑то этапе что‑то шло не так, запускалась отдельная задача, она проходила недостающие шаги и завершала процесс.
Первое правило миграции: никто не должен знать о миграции
А теперь — к самому сложному. Изменения схемы и логики — это одно. А как переехать с реальными данными так, чтобы всё продолжало работать? Для нас это означало одно главное правило: пользователи не должны заметить миграцию. Никаких ошибок, никаких замедлений — сервис должен работать по‑прежнему, как будто ничего не происходит.
Один из ключевых принципов миграции: downtime должен быть нулевым либо максимально близким к этому. Это касается и переходов на новую архитектуру, и любых экспериментальных изменений.
Ещё один принцип, который мы всегда соблюдаем: гранулярность переключения. Мы должны иметь возможность мигрировать отдельных пользователей (например, для тестирования) и выкатываться по процентам, постепенно расширяя охват.
Это всё нужно прежде всего ради безопасного отката. В любой момент мы можем «дёрнуть рубильник» и вернуть систему в исходное состояние, если вдруг обнаружится фатальная проблема. Такой подход позволяет избежать катастроф даже при ошибке в проде.
Рассмотрим типовую миграцию в базе данных. Например, мы хотим заменить колонку на новую.
Сначала у нас есть старая колонка, из которой мы читаем и в которую пишем.
Мы добавляем новую колонку, пока не трогая код, который её использует.
В течение следующих 2–3 дней выкатываем обновлённый код, который умеет работать с новой колонкой и начинает писать уже в две колонки сразу.
При необходимости заполняем значения в новой колонке разовыми скриптами.
После того как новая колонка заполнена и всё работает стабильно, начинаем читать часть данных из неё, проверяя корректность и производительность.
Постепенно увеличиваем процент чтения из новой колонки до 100%.
После этого прекращаем запись в старую колонку — и можем её безопасно удалить сначала из кода, а потом из самой БД.
Такая стратегия позволяет поэтапно переключать нагрузку, следить за поведением системы и при необходимости быстро откатываться назад.
Наш кейс явно не типовой. Раньше одна запись равнялась одной операции в Mongo. Теперь же у нас работает сага, которая затрагивает два шарда и две разные базы данных — Mongo и Postgres. Это уже не простая миграция колонки.
К тому же важно было, чтобы пользователи, не участвующие в эксперименте, продолжали работать ровно как раньше:
только с MongoDB;
безо всякой саги;
используя прежние соединения и сессии к БД.
Иначе говоря, всё должно было оставаться идентичным, чтобы при тестировании на небольшом проценте пользователей можно было отлавливать реальные проблемы (например, с производительностью), не влияя на всех остальных.
Мы приняли решение: во время миграции можно временно блокировать пользователю доступ к операциям изменения общих папок, то есть, например, приём инвайтов. И только на короткое время. Чтение настроек общих папок при этом должно оставаться доступным, как и все остальные операции с диском.
Чтобы определить допустимый тайм‑аут на перевод в read‑only, мы провели ряд замеров:
Исторический RPS для изменяющих операций. Чтение у нас всегда под высокой нагрузкой, а вот по изменяющим хендлерам (создание, приём инвайтов, удаление из папки) мы собрали статистику, чтобы понять их реальную нагрузку.
Процент пользователей с общими папками. Нам нужно было понять, сколько пользователей вообще затронуты миграцией и в какой доле случаев в принципе потребуется read‑only.
Время выполнения изменяющих запросов, которые затрагивались переводом в read‑only. Мы замерили тайминги этих операций. Нужно было понимать, сколько времени подождать, прежде чем переводить пользователя в read‑only, чтобы не прерывать активные процессы.
Среднее время миграции одной общей папки. Это помогло оценить общее время, необходимое для переноса некоторого процента пользователей после того, как завершатся все их пишущие операции.
С какими проблемами мы столкнулись
Когда мы провели все замеры, стало ясно: просто так взять и заблокировать запись даже для 1% пользователей недопустимо. Слишком высок риск того, что кто‑то получит ошибки или столкнётся с блокировками. Мы хотели избежать этого.
С другой стороны, идти по одному проценту за раз означало бы растянуть миграцию на неопределённо долгое время. Нам нужен был более гибкий и управляемый механизм.
Решение: конфиг миграции по пользователям. Мы разработали дополнительную обвязку — конфига миграции. В нём мы стали хранить:
общий статус миграции: активна ли она, какой процент пользователей уже переведён, какой — в очереди;
индивидуальные настройки для каждого пользователя: откуда читать (Mongo или Postgres), куда писать (в одну из баз, в обе или никуда — если пользователь временно в read‑only).
Таким образом, на каждого пользователя у нас есть точная схема маршрутизации: например, читать и писать в Mongo, или читать из Postgres, а писать в обе БД, или даже пока ничего не писать — тогда read‑only.

Как выглядели шаги миграции:
Миграция включена, процент выката — 0%. Все пользователи продолжают работать с CommonDB, читают и пишут только в Mongo.
В конфиге миграции включаем флажок, что миграция в процессе. Ставим, например, процент на 1 — будем мигрировать первый 1% пользователей.
Если приходит новый пользователь, у которого ещё нет общих папок и он попадает в эксперимент, мы сразу инициализируем его в новой конфигурации: читаем из Postgres или пишем и в Postgres, и в Mongo. Это безопасно: у пользователя ещё нет данных, с которыми можно что‑то сломать.
Из общего пула пользователей, попавших в этот 1%, мы выбираем небольшой чанк — скажем, 1000 человек. Переводим их временно в read‑only. Ждём завершения всех пишущих операций (по измеренным тайм‑аутам). Выполняем миграцию их данных. После успешного переноса данных между БД меняем им конфигурацию: теперь они читают из Postgres и пишут в обе базы.
Когда мы прошлись по всем пользователям, попадающим в процент выката, мы завершили миграцию. В конфиге миграции выставлялось, что миграция завершена, текущий процент — 1%, и теперь всем пользователям больше не нужно было сверяться с конфигом: они читали из Postgres и писали в обе базы (до момента полного отключения Mongo).
Пользователи, не попавшие в эксперимент, продолжали ходить только в Mongo. Мы повышали процент постепенно: с 1 до 5, затем до 10% и так далее, пока не дошли до полной миграции.
После выкатки на 100% мы смогли отключить запись в Mongo и перейти полностью на шардированный Postgres.
Это и было нашей финальной целью: убрать Mongo как точку отказа и упростить архитектуру — минус один компонент, плюс устойчивость.
Вся миграция заняла несколько месяцев. Мы осознанно не запускали её в автоматическом режиме — выкатывали вручную, раз в неделю, под контролем. А ещё постоянно следили за нагрузкой и корректностью. Мы хотели, чтобы в критический момент кто‑то из команды был «на месте».
По логам ни один пользователь не получил сообщение о read‑only. Это, кстати, говорит о том, что можно было бы смело брать чанк побольше — скажем, с парой единичных read‑only‑сообщений, но в пределах допустимого.
Всё вместе — разработка, тестирование, переезд — заняло около года. Это был крупный, системный проект.
Выводы
Вот несколько вещей, которые мы для себя вынесли из этого проекта:
Нужно собирать максимум данных до старта. Исторические RPS, тайминги, вероятность попадания пользователей в кейс изменения данных — всё это помогло нам выбрать правильную стратегию переезда. Без этой информации мы рисковали бы больше и шли бы вслепую.
Миграции — не про шаблоны. Подход всегда зависит от сервиса. Что работает у нас — может не сработать у вас. Но сам принцип пошагового контроля, откатов, измерений универсален. Возможно, наша схема пригодится вам не только для миграций баз данных, но и для выката других сложных изменений.
Всегда должен быть план Б. Независимо от стадии — даже если выкатка уже на 99% — должен быть способ откатиться в ноль. К счастью, нам это не понадобилось, но мы к этому были готовы. А если вы можете сделать полный бэкап — сделайте. Если не можете, лучше ответить на вопрос «почему?» и всё равно сделать.
В некоторых случаях миграция с даунтаймом допустима: можно погасить сервис, накатить миграцию и поднять сервис снова. Иногда это самый безопасный способ. При любых изменяющих действиях важно не только знать, что делать, но и иметь чёткий сценарий на случай, если что‑то пойдёт не так.
P. S.
Многие наши решения отталкиваются от архитектуры Яндекс Почты. В частности, Sharpei — это почтовый компонент, и с ним связано много полезного опыта. Если вам интересна тема шардирования, рекомендую:
доклад Кирилла Григорьева про Sharpei и резолв айдишников: как за 5–10 мс определить нужный шард при RPS в тысячи на кластер почти из тысячи шардов;
и вообще истории успеха Яндекс Почты по переходу на шардированный PostgreSQL.
На этом всё. Спасибо за внимание!
gsl23
Что за шардированный postgres? У postgres нет (своего) шардирования.