Всем привет! Меня зовут Женя Толмачев, я работаю тимлидом в компании Авито и руковожу командой продукта Авито Автозагрузка. Это инструмент, который позволяет продавцам массово управлять своими объявлениями.
Сейчас мы управляем почти половиной всего контента на Авито и каждый час наш сервис обрабатывает миллионы объявлений и фотографий, но так было не всегда.
За годы существования Автозагрузки мы привыкли к линейному росту — каждый год мы увеличивались в среднем в 1,5–2 раза. Мы строили свои планы исходя из этих цифр и следили, чтобы у нас всегда был «запас прочности» как минимум на год вперед.
Все изменилось в 2022 году, когда компания запустила новую модель оплаты за размещение объявлений. На горизонте 2 лет нас ожидал рост ×20 и тот «запас прочности», который мы имели, нам бы уже не помог. Его хватило бы максимум на полгода беззаботной жизни, а потом нас бы ждало громкое и эпичное падение.
С этим нужно было срочно что-то делать. В этой статье я расскажу, как мы выживали и решали проблему масштабирования.
Это адаптированная версия моего выступления на конференции Saint HighLoad++ 2023. Доклад «Как качать миллионы фотографий в сутки, выдержать кратный рост и не умереть?» можно посмотреть на YouTube-канале HighLoad Channel.
Что такое Автозагрузка
Автозагрузка — это большой продукт, в который входит множество сервисов. Я расскажу только про один из них, но прежде чем начать, надо разобраться с тем, как вообще наш продукт устроен.
Если коротко, то Автозагрузка — это инструмент для массовой публикации объявлений. Идея очень проста: у клиента есть файл-каталог товаров и любое изменение в этом файле должно быть применено на Авито:
если появился новый товар — мы должны опубликовать объявление,
если изменилась информация о каком-то товаре — мы должны отредактировать объявление,
если какой-то из товаров исчез — мы должны снять с публикации объявление, которое с ним связано.
Этот файл клиент может загрузить к нам двумя способами:
разово — через личный кабинет или API;
выложить на хостинг, указать в личном кабинете ссылку на него и настроить расписание автоматических выгрузок — как часто наш сервис будет ходить за этим файлом и синхронизировать информацию на Авито.
Когда у клиента начинается выгрузка, мы скачиваем файл и смотрим, есть ли в нём какие-то изменения. Если есть — применяем их.
Чтобы объявление попало на Авито, мы должны сделать как минимум две вещи:
провалидировать данные о нём из файла каталога;
скачать все его фотографии, ссылки на которые указаны в этом файле.
За скачивание изображений у нас отвечает отдельный сервис, об архитектуре которого я и буду дальше рассказывать. Каждый час через него проходит до 4 млн фотографий, которые нужно попытаться скачать. Для каждого объявления в файле клиент указывает список ссылок на его фотографии. Мы отправляем их скачиваться, когда видим, что:
этот список как-то поменялся относительно предыдущей выгрузки клиента — появилось что-то новое или изменился порядок ссылок;
в прошлой выгрузке мы не смогли скачать какие-то фотографии и нужно повторить попытку в рамках текущей выгрузки клиента.
Архитектура сервиса достаточно простая и состоит из 3 слоев:
оранжевый слой — отвечает за сохранение входящих задач;
синий слой — отвечает за скачивание изображений;
фиолетовый слой — отвечает за отправку результатов потребителям.
Архитектура сервиса скачивания фото в 2021 году
Оранжевый слой — сохранение входящих задач. Он состоит из HTTP-API, в который прилетают пачки задач на скачивание фотографий объявлений. Чтобы обеспечить быстродействие и устойчивость, API не работает с базой напрямую. Вместо этого все входящие задачи перекладываются в очередь, на которую уже подписаны воркеры — они осуществляют взаимодействие с БД.
Воркер получает задачу на обработку объявлений и проверяет, нужно ли что-то качать. Если нужно, создаётся задача на обработку всего объявления и по одной задаче для каждой из фотографий, которую нужно скачать.
Если ничего качать не надо, воркер просто достаёт существующие данные и сразу отправляет потребителю.
Синий слой — скачивание изображений. Он состоял из крона-планировщика, который работал в единственном экземпляре и запускался раз в минуту. Его цель — для каждого клиента достать из базы все задачи на скачивание фото, но за один проход — не больше задач, чем указано в настройках rate-limits клиента. Получается очень простенький rate-limiter, который работает на несложной эвристике. На тот момент нас это устраивало: требований жестко соблюдать эти rate-limits у нас не было. Главное здесь — не устроить DDoS-атаку на сервера клиентов.
Полученные из БД задачи крон публикует в одну из очередей. Для скачивания у нас было два пула воркеров: быстрые — с тайм-аутом на скачивание в 1 секунду и медленные — с тайм-аутом в 30 секунд. Архитектурно они абсолютно идентичны, разница только в тайм-аутах и размере пула.
Сам процесс скачивания простой: мы идём по ссылке и качаем фотографию. Полученные данные заливаем во внутреннее хранилище, оттуда получаем метаинформацию и сохраняем её в БД. С помощью этой метаинформации мы можем не пересохранять фотографии, которые ранее уже скачали. Для этого проверяем по хэшу, что это изображение у нас уже есть.
Здесь возникает вопрос — а что делать, если фотографию скачать не удалось? Мы разделяем ошибки скачивания на две группы:
Ошибки, которые нет смысла обрабатывать повторно. Это всякие 403 и 404 — все те ситуации, когда доступ к контенту либо ограничен, либо его ещё не существует.
Ошибки, которые стоит попытаться обработать ещё раз. Это различные 500 и тайм-ауты. Для их обработки как раз и существует медленный retry-контур.
Если ретраи нам не помогли и фото скачать так и не получилось — мы завершаем задачу и сохраняем ошибку, чтобы показать её клиенту. Здесь важно обратить внимание на логику работы Автозагрузки. Если в текущей выгрузке нам не удалось скачать какие-то фотографии, то когда у клиента начнется новая выгрузка, мы снова попытаемся скачать эти фотографии.
Фиолетовый слой — отправка результата. Здесь тоже работает крон, который получает из базы задачи на обработку объявлений, у которых завершились все задачи на скачивание фотографий. Здесь уже нет никаких rate-limits и крон запускается как можно чаще.
Полученные из базы данные крон публикует в очередь. На неё подписан пул воркеров, которые занимаются формированием и отправкой результата потребителям.
Эта архитектура служила нам на протяжении нескольких лет и готова была прослужить ещё столько же, если бы не стремительный рост сервиса.
Почему потребовалось менять архитектуру
Для начала я хочу зафиксировать пару важных, на мой взгляд, мыслей:
Не нужно строить космолёты там, где в этом нет необходимости. Простые решения могут служить годами, а сэкономленные ресурсы можно потратить на то, что принесет пользу уже сейчас. Наш сервис служит здесь хорошим примером.
Важно понимать, что всё же может настать момент, когда придётся строить космолет. Нужно уметь его вовремя отследить и желательно, чтобы у вас был какой-то план действий или хотя бы понимание, что вы будете делать, если он вдруг настанет. Именно такой момент и случился у нас в 2022 году.
Проблемные места в архитектуре
Прежде чем бежать что-то делать, мы выдохнули, спокойно проанализировали и выделили основные проблемные места, которые были в архитектуре на тот момент:
низкая производительность планировщика задач;
медленные хостинги, которые тормозили работу всей системы;
rate-limiter, который не защищал от DDoS-атак на сервера клиентов.
Планировщик задач. Пропускная способность планировщика в синем слое была ограничена, а сам он мог работать только в единственном экземпляре. Если бы мы просто подняли рядом ещё один его экземпляр, лучше бы не стало. Мы получили бы состояние гонки, дубли задач и, вероятно, поломали бы наш rate-limiter.
При этом мы уже начали сталкиваться с ситуациями, когда его производительности не хватало. Он переставал справляться, когда разом приходили несколько очень крупных клиентов с большим количеством фотографий.
Медленные хостинги. Около 60% фотографий не успевают скачаться за 1 секунду и «перетекают» в retry-воркеры. Это значит, что эти фотографии мы пытались скачать минимум два раза. Это лишняя нагрузка на систему, от которой очень хотелось избавиться.
В retry-воркере мы качали фотографии с тайм-аутом в 30 секунд. К нам приходили несколько крупных клиентов с медленными хостами, с которых фото скачиваются как раз за 30 секунд. Тогда они забивали нашу очередь и система начинала тормозить — образовывалась пробка.
Rate-limiter. Когда в системе образовывалась пробка, rate-limiter вел себя некорректно. Между планировщиком и воркерами, которые качают фото, не было обратной связи. Из-за этого планировщик продолжал активно закидывать задачи в очередь, из-за чего она еще больше распухала. Даже когда образовывалась пробка, он не останавливался.
Рано или поздно медленные фотографии заканчивалась, и очередь доходила до изображений, которые очень быстро качаются. Из-за них пробка резко рассасывалась и могла возникнуть лавина трафика, которым мы могли залить и даже положить сервера наших клиентов.
Вообще медленные хостинги — это очень интересный случай:
Мы можем качать максимум того, что к нам приходит, и терять минимум контента. Но тогда несколько крупных клиентов с медленными хостингами будут тормозить работу всей системы. Появятся задержки в публикации объявлений и это повлечёт за собой негатив клиентов.
Или мы можем пожертвовать медленными фотографиями и качать всё максимально быстро. Тогда производительность системы будет высокой, но мы начнём терять контент, что также повлечет за собой негатив клиентов.
План избавления от проблем
После анализа архитектуры мы собрали бэклог задач для решения основных проблем сервиса:
научиться масштабировать планировщик задач,
сделать честный rate-limiter,
научиться как-то иначе работать с медленными хостингами — так, чтобы из-за них не тормозила вся система.
Масштабирование планировщика задач
Если обобщить, то задача сводится к организации параллельного доступа к разделяемому ресурсу. Мы используем MongoDB и в решениях, о которых я буду рассказывать, может проскакивать специфика работы с этой базой. Но я постарался выделить именно общие решения и подходы, которые будут работать и для других технологий.
1. Пессимистичный подход. В системах с параллельным доступом могут возникать конфликты и он предлагает их предотвращать. Для этого используются блокировки. Чтобы начать работать с ресурсом, компонент ставит эксклюзивный лок на его обработку. А все остальные компоненты, которые хотят поработать с этим же ресурсом, выстраиваются в очередь и ждут, когда блокировка освободится. Такой подход гарантирует полную консистентность при работе с данными и отсутствие каких-либо состояний гонок.
Для реализации необходимо добавить Redis и увеличить количество планировщиков. При старте каждый из них будет идти в Redis и пытаться взять эксклюзивную блокировку на работу с базой.
Такой подход просто реализовать и он даже гарантирует какую-то отказоустойчивость.
Но производительность это не увеличивает. Все компоненты в системе будут работать последовательно и их общая пропускная способность не изменится в сравнении с тем, если бы планировщик работал в единственном экземпляре. Решение нам не подходит и нужно придумать что-то ещё.
2. Партицирование. Я буду рассматривать этот вариант как развитие пессимистичного подхода. Идея проста — разобьём все данные на части. Каждый компонент будет работать только со своей порцией данных, никак не пересекаясь с остальными. Так компоненты смогут работать параллельно.
Для реализации нужно разбить базу с задачами на части — некие партиции. У каждой задачи появится новый атрибут — идентификатор партиции, который будет вычисляться в момент её создания.
Это решение тоже легко реализовать и здесь у нас появляется возможность параллельной работы. Но помимо плюсов у него есть и недостаток — нельзя просто так взять и увеличить количество планировщиков, если не хватает пропускной способности существующих. Для этого сначала нужно заново поделить базу с задачами на нужное количество частей и только потом увеличивать количество планировщиков. Более того, их количество должно строго совпадать с количеством партиций, а в идеале — превышать его. Тогда при потере одного из экземпляров планировщика какая-то из партиций не будет простаивать.
Решение в целом хорошее, но нам хотелось иметь больше возможности для гибкого горизонтального масштабирования с минимальными накладными расходами. Поэтому мы его отложили.
3. Конкурентный доступ. Это вариант оптимистичного подхода. Он предполагает, что все компоненты в системе работают параллельно. Если возникает конфликт, то система его разрешает, а не предотвращает.
Первая мысль, которая возникла — отказаться от единого компонента-планировщика и перенести логику работы с базой на сторону воркеров, чтобы каждый из них самостоятельно получал и назначал на себя задачи. Это позволило бы полностью избавиться от узкого места и легко масштабировать логику работы с БД. Но чтобы это сделать, нужно научиться решать конфликты — ситуации, когда воркеры пытаются назначить на себя одни и те же задачи.
Мы добавили каждому экземпляру в пуле воркеров два новых атрибута:
worker_id - уникальный идентификатор экземпляра воркера,
tasks_limit - максимальное количество задач, которые воркер может взять в работу за один свой цикл.
При старте каждый воркер будет идти в базу и в рамках своего лимита получать список id свободных задач. После этого он будет пытаться назначить эти задачи на себя — по сути, просто делать update-запрос, пытаясь проставить им свой worker_id. В этот момент другой воркер может украсть часть из этих задач. Поэтому запрос вернёт реальное количество документов, которое удалось проапдейтить. Если оно меньше лимита — значит, возник конфликт, который нужно разрешить. Для этого воркер просто повторит цикл набора задач в попытке добрать недостающее количество.
Это решение не только даёт нам возможность параллельной работы, но еще и прекрасно масштабируется. Но здесь появляются накладные расходы — из-за конфликтов воркерам приходится повторять цикл набора задач, а это генерит лишнюю нагрузку на базу.
Несмотря на это, решение казалось нам очень перспективным, но хотелось убедиться, что это именно то, что нам нужно. Для этого мы реализовали прототип и провели его нагрузочное тестирование.
При трёх воркерах удалось добиться пропускной способности в 270 тысяч входящих задач в минуту. Напомню, что старое решение позволяло обрабатывать 40 тысяч задач в минуту. Текущая нагрузка составляет до 200 тысяч задач в пиках. При 120 воркерах пропускная способность составила всего 1 миллион задач в минуту — видно, что скорость падает из-за лишней нагрузкой на БД из-за повторения запросов.
Но полученные цифры нас абсолютно устраивали и мы остановились на этом решении. Вот как изменилась наша архитектура:
Реализация честного rate-limiter'а
Чтобы не устраивать DDoS-атаки на клиентов, нужно реализовать честный rate-limiter. Он будет ограничивать количество одновременно выполняющихся задач для каждого пользователя. Есть много вариантов его реализации, но я расскажу только о нашем.
Мы использовали алгоритм sliding log — он достаточно простой в реализации, точный и хорошо ведёт себя на границах минут.
Предположим, у нас есть ограничение, измеряемое в RPM. Заведём множество размером R — по максимальному количеству запросов, которые мы можем отправить в течении минуты. В нём будем хранить временные метки начала запросов, которые мы разрешили. Перед тем, как сделать запрос, будем ходить в это множество и первым делом удалять оттуда все временные метки старше минуты. Если после этого в нём есть свободные места — разрешим запрос и добавим его временную метку.
Для реализации мы использовали Redis, в котором для каждого клиента хранится собственное множество размера R. Воркер ходит в rate-limiter конкретного клиента с количеством задач, которые он хочет выполнить. Тот, в свою очередь, возвращает количество задач, которое он может выполнить.
Как и в случае с планировщиком, здесь мы также пошли по пути прототипирования и провели нагрузочное тестирование. Из всех сценариев я покажу вам самый интересный — худший случай, когда у всех клиентов настроены очень жесткие rate-limits всего в 10 RPM. Это очень мало и скорее всего даже нереалистично, но мы хотели вогнать систему в состояние максимального стресса.
Наш стресс-тест показал, что решение в целом рабочее, но его производительность далека от той цифры, к которой мы стремились. Это связано с тем, что воркеры теперь не просто воруют друг у друга задачи, но ещё и натыкаются на rate-limits. Из-за этого им приходится делать лишние запросы в базу и Redis. Нужно было как-то оптимизировать rate-limiter и сократить количество конфликтов, чтобы повысить эффективность этого решения.
Для оптимизации мы использовали подход с партиционированием. Основная идея — ограничить количество воркеров, которые могут обрабатывать одного клиента. rate-limits нужно было поделить между воркерами так, чтобы вместо общего ограничения у каждого из них было своё собственное.
В Redis для каждого клиента мы завели счётчик. Он был равен количеству воркеров, которые могут одновременно с ним работать (RPM пользователя / пропускная способность воркеров). Когда воркер хочет поработать с клиентом, он пытается уменьшить значение счётчика. Если оно не равно нулю, у него получится закрепить клиента за собой. Сам rate-limiter переедет из Redis в оперативную память каждого из воркеров. Теперь вместо общего лимита клиента у каждого из них будет свой собственный.
Один воркер сможет обрабатывать сразу несколько клиентов, но только короткое время. Когда оно истечёт, воркер будет его отпускать и увеличивать значение счётчика. Это нужно, чтобы защититься от падения и обеспечить равномерное распределение нагрузки.
После доработки прототипа мы снова провели нагрузочное тестирование. Результаты нас порадовали — мы очень близко подобрались к цифре, к которой стремились. Производительность составила 19 тысяч задач в минуту. Нас это число устраивало и на нём мы решили остановиться.
Теперь посмотрим на изменения в архитектуре:
Скачивание с медленных хостов
Последняя задача — научиться качать с медленных хостов, чтобы из-за них не тормозилась вся система. В медленный retry-контур попадали все фото, которые по каким-то причинам не смогли скачаться за одну секунду. Данные в нём были очень разнородны — туда приходили фотографии, которые качались как за 5, так и за 30 секунд. Если retry-контур забивался медленными фотографиями, все простаивали в ожидании своего часа.
Нужно было научиться разделять трафик и сделать так, чтобы из-за медленных фотографий не страдали все остальные. Для этого мы могли добавить новые пулы воркеров с нужными тайм-аутами. Но сначала — определиться, какие пулы нам нужны. Для этого мы провели небольшое исследование времени скачивания фотографий и выявили три основные группы:
успевают скачаться за 1 секунду — 40%;
за 5 секунд — 40%;
за 30 секунд — 20%.
Для начала мы остановились только на этих группах, но нам хотелось сделать гибкое и расширяемое решение, которое позволяло бы с лёгкостью добавлять новые пулы воркеров с нужным тайм-аутом, если возникнет такая необходимость.
Для этого мы ввели атрибут worker_type. Мы добавили его каждому из воркеров и его значение равно тайм-ауту, который используется в воркере. Этот атрибут появился и у задач и на старте всегда был равен 1 секунде.
Каждый воркер работает только с задачами своего типа. Если в процессе скачивания фотографии возникла ошибка и нужно повторить попытку с бо́льшим тайм-аутом — воркер проставит ей тип следующего пула и задача улетит в него.
Но в таком решении есть проблема: фотографиям, которые качаются за 30 секунд, нужно пройти всю цепочку воркеров прежде, чем они попадут в нужный пул. А это лишняя и бесполезная нагрузка.
Для оптимизации мы настроили маршрутизацию. Идея в том, чтобы сразу направлять задачу в нужную группу воркеров. Для этого на стороне воркеров мы реализовали сбор статистики времени скачивания фотографий и добавили новый компонент — крон. Для каждой связки «пользователь + домен» он рассчитывает среднее время скачивания фотографий. На основе этих данных всем новым задачам сразу проставляется тип нужного пула воркеров и они уходят сразу в него.
Для примера: если к нам придёт клиент с очень медленным хостингом, мы накопим и рассчитаем статистику, на основе которой будем роутить его задачи сразу в 30-секундные воркеры. Но если вдруг хостинг начнёт работать быстрее, статистика со временем пересчитается и задачи будут роутиться уже в пул с меньшим тайм-аутом.
Подход с разделением трафика позволил нам решить последнюю проблему — научиться работать с медленными хостами так, чтобы из-за них не тормозила вся система. Они теперь живут в отдельном пуле и никому не мешают. А маршрутизация позволила срезать лишнюю нагрузку на систему.
Итоговая архитектура
Что мы сделали, теперь коротко:
Убрали планировщик задач. Логику работы с БД перенесли на сторону воркеров.
Добавили Redis, который используется для закрепления пользователей за воркерами.
Добавили крон для расчёта статистики времени скачивания. Статистику считаем на стороне воркеров.
Планы на будущее
После всех оптимизаций нас больше не пугают темпы роста, с которыми мы столкнулись, а сервис готов поддержать рост на горизонте ещё в пару лет.
Но в начале я говорил, что не нужно раньше времени строить космолёты, но всегда должен быть план Б. Поэтому мы проработали дальнейшие шаги, которыми воспользуемся при необходимости.
Масштабирование БД. После того, как мы отказались от планировщика, у нас исчезло узкое место в виде невозможности масштабирования его логики. Но теперь может настать момент, когда мы упрёмся в производительность базы. Когда это случится, мы воспользуемся логикой из подхода с партиционированием и прикрутим его к нашему решению.
Повышение эффективности работы. Ещё одна точка до оптимизации — эффективность использования ресурсов. Над этим мы уже работаем. Если посмотреть на статистику ответов, то можно увидеть, что от 60 до 80% всех попыток скачивания занимают ошибки.
Если изучить ошибки внимательнее, то можно увидеть, что бОльшая часть из них это 403/404, которые мы пытаемся перекачать каждую выгрузку пользователей. А они могут не исправлять ошибки месяцами.
Уже сейчас мы начали прорабатывать изменение продуктовой логики. Совсем отказаться от попыток скачать эти фотографии мы не можем, но можем сократить их количество. Вместо того, чтобы пытаться перекачать их каждую выгрузку клиента, мы планируем ввести подход с прогрессивной шкалой. Если не получилось скачать в текущей выгрузке, то попробуем через одну, потом через две и так далее. Максимальная задержка, на которую готов пойти продукт — 24 часа.
Мы это ещё не реализовали, поэтому пока я не могу поделиться красивыми графиками. Но мы понимаем потенциал — после релиза оптимизации мы срежем до 40% от всего трафика. Тогда мы получим дополнительный запас прочности в 40% буквально на ровном месте.
Что помогло нам справиться с внезапным ростом нашей системы
мы не пытались сразу строить систему, готовую к любым нагрузкам;
при этом мы использовали компонентный подход и делали архитектуру максимально гибкой гибкой;
мы понимали свой запас прочности;
знали об основных проблемах и когда они выстрелят;
подошли к проработке целевого решения со стороны данных и делали прототипы.
Предыдущая статья: Используем JS Self-Profiling API для профилирования фронтенда на клиентах
Комментарии (2)
Gromilo
18.09.2023 17:54При старте каждый воркер будет идти в базу и в рамках своего лимита получать список id свободных задач. После этого он будет пытаться назначить эти задачи на себя — по сути, просто делать update-запрос, пытаясь проставить им свой worker_id.
видно, что скорость падает из-за лишней нагрузкой на БД из-за повторения запросов.
А какая у вас БД?
В postgresql можно использовать блокировку на строки + обновление в одном запросе. Тогда не будет перезапросов.
UPDATE task_table SET version = version + 1, available_after = now() at time zone 'utc' + @lockInterval, worker_id = @workerId WHERE id IN ( SELECT id FROM task_table WHERE (available_after is null OR available_after < now() at time zone 'utc') AND completed_at is null ORDER BY id LIMIT 1000 FOR UPDATE SKIP LOCKED ) RETURNING *, now() at time zone 'utc' - created_at AS Lifetime
Подзапрос ищет первые 1000 не заблокированных строк, обновление берёт строки в обработку без гонок.
stAndrei
Можно сервису, сохраняющему задачи на скачивание в базу, сразу публиковать их в кафку. Тогда воркеры сразу их будут разбирать по своим партициям, а крон оставить запасным вариантом.