Что делать, если сервис, который вырос из транзакции в монолите, за несколько лет стал входной точкой во все размещения на Авито? Когда через PostgreSQL проходят миллионы объявлений в день, привычные приёмы вроде «подождём, пока освободится блокировка» перестают помогать справляться с нагрузкой. А DELETE FROM больше не удерживает рост таблицы на диске.
Эта статья — ретроспектива развития продукта Listing Fee. В ней Евгений Константинов, backend-инженер Авито, рассказывает, как вместе с командой справлялся с ростом нагрузки и объёма данных без шардирования, а ещё про инциденты, дедлоки и «аварии первого числа», из-за которых критичные бизнес-сценарии оказывались недоступны. Материал подготовлен по мотивам выступления на Saint HighLoad++ 2025 — с разбором проектных ошибок, технических решений и приёмов, которые иногда спасали буквально одним запросом.

Как устроены размещения на Авито
Listing Fee — один из первых продуктов монетизации Авито (запущен в 2014-м) и сегодня это входная точка для размещений. Listing здесь — не про «строчки кода», а про сам процесс публикации объявлений на площадке.
Представьте, что вы заходите в общественный транспорт — автобус или метро. На входе получаете билет. Сегодня он уже виртуальный, но со сроком действия, ограничением на пересадки, время поездки и так далее. Можно купить и проездной, чтобы пользоваться транспортом многократно, не думая о каждом отдельном билете. Похожий процесс реализован в Listing Fee. Там тоже свой контроль, только не разовый, как в транспорте, а постоянный — примерно 10 тысяч проверок в секунду.
В терминах транспортной метафоры: билеты — это размещения, льготы — бесплатные лимиты, проездные — пакеты размещений. Всё это долгое время держалось одной большой транзакцией. Так было устроено в монолите в 2014 году, и на тот момент решение казалось единственно возможным. С тех пор многое изменилось.

К 2020 году сервис плавно вырос по нагрузке: примерно на миллион размещений в день за пять лет. Мы уже переехали в отдельный микросервис, но вся внутренняя архитектура продукта оставалась прежней.

В этот же период к Listing Fee добавился альтернативный способ монетизации — CPA (Cost Per Action). Он также позволяет публиковать объявления на площадке, но с другим принципом оплаты: если в Listing Fee деньги взимаются сразу за размещение, то в CPA пользователь платит только за результат — звонки, клики, чаты и другие действия.

Ключевая особенность CPA в том, что само размещение становится почти или полностью бесплатным, а иногда даже безлимитным. Логично, что это привело к резкому росту количества объявлений на сайте. А вместе с ними вырос и поток размещений в нашем сервисе.
К этому моменту Listing Fee был уже не просто продуктом монетизации, а фактически точкой входа во все размещения на Авито. Поэтому именно он первым принял на себя увеличение нагрузки.
С этого момента началась довольно тяжёлая борьба, про которую я расскажу в трёх актах. Как боролись с блокировками, замедляющими работу и пользовательские сценарии, затем с диском и бесконтрольно растущей таблицей, угрожавшей стабильности всего сервиса. И в финале — уже за выживание: сдерживали нагрузку и искали обходные решения.
Акт 1. Борьба с блокировками
В 2020 году наш продукт рос не слишком быстро, и главной задачей было поддерживать растущую нагрузку. Перенося его в отдельный сервис, мы унаследовали не только его функциональность, но и технические подходы из монолита. Вместе с ними досталась и работа с блокировками — механизмами, запрещающими нескольким процессам изменять одни и те же данные одновременно. Сами блокировки не представляют собой ничего плохого: этот встроенный в СУБД инструмент работает на благо системы, но имеет побочные эффекты — из-за них растёт время ответа, что критично для пользовательских сценариев, а ещё они неизбежно создают дополнительную нагрузку на базу.
Пессимистичный сценарий
Изначально ограничение накладывалось по пользователю — это самый пессимистичный способ. От него мы быстро отказались, перейдя к блокировке по ресурсам — лимитам или пакетам размещений.

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

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

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

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

Оптимизация на уровне бизнес-логики
Следующим шагом стала оптимизация бизнес-логики. С появлением CPA у нас появились и безлимитные пакеты размещений. Их можно сравнить с проездным: вы заходите в транспорт и просто проходите, предъявив его кондуктору.

Мы попробовали. Получилось хорошо, но недостаточно.

На графике выше время в миллисекундах, и видно, что изменения минимальные. Причина в том, что блокировка никуда не делась — она просто переехала в другой ресурс, в лимиты. А мы даже для бесконечных ресурсов продолжали эти лимиты списывать. И избавиться от них было непросто, для этого нужно было договариваться с бизнесом.
На графике выше видно: время отклика в миллисекундах с минимальными изменениями. Причина в том, что блокировка никуда не делась — она лишь сместилась в другой ресурс, в лимиты. А мы даже для бесконечных ресурсов продолжали их списывать. И избавиться было непросто — требовалось менять бизнес-логику.
К счастью, здесь у нас совпали интересы. В CPA лимиты мешали запуску продукта на новых пользователей: сначала списывались наши лимиты, и на такие размещения CPA не действовал. Мы запустили оптимизацию и избавились от большого числа блокировок. На этом продуктовые эксперименты закончились, и мы перешли к следующему этапу.
Архитектурный поиск
На уровне архитектуры было две основные точки входа в размещение: синхронные пользовательские сценарии (мобилки и веб) и автозагрузка.

Автозагрузка — инструмент, позволяющий профессиональным пользователям загружать тысячи или даже сотни тысяч объявлений одним файлом и редактировать их там же. Здесь нет непосредственного взаимодействия с пользователем, и публикация происходит асинхронно для него, но всё ещё синхронно для нас. Поэтому мы решили стать асинхронными по отношению к автозагрузке тоже.
В этом был смысл, поскольку на долю автозагрузки приходилось до 70% размещений, а значит, что работая даже с одним источником, мы имели шанс на значительную оптимизацию.
Первым шагом стала собственная очередь — чтобы самим распределять нагрузку на сервис. Это помогло, но оказалось лишь отправной точкой.
С очередью появилась возможность группировать размещения по ресурсу и отправлять их пачками. Чтобы сделать это более эффективно, мы разбили очередь на партиции.

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

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

Пользователи публиковали всё больше объявлений: если в 2020-м дневной объём размещений за год вырос примерно на миллион, то к 2023-му — по миллиону каждые три месяца.

Мы были уверены, что готовы к таким темпам, ведь у нас появились собственные инструменты управления нагрузкой. Но забыли об одной важной вещи — о хранилище.
Акт 2. Борьба за место на диске
К 2023 году пространство на диске стало стремительно заканчиваться. Отдельной проблемой был размер таблицы размещений — к тому моменту он достиг примерно 1 ТБ.
Поддерживать таблицу такого размера, в которой постоянно обновляются и активно читаются данные, слишком сложно. Наши DBA в Авито рекомендуют ориентироваться на 5-10 ГБ как верхнюю границу. Размеры, превышающие её на два порядка, — заведомо плохая идея.
Так мы взялись за уменьшение размера таблицы. Было два варианта:
Простой — удалять данные.
Сложный — вводить партиции.
Мы, конечно, начали с варианта попроще и надеялись, что он сработает.
DELETE FROM
На первый взгляд, всё выглядело неплохо:
данные удаляются,
оператор достаточно гибкий в настройке (нам приходилось задавать правила, потому что удалять всё подряд мы не могли).
У подхода были и минусы:
Нагрузка на базу
Данных накопилось очень много, удалять их приходилось активно. Это создавало нагрузку, к которой база была не готова.
Рост только сдерживался
Удаление не уменьшало фактическое количество данных. Если Vacuum не очищал страницу полностью, она не возвращалась, и реального сокращения базы не происходило.
Двойная работа
В этой архитектуре был ещё один важный момент: мы переносили часть данных логической репликацией в архивный Postgres. Его рано или поздно тоже нужно было чистить. Получалось двойное удаление и двойная нагрузка на поддержку.
Tx Wraparound
Самое неприятное — риск переполнения счётчика транзакций в Postgres.

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

Представьте, что все строки имеют условный «срок годности». Postgres как заботливая хозяйка понимает: если что-то залежалось, а съедать не собираемся — пора в морозилку. Так работает процесс Vacuum Freeze. Он замораживает строки, которые продолжают жить, но при полном обороте счётчика могли бы «уйти в прошлое». После фриза они оказываются в минус бесконечности — всегда в прошлом по отношению к любому положению счётчика.
Проблема возникает, когда обычный Vacuum не успевает вовремя заморозить данные. Переполнения счётчика Postgres допустить не может, поэтому запускается более агрессивный Vacuum — настолько тяжёлый, что сервис буквально «чувствует себя плохо»: по метрикам видно, как падает производительность. Иногда такой Vacuum срабатывал днём и на несколько часов замедлял всю работу Авито.

Итоги перехода на DELETE FROM
Мы попытались сократить таблицу простым способом — удалением строк. Оператор гибкий, его можно настраивать под разные сценарии, но на практике вместо решения одной проблемы получили несколько новых:
размер таблицы всё равно оставался огромным;
мы быстро упёрлись в лимиты диска;
удаление оказалось неэффективным — создавало повышенную нагрузку и при этом не освобождало место;
возник риск Tx Wraparound Vacuum.
На наших масштабах DELETE FROM (терабайт+ только в одной таблице) оказался непригодным. Пришлось переходить ко второму способу.
DETACH PARTITION
Когда сроки начали поджимать, мы отключили удаление и срочно занялись партиционированием таблицы.
С партициями проще: удаление данных сводится к DETACH самой старой партиции, после чего её можно дропнуть или перенести в другое место. Но у этого механизма есть требования к бизнес-логике:
Нужен последовательный ключ, который станет ключом партиционирования.
Нужна временность данных, чтобы у них был срок годности и они в какой-то момент становились достаточно старыми для удаления.
У наших размещений срок действия есть. Но, как обычно, нашёлся нюанс: помимо объявлений на 30 дней в Авито есть продвижение с помощью платных услуг, которые могут продлевать срок работы объявления практически без ограничений.
Например, Дмитрий разместил объявление в 2020 году и всё это время исправно продлевает его с помощью услуг продвижения.

Мы вынуждены поддерживать такие объявления актуальными, поэтому в базе могут оставаться размещения пятилетней давности и старше. Решение было таким: добавить ещё одну партицию с условным dt = -infinity (Postgres это позволяет). Перед удалением старой партиции мы проверяем её содержимое, находим данные, которые относятся к активным объявлениям, и переносим их в эту специальную партицию.

Такой подход сработал. Он треб��вал регулярного обслуживания, но позволял держать часть данных вне времени. По сути, мы переизобрели Vacuum Freeze для своей бизнес-логики, и этот механизм до сих пор работает.
Одну проблему мы решили — таблица перестала бесконтрольно расти. Но ещё до запуска стало ясно, что нас ждёт новая сложность.
Проблемы с ключом партиционирования
Ключ партиционирования по datetime оказался для нас невыгодным. Мы практически не запрашиваем данные по таким запросам — наши данные связаны либо с объявлением, либо с уникальным идентификатором, а значит, если мы ничего не предпримем, то просто будем ходить и опрашивать все партиции. Это окажется даже дороже, чем работать с терабайтной таблицей.
Мы перебрали несколько вариантов и в итоге нашли рабочий. Посмотрев на распределение запросов, выяснили: в среднем объявление живёт около 30-ти дней — то есть попадает всего в одну-две партиции. Логично было сначала отправлять запросы только туда, а остальные партиции опрашивать лишь при необходимости.

Подход сработал: более 99% запросов получали успешный ответ, а оставшийся 1% без проблем дочитывался из остальных партиций. Так вероятностный подход и ориентация на данные спасли наш запуск.
«Аварии первого числа»
Мы запустились: всё выглядело отлично, старые данные удалили, таблица разгрузилась. Можно было бы сказать, что это успех, но одним утром мы поняли, что уронили сервис. Так началась череда так называемых «аварий первого числа».
Причина здесь угадывается: именно в этот день происходила смена партиций. Postgres начинал писать в новую партицию, но был к этому не готов. База всё ещё оставалась большой, воркеры не успевали обслуживать таблицы, а сам Postgres по какой-то причине считал новую партицию неинтересной для Vacuum. А вместе с Vacuum не запускался и ANALYZE, который должен был подсказать планировщику, когда использовать индекс и какой именно.

Из-за этого примерно на первом миллионе записей (иногда на втором) база переключалась на крайне неудачный способ чтения: либо выбирала неподходящий индекс, либо уходила в Seq Scan, и чтение останавливалось.
Помог обычный ручной ANALYZE. В итоге проблема была решена прибитым для первого числа кроном, который делал ANALYZE вручную. Важно понимать: ANALYZE пустой таблицы бессмысленен, его нужно запускать только после того, как в ней накопились данные.
Кстати, слово «ANALYZE» стало по-своему волшебным — на наших размерах базы и конкретно этой таблицы очень многие аварии решались буквально одной этой конструкцией.
Например, мы пытались запустить GIN-индекс, но это всегда заканчивалось Seq Scan или неэффективными индекс-сканами. Только ANALYZE позволял индексу заработать и решал часть аварий.
Вернёмся к предыдущему графику с временем ответа базы. Причиной проблемы оказался индекс, на который Postgres переключался первого числа. Мы его удалили — он не был особенно нужен. Казалось бы, победа. Затем мы убрали крон с ANALYZE, будучи уверенными, что всё работает: на тестовом стенде проблем не было и поначалу всё было хорошо. Но авария лишь сменила время: вместо трёх ночи стала происходить в семь утра — когда проснулся не только Дальний Восток, но и вся центральная часть страны уже активно пользовалась Авито. Сбой оказался на виду у всей компании.
С тех пор каждое первое число, вплоть до перехода в шарды, нам приходилось назначать дежурного, который просто следил за тем, чтобы запрос успешно переходил на нужный индекс, и у него была ровно одна инструкция — ANALYZE.
Случались и другие аварии, потому что инструмент был для нас новым, а база слишком большой:
DETACH/DROP table требуют эксклюзивной блокировки
Мы «погорели» на том, что DETACH всегда требует эксклюзивной блокировки. Обычно он сталкивался с нашими Vacuum: худо-бедно Vacuum завершался, DETACH проходил, и всё заканчивалось нормально. Но однажды пришёл тот самый агрессивный prevent wraparound Vacuum, который не пропустил DETACH. Так случилась новая авария.
Особенность default-партиции — эксклюзивная блокировка на attach партиции
Ещё одну аварию поймали на ATTACH. В норме в ходе процесса создаётся новая партиция без эксклюзивных блокировок. Решение, что самая старая партиция должна быть DEFAULT привело к новым сложностям. Мы пытались обложиться костылями, чтобы скипать эти блокировки, но ничего не вышло. В итоге идея с default-партицией оказалась провальной. Достаточно было просто создать ещё одну «резервную» партицию и складывать данные туда.
Дедлоки на DDL
Самой эпичной стала авария с новым для нас видом дедлоков. Когда я их впервые увидел, то даже не понял, откуда они взялись и что с ними делать. Это были дедлоки на DDL при дропе и пересоздании триггера.

Проблема была в том, что когда мы выполняли на родителе DROP TRIGGER, у него своего триггера нет. Команда транслировалась во все дочерние партиции, но порядок её выполнения нигде явно не задавался — партиции обрабатывались в случайном порядке. Если в этот момент другой процесс захватывал несколько партиций тоже в произвольном порядке, возникал дедлок.
В итоге мы всё же настроили удаление данных достаточно эффективно, но прошли через семь кругов ада, прежде чем наладить процесс. Этот механизм до сих пор используется для управления данными в нашем сервисе.
Акт 3. Борьба за выживание
Со временем данных становилось всё больше, и началась финальная часть — борьба не за стабильность, а за выживание. На этом этапе любые инструменты были хороши, если позволяли выиграть хоть немного времени.
Основной запас мы получали за счёт шардирования — это был единственный рабочий способ сдержать нагрузку. Но шардироваться в условиях, когда несколько сущностей всё ещё связаны общей транзакцией, было невозможно. К этому моменту каждая крупная сущность фактически стала отдельным продуктом со своим профилем нагрузки и требовала собственного способа шардирования. Поэтому просто разрезать базу сервиса «как есть» на шарды было бы плохой идеей — нас такой вариант не устраивал.
Чтобы выиграть время, мы использовали очевидные решения вроде кэширования — снять нагрузку с базы для самых «тяжелых», ускорить ответ для самых «медленных». Но были и менее очевидные находки, некоторые из них мы делали случайно.

В какой-то момент мы заметили, что страдает не только Postgres, но и его балансировщик. Метрик на pgBouncer у нас не было, поэтому мы долго не замечали, что CPU уходит в потолок. Мы смотрели лишь на привычные показатели — активные и ожидающие соединения, и кто бы мог подумать, что настоящим bottleneck окажется именно CPU.
Когда проблему нашли, то попытались сначала также приложить подорожник: отказались от утяжеляющих функций вроде внутренней аутентификации, старались гонять как можно меньше JSON (раньше они были повсюду, теперь — только по отдельным запросам). Но это не помогало.
В итоге пошли на отчаянный шаг и попросили у DBA multi-instance pgBouncer. Технически он это умеет, но в большой компании, где всё развёртывается и настраивается автоматически, любой кастомный сетап — это проблема, в том числе для дальнейшей поддержки. В итоге только под страхом полного отказа Авито нам разрешили поставить второй балансировщик. И стало чуть легче.
Мы пошли практически ва-банк. К счастью, у нас оставались возможности немного подкрутить железо.
Eventual Consistency
Чтобы пережить следующий шаг, предпоследний — переход в режим eventual consistency, мы усилили систему по ресурсам. Такой подход позволял распилить транзакцию и при этом продолжать работать консистентно.
Было два варианта. В терминах саг — либо хореографическая, либо оркестрируемая. Подход с сагами предполагает централизованный формат, а события полностью децентрализованы. Поэтому мы выбрали гибрид.
Основное действие должно было происходить централизованно, но не было финальным: мы лишь резервировали нужные данные, не списывая их окончательно. На этом синхронная часть заканчивалась и в игру вступали две асинхронные ветки.

Если размещение с бронью всё же создавалось, оно как можно скорее рассылало всем источникам событий сигнал о том, что ресурсы нужно признать и списать окончательно. В это время сервис, в котором происходила бронь, начинал отсчитывать TTL. Если время истекало, он пытался отменить резерв, потому что ресурсы никому не понадобились.
С таким подходом мы масштабировались в основном в области ресурсов. Расширение по ним получилось если не безграничным, то как минимум комфортным. В итоге появилась возможность для шардирования.
Мы разрезали сервис на шарды: 14 инстансов PostgreSQL и 10 инстансов MongoDB. JSON-файлы, которые мешали и росли слишком быстро, вынесли в MongoDB, чтобы не перегружать Postgres.
Шардирование запускали в конце декабря, и первое переключение партиций внутри всех 14 шардов выходило как раз на 1 января. Пришлось снова оставить дежурного, чтобы следить за переключением. Но всё прошло успешно: каждый шард не превышал 200 ГБ, Postgres работал в комфортном режиме. Мы убрали кроны с ANALYZE и оставили управление данными через партиции. В итоге получили сетап, который позволяет масштабироваться и дальше — практически без ограничений.

Вместо заключения
На дворе 2025-й. За последние пять лет Авито из сервиса объявлений вырос в целую платформу: мы преодолели отметку в 10 миллионов размещений в день и продолжаем расти — пусть уже и не такими темпами.
Мы прошли долгий путь: научились работать с блокировками и ушли от пессимистичных сценариев, справились с ростом таблицы, пережили «аварии первого числа», внедрили партиционирование и шардирование, а в самых критичных местах изменили архитектуру.
Но главная проблема была не в технологиях. Мы слишком поздно заметили, что продукт перестал быть тем, чем был: из одного сервиса выросло несколько больших и независимых продуктов со своими требованиями.
Такое может случиться с любым сервисом. Поэтому главный вопрос, который полезно задавать себе время от времени: чем сегодня является мой продукт? Всё ещё тем, чем мы его задумывали, или уже чем-то другим — и пора менять архитектуру?
Скрытый текст
Этот доклад прозвучал на Saint HighLoad++ 2025 — крупнейшей конференции про архитектуру, базы данных и жизнь под нагрузкой.
В этом году 22-23 июня мы ещё раз соберём инженеров, архитекторов и платформенные команды, чтобы обсуждать, как мы масштабируем продукты, строим устойчивые системы и справляемся с последствиями роста. Если вам тоже важно понять, чем стал ваш продукт — просто набором фичей или точкой входа для миллионов пользователей, — приходите!
