Всем привет! Меня зовут Женя Толмачев, я работаю тимлидом в компании Авито и руковожу командой продукта Авито Автозагрузка. Это инструмент, который позволяет продавцам массово управлять своими объявлениями. 

Сейчас мы управляем почти половиной всего контента на Авито и каждый час наш сервис обрабатывает миллионы объявлений и фотографий, но так было не всегда.

За годы существования Автозагрузки мы привыкли к линейному росту — каждый год мы увеличивались в среднем в 1,5–2 раза. Мы строили свои планы исходя из этих цифр и следили, чтобы у нас всегда был «запас прочности» как минимум на год вперед.

Количество активных объявлений на Авито, управляемых через Автозагрузку 2020-2022
Количество активных объявлений на Авито, управляемых через Автозагрузку 2020-2022

Все изменилось в 2022 году, когда компания запустила новую модель оплаты за размещение объявлений. На горизонте 2 лет нас ожидал рост ×20 и тот «запас прочности», который мы имели, нам бы уже не помог. Его хватило бы максимум на полгода беззаботной жизни, а потом нас бы ждало громкое и эпичное падение.

Количество активных объявлений на Авито, управляемых через Автозагрузку 2020-2023
Количество активных объявлений на Авито, управляемых через Автозагрузку 2020-2023

С этим нужно было срочно что-то делать. В этой статье я расскажу, как мы выживали и решали проблему масштабирования.

Это адаптированная версия моего выступления на конференции Saint HighLoad++ 2023. Доклад «Как качать миллионы фотографий в сутки, выдержать кратный рост и не умереть?» можно посмотреть на YouTube-канале HighLoad Channel.

Что такое Автозагрузка

Автозагрузка — это большой продукт, в который входит множество сервисов. Я расскажу только про один из них, но прежде чем начать, надо разобраться с тем, как вообще наш продукт устроен.

Если коротко, то Автозагрузка — это инструмент для массовой публикации объявлений. Идея очень проста: у клиента есть файл-каталог товаров и любое изменение в этом файле должно быть применено на Авито:

  • если появился новый товар — мы должны опубликовать объявление,

  • если изменилась информация о каком-то товаре — мы должны отредактировать объявление,

  • если какой-то из товаров исчез — мы должны снять с публикации объявление, которое с ним связано.

Схема загрузки каталога товаров
Схема загрузки каталога товаров

Этот файл клиент может загрузить к нам двумя способами:

  • разово — через личный кабинет или API;

  • выложить на хостинг, указать в личном кабинете ссылку на него и настроить расписание автоматических выгрузок — как часто наш сервис будет ходить за этим файлом и синхронизировать информацию на Авито.

Разные способы запуска выгрузок
Разные способы запуска выгрузок

Когда у клиента начинается выгрузка, мы скачиваем файл и смотрим, есть ли в нём какие-то изменения. Если есть — применяем их.

Чтобы объявление попало на Авито, мы должны сделать как минимум две вещи:

  • провалидировать данные о нём из файла каталога;

  • скачать все его фотографии, ссылки на которые указаны в этом файле.

Только после того, как мы всё это сделаем, объявление можно публиковать
Только после того, как мы всё это сделаем, объявление можно публиковать

За скачивание изображений у нас отвечает отдельный сервис, об архитектуре которого я и буду дальше рассказывать. Каждый час через него проходит до 4 млн фотографий, которые нужно попытаться скачать. Для каждого объявления в файле клиент указывает список ссылок на его фотографии. Мы отправляем их скачиваться, когда видим, что:

  • этот список как-то поменялся относительно предыдущей выгрузки клиента — появилось что-то новое или изменился порядок ссылок;

  • в прошлой выгрузке мы не смогли скачать какие-то фотографии и нужно повторить попытку в рамках текущей выгрузки клиента.

Задачи на скачивание попадают в сервис, когда список фотографий объявления как-то поменялся
Задачи на скачивание попадают в сервис, когда список фотографий объявления как-то поменялся

Архитектура сервиса достаточно простая и состоит из 3 слоев:

  • оранжевый слой — отвечает за сохранение входящих задач;

  • синий слой — отвечает за скачивание изображений;

  • фиолетовый слой — отвечает за отправку результатов потребителям.

В 2021 году фотографии скачивались так
В 2021 году фотографии скачивались так

Архитектура сервиса скачивания фото в 2021 году

Оранжевый слой — сохранение входящих задач. Он состоит из HTTP-API, в который прилетают пачки задач на скачивание фотографий объявлений. Чтобы обеспечить быстродействие и устойчивость, API не работает с базой напрямую. Вместо этого все входящие задачи перекладываются в очередь, на которую уже подписаны воркеры — они осуществляют взаимодействие с БД. 

Очередь входящих задач
Очередь входящих задач

Воркер получает задачу на обработку объявлений и проверяет, нужно ли что-то качать. Если нужно, создаётся задача на обработку всего объявления и по одной задаче для каждой из фотографий, которую нужно скачать.

Создание задач
Создание задач

Если ничего качать не надо, воркер просто достаёт существующие данные и сразу отправляет потребителю.

Отправка результата
Отправка результата

Синий слой — скачивание изображений. Он состоял из крона-планировщика, который работал в единственном экземпляре и запускался раз в минуту. Его цель — для каждого клиента достать из базы все задачи на скачивание фото, но за один проход — не больше задач, чем указано в настройках rate-limits клиента. Получается очень простенький rate-limiter, который работает на несложной эвристике. На тот момент нас это устраивало: требований жестко соблюдать эти rate-limits у нас не было. Главное здесь — не устроить DDoS-атаку на сервера клиентов.

Раз в минуту крон получает задачи на скачивание фото для каждого пользователя, но не больше, чем его rate-limit
Раз в минуту крон получает задачи на скачивание фото для каждого пользователя, но не больше, чем его rate-limit

Полученные из БД задачи крон публикует в одну из очередей. Для скачивания у нас было два пула воркеров: быстрые — с тайм-аутом на скачивание в 1 секунду и медленные — с тайм-аутом в 30 секунд. Архитектурно они абсолютно идентичны, разница только в тайм-аутах и размере пула.

В быстрые воркеры попадали все новые задачи, а в медленные — все попытки повторного скачивания (ретраи)
В быстрые воркеры попадали все новые задачи, а в медленные — все попытки повторного скачивания (ретраи)

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

В БД храним метаинформацию — она позволяет не пересохранять фотографии, которые мы уже скачивали
В БД храним метаинформацию — она позволяет не пересохранять фотографии, которые мы уже скачивали

Здесь возникает вопрос — а что делать, если фотографию скачать не удалось? Мы разделяем ошибки скачивания на две группы:

  1. Ошибки, которые нет смысла обрабатывать повторно. Это всякие 403 и 404 — все те ситуации, когда доступ к контенту либо ограничен, либо его ещё не существует.

  2. Ошибки, которые стоит попытаться обработать ещё раз. Это различные 500 и тайм-ауты. Для их обработки как раз и существует медленный retry-контур.

Слева — ошибки, которые не будем обрабатывать. Справа — попробуем обработать еще раз
Слева — ошибки, которые не будем обрабатывать. Справа — попробуем обработать еще раз

Если ретраи нам не помогли и фото скачать так и не получилось — мы завершаем задачу и сохраняем ошибку, чтобы показать её клиенту. Здесь важно обратить внимание на логику работы Автозагрузки. Если в текущей выгрузке нам не удалось скачать какие-то фотографии, то когда у клиента начнется новая выгрузка, мы снова попытаемся скачать эти фотографии.

Фиолетовый слой — отправка результата. Здесь тоже работает крон, который получает из базы задачи на обработку объявлений, у которых завершились все задачи на скачивание фотографий. Здесь уже нет никаких rate-limits и крон запускается как можно чаще.

Крон для получения списка завершенных задач
Крон для получения списка завершенных задач

Полученные из базы данные крон публикует в очередь. На неё подписан пул воркеров, которые занимаются формированием и отправкой результата потребителям.

Завершенные задачи крон публикует в очередь, на которую подписаны воркеры, занимающиеся отправкой результатов
Завершенные задачи крон публикует в очередь, на которую подписаны воркеры, занимающиеся отправкой результатов

Эта архитектура служила нам на протяжении нескольких лет и готова была прослужить ещё столько же, если бы не стремительный рост сервиса.

Почему потребовалось менять архитектуру

Для начала я хочу зафиксировать пару важных, на мой взгляд, мыслей:

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

  • Важно понимать, что всё же может настать момент, когда придётся строить космолет. Нужно уметь его вовремя отследить и желательно, чтобы у вас был какой-то план действий или хотя бы понимание, что вы будете делать, если он вдруг настанет. Именно такой момент и случился у нас в 2022 году.

Проблемные места в архитектуре

Прежде чем бежать что-то делать, мы выдохнули, спокойно проанализировали и выделили основные проблемные места, которые были в архитектуре на тот момент:

  • низкая производительность планировщика задач; 

  • медленные хостинги, которые тормозили работу всей системы;

  • rate-limiter, который не защищал от DDoS-атак на сервера клиентов.

Планировщик задач. Пропускная способность планировщика в синем слое была ограничена, а сам он мог работать только в единственном экземпляре. Если бы мы просто подняли рядом ещё один его экземпляр, лучше бы не стало. Мы получили бы состояние гонки, дубли задач и, вероятно, поломали бы наш rate-limiter.

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

40 тысяч задач в минуту — пропускная способность. До 200 тысяч в минуту — количество входящих задач
40 тысяч задач в минуту — пропускная способность. До 200 тысяч в минуту — количество входящих задач
Время работы планировщика — деградации при большом количестве входящих задач
Время работы планировщика — деградации при большом количестве входящих задач

Медленные хостинги. Около 60% фотографий не успевают скачаться за 1 секунду и «перетекают» в retry-воркеры. Это значит, что эти фотографии мы пытались скачать минимум два раза. Это лишняя нагрузка на систему, от которой очень хотелось избавиться.

В retry-воркере мы качали фотографии с тайм-аутом в 30 секунд. К нам приходили несколько крупных клиентов с медленными хостами, с которых фото скачиваются как раз за 30 секунд. Тогда они забивали нашу очередь и система начинала тормозить — образовывалась пробка.

Из-за медленных хостингов образуется пробка и система начинает тормозить
Из-за медленных хостингов образуется пробка и система начинает тормозить

Rate-limiter. Когда в системе образовывалась пробка, rate-limiter вел себя некорректно. Между планировщиком и воркерами, которые качают фото, не было обратной связи. Из-за этого планировщик продолжал активно закидывать задачи в очередь, из-за чего она еще больше распухала. Даже когда образовывалась пробка, он не останавливался.

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

Если пробка резко рассосётся, может случиться DDoS серверов клиентов
Если пробка резко рассосётся, может случиться DDoS серверов клиентов

Вообще медленные хостинги — это очень интересный случай:

  • Мы можем качать максимум того, что к нам приходит, и терять минимум контента. Но тогда несколько крупных клиентов с медленными хостингами будут тормозить работу всей системы. Появятся задержки в публикации объявлений и это повлечёт за собой негатив клиентов.

  • Или мы можем пожертвовать медленными фотографиями и качать всё максимально быстро. Тогда производительность системы будет высокой, но мы начнём терять контент, что также повлечет за собой негатив клиентов.

Важно найти баланс между высокой скоростью работы всей системы и минимальными потерями контента
Важно найти баланс между высокой скоростью работы всей системы и минимальными потерями контента

План избавления от проблем

После анализа архитектуры мы собрали бэклог задач для решения основных проблем сервиса:

  • научиться масштабировать планировщик задач,

  • сделать честный rate-limiter,

  • научиться как-то иначе работать с медленными хостингами — так, чтобы из-за них не тормозила вся система.

Масштабирование планировщика задач

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

1. Пессимистичный подход. В системах с параллельным доступом могут возникать конфликты и он предлагает их предотвращать. Для этого используются блокировки. Чтобы начать работать с ресурсом, компонент ставит эксклюзивный лок на его обработку. А все остальные компоненты, которые хотят поработать с этим же ресурсом, выстраиваются в очередь и ждут, когда блокировка освободится. Такой подход гарантирует полную консистентность при работе с данными и отсутствие каких-либо состояний гонок.

В один момент времени с ресурсом может работать только один компонент. Остальные ждут
В один момент времени с ресурсом может работать только один компонент. Остальные ждут

Для реализации необходимо добавить Redis и увеличить количество планировщиков. При старте каждый из них будет идти в Redis и пытаться взять эксклюзивную блокировку на работу с базой. 

Если у крона всё получится — он продолжит работу
Если у крона всё получится — он продолжит работу
Если не получится и блокировка уже кем-то занята — крон заснёт и повторит попытку через какое-то время
Если не получится и блокировка уже кем-то занята — крон заснёт и повторит попытку через какое-то время

Такой подход просто реализовать и он даже гарантирует какую-то отказоустойчивость.

Но производительность это не увеличивает. Все компоненты в системе будут работать последовательно и их общая пропускная способность не изменится в сравнении с тем, если бы планировщик работал в единственном экземпляре. Решение нам не подходит и нужно придумать что-то ещё.

2. Партицирование. Я буду рассматривать этот вариант как развитие пессимистичного подхода. Идея проста — разобьём все данные на части. Каждый компонент будет работать только со своей порцией данных, никак не пересекаясь с остальными. Так компоненты смогут работать параллельно.

Делим данные на части и каждый компонент имеет эксклюзивный доступ к своей порции данных
Делим данные на части и каждый компонент имеет эксклюзивный доступ к своей порции данных

Для реализации нужно разбить базу с задачами на части — некие партиции. У каждой задачи появится новый атрибут — идентификатор партиции, который будет вычисляться в момент её создания.

Список всех доступных партиций будет храниться в Redis
Список всех доступных партиций будет храниться в Redis
При старте каждый планировщик будет идти в Redis, рандомно выбирать никем не занятую партицию и ставить лок на её обработку
При старте каждый планировщик будет идти в Redis, рандомно выбирать никем не занятую партицию и ставить лок на её обработку
После он продолжит своё выполнение так, будто работает в единственном экземпляре и имеет эксклюзивный доступ только к своей части данных
После он продолжит своё выполнение так, будто работает в единственном экземпляре и имеет эксклюзивный доступ только к своей части данных

Это решение тоже легко реализовать и здесь у нас появляется возможность параллельной работы. Но помимо плюсов у него есть и недостаток — нельзя просто так взять и увеличить количество планировщиков, если не хватает пропускной способности существующих. Для этого сначала нужно заново поделить базу с задачами на нужное количество частей и только потом увеличивать количество планировщиков. Более того, их количество должно строго совпадать с количеством партиций, а в идеале — превышать его. Тогда при потере одного из экземпляров планировщика какая-то из партиций не будет простаивать.

Решение в целом хорошее, но нам хотелось иметь больше возможности для гибкого горизонтального масштабирования с минимальными накладными расходами. Поэтому мы его отложили.

3. Конкурентный доступ. Это вариант оптимистичного подхода. Он предполагает, что все компоненты в системе работают параллельно. Если возникает конфликт, то система его разрешает, а не предотвращает.

Все компоненты функционируют одновременно
Все компоненты функционируют одновременно

Первая мысль, которая возникла — отказаться от единого компонента-планировщика и перенести логику работы с базой на сторону воркеров, чтобы каждый из них самостоятельно получал и назначал на себя задачи. Это позволило бы полностью избавиться от узкого места и легко масштабировать логику работы с БД. Но чтобы это сделать, нужно научиться решать конфликты — ситуации, когда воркеры пытаются назначить на себя одни и те же задачи.

Все воркеры работают параллельно и сами получают задачи из базы
Все воркеры работают параллельно и сами получают задачи из базы

Мы добавили каждому экземпляру в пуле воркеров два новых атрибута:

  • worker_id - уникальный идентификатор экземпляра воркера,

  • tasks_limit - максимальное количество задач, которые воркер может взять в работу за один свой цикл.

При старте каждый воркер будет идти в базу и в рамках своего лимита получать список id свободных задач. После этого он будет пытаться назначить эти задачи на себя — по сути, просто делать update-запрос, пытаясь проставить им свой worker_id. В этот момент другой воркер может украсть часть из этих задач. Поэтому запрос вернёт реальное количество документов, которое удалось проапдейтить. Если оно меньше лимита — значит, возник конфликт, который нужно разрешить. Для этого воркер просто повторит цикл набора задач в попытке добрать недостающее количество.

Воркер будет добирать задачи, пока не достигнет лимита или пока в базе не закончатся свободные
Воркер будет добирать задачи, пока не достигнет лимита или пока в базе не закончатся свободные

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

Несмотря на это, решение казалось нам очень перспективным, но хотелось убедиться, что это именно то, что нам нужно. Для этого мы реализовали прототип и провели его нагрузочное тестирование.

 Результат нагрузочного тестирования прототипа с конкурентным доступом
Результат нагрузочного тестирования прототипа с конкурентным доступом

При трёх воркерах удалось добиться пропускной способности в 270 тысяч входящих задач в минуту. Напомню, что старое решение позволяло обрабатывать 40 тысяч задач в минуту. Текущая нагрузка составляет до 200 тысяч задач в пиках. При 120 воркерах пропускная способность составила всего 1 миллион задач в минуту — видно, что скорость падает из-за лишней нагрузкой на БД из-за повторения запросов.

Но полученные цифры нас абсолютно устраивали и мы остановились на этом решении. Вот как изменилась наша архитектура:

У нас исчез единый компонент-планировщик, а логика работы с базой переехала на сторону воркеров
У нас исчез единый компонент-планировщик, а логика работы с базой переехала на сторону воркеров

Реализация честного rate-limiter'‎а

Чтобы не устраивать DDoS-атаки на клиентов, нужно реализовать честный rate-limiter. Он будет ограничивать количество одновременно выполняющихся задач для каждого пользователя. Есть много вариантов его реализации, но я расскажу только о нашем.

Мы использовали алгоритм sliding log — он достаточно простой в реализации, точный и хорошо ведёт себя на границах минут.

Предположим, у нас есть ограничение, измеряемое в RPM. Заведём множество размером R — по максимальному количеству запросов, которые мы можем отправить в течении минуты. В нём будем хранить временные метки начала запросов, которые мы разрешили. Перед тем, как сделать запрос, будем ходить в это множество и первым делом удалять оттуда все временные метки старше минуты. Если после этого в нём есть свободные места — разрешим запрос и добавим его временную метку.

Принцип работы sliding log
Принцип работы sliding log
Если свободных мест нет — мы наткнулись на ограничение, запрос не выполнится
Если свободных мест нет — мы наткнулись на ограничение, запрос не выполнится

Для реализации мы использовали Redis, в котором для каждого клиента хранится собственное множество размера R. Воркер ходит в rate-limiter конкретного клиента с количеством задач, которые он хочет выполнить. Тот, в свою очередь, возвращает количество задач, которое он может выполнить.

Прототип решения с алгоритмом sliding log. Rate-limiter — в Redis
Прототип решения с алгоритмом sliding log. Rate-limiter — в Redis

Как и в случае с планировщиком, здесь мы также пошли по пути прототипирования и провели нагрузочное тестирование. Из всех сценариев я покажу вам самый интересный — худший случай, когда у всех клиентов настроены очень жесткие rate-limits всего в 10 RPM. Это очень мало и скорее всего даже нереалистично, но мы хотели вогнать систему в состояние максимального стресса.

Настройки для нагрузочного тестирования. «Максимальная скорость» это производительность, которую в теории можно выжать с такими настройками. По сути, это количество клиентов, умноженное на rate-limits. 
Настройки для нагрузочного тестирования. «Максимальная скорость» это производительность, которую в теории можно выжать с такими настройками. По сути, это количество клиентов, умноженное на rate-limits. 

Наш стресс-тест показал, что решение в целом рабочее, но его производительность далека от той цифры, к которой мы стремились. Это связано с тем, что воркеры теперь не просто воруют друг у друга задачи, но ещё и натыкаются на rate-limits. Из-за этого им приходится делать лишние запросы в базу и Redis. Нужно было как-то оптимизировать rate-limiter и сократить количество конфликтов, чтобы повысить эффективность этого решения.

Для оптимизации мы использовали подход с партиционированием. Основная идея — ограничить количество воркеров, которые могут обрабатывать одного клиента. rate-limits нужно было поделить между воркерами так, чтобы вместо общего ограничения у каждого из них было своё собственное.

В Redis для каждого клиента мы завели счётчик. Он был равен количеству воркеров, которые могут одновременно с ним работать (RPM пользователя / пропускная способность воркеров). Когда воркер хочет поработать с клиентом, он пытается уменьшить значение счётчика. Если оно не равно нулю, у него получится закрепить клиента за собой.  Сам rate-limiter переедет из Redis в оперативную память каждого из воркеров. Теперь вместо общего лимита клиента у каждого из них будет свой собственный.

Один воркер сможет обрабатывать сразу несколько клиентов, но только короткое время. Когда оно истечёт, воркер будет его отпускать и увеличивать значение счётчика. Это нужно, чтобы защититься от падения и обеспечить равномерное распределение нагрузки.

Оптимизированный прототип решения с алгоритм sliding log. Семафор — в Redis, rate-limiter — в ОП каждого воркера
Оптимизированный прототип решения с алгоритм sliding log. Семафор — в Redis, rate-limiter — в ОП каждого воркера

После доработки прототипа мы снова провели нагрузочное тестирование. Результаты нас порадовали — мы очень близко подобрались к цифре, к которой стремились. Производительность составила 19 тысяч задач в минуту. Нас это число устраивало и на нём мы решили остановиться.

Результат нагрузочного тестирования rate-limiter: до и после оптимизации
Результат нагрузочного тестирования rate-limiter: до и после оптимизации

Теперь посмотрим на изменения в архитектуре:

У нас появился Redis, который хранит в себе данные для закрепления клиентов за воркерами, а у каждого из воркеров появился собственный rate-limiter в оперативной памяти
У нас появился Redis, который хранит в себе данные для закрепления клиентов за воркерами, а у каждого из воркеров появился собственный rate-limiter в оперативной памяти

Скачивание с медленных хостов

Последняя задача — научиться качать с медленных хостов, чтобы из-за них не тормозилась вся система. В медленный retry-контур попадали все фото, которые по каким-то причинам не смогли скачаться за одну секунду. Данные в нём были очень разнородны — туда приходили фотографии, которые качались как за 5, так и за 30 секунд. Если retry-контур забивался медленными фотографиями, все простаивали в ожидании своего часа. 

60% трафика попадает в retry-контур с 30-секундным тайм-аутом, который может быть избыточен
60% трафика попадает в retry-контур с 30-секундным тайм-аутом, который может быть избыточен

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

  • успевают скачаться за 1 секунду — 40%;

  • за 5 секунд — 40%;

  • за 30 секунд — 20%.

График распределения фотографий по времени скачивания
График распределения фотографий по времени скачивания

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

Для этого мы ввели атрибут worker_type. Мы добавили его каждому из воркеров и его значение равно тайм-ауту, который используется в воркере. Этот атрибут появился и у задач и на старте всегда был равен 1 секунде.

Каждый воркер работает только с задачами своего типа. Если в процессе скачивания фотографии возникла ошибка и нужно повторить попытку с бо́льшим тайм-аутом — воркер проставит ей тип следующего пула и задача улетит в него.

Ввели новые группы воркеров с атрибутом worker_type, равный тайм-ауту соответствующего пула
Ввели новые группы воркеров с атрибутом worker_type, равный тайм-ауту соответствующего пула

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

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

Для оптимизации мы настроили маршрутизацию. Идея в том, чтобы сразу направлять задачу в нужную группу воркеров. Для этого на стороне воркеров мы реализовали сбор статистики времени скачивания фотографий и добавили новый компонент — крон. Для каждой связки «пользователь + домен» он рассчитывает среднее время скачивания фотографий. На основе этих данных всем новым задачам сразу проставляется тип нужного пула воркеров и они уходят сразу в него. 

Для примера: если к нам придёт клиент с очень медленным хостингом, мы накопим и рассчитаем статистику, на основе которой будем роутить его задачи сразу в 30-секундные воркеры. Но если вдруг хостинг начнёт работать быстрее, статистика со временем пересчитается и задачи будут роутиться уже в пул с меньшим тайм-аутом.  

Маршрутизации с учётом среднего времени скачивания с определённого домена
Маршрутизации с учётом среднего времени скачивания с определённого домена

Подход с разделением трафика позволил нам решить последнюю проблему — научиться работать с медленными хостами так, чтобы из-за них не тормозила вся система. Они теперь живут в отдельном пуле и никому не мешают. А маршрутизация позволила срезать лишнюю нагрузку на систему.

Итоговая архитектура

Архитектура сервиса скачивания фотографий после изменений
Архитектура сервиса скачивания фотографий после изменений

Что мы сделали, теперь коротко:

  1. Убрали планировщик задач. Логику работы с БД перенесли на сторону воркеров. 

  2. Добавили Redis, который используется для закрепления пользователей за воркерами.

  3. Добавили крон для расчёта статистики времени скачивания. Статистику считаем на стороне воркеров.

Планы на будущее

После всех оптимизаций нас больше не пугают темпы роста, с которыми мы столкнулись, а сервис готов поддержать рост на горизонте ещё в пару лет.

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

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

Мы шардируем базу и добавим нужное количество железа
Мы шардируем базу и добавим нужное количество железа

Повышение эффективности работы. Ещё одна точка до оптимизации — эффективность использования ресурсов. Над этим мы уже работаем. Если посмотреть на статистику ответов, то можно увидеть, что от 60 до 80% всех попыток скачивания занимают ошибки.

Больше половины всего трафика в системе не несут никакой пользы
Больше половины всего трафика в системе не несут никакой пользы

Если изучить ошибки  внимательнее, то можно увидеть, что бОльшая часть из них это 403/404, которые мы пытаемся перекачать каждую выгрузку пользователей. А они могут не исправлять ошибки месяцами.

С ростом трафика в системе растёт и количество ошибок
С ростом трафика в системе растёт и количество ошибок

Уже сейчас мы начали прорабатывать изменение продуктовой логики. Совсем отказаться от попыток скачать эти фотографии мы не можем, но можем сократить их количество. Вместо того, чтобы пытаться перекачать их каждую выгрузку клиента, мы планируем ввести подход с прогрессивной шкалой. Если не получилось скачать в текущей выгрузке, то попробуем через одну, потом через две и так далее. Максимальная задержка, на которую готов пойти продукт — 24 часа.

Мы это ещё не реализовали, поэтому пока я не могу поделиться красивыми графиками. Но мы понимаем потенциал — после релиза оптимизации мы срежем до 40% от всего трафика. Тогда мы получим дополнительный запас прочности в 40% буквально на ровном месте.

Что помогло нам справиться с внезапным ростом нашей системы

  • мы не пытались сразу строить систему, готовую к любым нагрузкам;

  • при этом мы использовали компонентный подход и делали архитектуру максимально гибкой гибкой;

  • мы понимали свой запас прочности;

  • знали об основных проблемах и когда они выстрелят;

  • подошли к проработке целевого решения со стороны данных и делали прототипы.

Предыдущая статья: Используем JS Self-Profiling API для профилирования фронтенда на клиентах

Комментарии (2)


  1. stAndrei
    18.09.2023 17:54
    +1

    Можно сервису, сохраняющему задачи на скачивание в базу, сразу публиковать их в кафку. Тогда воркеры сразу их будут разбирать по своим партициям, а крон оставить запасным вариантом.


  1. 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 не заблокированных строк, обновление берёт строки в обработку без гонок.