Часть V · Инженерия · глава 18 из 19
Рантайм
Последняя техническая глава — про то, как всё предыдущее работает под нагрузкой и не падает. Здесь мало математики и много решений, каждое из которых на собеседовании проверяет, работал ли человек с живой системой: где разрезать сервисы, где ставить фильтры, что кешировать и что показывать, когда половина стека недоступна.
- Сервисы разносят из-за разного паттерна нагрузки. При восьми репликах монолит держит 832 ГБ против 232, и вся разница — лишние копии одного индекса.
- Фильтровать надо сразу после кандидатогенерации. Поздний фильтр при 30% отсева даёт на 43% меньше полезных кандидатов за тот же бюджет.
- Чем лучше работал кеш, тем сильнее удар от его сброса. При hit rate 0.95 массовая инвалидация даёт нижнему слою скачок в 20 раз.
- Система не должна умирать героически — она должна деградировать предсказуемо. Цепочка из пяти сервисов по 0.999 даёт 43.7 часа простоя в год.
1. Архитектура сервиса
Базовый вариант, с которого все начинают: на сервисе живёт индекс айтемов, обновляется раз в сутки; в индексе лежит всё нужное для расчёта моделей; прилетает запрос — идём в сервис профиля, поднимаем кандидатов, считаем признаки, считаем модель, отдаём.
- Обновление раз в сутки нереалистично для части продуктов — например, для новостей.
- Один сервис делает всю работу: сложности с параллельностью и устойчивостью, риск превратиться в монолит-легаси, тяжело работать большой командой.
- Память. Отвечать надо за 50–300 мс; если айтемов много, положить их все в память одной машины сложно, а читать много данных с диска в рантайме — самоубийство.
Почему кандидатогенерацию и признаки выносят
Признаки и модели упираются в CPU и GPU (признаки отчасти и в память). Кандидатогенерация упирается в память, причём паттерн использования памяти у неё другой.
Держать это в одном процессе означает, что сервису одновременно нужно и много памяти, и много вычислений. Такой сервис тяжело масштабировать горизонтально: добавляя реплики ради CPU, вы дублируете и весь индекс.
Индекс кандидатов 100 ГБ на реплику, слой признаков и моделей — 4 ГБ. Индексу для надёжности хватает двух реплик, а вычислительный слой масштабируем под нагрузку:
| Реплик под CPU | Монолит | Разнесено | Экономия |
|---|---|---|---|
| 2 | 208 ГБ | 208 ГБ | — |
| 4 | 416 ГБ | 216 ГБ | в 1.9 раза |
| 8 | 832 ГБ | 232 ГБ | в 3.6 раза |
| 16 | 1664 ГБ | 264 ГБ | в 6.3 раза |
Числа воспроизводятся скриптом _tools/runtime_demo.py в этом репозитории.
При восьми репликах разница — шесть лишних копий одного и того же индекса. И заметьте характер зависимости: при двух репликах выигрыша нет вовсе, а дальше он растёт линейно. Разнесение окупается не сразу, и это честный аргумент за то, чтобы начинать с монолита.
Остальные причины стандартные: разделение ответственности, устранение единой точки отказа, возможность экспериментировать не затрагивая соседа, естественная граница между командами. Плата — признаки отдельно от кандидатов означают возможное дублирование части данных в памяти; на практике на это идут.
Свежие айтемы и динамический индекс
Что происходит при появлении нового айтема: он проезжает по очереди событий, из названия, описания и картинки строится контентный эмбеддинг, и айтем едет в отдельный индекс, который используется в кандидатогенерации.
Практический путь: основной сервис отдаёт своих кандидатов, отдельный «свежий» сервис отдаёт только свежие, а блендер замешивает их контролируемым способом.
Резонный вопрос, и ответ тот же, что для выноса кандидатогенерации вообще: дело в паттерне нагрузки и в изоляции.
В простом варианте свежий сервис работает источником свежих кандидатов — трендовый топ и подобное. В сложном на нём происходит отдельное ранжирование со своими правилами: у холодных айтемов другой набор доступных признаков, и смешивать их с основным ранкером неудобно.
Заодно это даёт изоляцию сбоев: индекс свежих обновляется постоянно и потому ломается чаще, а падать вместе с основной выдачей он не должен.
Когда памяти не хватает даже после разнесения
| Вариант | Что делаем | Когда |
|---|---|---|
| Префильтрация | выбираем подмножество айтемов, которое вообще может рекомендоваться: только относительно новые (новости) или отсечка по предсказанной неперсональной пользе | когда каталог заведомо содержит много мусора |
| Шардирование | разбиваем айтемы на \(X\) кусков, внутри каждого строим по сути независимую систему, сверху — оркестратор | когда каталог велик и однороден |
На практике шардирование чаще всего используют вместе с префильтрацией, а не вместо неё.
2. Где фильтровать
Мы говорили о шардах так, будто кандидатов надо генерировать по всем айтемам. На практике это не так: часть товаров нельзя показывать никогда, часть — конкретному пользователю, часть — временно (нет на складе), и почти всегда нужен механизм мгновенного бана айтема.
Пусть бюджет ранжирования — 500 кандидатов, а фильтры отсеивают 30%.
| фильтруем после ранжирования | поскорили 500, выжило 350 |
| фильтруем сразу после кандгена | поскорили 500 валидных |
| прирост полезных кандидатов | 43% |
Числа воспроизводятся скриптом _tools/runtime_demo.py.
Две беды позднего фильтра, и обе конкретные.
- Потратим дорогое время инференса на айтемы, про которые заранее знаем, что не покажем. Могли бы потратить его на других кандидатов — выдача была бы строго не хуже.
- Выдача может оказаться короткой. Чтобы гарантированно показать 20 позиций при 30% отсева, надо поскорить 29; иначе дозапрос, который увеличивает общее время ответа.
Дальше вопрос, как гарантировать, что ранжируется ровно \(X\) кандидатов. Два варианта: набирать с запасом либо устроить саму кандидатогенерацию так, чтобы кандидаты генерировались уже отфильтрованными. Второй лучше по той же логике — не тратить работу впустую, — но он дороже в реализации.
Ограничения бывают двух типов: глобальная доступность и доступность контента, разбитая на кластеры. Где можем — поддерживаем лёгкую фильтрацию на битсетах или фильтрах Блума; но иногда приходится строить отдельный индекс на каждый кластер доступности, и это дорого.
3. Кеширование
Данные бесконечно летают по сети между сервисами, и ответ, как всегда, в кешировании. Простой случай — неперсональный поиск, где кешировать можно весь ответ. Но и в персональном почти наверняка есть неперсональная часть: например, в цепочке «модель релевантности → модель покупки» первая от пользователя не зависит вовсе.
Что кешировать в общем случае — вопрос по сути продуктовый. Безопасный базовый вариант: признаки айтемов с разумным TTL. Более интересный: устроить кандидатогенерацию так, чтобы одна её часть явно соответствовала долгосрочным интересам пользователя, — и кешировать именно её.
Механика проста: при массовой инвалидации весь трафик одномоментно уходит вниз по стеку, к которому он не готов.
| Hit rate | Доля запросов вниз по стеку | Скачок при сбросе кеша |
|---|---|---|
| 0.50 | 0.50 | в 2 раза |
| 0.80 | 0.20 | в 5 раз |
| 0.90 | 0.10 | в 10 раз |
| 0.95 | 0.05 | в 20 раз |
| 0.99 | 0.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 $$Массив 8000 бит, вставлена тысяча элементов:
| Хеш-функций \(k\) | 1 | 2 | 4 | 6 | 8 | 12 |
|---|---|---|---|---|---|---|
| ложных срабатываний | 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\). Проблема в том, что рекомендательная система нестабильна — между запросами меняются признаки, модель, индекс, — и наивная реализация приводит к дублям.
Элегантное решение: бросать между фронтом и бэком в отдельном параметре сериализованный объект — например, фильтр Блума — с тем, что уже было отдано начиная с первой страницы. Каждая следующая страница фильтрует уже показанное, независимо от того, что произошло с моделью между запросами.
И вот почему здесь годится именно Блум. Ложноотрицательных не бывает: сказал «не видел» — точно не видел. Значит ошибка возможна только в сторону «спрячем лишнее»: в худшем случае мы уберём из выдачи хороший айтем, но никогда не покажем один и тот же дважды. Односторонность ошибки совпадает с тем, что дороже.
- Двигайте \(k\) и смотрите на U-образную кривую: один хеш даёт 11.75%, шесть — 2.16%, двенадцать — снова 4.83%.
- Проверьте, что в оптимуме занята ровно половина бит.
- Смотрите на память: вместо всей выданной ранее ленты за пользователем носится компактный фильтр на единицы килобайт.
Что сказать на собесе: «Блум даёт односторонние ошибки, и это ровно то, что нужно для дедупликации выдачи: ошибиться в сторону „спрячем лишнее“ дешевле, чем показать дубль».
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-м шаге спрос проседает.
| P | I | Средняя доля в конце | Промах от цели |
|---|---|---|---|
| 0.8 | 0.00 | 12.3% | −17.7 п.п. |
| 2.0 | 0.00 | 17.3% | −12.7 п.п. |
| 0.8 | 0.15 | 28.9% | −1.1 п.п. |
| 3.0 | 0.15 | 29.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 п.п., контур раскачивается.
- Поставьте \(I = 0\): доля стабилизируется не на цели. Убедитесь, что усиление \(P\) промах не убирает.
- Верните \(I\) — доля выходит на цель.
- Поднимите \(P\) до 3: контур раскачивается.
- Выключите и включите шок: с интегральной частью контроллер сам возвращается к цели после падения спроса.
Что сказать на собесе: «PID держат в блендинге потому, что доля категории зависит от спроса, который меняется. P реагирует быстро, но всегда недобирает; I добивает до цели; слишком большой P раскачивает выдачу».
6. Устойчивость
Система не должна умирать героически — она должна деградировать предсказуемо.
Возможные проблемы: умер кандидатный сервис; недоступно хранилище признаков; не подгрузилась модель; отвалился шард; данные устарели; всё живо, но не укладываемся в бюджет. Во всех этих случаях отдать пятисотую — точно худший вариант.
| Сервисов в цепочке | Каждый 0.999 | Каждый 0.9999 | Простой в год при 0.999 |
|---|---|---|---|
| 1 | 0.99900 | 0.99990 | 8.8 ч |
| 3 | 0.99700 | 0.99970 | 26.3 ч |
| 5 | 0.99501 | 0.99950 | 43.7 ч |
| 10 | 0.99004 | 0.99900 | 87.2 ч |
Числа воспроизводятся скриптом _tools/runtime_demo.py.
Цепочка из пяти сервисов с очень приличной доступностью 0.999 у каждого даёт 43.7 часа простоя в год. Без фоллбеков это часы, которые пользователь видит как ошибку. С фоллбеками — часы деградации: выдача хуже, но она есть.
И заметьте, что разнесение на сервисы из раздела 1 ухудшает эту арифметику. За гибкость масштабирования платят количеством мест, где можно сломаться, — и именно поэтому фоллбеки не опция, а часть той же архитектурной сделки.
Тыква — намеренно не самый интеллектуальный, но точно работающий режим: предрассчитанные (один раз!) популярные товары, популярное по сегменту, редакторские подборки, простая бизнес-логика без персонализации.
- Чем тупее ваша тыква, тем лучше. Нет ничего менее приятного, чем в решающий момент узнать, что тыква не работает.
- На случай жёсткого инцидента лучше просто зафиксировать в конфиге огромный список айтемов, чтобы точно пролетели все фильтрации.
- Либо иметь несколько последовательных тыкв.
Мысль, которую стоит унести дословно: если вы давно не тестировали свою тыкву, значит, у вас её скорее всего уже нет.
И мало придумать фоллбеки — надо научиться их правильно включать. Помогают предохранители на вызовах, привязка к утилизации, и золотое правило: делать тыквы снаружи от вашего сервиса. Если сервис лежит, он не сможет отдать даже тыкву.
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 ч простоя в год. Не умирать героически.
Тыква
Чем тупее, тем лучше. Снаружи от сервиса. Не тестировали — значит, её нет.
Первоисточники
- B. H. Bloom. Space/Time Trade-offs in Hash Coding with Allowable Errors, CACM 1970 — исходная работа по фильтру.
- M. Nygard. Release It! Design and Deploy Production-Ready Software, Pragmatic Bookshelf — предохранители, переборки и предсказуемая деградация.
- B. Beyer et al. Site Reliability Engineering, O'Reilly 2016 — бюджеты ошибок и арифметика доступности цепочек.
- F. Borisyuk et al. LiNR: Model Based Neural Retrieval on GPUs at LinkedIn, 2024 — как фильтрация встраивается в сам поиск кандидатов.
- J. Dean, L. Barroso. The Tail at Scale, CACM 2013 — почему хвостовые задержки в цепочке сервисов складываются так плохо.
- Числа главы:
_tools/runtime_demo.pyв этом репозитории.