Сервис рекомендаций: куда девать топ-K, если скоринг пересчитывается каждую минуту

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

Первая ошибка — пересчитывать скор при каждом запросе топ-K. Если скоринг — это агрегация по сотням тысяч событий, повторять её на каждый GET /recommendations может быть слишком дорого. Результат можно материализовать заранее, чтобы запрос читал готовый отсортированный список.

Вторая ошибка — хранить топ-K в одной структуре с сырыми событиями. Запись события (вставка строки, инкремент счётчика) и чтение топ-K — разные паттерны доступа с разной частотой. Разделить: write-path пишет события быстро и дёшево, отдельный воркер пересчитывает скор и кладёт готовый список туда, откуда читает read-path.

Если скор — сумма фиксированных весов событий, подойдёт отсортированное множество Redis: ZINCRBY увеличивает скор элемента, ZRANGE с REV возвращает топ-K. Обычный ZADD задаёт значение скора, а не прибавляет его. Обновление одного элемента занимает O(log n), чтение топ-K — O(log n + k). Для такой формулы отдельный батчевый пересчёт не нужен.

-- если скор считается батчем в БД, -- то топ-K читают из отдельной -- материализованной таблицы, -- а не агрегируют на лету SELECT item_id, score FROM recommendation_scores ORDER BY score DESC LIMIT 20;

Следом спросят: что если скор должен учитывать свежесть события — вчерашний лайк весит меньше сегодняшнего? Тогда обычный инкремент с фиксированным весом не учитывает старение. Нужна формула затухания по времени (time decay) и подходящий способ её вычисления: периодический пересчёт или математически эквивалентная схема обновлений.

Топ-K — это почти всегда вопрос о том, где именно происходит дорогая агрегация: на запись, по расписанию или на чтение. Выбор решает всё остальное.

senior·base — что спрашивают на самом деле


В этом посте были ссылки, но мы их удалили по правилам Сетки