Шардирование сервиса объявлений Авито Доставки. Часть II
Меня зовут Артем. Я в Авито с 2016 года: начинал тестировщиком, затем стал бэкенд‑инженером, с 2019 пишу на Go, сейчас руковожу командой разработки в Авито Доставке. Это продолжение истории про то, как мы перевели сервис объявлений на шардированное хранение, какие архитектурные решения приняли и к каким результатам пришли спустя два года эксплуатации.
С чем пришлось разобраться перед стартом
Мы уже знали из первой части, почему выбрали шардирование: вертикальный рост уперся в потолок, единая база становилась узким местом, а кросс‑функциональные фичи требовали масштабируемого и предсказуемого по SLA решения. На старте 2023Q2 мы сформулировали план внедрения, где ключевыми этапами стали:
- создание нового шардированного хранилища;
- реализация запросов с учетом шардинга;
- тестирование и валидация корректности;
- переход от одиночной инсталляции к работе с несколькими шардовыми базами;
- финальный перевод трафика и стабилизация.
Как подключали дополнительную базу к историческому сервису
Создать сам кластер баз — рутинная задача. Сложность оказалась в интеграции нескольких баз с одним сервисом. В инфраструктуре действовал принцип «один сервис — одна база»: он исключает коммунальные базы и снижает риски пересечений между командами, но одновременно осложняет подключение второй БД внутри одного сервиса. Итерации с DBA шли медленно: правила доступа, политики, пул соединений — многое было заточено под одиночную базу.
Выручил бывший DBA, перешедший к нам в бэкенд. Он подробно описал, как корректно:
- оформить доступ одного сервиса к хранилищу другого;
- настроить пулы соединений под несколько БД;
- разделить роли и ограничить права так, чтобы не нарушать изоляцию.
Фактически он составил пошаговую инструкцию, понятную как нашей команде, так и коллегам из DBA. Этот эпизод показал ценность горизонтальных связей: человек с опытом по обе стороны границы «приложение–СУБД» может снять блокирующие факторы в один подход.
Архитектура клиентской части: «кластер» как набор коннектов
На уровне приложения мы перестали мыслить «одна база» и перешли к «кластеру» — слайсу соединений к отдельным шардам. Это прозрачная для бизнес‑логики абстракция: есть интерфейс кластера, а за ним несколько соединений. Интерфейс получился компактным, но выразительным, и включает шесть методов:
- ShardByItemId — возвращает подключение к нужному шарду по ключу шардирования item_id;
- QueryContext — самый гибкий метод: позволяет распараллеливать запросы по набору ключей и агрегировать результаты, мы используем его и для item_id, и для user_id;
- ShardMappingByItemId — строит маппинг элементов на шарды, чтобы сформировать shardsMapping;
- MainShard — подключение к главному шарду с не шардируемыми таблицами и служебными запросами;
- Shards — возвращает все подключения к шардам, пригодится, когда заранее неизвестно, где лежат данные (например, выборка по user_id);
- Close — аккуратно закрывает коннекты.
Внутренности QueryContext
Этот метод принимает три ключевых параметра:
- shardsMapping — мапа вида номер_шарда → список ключей, которые нужно спросить на этом шарде;
- buildQueryFunc — функция‑конструктор SQL, получающая список ключей для батч‑запроса и возвращающая текст и аргументы;
- scanFunc — функция сборки результата, которая парсит строки ответа и агрегирует их; при необходимости возвращает массив ошибок по шардовому контексту.
Семантика проста: мы строим маппинг, затем запускаем по горутине на каждый шард, внутри которой формируем запрос через buildQueryFunc и выполняем его. Результаты аккумулируем через scanFunc. Важно, что QueryContext не фиксирован на одном типе ключа — он работает и для item_id, и для пользовательских выборок по user_id, если грамотно подготовить shardsMapping.
Пример: получить все объявления пользователя по user_id
У нас была функция getAllByUserID. Чтобы адаптировать её к шардам, мы выделили подфункцию getAllByUserIDCluster:
- shardsMapping: каждому шарду присваиваем один и тот же user_id, так как объявления пользователя могут быть распределены по разным шардам;
- buildQueryFunc: формирует один и тот же запрос по user_id, аргумент ровно один;
- scanFunc: дополняет общий слайс items результатами с каждого шарда; требуется синхронизация, так как каждый шард обрабатывается в собственной горутине.
Итоговый исполнение через QueryContext как раз и инкапсулирует параллельные вызовы и сбор ответов.
Как мы тестировали шардирование
Мы выстроили несколько уровней проверки:
- юнит‑тесты на интерфейс кластера с моками соединений и контролем корректности маппинга ключей на шарды;
- интеграционные тесты с локальным стендом из нескольких инстансов базы, где проверяли консистентность выборок и падение в деградированный режим при отказе одного шарда;
- нагрузочные прогоны с профилированием горутин, пулов соединений и времени жизни транзакций;
- проверка идемпотентности и ретраев: критично для нашего слоя, чтобы повторный вызов не приводил к дублированию сайд‑эффектов;
- хаос‑инъекции: искусственно увеличивали латентность одного шарда и оценивали поведение QueryContext, тайм‑аутов и обработки ошибок.
Переход от одиночной к шардированной инсталляции
Мы отказались от «большой кнопки» и делали многоступенчатый прогрев:
- сначала подняли шардовый кластер и подключили сервис в пассивном режиме, выполняя теневые чтения без влияния на ответы, сравнивали результаты;
- затем начали перевод части трафика на чтение из шардов при записи всё ещё в основную базу;
- реализовали дуальные записи: основная база и соответствующий шард получали одинаковые изменения;
- провели бэкфилл исторических данных в шарды, валидировали количество и контрольные суммы;
- после стабилизации переключили основной путь чтения на шарды, оставив fallback в одиночную базу на короткий период;
- отключили fallback и законсервировали старые таблицы.
Финальный этап перехода
Ключевые задачи на финише:
- пересмотр ограничений пулов соединений, чтобы не истощать ресурсы при параллельных запросах;
- настройка тайм‑аутов и политики отказоустойчивости: быстрая деградация лучше, чем глобальная блокировка;
- мониторинг p50/p95/p99, доли ошибок по шард‑группам, ретраи, saturation на шард‑узлах, GC‑паузы на приложениях;
- дожим corner‑кейсов: большие списки объявлений пользователя, пагинация, сортировка по нескольким критериям.
Нюансы проектирования ключей и карта шардов
Отдельно уделили внимание распределению ключей:
- ключ шардирования — item_id, он хорошо распределяется и не создаёт горячих точек;
- запросы по user_id не используются как шардирующий ключ, поэтому для них предусмотрены «веерные» чтения с осторожной конфигурацией тайм‑аутов;
- применили виртуальные шарды: поверх физической сетки мы поддерживаем большее число логических слотов, что упрощает ребалансировку;
- карта шардов хранится в сервисе конфигурации и подхватывается без рестартов, есть версия карты и механизм атомарного обновления;
- при плановом увеличении числа шардов мы двигаем виртуальные слоты, уменьшая объем миграций.
Порядок и сортировка при кросс‑шардовых выборках
Сложность не в том, чтобы прочитать, а в том, чтобы корректно отсортировать и странично отдать пользователю:
- в каждом шарде применяем одинаковые предикаты и ограничения;
- собираем результаты, делаем merge‑sort по ключу сортировки (например, по времени обновления);
- пагинация на уровне агрегатора требует аккуратных курсоров: мы используем маркеры, включающие ключ сортировки и идентификатор записи, чтобы корректно продолжать выборку, не дублируя и не пропуская элементы.
Ошибки, тайм‑ауты и ретраи
Мы пришли к правилам:
- time‑to‑first‑byte ограничен коротким тайм‑аутом на шард, общий запрос имеет верхнюю границу по времени;
- ретраи допустимы только для идемпотентных операций; записи — строго с защитой от повторов;
- если один шард деградирует, мы возвращаем частичный результат только для сценариев, где это допустимо; в остальном — честная ошибка и метрика инцидента;
- для чтений по user_id ограничиваем веерный параллелизм и вводим backpressure, чтобы запросы не раздували пулы.
Наблюдаемость
Чтобы управлять многокомпонентной системой, мы сделали:
- разметку метриками на уровне QueryContext: время построения маппинга, латентность на шард, доля ошибок по типам, количество горутин;
- трейсинг с пропуском контекста через все вызовы, чтобы видеть, где «умирает» запрос;
- алерты по отклонению p95/p99, росту ошибок на конкретном шарде и несоответствию объема данных между шардом и эталонными выборками.
Какие уроки мы получили
- Принцип «один сервис — одна база» хорош, но к нему нужна оговорка о шардировании. Добавочный слой абстракции в приложении снимает конфликт.
- Универсальный интерфейс поверх кластера баз позволяет развивать код без множества условных веток.
- Виртуальные шарды упрощают жизнь: не нужно постоянно «двигать гору» данных, достаточно переставлять слоты.
- Кросс‑шардовая пагинация и сортировка — это не просто SQL и «собрать в кучку», нужен аккуратный мерж и курсоры.
- Тесты с хаос‑инъекциями выявляют реальные проблемы с тайм‑аутами, а не теоретические.
Результаты спустя два года, на Q3 2025
Мы оценивали систему по ряду метрик и организационных эффектов:
- производительность: средняя нагрузка выросла кратно, p95 по ключевым чтениям снизился за счет распараллеливания; p99 стабилизирован и меньше зависит от «тяжелых» пользователей;
- отказоустойчивость: отказ одного шарда приводил к деградации только части трафика, остальной продолжал работать без заметных просадок;
- стоимость: масштабирование по нескольким узлам вместо монолита позволило оптимизировать затраты, особенно с учетом профилей нагрузки;
- операционная стабильность: количество ночных инцидентов сократилось, алерты стали локальными, а не глобальными;
- развитие: стало проще запускать новые фичи, не боясь обрушить единый «центральный» узел.
Что бы мы сделали иначе
- Изначально заложили бы контракт на кросс‑шардовую пагинацию и курсоры, чтобы не переделывать API позднее.
- Раньше внедрили бы виртуальные шарды: первые миграции оказались тяжелее, чем могли быть.
- Больше внимания уделили бы лимитам параллелизма на уровне приложения, чтобы не передавливать базы в пиковые моменты.
Практические советы тем, кто идет тем же путем
- Четко определите ключ шардирования, оцените распределение и риск «горячих» ключей. Если активность «схлопывается» на user_id, избегайте делать его ключом.
- Сделайте универсальный интерфейс кластера; один метод для веерных запросов с понятными хуками на конструирование и сбор результатов сильно упрощает жизнь.
- Введите виртуальные шарды с первого дня. Это дешевле, чем переносить терабайты.
- Планируйте миграцию как продуктовый релиз: с метриками успеха, канареечными этапами, теневыми чтениями и откатами.
- Много инвестируйте в наблюдаемость: трейсинг, метрики на шард, алерты по SLA.
Завершение
Шардирование для нас стало не только способом расширить производительность, но и драйвером инженерной дисциплины. Мы оформили четкие контракты на доступ к данным, создали слой абстракции, который переживет смены СУБД и инфраструктурные решения, и получили систему, легче масштабируемую и предсказуемую по поведению. За проектом стояла большая командная работа — и архитектурная, и операционная, и организационная. Именно эта связка позволила пройти путь от монобазы до распределенного хранилища, не потеряв темп разработки и качество сервиса для пользователей.



