RecSys · учебник
Тренажёр Виджеты Повторение О проекте Все главы ← Данные Проектирование →

Часть V · Инженерия · глава 18 из 19

Рантайм

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

Что унести из главы
  • Сервисы разносят из-за разного паттерна нагрузки. При восьми репликах монолит держит 832 ГБ против 232, и вся разница — лишние копии одного индекса.
  • Фильтровать надо сразу после кандидатогенерации. Поздний фильтр при 30% отсева даёт на 43% меньше полезных кандидатов за тот же бюджет.
  • Чем лучше работал кеш, тем сильнее удар от его сброса. При hit rate 0.95 массовая инвалидация даёт нижнему слою скачок в 20 раз.
  • Система не должна умирать героически — она должна деградировать предсказуемо. Цепочка из пяти сервисов по 0.999 даёт 43.7 часа простоя в год.

1. Архитектура сервиса

Базовый вариант, с которого все начинают: на сервисе живёт индекс айтемов, обновляется раз в сутки; в индексе лежит всё нужное для расчёта моделей; прилетает запрос — идём в сервис профиля, поднимаем кандидатов, считаем признаки, считаем модель, отдаём.

Три узких места
  1. Обновление раз в сутки нереалистично для части продуктов — например, для новостей.
  2. Один сервис делает всю работу: сложности с параллельностью и устойчивостью, риск превратиться в монолит-легаси, тяжело работать большой командой.
  3. Память. Отвечать надо за 50–300 мс; если айтемов много, положить их все в память одной машины сложно, а читать много данных с диска в рантайме — самоубийство.

Почему кандидатогенерацию и признаки выносят

Главная причина — разный паттерн нагрузки

Признаки и модели упираются в CPU и GPU (признаки отчасти и в память). Кандидатогенерация упирается в память, причём паттерн использования памяти у неё другой.

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

Во что это обходится

Индекс кандидатов 100 ГБ на реплику, слой признаков и моделей — 4 ГБ. Индексу для надёжности хватает двух реплик, а вычислительный слой масштабируем под нагрузку:

Реплик под CPUМонолитРазнесеноЭкономия
2208 ГБ208 ГБ
4416 ГБ216 ГБв 1.9 раза
8832 ГБ232 ГБв 3.6 раза
161664 ГБ264 ГБв 6.3 раза

Числа воспроизводятся скриптом _tools/runtime_demo.py в этом репозитории.

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

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

Свежие айтемы и динамический индекс

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

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

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

Резонный вопрос, и ответ тот же, что для выноса кандидатогенерации вообще: дело в паттерне нагрузки и в изоляции.

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

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

Когда памяти не хватает даже после разнесения

ВариантЧто делаемКогда
Префильтрациявыбираем подмножество айтемов, которое вообще может рекомендоваться: только относительно новые (новости) или отсечка по предсказанной неперсональной пользекогда каталог заведомо содержит много мусора
Шардированиеразбиваем айтемы на \(X\) кусков, внутри каждого строим по сути независимую систему, сверху — оркестраторкогда каталог велик и однороден
оркестратор / блендер мерж шардов + тяжёлое ранжирование Шард 1 candidate service · память feature service · CPU Шард 2 candidate service feature service Шард N candidate service feature service инференс-сервер шардировать смысла нет — но несколько параллельных дают пропускную способность
Вторая причина шардировать — не только память: параллельные шарды позволяют скорить больше кандидатов суммарно.

На практике шардирование чаще всего используют вместе с префильтрацией, а не вместо неё.

2. Где фильтровать

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

Правильный ответ: сразу после кандидатогенерации

Пусть бюджет ранжирования — 500 кандидатов, а фильтры отсеивают 30%.

фильтруем после ранжированияпоскорили 500, выжило 350
фильтруем сразу после кандгенапоскорили 500 валидных
прирост полезных кандидатов43%

Числа воспроизводятся скриптом _tools/runtime_demo.py.

Две беды позднего фильтра, и обе конкретные.

  1. Потратим дорогое время инференса на айтемы, про которые заранее знаем, что не покажем. Могли бы потратить его на других кандидатов — выдача была бы строго не хуже.
  2. Выдача может оказаться короткой. Чтобы гарантированно показать 20 позиций при 30% отсева, надо поскорить 29; иначе дозапрос, который увеличивает общее время ответа.

Дальше вопрос, как гарантировать, что ранжируется ровно \(X\) кандидатов. Два варианта: набирать с запасом либо устроить саму кандидатогенерацию так, чтобы кандидаты генерировались уже отфильтрованными. Второй лучше по той же логике — не тратить работу впустую, — но он дороже в реализации.

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

3. Кеширование

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

Что кешировать в общем случае — вопрос по сути продуктовый. Безопасный базовый вариант: признаки айтемов с разумным TTL. Более интересный: устроить кандидатогенерацию так, чтобы одна её часть явно соответствовала долгосрочным интересам пользователя, — и кешировать именно её.

Правило безопасности: не инвалидируйте весь кеш разом

Механика проста: при массовой инвалидации весь трафик одномоментно уходит вниз по стеку, к которому он не готов.

Hit rateДоля запросов вниз по стекуСкачок при сбросе кеша
0.500.50в 2 раза
0.800.20в 5 раз
0.900.10в 10 раз
0.950.05в 20 раз
0.990.01в 100 раз

Числа воспроизводятся скриптом _tools/runtime_demo.py.

Обратите внимание на форму зависимости — она контринтуитивна и потому опасна. Чем лучше работал кеш, тем сильнее удар от его сброса. Система с hit rate 0.99 выглядит здоровее, чем с 0.90, а на самом деле стоит на пороховой бочке: нижний слой отмасштабирован на процент трафика.

Отсюда два правила: бережно выбирать ключи кеширования и завести на это отдельные тесты. Инвалидация всего кеша — это не «медленнее ответим», это отказ.

Про метаинформацию

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

Хороший: поверх живёт отдельный сервис, который этим занимается.

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

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

4. Фильтр Блума и пагинация

Как он устроен

Структура данных с интерфейсом: insert() за \(O(1)\) и maybe_has() за \(O(1)\). Если ответила «нет» — элемента точно нет; если «да» — элемент всё же может отсутствовать. Ошибка односторонняя, и это ключевое свойство.

Устройство: \(k\) независимых хеш-функций с образом \([0, m-1]\) и массив длины \(m\) из нулей и единиц. При добавлении ставим единицы в позиции, полученные хешами; при проверке смотрим те же позиции — если везде единицы, говорим «есть».

$$ p \approx \Bigl(1 - e^{-kn/m}\Bigr)^{k}, \qquad k^{*} = \frac{m}{n}\ln 2 $$
Почему кривая ошибок U-образная

Массив 8000 бит, вставлена тысяча элементов:

Хеш-функций \(k\)1246812
ложных срабатываний11.75%4.89%2.40%2.16%2.55%4.83%

Числа воспроизводятся скриптом _tools/runtime_demo.py; он же независимо повторяет вычисления виджета.

Мало хешей — мало различающей силы; много — фильтр забивается битами. Оптимум здесь \(k^{*} = 5.55\), и при нём занята ровно половина бит — красивый и легко запоминающийся признак того, что \(k\) подобран верно.

Память: 8000 бит — это 1.0 КБ против 7.8 КБ на тысячу идентификаторов по 8 байт, экономия в 8 раз, и она растёт с числом показанных айтемов.

Зачем это в рекомендациях: пагинация

Обычная идея пагинации: показали первые \(X\) товаров, потом следующие \(X\). Проблема в том, что рекомендательная система нестабильна — между запросами меняются признаки, модель, индекс, — и наивная реализация приводит к дублям.

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

И вот почему здесь годится именно Блум. Ложноотрицательных не бывает: сказал «не видел» — точно не видел. Значит ошибка возможна только в сторону «спрячем лишнее»: в худшем случае мы уберём из выдачи хороший айтем, но никогда не покажем один и тот же дважды. Односторонность ошибки совпадает с тем, что дороже.

Что здесь надо увидеть
  1. Двигайте \(k\) и смотрите на U-образную кривую: один хеш даёт 11.75%, шесть — 2.16%, двенадцать — снова 4.83%.
  2. Проверьте, что в оптимуме занята ровно половина бит.
  3. Смотрите на память: вместо всей выданной ранее ленты за пользователем носится компактный фильтр на единицы килобайт.

Что сказать на собесе: «Блум даёт односторонние ошибки, и это ровно то, что нужно для дедупликации выдачи: ошибиться в сторону „спрячем лишнее“ дешевле, чем показать дубль».

5. Блендинг и PID-контроллер

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

Почему не жёсткая квота

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

$$ u(t) = K_p\, e(t) + K_i \int_0^t e(\tau)\,d\tau + K_d\, \frac{d e(t)}{dt} $$

где \(e(t)\) — ошибка, то есть разница между целевой долей и фактической.

  • P — реакция на текущую ошибку: чем больше разница, тем сильнее воздействие.
  • I — реакция на накопленную ошибку: чтобы действительно добегать до цели, а не болтаться рядом.
  • D — реакция на скорость изменения ошибки: гасит перерегулирование.
Почему без интегральной части не работает

Цель — 30% категории в выдаче; естественная доля без буста 10%; на 60-м шаге спрос проседает.

PIСредняя доля в концеПромах от цели
0.80.0012.3%−17.7 п.п.
2.00.0017.3%−12.7 п.п.
0.80.1528.9%−1.1 п.п.
3.00.1529.1%−0.9 п.п.

Числа воспроизводятся скриптом _tools/runtime_demo.py.

Главное здесь — первая строка. При \(I = 0\) доля стабилизируется на 12% вместо 30%: промах 17.7 п.п. Причина не в слабости настройки, а в устройстве пропорционального регулятора — он даёт воздействие, пропорциональное ошибке. Значит при нулевой ошибке нет и буста, и система вынуждена жить с постоянным недобором. Это статическая ошибка.

Усиление \(P\) до 2 сокращает промах до 12.7 п.п., но не убирает его — только уменьшает, добавляя взамен колебания. Статическую ошибку убирает исключительно интегральная часть.

С \(I = 0.15\) сглаженная доля выходит в коридор ±2 п.п. и больше его не покидает. А после шока на 60-м шаге доля проваливается до 20%, но интеграл добирает буст и возвращает её к 29% — статическая квота этого не умеет: она не знает, что спрос изменился.

И обратная сторона: при \(P = 3\) максимум разгона доходит до 58% против цели 30% — перелёт 28 п.п., контур раскачивается.

Что здесь надо увидеть
  1. Поставьте \(I = 0\): доля стабилизируется не на цели. Убедитесь, что усиление \(P\) промах не убирает.
  2. Верните \(I\) — доля выходит на цель.
  3. Поднимите \(P\) до 3: контур раскачивается.
  4. Выключите и включите шок: с интегральной частью контроллер сам возвращается к цели после падения спроса.

Что сказать на собесе: «PID держат в блендинге потому, что доля категории зависит от спроса, который меняется. P реагирует быстро, но всегда недобирает; I добивает до цели; слишком большой P раскачивает выдачу».

6. Устойчивость

Самое важное правило главы

Система не должна умирать героически — она должна деградировать предсказуемо.

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

Почему это не редкий случай
Сервисов в цепочкеКаждый 0.999Каждый 0.9999Простой в год при 0.999
10.999000.999908.8 ч
30.997000.9997026.3 ч
50.995010.9995043.7 ч
100.990040.9990087.2 ч

Числа воспроизводятся скриптом _tools/runtime_demo.py.

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

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

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

Тыква — намеренно не самый интеллектуальный, но точно работающий режим: предрассчитанные (один раз!) популярные товары, популярное по сегменту, редакторские подборки, простая бизнес-логика без персонализации.

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

Мысль, которую стоит унести дословно: если вы давно не тестировали свою тыкву, значит, у вас её скорее всего уже нет.

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

7. Что мониторить

Обычные техническиеСпецифичные для рекомендаций
задержка p50 / p95 / p99; доля ошибок и таймаутов; попадание в кеш; лаги очередей доля разных источников кандидатов в выдаче; доля свежих айтемов; доля фоллбек-ответов по каждому фоллбеку; покрытие выдачи и сессии категориями; возраст модели, индекса и признаков; покрытие признаков и дрейфы
Абстракция, которой стоит пользоваться: мониторить воронку

Полезно иметь мониторы не только на финальную выдачу, но и на каждый её этап. Буквально:

  • сколько кандидатов подняли;
  • сколько выжило после фильтрации;
  • сколько поскорили;
  • сколько попало в ответ;
  • сколько времени занял каждый этап.

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

Вопросы с собеседований

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

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

Счёт: индекс 100 ГБ, вычислительный слой 4 ГБ. При восьми репликах монолит держит 832 ГБ, разнесённая система — 232 ГБ, в 3.6 раза меньше; разница это шесть лишних копий индекса.

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

Где ставить фильтры и почему?

Сразу после кандидатогенерации. Две причины.

Первая: иначе тратим дорогое время инференса на айтемы, про которые заранее знаем, что не покажем. При бюджете в 500 кандидатов и 30% отсева поздний фильтр оставляет 350 полезных вместо 500 — это на 43% меньше за те же деньги.

Вторая: выдача может оказаться короткой, что роняет бизнес-метрики. Чтобы гарантированно показать 20 позиций при 30% отсева, надо поскорить 29, иначе дозапрос и рост времени ответа.

Идеальный вариант — устроить кандидатогенерацию так, чтобы кандидаты рождались уже отфильтрованными: не тратить работу впустую вообще. Дороже в реализации.

Чем опасна массовая инвалидация кеша?

Тем, что весь трафик одномоментно уходит вниз по стеку, к которому он не готов. И зависимость контринтуитивна: чем лучше работал кеш, тем сильнее удар. При hit rate 0.95 нижний слой рассчитан на 5% трафика и получает 100% — скачок в 20 раз; при 0.99 — в 100 раз.

То есть система с отличным hit rate выглядит здоровее, а на самом деле уязвимее. Отсюда два правила: бережно выбирать ключи кеширования и завести на это отдельные тесты. Инвалидация всего кеша — это не «медленнее ответим», это отказ.

Как устроен фильтр Блума и зачем он в рекомендациях?

k хеш-функций и битовый массив длины m. При вставке ставим единицы по хешам, при проверке смотрим те же позиции. Вероятность ложного срабатывания \((1 - e^{-kn/m})^k\), оптимум \(k^* = (m/n)\ln 2\), и при нём занята ровно половина бит.

Кривая по k U-образная: при m = 8000 и n = 1000 один хеш даёт 11.75%, шесть — 2.16%, двенадцать — снова 4.83%. Мало хешей — мало различающей силы, много — фильтр забивается.

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

Зачем в блендинге PID-контроллер?

Чтобы удерживать долю категории в выдаче, когда спрос меняется. Жёсткая квота вставляет айтемы на фиксированные позиции и ломает осмысленность выдачи; регулятор вместо этого подкручивает бонус к скору, и выдача остаётся отсортированной по смыслу.

Ключевое — зачем нужна интегральная часть. Пропорциональный регулятор даёт воздействие, пропорциональное ошибке, значит при нулевой ошибке нет и буста: система живёт с постоянным недобором. В симуляции при I = 0 доля стабилизируется на 12% вместо 30% — промах 17.7 п.п., и усиление P до 2 сокращает его только до 12.7, добавляя колебания. Убирает статическую ошибку исключительно I.

Обратная сторона — слишком большой P раскачивает контур: при P = 3 разгон доходит до 58% против цели 30%.

Что делать, когда часть системы недоступна?

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

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

Переходы между ступенями надо продумать заранее, а включать их автоматически — предохранителями и привязкой к утилизации.

Что такое тыква и как с ней работать?

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

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

Главное: если вы давно не тестировали свою тыкву, значит, у вас её скорее всего уже нет.

Что мониторить в рекомендательном сервисе?

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

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

Шпаргалка одним экраном

Разные нагрузки

Кандген memory-bound, модели CPU-bound. 8 реплик: 832 ГБ монолит против 232.

Фильтры

Сразу после кандгена. Поздний фильтр при 30% отсева теряет 43% полезных кандидатов.

Кеш

Чем выше hit rate, тем страшнее сброс: при 0.95 скачок в 20 раз, при 0.99 — в 100.

Блум

\((1-e^{-kn/m})^k\), \(k^*=(m/n)\ln2\), половина бит занята. Ошибка односторонняя.

Пагинация

Гоняем фильтр с показанным между фронтом и бэком. Дубль не покажем никогда.

PID

Без I статическая ошибка 17.7 п.п. Усиление P её не убирает, только раскачивает.

Деградация

Пять сервисов по 0.999 → 43.7 ч простоя в год. Не умирать героически.

Тыква

Чем тупее, тем лучше. Снаружи от сервиса. Не тестировали — значит, её нет.

Первоисточники