
На связи Денис Волков из команды платформы данных в Yandex Cloud. В предыдущей статье мы рассказали, как устроен SPQR (Stateless Postgres Query Router): архитектура, компоненты и принципы. Красивая теория. Эта статья — про то, что происходит, когда теорию начинаешь применять к живому продакшену с десятками таблиц, набором микросервисов и новогодней нагрузкой. Про грабли, решения и, конечно же, проблемы.
Про кейс клиента и финтех-индустрию в целом
Ещё в середине 2010-х годов в России начался переход на онлайн‑передачу фискальных данных — цифровой информации о кассовых операциях, которая поступает в ОФД и ФНС. Едадил — сервис кешбэков и скидок в ритейле, разработка которого началась ещё до этой эпохи. Сегодня пользователь просто сканирует чек в этом приложении, система получает данные из ФНС, анализирует состав покупок и начисляет кешбэк по активным акциям. Но за этим простым сценарием стоит сложная система: семь микросервисов на Python и Go, сотни таблиц, тысячи запросов в секунду.

Почему шардирование стало необходимостью
Когда сервисы кешбэков набирали популярность в период 2017–2019 годов, полный объём данных за 2–3 года, включая всю историю, не превышал 500 ГБ. Как следствие, на старте их разработки мало кто задумывался о вопросах шардирования.
Исходная архитектура сервиса Едадил представляла собой один кластер Yandex Managed Service for PostgreSQL на инстансе с 8 ТБ локального SSD‑хранилища, к которому обращались семь микросервисов, написанных на разных стеках (Python, Go) и в разное время.

Точкой невозврата, после которой команда всерьёз занялась вопросом шардирования, стал взрывной рост нагрузки, вызванный несколькими факторами:
Рост объёма данных. С подключением к системе электронных чеков из ФНС объём обрабатываемых данных вырос с нескольких сотен тысяч до пяти миллионов чеков в день. Объём оперативных данных, которые необходимо было хранить в PostgreSQL, достиг 650 гигабайт в месяц — это только данные, которые хранятся в течение 6 месяцев и позже могут быть удалены (чеки, состав чека). Также существуют данные, которые нельзя удалять (транзакции, кумулятивные начисления), их объём продолжает неограниченно расти.
Рост нагрузки. Новые бизнес‑инициативы, так называемые «мега‑кампании» (например, геймификационные акции), создавали пиковые нагрузки, буквально исчерпывая ресурсы CPU и IOPS единственного кластера.
Исчерпание возможностей вертикального масштабирования. Дальнейший апгрейд инстанса стал неэффективен по стоимости, а значит было необходимо горизонтальное масштабирование. Вопрос стоял ребром: нужно было выжить, чтобы сервис, завязанный на эту систему, продолжал работать.
Поскольку кластер уже работал в Yandex Cloud, переход на шардированную версию не требовал переезда между облаками. Однако миграция осложнялась тем, что близились новогодние праздники — горячая пора для ритейла.
Почему SPQR?
Команда искала решение, которое позволит разрезать данные по нескольким серверам, но при этом не потребует переписывать семь микросервисов. SPQR подошёл именно потому, что он работает как прозрачный прокси: встаёт между приложением и кластерами PostgreSQL, парсит SQL‑запросы, определяет на какой шард их отправить, и возвращает результат. Приложение при этом думает, что работает с обычным PostgreSQL — для него ничего не меняется.

Под капотом несколько компонентов: Router принимает подключения и маршрутизирует запросы на нужный шард, Coordinator управляет метаданными и переносом данных, QDB (etcd) хранит информацию о распределении данных, а Shards — это обычные кластеры PostgreSQL с мастером и репликами. Подробнее про архитектуру — в предыдущей статье.
Но помимо архитектуры были важны конкретные критерии — и после рассмотрения альтернатив, включая Citus, участники проекта выбрали SPQR:
Поддержка со стороны Yandex Cloud. Критически важным было получать поддержку решения, чтобы снять с команды Едадила бремя полной самостоятельной эксплуатации системы.
Совместимость со стандартным протоколом PostgreSQL. SPQR работает как прокси, понимающий нативный протокол, что избавляло от необходимости переписывать клиентские части многочисленных микросервисов или использовать кастомные драйверы.
Динамическая балансировка данных. Наличие в SPQR встроенного балансировщика, способного переносить данные между шардами, было ключевым для управления нагрузкой в будущем.
Архитектура миграции: поэтапный подход
Миграция заняла около года и была разбита на несколько логических этапов. Сначала картина казалась сложной и непонятной (ха, да и потом она казалась сложной и непонятной). Много проблем и вопросов на старте, надо куда‑то бежать, а куда бежать не ясно, и получается такой analysis paralysis. Но мы составили план и просто его придерживались:
1. Подготовка и анализ запросов
Начинать шардирование PostgreSQL нужно с анализа всей системы. То, что разрабатывалось годами, предстояло как‑то систематизировать за пару недель. Мы начали отсматривать запросы в pg_stat_statements (вкладка «Диагностика производительности» в Yandex Managed PostgreSQL) и пытались ответить на вопрос какая сущность в системе изменяется атомарно.
В финтехе это обычно account/merchant, в SaaS — tenant/organization или user_id. В нашем случае лучшим вариантом стал
user_idс типомuuid.
А дальше, исходя из нашего ключа, мы для каждой таблицы прикидывали как она ложится на шардирование с нашим ключом (нет ли cross‑shard FK/unique, можно ли жить без distributed JOIN и так далее). Все таблицы поделили на категории:
Шардированные (distributed). Таблицы с данными пользователей, где
user_idявляется ключом шардирования. Именно эти таблицы занимали 95% объёма базы. Ключ обязан присутствовать почти во всех WHERE и JOIN.Справочные (reference). Таблицы‑справочники, копии которых нужны на каждом шарде. Важное ограничение: по смыслу референсные таблицы это то, что меняется не часто и занимает относительно немного места на шарде.
На удаление. Legacy‑таблицы без явного владельца, давно не используемые в коде. Да, и такое тоже бывало:)
Но самое главное в шардировании не столько таблицы, сколько сами запросы. Для каждой таблицы выписали запросы с помощью pg_stat_statements. Далее все запросы разделились на четыре категории:
С явным
user_id— уже готовые для шардирования запросы. SPQR автоматически маршрутизирует их на нужный шард:
-- Роутер видит user_id и направляет запрос на соответствующий шард SELECT * FROM calculations WHERE user_id = 'a]b2c3d4-e5f6-7890-abcd-ef1234567890'; INSERT INTO checks (user_id, amount, created_at) VALUES ($1, $2, NOW());
Кросс-шардовые запросы — без ключа шардирования, для runtime-процессов (закрытие акций) и выгрузки данных. Чтобы не менять схему, решили добавить специальный комментарий (hint):
-- Выполнить на всех шардах (scatter query) /* __spqr__scatter_query: true */ SELECT COUNT(*) FROM calculations WHERE campaign_id = 123; -- Выполнить на конкретном шарде (для административных задач) SET __spqr__execute_on TO 'shard-001'; SELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE query LIKE '%heavy_query%';
Не шардированные, но используемые — например, таблица с миграциями
schema_migrations, которая существует локально на каждом шарде.Справочные таблицы — те, актуальную копию которых нужно иметь на каждом шарде. Специально для этой ситуации в SPQR были добавлены Reference tables:
-- Запросы к reference-таблицам выполняются локально на любом шарде SELECT * FROM campaigns WHERE id = 42; -- JOIN шардированной и справочной таблицы работает без scatter SELECT c.*, camp.name FROM calculations c JOIN campaigns camp ON c.campaign_id = camp.id WHERE c.user_id = $1;
2. Настройка правил шардирования
После классификации таблиц настраиваем SPQR через его административную консоль (coordinator, порт 7432). Сначала создаём distribution — логическую схему распределения данных:
-- Подключаемся в место, где задаются правила шардирования psql -h spqr-console -p 7432 -- Создаём distribution с указанием типа ключа шардирования -- В нашем случае user_id хранится как UUID в формате varchar CREATE DISTRIBUTION ds_user_id COLUMN TYPES UUID;
Затем определяем key ranges — диапазоны ключей, которые будут храниться на каждом шарде:
-- Создаём key range для первого шарда -- Все user_id от 00000000-... до 1fffffff-... идут на shard0 -- Это нужно для следующего шага "бесшовной миграции" -- Далее каждый key range будет переносится на соотвествующий шард -- Но это потом CREATE KEY RANGE kr_shard_001 FROM '00000000-0000-0000-0000-000000000000' ROUTE TO 'shard0' FOR DISTRIBUTION ds_user_id; CREATE KEY RANGE kr_shard_002 FROM '20000000-0000-0000-0000-000000000000' ROUTE TO 'shard0' FOR DISTRIBUTION ds_user_id; -- ... и так далее для каждого шарда
Чтобы SPQR мог как‑то догадываться на какие колонки в каких таблицах обращать внимание, чтобы принять решение а куда надо запрос отправить, надо об этих таблицах рассказать. Привязываем таблицы к distribution, указывая колонку‑ключ шардирования:
-- Привязываем шардированные таблицы -- в cashback.users ключ лежит в колонке id ALTER DISTRIBUTION ds_user_id ATTACH RELATION cashback.users DISTRIBUTION KEY id; -- в cashback.calculations ключ лежит в колонке user_id ALTER DISTRIBUTION ds_user_id ATTACH RELATION cashback.calculations DISTRIBUTION KEY user_id; -- в cashback.checks ключ лежит в колонке user_id ALTER DISTRIBUTION ds_user_id ATTACH RELATION cashback.checks DISTRIBUTION KEY user_id;
Для справочных таблиц используем отдельный механизм:
-- Создаём reference table — копия будет на всех шардах CREATE REFERENCE TABLE campaigns; CREATE REFERENCE TABLE strategy_templates; -- Для таблиц с auto-increment ID указываем это явно CREATE REFERENCE TABLE cashback.product_aliases AUTO INCREMENT id;
Reference tables в SPQR поддерживают INSERT ... ON CONFLICT DO UPDATE и COPY FROM STDIN для массовых вставок — это было критично для наших ETL‑процессов.
3. Одношардовая инсталляция (теневая миграция)
Путь к 1000 шардам начинается с первого шарда. Существующий монолитный кластер был объявлен первым шардом в SPQR, и весь трафик к базе стал проходить через SPQR‑роутер.
Если раньше микросервисы подключались напрямую к кластеру PostgreSQL, то теперь они стали подключаться к SPQR Router, а роутер стал выполнять роль прокси.

Это позволило в боевых условиях:
Оценить накладные расходы. В начале мы недооценили нагрузку на роутеры, и их ресурсы пришлось оперативно расширять, особенно с учётом предстоящих новогодних пиков.
Выявить скрытые проблемы, такие как «висящие» соединения с тяжелыми запросами, которые могли привести к переполнению диска. Команда SPQR оперативно отреагировала и исправила связанные с этим баги в течение недели.
4. Миграция «1 шард → 8 шардов»
Следующим шагом было превратить один шард в несколько. Самый важный этап, после которого нет пути назад. Со стороны пользователя это выглядело как выполнить terraform apply (чтобы добавить шарды в конфиг SPQR) и набрать несколько команд в консоли SPQR, которые начнут переносить данные. По факту всё оказалось несколько сложнее, потому что мы делали такую миграцию в первый раз.
Как работает перевоз данных под капотом
SPQR использует команду REDISTRIBUTE для переноса данных между шардами без downtime на чтение и запись:
-- Переносим key range на новый шард REDISTRIBUTE KEY RANGE 'kr_shard_003' TO 'shard-003' BATCH SIZE 200000;
Координатор разбивает key range на порции и переносит их в транзакциях. При этом:
Запросы к переносимым данным временно блокируются (на миллисекунды).
Остальные данные доступны без ограничений.
После переноса роутинг автоматически обновляется.
Важно, что на время переноса доступ на чтение/запись блокируется не к целому диапазону, а только к его небольшой части, которая в данный момент переносится.
Команды для мониторинга и управления задачами переноса:
-- Посмотреть текущие задачи переноса SHOW redistribute_tasks; -- Остановить перенос STOP TASK GROUP '2157de5a-e8cf-4978-9955-e570ad36e606';
Batch size — ключевой параметр. Мы начинали с 3000 записей и постепенно увеличивали до 300–400 тысяч, наблюдая за инсталляцией в целом. Ключевой метрикой было отсутствие ошибок на стороне приложения, отсутствие обращений пользователь, графики свободного места, утилизация ресурсов и replication lag.
Автоматизация с помощью redmon
Полностью автоматической балансировки в SPQR пока нет, а сценарий «1 шард → 8 шардов» требовал частого ручного вмешательства. Плюс была проблема с sequences в бизнес‑логике: все ID конкретного пользователя должны монотонно возрастать.
Для автоматизации был написан скрипт redmon, который:
Автоматически останавливает перенос, если видит проблемы.
Возобновляет перенос, когда кластер в норме.
Ведёт лог всех операций.
Запускает новые переносы, если старые завершились.
Поддерживает определённый rate задач, чтобы излишне не перегружать кластер, но и не недогружать.
Грабли и решения
Во время миграции мы столкнулись с несколькими неочевидными проблемами.
walreceiver на репликах
При интенсивном перевозе данных лаг на репликах начинал расти и не останавливался даже после полной остановки переноса. Каждый раз стабильно помогало убийство процесса walreiver и остановка переноса. Мы перепробовали разное:
Накидывали ресурсов репликам и мастеру.
Крутили
readahead.Анализировали запросы, искали тяжёлые.
Делали
pg_repackтаблиц.Настроили
kill walreceiverпо таймеру.Анализировали, на чём именно тупит walreceiver.
Ничего не помогло понять корневую причину. Это редкий баг в самом PostgreSQL, который мы встречали уже пару лет у разных клиентов, но каждый раз его природа оставалась загадкой. Чем интенсивнее работает перевоз, тем интенсивнее работает vacuum, и тем вероятнее срабатывание бага.
Самый страшный момент был в начале миграции: мы чуть не отправили одну из реплик в режим read‑only — счётчик свободного места остановился на 1%. Это была реплика с 8 ТБ local‑ssd, отступать было некуда.
Единственное, что помогало временно:
# Если лаг продолжает расти даже после остановки переноса kill $(pgrep walreceiver) # Останавливает рост лага service odyssey restart # Полный fix, включает catchup timeout
В итоге мы смирились и просто замедлились. Вместо теоретически возможных 600+ ГБ в день мы перевозили по ~300 ГБ в сутки, чтобы минимизировать импакт на реплики. Это увеличило общее время миграции, но позволило держать систему стабильной.
О скорости перевоза. В идеальных условиях (нет лага на репликах, нет работ в дата‑центрах, не бежит vacuum/pg_repack) можно перевозить до 550 ГБ в день. Но это не значит, что за 5 дней будет перевезено 2,5 ТБ — реальность вносит свои коррективы. В среднем у нас получалось около 1 ТБ в неделю. При слишком высокой скорости потом приходилось больше заниматься pg_repack.
Решение проблем sequences в PostgreSQL
Проект развивался с середины 2010-х годов, когда о шардировании ещё никто не думал. В результате сложилась ситуация:
Во всех таблицах (как справочных так и данных пользователей) есть колонка
idзначение которой генерируется из PG‑последовательностей.Все FK‑связи и ограничения построены на колонке
id.Бизнес‑логика в коде и запросах имеет привязку к тому, что значение
idвсегда монотонно возрастает в рамках данных одного пользователя.
Для референсных таблиц удалось переиспользовать механизм sequences в самом SPQR. Условно, приложение отправляет INSERT INTO в роутер без колонки id, а роутер сам подставляет значение в запрос.
Для шардированных таблиц в SPQR готового решения тогда не было, и команде Едадила пришлось реализовать эту логику на своей стороне. В первой итерации настроили последовательности на шардах так, чтобы:
значения, генерируемые на шарде A, не пересекались бы со значениями на шарде B (где A и B — произвольная пара шардов);
значения, генерируемые в рамках одного шарда, оставались монотонными.
Для релизации подобной схемы следует настроить начальное значение и шаг последовательности. Для этого нужно определить:
начальную точку отсчёта, допустим 1M;
максимальное количество шардов, допустим 200 штук.
Тогда шарды будут генерировать значения вида:
shard0: 1 000 000, 1 000 200, 1 000 400, 1 000 600, …
shard1: 1 000 001, 1 000 201, 1 000 401, 1 000 601, …
shard199: 1 000 199, 1 000 399, 1 000 599, 1 000 799, …
Схема генерации последовательностей, описанная выше для шардированных таблиц, имеет один существенный недостаток: произвольное перемещение данных между шардами может нарушить монотонность генерации ID в данных. Как следствие, отдельные участки логики будут работать некорректно. В следующей итерации команда переиспользует механизм sequences в самом SPQR, когда он появится.
Текущее состояние и выводы
На сегодняшний день система работает на восьми шардах, миграция завершена. Во время миграции нам удалось избежать проблем с доступностью сервиса. Вот что мы вынесли из этого опыта:
80% работы — это инвентаризация. Классификация SQL‑запросов и таблиц заняла больше времени, чем сам перевоз данных. Но именно эта работа определила, насколько гладко прошла миграция.
Sequences продумывайте заранее. Стратегия работы с первичными ключами и последовательностями — то, что легко недооценить на старте и больно исправлять потом.
Начинайте с одного шарда. Поэтапный подход с «теневым» прогоном трафика через роутер — это не перестраховка, а способ найти проблемы до того, как они станут критичными.
И главный совет: не ждите идеального момента для шардирования — его не будет. Если вы читаете эту статью и узнаёте свою ситуацию — база растёт, вертикальное масштабирование упирается в потолок, а бизнес подкидывает всё новые нагрузки, — то помните, что идеального понимания всех нюансов не будет, пока вы не начнёте пробовать на практике.
SPQR развивается как открытый проект на GitHub под лицензией PostgreSQL Global Development Group. Будем рады вашему вниманию и участию в нём: звёздочкам и PR на GitHub, а также вопросам и предложениям в нашем чате Data Platform.
