
Привет, Хабр! Меня зовут Алексей Васильев, я руковожу группой рантайма рекомендаций в AI VK. Наша команда отвечает за то, чтобы рекомендательные системы работали быстро и надёжно под нагрузкой в сотни тысяч запросов в секунду. Сегодня расскажу историю о том, как мы переработали архитектуру item2item-отбора кандидатов, сократили потребление память в десять раз и при этом увеличили ключевые метрики — казалось бы, взаимоисключающие результаты, но в мире больших данных возможно всё.
Поговорим про горячие и холодные данные, про то, как мы разменяли память на сеть, почему после всех оптимизаций timespent вырос на 4 %, и что мы сделали для open-source сообщества в процессе решения наших задач.
Масштабы, при которых каждый процент на счету
Наша рекомендательная система — это порядка сотни тысяч ядер, разбросанных по нескольким ЦОДам, которые круглосуточно обрабатывают запросы пользователей. Вся эта махина занимает примерно петабайт оперативной памяти. Прибавьте к этому сотни тысяч ядер, которые обслуживают кластеры.
В пике к нам прилетает порядка ста пятидесяти тысяч внешних запросов в секунду. Число, вроде бы, не космическое, но это только верхушка айсберга: внутри кластера каждый запрос размножается примерно в сто раз. Получаются уже миллионы операций в секунду, и всё это происходит в онлайне, без права на ошибку.
На таких масштабах даже экономия в 1 % превращается в ощутимые деньги и железо. А когда удаётся сэкономить десятки терабайтов памяти — это уже серьёзная оптимизация, которая не только окупается финансово, но и открывает новые возможности для развития.

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

Item2item-списки — это предварительно построенные структуры данных, которые для каждого элемента хранят список похожих на него элементов. Представьте: вы смотрите видео про котиков, а справа видите блок «Смотрите также», где вам предлагают ещё больше котиков, собачек и прочей милоты. Или читаете в Дзене статью про спаржу, долистываете до конца, а там уже ждёт блок «Рекомендуем почитать» с каперсами и морковью по-корейски. Это всё примеры работы item2item-рекомендаций.
Технически это выглядит как таблица в YTsaurus, где по идентификатору элемента можно получить массив идентификаторов похожих элементов. Списки формируются офлайн на основе журналов взаимодействий, попарной встречаемости или векторной близости эмбеддингов — способов много, но суть одна: заранее найти похожий контент, чтобы в рантайме не тратить ресурсы на вычисления.
Как работает рекомендательный конвейер

Когда вы открываете приложение, формируется запрос с вашим User ID, который приходит на бэкенд. Первым делом мы извлекаем данные о вас: историю рекомендаций, историю взаимодействий (лайки, репосты — мы называем их позитивами, дизлайки), счётчики активности и профильные данные.
Дальше начинается кандидатогенерация: используя данные о пользователе, мы генерируем большой объём кандидатов из кандидатной базы. Методов отбора десятки: item2item (герой нашего рассказа), user2item, HNSW-индексы и множество других алгоритмов.
После отбора кандидатов мы фильтруем их: убираем уже просмотренный контент и скрытых авторов, применяем возрастные ограничения и настройки приватности. На выходе получается примерно десять тысяч кандидатов — их мы будем дальше ранжировать.
Для каждого кандидата считаем сотни признаков: количество лайков, просмотров, подписчиков у автора, коллаборативные фичи и так далее. Получается матрица 10000×500, которую мы скармливаем моделям ранжирования — обычно это градиентный бустинг или нейросети.
После ранжирования отбираем топ-10 с учётом разнообразия, бизнес-правил и квот. Именно эти десять элементов вы и увидите в ленте.
Проблема масштабирования: когда памяти уже не хватает
Селекторы item2item работают просто: берут последние позитивы пользователя (лайки и репосты) и находят в списках для каждого позитива похожие элементы. Например, если вы лайкнули видео с ID 123 и 456, то мы найдём для них похожие [A1, A2, A3] и [B1, B2], объединим и получим кандидатов для показа.

Эти списки хранятся в off-heap структурах в оперативной памяти. При кандидатной базе в десять миллионов элементов и длине списков в сто элементов получается миллиард записей по восемь байтов — это восемь гигабайтов на один список. А таких списков десятки, потому что каждая ML-команда хочет составить их по своим критериям.
У нас было восемь шардов базового рекомендательного сервиса, по шестьсот подов в каждом, и каждый под занимал около сорока гигабайтов памяти: это снапшоты данных, i2i-списки, HNSW-индексы и прочее. Однажды пришёл бизнес с простыми запросами: увеличить кандидатную базу в два раза, увеличить длину списков для лучшего покрытия, добавить возможность A/Б-тестирования новых алгоритмов. Звучит разумно, но в ЦОДах физически нет столько памяти, пришлось бы строить новые, а это огромные деньги и время.
Шаг первый: выделение i2i в отдельный сервис
Первое, что мы сделали — вынесли все списки item2item в отдельные микросервисы. Разделили их по группам: i2i-сервис №1, i2i-сервис №2, i2i-сервис для A/Б-тестов. Все ответы от этих сервисов собирали в шлюзе и распределяли по нужным шардам.

Вместо 4800 ядер (8 шардов × 600 подов) для обработки всех запросов теперь хватает 240 ядер только для i2i. Это колоссальная экономия! Также мы перестали пересылать позитивы пользователей на базовые рекомендательные сервисы — они нужны только i2i-сервисам. Это сэкономило около двух гигабитов в секунду сетевого трафика.
Мы решили запросы бизнеса и освободили примерно тридцать терабайтов оперативной памяти. Базовые рекомендательные сервисы перестали быть такими монструозными. Но есть нюанс: теперь у нас была куча отдельных сервисов, и это выглядело не очень элегантно.
Шаг второй: разделение на горячие и холодные данные
Мы внимательно посмотрели на паттерны использования i2i-списков и обнаружили две важные вещи. Первое: 95 % запросов используют только первые несколько десятков элементов из каждого списка. Хвост списка практически не используется. Второе: есть горячие элементы (например, котики), к которым обращаются тысячи раз в секунду, и холодные (условные кенгуру), которые запрашивают редко.

Решение напрашивалось само собой: разделить списки на две части. Горячую часть, которая обслуживает 95 % трафика, оставляем в памяти. Холодную часть выносим в YTsaurus — пусть те 5 % запросов, которым нужен хвост, сходят за ним в хранилище.
Ввели жёсткие ограничения на размер списков в снапшотах: режем их и по длине, и по ширине. Снапшоты стали крошечными: вместо десятков гигабайтов теперь сотни мегабайтов. А если запросу не хватило кандидатов из памяти, то добираем из динамических таблиц.
Это решение открыло новые возможности. Теперь мы можем проводить множественные A/Б-тесты, храня экспериментальные списки только в YTsaurus — они используются в маленькой доле трафика, поэтому проблем с производительностью нет. Можем увеличивать длину списков в YTsaurus практически до бесконечности, туда всё равно идут только редкие запросы. И самое главное — все десятки различных списков теперь умещаются в один микросервис, который мы без затей назвали i2i.
Доработки: YTsaurus Java SDK
Но нельзя просто так взять и увеличить обращения к YT в высоконагруженном сервисе. При 150 тыс. RPS и доступности в четыре девятки нужно быть готовым к любым сбоям.

Мы сделали две важные доработки в Java-клиенте YTsaurus, которые потом выложили в open-source. Первая — поддержка multi-lookup запросов. Раньше для десяти позитивов нужно было сделать десять отдельных запросов в YT. Теперь мы пакуем их в один multi-lookup запрос, что критически важно для снижения нагрузки.
Вторая доработка — хеджирующий клиент. Помните про несколько ЦОДов? Теперь, если локальный YTsaurus не отвечает в течение заданного времени, мы автоматически посылаем запрос в соседний ЦОД. Кто первый ответил, того и данные используем. Это обеспечивает устойчивость к сбоям отдельных компонентов.
Что получилось в итоге
Теперь наша система выглядит элегантно: позитивы и история просмотров летят в единый i2i-сервис, который берёт горячие данные из памяти, а при необходимости добирает холодные из YTsaurus. Все ответы собираются в шлюзе и распределяются по шардам базового рекомендательного сервиса.
Что мы получили:
Можем составлять списки любой длины для любого количества кандидатов.
Легко проводим A/Б-тесты не только новых списков, но и алгоритмов их обхода.
Timespent вырос на 4 % — убрав ограничения на количество кандидатов с каждого шарда, мы стали находить более релевантный контент.
Сократили использование памяти до 3 ТБ!
Чем пришлось заплатить? Во-первых, частично продублировали логику фильтрации в i2i-сервисах и базовых рекомендательных сервисах: фильтры по уже просмотренному отсеивают много кандидатов, поэтому выгоднее применить их как можно раньше. Во-вторых, разменяли память на сеть: добавили несколько гигабитов сетевого трафика. Классический компромисс в распределённых системах, но в нашем случае он того стоил.
Что можно применить в своих проектах
Посмотрите на свои данные с высоты птичьего полёта. Возможно, там тоже есть разделение на горячие и холодные, и вы сможете оптимизировать их хранение. На наших масштабах это дало ощутимый эффект, но принцип работает и на меньших объёмах.
Подумайте, какие ресурсы у вас в избытке, а каких не хватает. Мы разменяли дефицитную память на относительно дешёвую сеть — может, у вас есть похожие возможности для оптимизации.
Маленький сервис — это удобно. В нём проще экспериментировать с алгоритмами, быстрее внедрять изменения, легче проводить A/Б-тесты. Не бойтесь выделять логически обособленные части в отдельные микросервисы, потом скажете себе спасибо.
И помните: даже на огромных масштабах с петабайтами данных можно найти элегантные решения, которые и ресурсы сэкономят, и метрики улучшат. Главное — не бояться переосмысливать существующую архитектуру.
sabay
Если изменить принцип хранения/кодирования кандидатов то можно сократить место в памяти в среднем в 4-6 раз ( можно компактнее но это уже скажется на производительности).
Тривиальный вариант: хранить кандидатов в сортированном по id порядке и применить инкрементальное кодирование ( хранить только диффы и их длина будет кодироваться переменным количеством байт). Поскольку обход идет при слиянии сортированых списков последовательно - производительность при таком кодировании не упадет ( а в некоторых случаях даже вырастет).
Тривиальный вариант не учитывает веса каждого из кандидатов - но учет весов тоже делается аналогично. Просто немного сложнее чтобы текстом это описывать.