Когда система растёт, нагрузка на базу становится критической, а готовых решений для шардирования PostgreSQL не хватает. Стандартные подходы не устраивали — поэтому команда сделала своё решение для шардирования в ядре процессинга заказов. Оно позволяет решардировать данные без простоев и деградации метрик.

Привет! Меня зовут Игорь Березняк, я руковожу группой процессинга в Техплатформе Городских сервисов Яндекса. В этой статье по мотивам моего доклада на HighLoad++ расскажу, как мы проектировали шардирование PostgreSQL, чтобы выдерживать нагрузку, сохранить доступность и уложиться в строгие требования по latency.
Раздел 01. Про сервис процессинга
Яндекс Такси — это часть Городских сервисов Яндекса, так же как и Лавка, Еда, Маркет и Доставка. Техплатформа Городских сервисов разрабатывает общую технологическую платформу, обеспечивающую инфраструктуру для всех. А это более 2500 микросервисов с суммарным RPS 2,5 млн.
Я расскажу про один из компонентов Техплатформы — Processing as a Service (ProcaaS, или процессинг) — сервис, за который отвечаю. Это ядро, связывающее различные микросервисы в единый цикл обработки заказа — от поиска машины до оплаты и завершения поездки.
Чтобы понять, как устроена наша система шардирования и почему именно так, сначала разберёмся, какие задачи решает процессинг:
-
Обрабатывает заказы Такси и других Городских сервисов Яндекса
Cервис процессинга занимается тем, что обрабатывает события и сущности вокруг заказов. Исторически он работал только с Такси, но со временем, когда бизнес-группа разрослась, его начали использовать другие проекты: Еда, Лавка, Маркет, Доставка. Это базовый элемент всей экосистемы: без него остальные сервисы просто не смогут хорошо работать.
-
Является одним из микросервисов бэкенда Такси
Процессинг реализован как отдельный микросервис. Бэкенд в Техплатформе — микросервисный, и процессинг подчиняется тем же принципам. В нём нет продуктовой логики, её реализуют другие команды, изолированные в своих микросервисах.
-
Оркестрирует цикл заказа Такси
Главная функция процессинга — быть связующим звеном между разными компонентами вроде поиска, оплаты и логистики, объединяя их в единый цикл заказа. В статье я буду ссылаться на примеры из Такси — они самые наглядные и многочисленные, но всё сказанное актуально и для остальных Городских сервисов Яндекса.
Модель работы сервиса процессинга
В основе сервиса — поток событий, поступающих через API от клиентов. Эти события описывают происходящее в системе: создание заказа, назначение исполнителя, изменение статуса и т. д. Процессинг объединяет события в цепочки, сгруппированные по топикам — сущностям, которые связывают события по смыслу, — и асинхронно их обрабатывает.
Обработка включает обращения к другим микросервисам: вызовы ручек, обмен данными и синхронизацию состояний. Таких цепочек много — на каждый заказ такси или другую сущность приходится своя. Цепочки полностью изолированы друг от друга.
Примеры событий сервиса заказа такси: заказ создан, найден исполнитель, исполнитель согласился или отказался принять заказ и тому подобное.
Эти цепочки работают как входные символы автомата заказа: события проходят через процессинг и продвигают заказ по стадиям, заложенным в бизнес-логике.
Чтобы это работало, сервису нужно хранилище, которое позволяет:
-
сохранять цепочки событий;
-
доставать и обрабатывать их пакетами;
-
периодически обновлять данные — например, помечать события как обработанные, когда они прошли нужную стадию.
Дальше о том, как устроено хранилище и как мы его масштабируем.
Требования бизнеса
На процессинг ложится значительная часть нагрузки всей экосистемы, поэтому к нему предъявляются жёсткие требования по надёжности, скорости и масштабируемости.
Критический компонент. Процессинг — mission-critical-часть цикла заказа Яндекс Такси. Без него сервис просто не будет работать: невозможно создать заказ, провести оплату или завершить поездку. Отсюда главное требование — высокая доступность.
Целевая доступность — 99,999%. Фактическая пока чуть ниже — около 99,99%+, но команда активно движется к «пяти девяткам».
Минимальная задержка ответа — меньше 20 мс в p98. И для пользователей, и для бизнеса это критичный показатель. Также бизнес хочет, чтобы латентность обработки событий была минимальной. Чем быстрее находится заказ, тем выше утилизация ресурсов и эффективность всей экосистемы: водитель быстрее получает заказ, клиент — машину, а бизнес — прибыль.
Относительно высокая нагрузка — больше 10 000 RPS. По меркам highload нагрузка у процессинга не экстремальная, но нужно учитывать, что сервис выполняет транзакционную работу. Он должен обрабатывать события в моменте — иначе бизнес-процессы останавливаются, а любое увеличение таймингов может ухудшить пользовательский опыт.
Персистентность данных. Данные по заказам — постоянные, их нельзя ни терять, ни портить. И хранить их нужно фактически вечно. К примеру, может затянуться проведение платежей или получение обратной связи о поездке. Чтобы это сработало, нужно поднять старый заказ, вернуть его в процессинг и обработать автоматически, как будто он только что пришёл. Это не кеш, который можно очистить, и не transient-данные, которые просто «протухают» со временем. Это персистентные данные, которые должны храниться гарантированно и без потерь.
Масштабирование. Нагрузка на систему постоянно растёт по трём причинам:
-
Сезонные пики — высокий период для Такси и Маркета (праздники, Новый год, скидочные кампании).
-
Органический рост: рост числа пользователей, поездок, покупок и так далее.
-
Новые бизнесы: запуск новых направлений, под которые нужно оперативно добавлять мощности.
Технологический стек
Когда мы только начинали, подходящих решений для шардирования PostgreSQL не существовало — ни по надёжности, ни по требованиям к производительности. Поэтому мы решили разрабатывать свою систему.
Так в 2019 году появился наш собственный процессинг — по идее он напоминает то, что позже стало известно как Temporal, но только в общих чертах. Подробнее об этом я рассказывал в другой статье.
Основные технологии, на которых мы построили систему:
-
Userver и STQ — внутренняя инфраструктура для асинхронных сервисов.
-
PostgreSQL (в нашем случае — облачная версия).
-
Yandex Cloud, в котором развёрнуты compute- и storage-мощности.
Проблема масштабирования
Когда процессинг вырос, мы столкнулись с типичной для высоконагруженных систем задачей — масштабированием со стороны приложения и базы данных под ним.
Масштабирование приложения — задача относительно простая. У него нет состояния, и узлы стоят за балансером. Когда нужно увеличить мощность, мы добавляем новый узел — балансер распределяет запросы и задачи, и всё продолжает работать.
А вот масштабирование базы данных, в нашем случае PostgreSQL, — совсем другая история. У базы есть состояние, и его нужно корректно реплицировать между нодами. Это долгий и рискованный процесс: данные нельзя повредить, иначе их невозможно будет восстановить. Также их нужно правильно синхронизировать — сервис должен оставаться доступным.
Чтобы решить эту задачу, мы пошли по пути горизонтального масштабирования через шардирование PostgreSQL. И самое интересное в шардированных системах — это то, как произвести решардирование, то есть перераспределить данные между шардами без простоя, потери консистентности и просадки производительности.

Основные сложности при решардировании мы сформулировали так:
-
Гибкость схемы шардирования
Система должна позволять не только добавлять шарды, но и сокращать их или перераспределять между ними нагрузку. Например, когда часть данных нужно перенести в другой регион или дата-центр.
-
Работа на живой системе
Решардирование должно происходить без остановки сервиса. Процесс не должен вызывать деградацию или расхождения данных: база обязана оставаться консистентной, а бизнес-метрики — стабильными.
Раздел 02. Подход к шардированию
Готовые решения для шардирования PostgreSQL уже существуют — например, система SPQR (Stateless Postgres Query Router). Этот и подобные инструменты построены по схожему принципу: есть центральный координатор, через который проходят запросы из сервиса. Этот координатор определяет, в какой шард направить конкретный запрос и как объединить результаты.

Наша схема устроена иначе:
-
Нет центрального координатора или иной прослойки.
-
Каждый узел приложения подключён напрямую ко всем шардам.
Такое подключение даёт минимальную задержку, потому что данные идут напрямую, нет лишних сетевых хопов. Плюс это снижает вероятность отказа, потому что на пути запроса становится на одну точку отказа меньше. И это сильно упрощает всю систему шардирования — становится проще код и его поддержка.
-
Для отказоустойчивости — синхронная репликация.
Каждый шард представляет собой реплицированный кластер PostgreSQL, где в стандартной конфигурации, например для облачных баз, — один лидер и два фолловера. Если лидер шарда отказывает, один из фолловеров автоматически принимает его роль, и система продолжает работу без простоев.
-
Шардирование ручное и статическое.
У нас нет непрерывного процесса, как, например, в YDB. Если нужно изменить схему, инженер пишет и деплоит YAML, а затем следит за выкаткой. Этого достаточно, потому что решардирование происходит крайне редко — один-два раза в год.

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

Что шардируется
Процессинг асинхронно обрабатывает потоки событий — сущностей, которые поступают в систему. Все они хранятся в одной большой таблице, которая служит центральным хранилищем процессинга.
Именно эта таблица и стала объектом шардирования — всё сводится к ней одной.
Мы делим данные по содержимому, а не по времени. В системе действительно есть разделение на горячее и холодное хранилища, но это не шардирование в строгом смысле: в PostgreSQL остаются только оперативные, «горячие» данные.
Шардирование выполняется по строкам, а не по столбцам — так удобнее, тем более что строк в нашей таблице на порядки больше.
Полученную схему таблицы я называю sharding-friendly. Каждая колонка в ней относится к одному из трёх типов:
-
первичный ключ;
-
метаданные;
-
непрозрачные данные (opaque), о которых процессинг ничего не знает (это полезная нагрузка событий, которую он просто хранит и передаёт дальше).
В основе схемы лежат два принципа — локальность и неконфликтность. Не уверен, что это официальные термины, но смысл в них есть, и дальше я расскажу, что под ними понимаю.
Локальность данных
Наше основное хранилище — это таблица с событиями, при этом процессинг — мультитенантный сервис. Такси, Еда, Лавка — все они хранят свои данные в общей таблице.

Благодаря сегментации потребителей мы можем спроектировать первичный ключ так, чтобы обеспечить локальность данных. В нашем случае он состоит из трёх колонок:
-
Большой проект, например Такси, Еда, Лавка и пр.

-
Топик внутри этого проекта — цепочка событий: заказ, платёж, доставка и т. д.

-
Набор событий в рамках топика — последний компонент первичного ключа.

Такое естественное разделение позволяет выбрать префикс первичного ключа в качестве ключа шардирования: shard_id = F(project_id, topic_id)

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

Непрозрачные данные (opaque). Процессинг их не интерпретирует. Мы считаем их immutable, и это гарантируется на уровне идемпотентности. Если данные неизменяемы, то конфликтов не возникает.

Метаданные. Это единственный тип колонок, которые могут меняться. Но они спроектированы так, что изменяются монотонно.

Здесь можно вспомнить идею CRDT (Conflict-free Replicated Data Type). Мы не используем «честный» CRDT, но принципы похожие. Для колонок с монотонными изменениями задаются простые правила разрешения конфликтов. В нашем процессинге используется три типа:
-
boolean — флажки, которые могут изменяться только в одну сторону, например
true→false.
Когда событие обработано, флаг сбрасывается и обратно вtrueне возвращается. Если при решардировании две записи с одинаковым первичным ключом имеют разные значения флажка, конфликт решается простой логической операцией:-
AND — если движение в сторону
false; -
OR — если в сторону
true.
-
-
nullable — переход
NULL→ non-NULL. Когда колонка заполняется, значение больше не меняется. При конфликте записи смёржить можно с помощью функцииCOALESCE(a, b), которая выбирает непустое значение. -
timestamp — значение только растёт. Здесь применяется операция
MAX(a, b), которая берёт более актуальную метку времени. Здесь есть свои особенности, связанные с тем, что метки могут флуктуировать, но в нашем случае это никакой роли не играет.
В других предметных областях могут понадобиться другие типы колонок — например, счётчики, векторы или словари, которые также можно реализовать по аналогии с CRDT (или просто использовать честные CRDT-типы данных).
Как сделать запрос
Все запросы в такой системе идут по первичному ключу. Вторичные индексы для критичных путей мы не используем.
Префикс первичного ключа используется как ключ шардирования. Из него вычисляется виртуальный шард:
virt_shard_id = hash(project_id, topic_id) % N_virt_shards
Затем по статическому маппингу виртуальных шардов на физические определяется, куда именно направить запрос:
shard_id = static_mapping(virt_shard_id)
Если требуется решардирование, в этот маппинг можно добавить или убрать из него физические шарды, а также перераспределить нагрузку, изменив диапазоны виртуальных шардов.
В момент выкатки новой схемы шардирования может возникнуть временная неконсистентность данных. В распределённой системе обновления не доходят до всех узлов одновременно: пока одни уже применили изменения, другие продолжают работать по старой схеме.

Маппинг хранится в YAML-конфиге, который доставляется на узлы через CI/CD-пайплайн, — тот компилирует код, добавляет конфигурации и раскатывает новые образы. Процесс стандартный, но растянутый во времени, поэтому легко возникает ситуация, когда часть узлов уже использует новый конфиг, а часть — всё ещё использует старый. В результате одни узлы считают, что данные по ключу лежат в одном шарде, а другие — что в другом. Это и создаёт риск рассинхронизации, но у такой проблемы есть решение.
Как поменять схему шардирования
Мы выкатываем не одну конкретную схему, а историю версий схем. Полный список версий хранится в конфиге. Когда нужно решардировать PostgreSQL, мы добавляем новую версию и выкатываем весь файл с историей целиком.
Процесс похож на классическую Zero Downtime Migration, только мигрируется не база данных, а схема шардирования.

Изначально, после появления новой схемы, она просто лежит рядом, и ничего не происходит. Следующим шагом выкатки отмечаем новую схему как доступную для чтения.

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

Сжато, как работает тот же самый алгоритм решардирования:
-
Выкатываем схему в режиме read-only. На этом этапе новая схема только читает данные.
-
Дожидаемся завершения переходных процессов. Все промежуточные операции должны полностью отработать, иначе можно получить несогласованность.
-
Переключаем запись на новую схему. И после этого запускаем фоновую переливку хвоста — перенос оставшихся данных из старых шардов в новые.
-
Проверяем консистентность. Валидируем состояние системы. Смелые разработчики могут этот шаг пропустить, но лучше не рисковать.
-
Удаляем старую версию. Когда всё выровнено, старую схему удаляем и запускаем следующий цикл решардирования.
Плюсы и минусы подхода
После нескольких лет эксплуатации мы хорошо поняли, в чём сильные стороны нашей схемы и какие с ней связаны ограничения. Подход оказался простым и надёжным, но не универсальным, потому что требует дисциплины и ручного контроля.
Плюсы:
-
Нет привязки к СУБД
Несмотря на то что я рассказываю про PostgreSQL, сам подход не зависит от конкретной СУБД. Его можно реализовать на MongoDB, Redis, MySQL и других системах.
-
Минимальные тайминги запросов
Отсутствие центрального координатора даёт низкие тайминги запросов. Запросы идут напрямую, и это особенно радует аналитиков и бизнес, которым важна скорость отклика.
-
Гибкость при решардировании
Гибкость практически не ограничена: можно как менять саму формулу шардирования (хеш-функция, размер кольца), так и просто корректировать статический маппинг для перераспределения виртуальных шардов.
-
Нет хранимых состояний
Нет хранимых состояний, как в некоторых системах шардирования. А значит, нет и проблемы менеджмента этих состояний и сама архитектура остаётся предельно простой.
Минусы:
-
Ручные операции
Все действия при шардировании выполняются вручную. Для нас это не проблема, но не всем такой подход подойдёт.
-
Возможная неравномерность нагрузки
Это потенциальная проблема «горячих» шардов. На практике встречается редко, но если возникает, решается переподбором ключа шардирования или переходом на системы с автоматическим решением таких кейсов. Например, в YDB, где шарды могут автоматически сплититься и становятся чуть менее «горячими».
-
Масштабирование подключений к PG
Из-за отсутствия координатора каждый узел приложения подключается напрямую к лидерам. А у лидеров есть лимит на число соединений. Это ограничивает уже масштабирование самого приложения. Даже если уменьшить пул коннектов до минимума, лимит рано или поздно станет узким местом.
-
Особенности схемы
Подход не универсален и требует специфической структуры данных. Поэтому схема подойдёт не всем, но может служить готовым кейсом или источником идей.
Итоги
Из нашего опыта можно сделать несколько выводов:
1. Шардирование неизбежно
Если вы строите не игрушечный интернет-магазин, а систему с растущей нагрузкой, то рано или поздно вам придётся шардировать базу. Лучше продумать это заранее: спроектировать схему данных так, чтобы её потом можно было легко масштабировать и решардировать без боли.
2. Стремление к локальности
Разделение данных по естественным границам — проект, топик, цепочка событий — позволяет обойтись без мультишардовых транзакций и сложных распределённых блокировок. Это делает систему проще, надёжнее и понятнее.
3. Немутабельность и монотонность
Эти два принципа позволяют решать конфликты детерминированно и держать данные консистентными даже во время решардирования.
4. Простота лучше магии
Мы сознательно отказались от координаторов, автошардинга и других «умных» прослоек. Взамен получили систему, где всё прозрачно, и инженеры понимают, что происходит. Это снижает риски и облегчает поддержку.
5. Гибкость через осознанные компромиссы
Ручное управление требует дисциплины. Но кроме этого даёт контроль и предсказуемость — важные качества для систем, которые работают в режиме 24/7 и где ошибка стоит дорого.
А чтобы узнать больше о мире высоконагруженных систем...
..., приходите на Saint HighLoad 2026! Конференция ежегодно собирает лучших специалистов IT-отрасли. Программа охватывает такие аспекты веб-разработок, как архитектуры крупных проектов, базы данных и системы хранения, devops и системное администрирование, нагрузочное тестирование, эксплуатация крупных проектов и другие направления, связанные с большими и высоконагруженными IT-системами.
Автор: bznk
