Отранжированные и смешные: создаем систему выстраивания пользовательской ленты
- среда, 2 августа 2023 г. в 00:00:21
Всех приветствую! Меня зовут Кирилл, я Go-разработчик в компании Ozon. Сейчас моим полем деятельности является разработка микросервисов в департаменте Fresh, однако я также успел принять участие в некоторых других интересных проектах.
Сегодня мне бы хотелось поговорить с вами о ранжировании информации для её последующей выдачи в пользовательскую ленту. Предлагаю поговорить о самом термине «ранжирование», а ещё об использовании этого процесса в современных информационных системах. Во время обсуждения я набросаю простую схему ранжирования внутри продукта, выбранного в качестве примера, а также поделюсь некоторыми примерами из опыта построения подобной системы.
Для начала определимся с примером. Кажется, что приложение с бесконечной лентой анекдотов, которыми могут делиться пользователи друг с другом, — довольно забавный и рабочий вариант. Поэтому его и выберем. Какими же условиями ограничимся во время описания нашего примера?
Лента формируется пользователями. Они вправе добавлять, редактировать и удалять анекдоты.
Лента в некотором смысле персонализирована: каждому пользователю в большинстве своём показываются анекдоты, подходящие по мнению алгоритма конкретно ему. Но некоторые анекдоты буду попадать в ленту и по другим принципам.
Каждый анекдот описывается совокупностью параметров метаинформации, в том числе некоторой «стоимостью» (далее без кавычек) — численным эквивалентом его значимости в контексте общей ленты. Получение этой величины имеет ёмкое определение — скоринг. На стоимость могут влиять такие показатели, как общее количество просмотров; лайки и дизлайки; количество действий с постом за первые n-часов, чтобы выявить наиболее «горячие» или релевантные, и так далее.
Лента модерируется. То есть модераторы имеют полное право забраковать конкретный анекдот исходя из правил модерации, или, возможно, снизить стоимость анекдота в ручном режиме для уменьшения количества его показов в ленте. Поэтому надо уметь ловить такие события.
Команда ранжирования отвечает только за способы группировки, выборки анекдотов, то есть нам достаточно знать только о метаданных. Хранение анекдотов на совести соседней команды.
А раз так, то нам необходимо постоянно получать данные по изменению метаинформации анекдотов (появление новых, удаление, увеличение счётчика просмотров и т. п.). Для этого используем топики брокеров сообщений.
Теперь поговорим о процессе ранжирования. По большому счёту, это фильтрация и сортировка интересующей нас информации, в данном случае анекдотов, с их последующим размещением в ленте пользователя согласно результатам выбранного запроса. Алгоритм ранжирования может оценивать общую полезность информации в выдаче для всех клиентов (поисковики веб-сайтов), или полезность для каждого конкретного пользователя с учётом его действий и предпочтений (социальные сети, наш случай). Кажется, что эта часть информационной системы, связанной с выдачей анекдотов, является одной из самых важных. Ведь именно благодаря ей в определённой степени достигается удовлетворенность пользователей сервисом.
Ранжировать можно как на этапе запроса в базу данных, где хранится метаинформация, влияющая на выборку, так и силами сервисов на бэкенде. Такой комбинированный подход позволяет не нагружать базу трудоёмкими запросами, добавляя гибкости процессу.
Во время ранжирования достаточно много внимания уделяется стоимости анекдотов. Как я сказал выше, она определяется на этапе скоринга — по сути, независимой от ранжирования процедуры. В её задачу входит расчёт «баллов» для каждого из анекдотов по выбранной для этого формуле. Подобные формулы — тоже зачастую тайна, потому что они в реальном времени влияют на выдачу анекдотов или постов в социальной сети. В нашем примере мы можем оперировать несколькими величинами для подсчёта, как указано в пункте 3. Формулы скоринга рождаются после многих проб и ошибок на основе экспериментов и наблюдений. Они содержат в себе не только параметры, известные нам по содержанию документа в базе данных, но и коэффициенты, повышающие или понижающие важность используемых для расчёта значений. Изменение коэффициентов влечёт за собой мгновенное изменение выдачи анекдотов в ленте. Скоринг и полученная стоимость — это отличная основа для проведения А/Б-экспериментов, когда необходимо проверить множественные гипотезы, рождающиеся во время взаимодействия приложения с пользователями. Ведь создавая на основе стоимости группы анекдотов, имеющих различную конечную цену, мы можем исследовать вовлечённость пользователей, экспериментировать с наполнением ленты и многое другое.
Создание подобных информационных систем — работа кропотливая и поэтапная. В любой момент времени требуется иметь доступ к метрикам, предоставляемым сервисами, чтобы корректировать параметры работы не только ранжирования, но и прочих подсистем. Такие изменения и их влияние на ленту будут видны практически сразу. Тут мы подходим к тому, что к системе ранжирования необходимо применить некоторые, в нашем случае немного абстрактные, требования:
Считаем, что количество запросов позволяет говорить о сервисах как о высоконагруженных, то есть пользователей много и анекдоты они читают постоянно.
Никаких монолитов — работаем с микросервисами. Не будем устраивать холивар. Это решение обычно диктуется политикой компании.
Сервисы пишутся на Go.
Журналирование, трассировка и мониторинг — наши друзья. Использование этих инструментов просто необходимо для выявления аномалий работы сервисов, особенно когда формулу скоринга можно менять через настройки в любой момент.
Одно из главных требований — масштабируемость. Никто бы не хотел, чтобы система загнулась через несколько месяцев работы.
Прирост контента у нас стабильно высокий для анекдотов: около 200-500 штук в день. В мире столько всего происходит, а анекдоты с Вовочкой плодятся после каждого мало-мальски заметного информационного повода.
Метрика вовлечённости уникальных пользователей — DAU/MAU — ожидается на уровне 30-40 %. Целимся сюда, потому что предполагаем, что люди обожают юмор и анекдоты дня, присылаемые им каждое утро, чтобы напомнить о сокровищнице хорошего настроения на целый день. Амбиционзно? Еще бы.
Мы стремимся к тому, чтобы пользователи использовали сервис на протяжении долгого времени, то есть хотим удерживать их у себя как можно дольше. Поэтому для оценки привлекательности сервиса используем метрики retention за один день, за месяц. Это количество пользователей, запустивших приложение в первый день после регистрации в сервисе или установки приложения, а также вернувшихся в него в течении месяца и так далее. Если эти метрики снижаются, то можно заключить, что это реакция пользователей на изменения в сервисе — они их отталкивают. Пусть retention на следующий день ожидается нами в районе 50 %, а за месяц — 55 %.
Прирост количества новых пользователей — ещё одна немаловажная метрика, перерастающая в требование. Ранжирование тут задействовано косвенно, потому что обычно за это отвечает реклама и продвижение, так что просто будем иметь ввиду.
Продолжительность сессии, глубина пролистывания — на это ранжирование влияет напрямую. Алгоритму необходимо выдавать качественную ленту контента, способную зацепить и вас, и меня. Предположим, что в московском метро путь от домашней станции до рабочей у пользователя занимает в среднем 30 минут. Мы бы хотели, чтобы минимум 10 из них он проводил в приложении. Пусть человек в среднем читает 100 слов в минуту. Тогда требование по глубине пролистывания будет варьироваться от 8 до 12 анекдотов на одного пользователя. И это мы ещё не считали обеденные перерывы и время, когда работник филонит в телефоне.
Кажется, что на этом можно и остановиться. Все эти требования определяют архитектуру нашей системы ранжирования и влияют на сам алгоритм. Стоит заметить, что желательно заложиться на максимальное количество настраиваемых параметров, вынесенных из сервисов в админку, обеспечивающих быструю реакцию на негативное для бизнеса изменение метрик. Требования озвучены, поэтому предлагаю переходить к примерной реализации рабочей схемы ранжирования наших анекдотов.
Что мы имеем? В начале были топики, и имя им UPDATE, INSERT, DELETE — обновление информации об анекдоте, создание нового анекдота, удаление анекдота. В эти топики без остановки льётся информация, требующая хранения и обработки в процессе ранжирования. Отсюда понятна необходимость озаботиться подходящим хранилищем данных. К тому же, имея дело с микросервисами, по канонам следует локализовать работу с нашим хранилищем, то есть описать интерфейс работы с ним не напрямую из разных приложений, а через единственный микросервис, имеющий единоличное право взаимодействовать с хранящимися данными. Остальные сервисы при необходимости будут обращаться к хранилищу через него.
У нас уже вырисовываются первые связи. Мы идём от внешних каналов, то есть от топиков брокера сообщений, получая следующую структуру: хранилище метаинформации о постах и сервис, в чью зону ответственности входит лишь взаимодействие с хранилищем. Теперь поподробнее об этом.
Обычно хранилище выбирается в зависимости от различных условий: количество операций ввода-вывода, объём данных и их структура, наличие параллельных запросов и дистанция между данными, масштабируемость. Так как событий через топики нам будет всегда приходить много в связи с многочисленностью общества любителей кринжовых анекдотов, то и соответствующих операций стоит ожидать существенное количество, нагружающее хранилище. Это проблема. Что касается структуры данных, то тут примем допущение: число параметров, описывающих один анекдот, выходит за два десятка, и это не окончательное число. У нас могут как добавляться новые параметры, так удаляться старые. То есть, описывая структуру метаданных, приходим к выводу, что она довольно гибкая. Особенно на начальных этапах разработки, когда не совсем понятны реакции пользователей, ожидаемые цели корректируются в соответствии с реальными данными.
Также возникает ещё один нюанс: многие поля представляют собой строки в одно-два слова. Для ускорения поиска по хранилищу будет логично использовать индексы и, возможно, полнотекстовый поиск. В целом, хранилище можно выбрать любое, тут нет по-настоящему верного решения. Можно остановиться и на PostgreSQL, если мы уверены в том, что в конце концов метаинформация анекдотов, которую мы хотим хранить, приобретёт чёткую структуру. Эта база данных способна строить инвертированный GIN-индекс. Можно остановиться на NoSQL- решениях типа Cassandra, MongoDB или ElasticSearch. Как я уже сказал, выбор базы данных зависит от самых разных условий. А ещё компетенция команды должна удовлетворять требованиям быстрой разработки и развёртывания, что, обычно, перевешивает многие другие факторы при быстром запуске продукта.
Я предлагаю выбрать для нашего примера ElasticSearch. Почему именно он? Эта база данных сходу решает некоторые стоящие здесь перед разработчиками задачи: и масштабируемость при росте количества контента; и фасетный поиск (по нескольким характеристикам) у него подходящий под нашу задачу; в ранжирование умеет; и в целом учтём, что в компании имеется компетенция, закрывающая технические и бизнес-задачи с помощью этого инструмента. В нём мы удобно расположим метаданные в JSON-объектах, на основании полей которых Apache Lucene составит инвертированный индекс. Это структура данных, хранящая вместо соответствия документ-слово связку слово-документ, что упрощает и ускоряет поиск слова внутри базы.
Перейдём к нашему сервису, в котором описываем интерфейс работы с хранилищем метаинформации анекдотов. Жак Фреско даёт нам 30 секунд на подбор названия для сервиса. Назовём его el-dispatcher. Так как общение с ElasticSearch строится по HTTP с помощью JSON-объектов, то запланируем, что сервис и выполняет выборку для других сервисов, и перекладывает информацию пачками из топиков на хранение.
Стоп. Это не очень хорошее решение, потому что сейчас мы расширяем ответственность сервиса необходимостью взаимодействовать с топиками брокера. Перенесём её в отдельный сервис-consumer, взаимодействующий с el-dispatcher по той же схеме: собираем пачку сообщений из топиков, формируем запрос, отправляем в el-dispatcher, а там он забросит данные анекдотов в ElasticSearch. Пока выглядит неплохо.
Предлагаю вернуться к вопросу о том, как именно ранжировать анекдоты. Обычно в подобных сервисах информация выдаётся не «в лоб», а, условно говоря, блоками. Для этого в метаинформации хранятся различные параметры, которые помогают команде ранжирования выдавать релевантные анекдоты с помощью тонкой настройки запросов. Из таких параметров применительно к нашей системе можно выделить следующие:
дата создания поста;
количество действий с анекдотом за последний день;
разница количества лайков и дизлайков;
автор поста, его рейтинг в общем зачёте авторов;
к какому классу анекдотов относится конкретный экземпляр: короткий, средний или длинный;
стоимость поста, подсчитываемая на основании принятого алгоритма. Этот параметр может существовать как отдельно, так и являться дополнительным фильтром для остальных запросов. Выберем второй вариант.
Мы набросали некоторое количество параметров, которыми будем оперировать при ранжировании. По факту, теперь мы можем создавать на их основе определённые группы анекдотов, тем самым упростив задачу и себе, и бизнесу. Сначала разберёмся, как это поможет механике ранжирования.
Имея вышеописанные параметры, у разработчиков появляется возможность организовать ранжирование на основе паттерна пулов. Что такое пул? В нашем случае это набор уже загруженных в память сгруппированных по определённым критериям объектов, готовых к работе, связанной с выдачей в ленту. Как её организовать? Например, мы можем создать по одному пулу на каждый из пяти указанных выше параметров (абстрактную стоимость мы считаем дополнительным фильтром. Конечно, чем больше значение, тем лучше). Пишем фабрику, которая будет организовывать создание однотипных объектов хранения анекдотов по этим критериям, посылаем запросы в ElasticSearch, чтобы заполнить объекты n-ым количеством анекдотов, и получаем кэширование данных. Запросы от мастер-системы теперь работают быстрее, а в ElasticSearch не летит неимоверное количество запросов выборки вкупе с запросами на обновление индекса. Да, ElasticSearch отлично работает в кластере, неплохо держит нагрузку, но это не значит, что мы не можем облегчить жизнь хранилищу. Тем более, что в уже созданный индекс он довольно неохотно вносит изменения. Выставим разумное время обновления для пулов. Скажем, для анекдотов это действие может происходить и раз в 30 минут. Этот параметр можно варьировать в зависимости от конечных потребностей и целей. Оставим для упрощения такую логику работы, ведь и логика обновления каждого пула может быть своя, но это уже детали реализации.
Для каждого из пяти запросов у нас изначально прописываются JSON-чики с запросом, в которых мы описываем, что необходимо получить от индекса:
Имея дату создания, запрашиваем только n анекдотов, созданных за последний день, сортируя по этой самой дате от новых к старым, дополнительно фильтруя по стоимости и, например, по количеству лайков от наименьшего количества к наибольшему. Настройка запросов зависит от цели. В данном случае получаем группу анекдотов, именуемую «новыми».
По количеству действий над анекдотами за последний день (лайк, прочтение, дизлайк, поделиться) мы можем отобрать пул самых горячих из них, назначая действиям приоритет и сортируя ответ индекса согласно принятой приоритизации.
Разница количества лайков и дизлайков может помочь в создании топа анекдотов, над которыми смеются пользователи.
Автор и его рейтинг позволяет продвигать авторов, а также выдавать лучший анекдот каждого топового автора в выдаче для каждого пользователя. Дополнительно фильтруем по стоимости.
Класс анекдотов поможет разбить индекс на группы и через запрос гибко настраивать пропорции в ленте: 40 % коротких анекдотов, 40 % анекдотов средней длины, 20 % длинных анекдотов.
Кто-то со мной может не согласиться, но синтаксис создания запросов в ElasticSearch, по моему мнению, довольно прост, гибок и очень функционален. В JSON сразу описывается и логика выборки, и сортировки. На выходе получаем готовый к работе список анекдотов по любой из пяти групп. Пример простейшего запроса:
{
"_source": {
"includes": [
"id",
"title",
"author"
]
},
"query": {
"bool": {
"must": {"match": {"content": "заходят в бар"}},
"should": [
{"term": {"anek_type": "short"}},
{"terms": {"tags": ["it","айти","программисты"]}},
{"match": {"content": ["программист", "компьютер", "вовочка"]}}
],
"must_not": [
{"terms": {"tags": ["devops"]}},
{"terms": {"author": "oleg"}}
],
"filter": [
{"range": {"likes": {"gte": 900}}},
{"range": {"publish_date": {"gte": "now-7d"}}},
{"range": {"cost": {"gte": "42"}}}
]
}
},
"size": 150
}
В произвольном запросе мы очень хотим увидеть 150 анекдотов, отфильтрованных по лайкам и дате добавления, после их ранжирования по значениям полей, которые должны будут обязательно присутствовать в документе, и по текстовым значениям, которые не обязательны, но добавляют веса значению _score
, отвечающему за релевантность и порядок выдачи. Также ElasticSearch отслеживает те значения, которые в документе не должны фигурировать. В таком запросе фильтрация будет применяться после оценки релевантности, никак на неё не влияя. Замечу, что здесь мы используем значение стоимости каждого анекдота в качестве параметра для фильтрации, а не ранжирования. Опять же, запрос строится в зависимости от требований. И обычно из-за назначения каждого пула мы кастомизируем запросы совершенно по-разному. Да, их синтаксис специфичен, но к нему легко привыкнуть. Так что лично я не могу сказать, что запросы в ElasticSearch неудобно конструировать.
В запросе мы описали в поле includes
мизерную часть документа, хранящего в себе метаинформацию анекдотов. Вообще правильно запрашивать у ElasticSearch только ID документов, чтобы затем обогащать по ним структуры, возможно, из других хранилищ. Иначе при запросе полей может возникнуть ситуация, при которой ElasticSearch пойдёт за ними на диск, что сразу увеличит время отклика. Однако в данном случае эти поля позволяют использовать более гибкую фильтрацию: и на стороне ElasticSearch, и на стороне сервиса. Тем более, что некоторые операции, заложенные в алгоритм, могут сложнее реализовываться на стороне хранилища, чем на стороне бэкенда. В любом случае, мы используем искусственный пример, больше подходящий для наглядности, чем для действующего продукта.
Кстати, созданием пулов, их наполнением, выдачей анекдотов мастер-системе и дальнейшим обновлением пулов будет заниматься один сервис — mainFeed. В его задачу входит:
общение с el-dispatcher, чтобы вытащить информацию об анекдотах в пулы;
получение запроса от мастер-системы, регулирующей количество анекдотов, забираемых из сервиса, и их пропорцию;
а также исключение повторов из передаваемого массива анекдотов.
Ведь легко может возникнуть ситуация, в которой один и тот же анекдот (в нашем случае всё-таки метаинформация, которая его описывает на стороне ранжирования) попадёт в больше, чем один пул. Поэтому следует следить за чистотой выдачи. Самый тупой способ: при обработке запросов от мастер-системы отслеживать выдачу из различных пулов через карту, в которой ключом является ID документа. Если ключ есть, то документ пропускается. Тут вполне может возникнуть ситуация, при которой пул новых отдаст не 10, а 8 постов, так как мы проигнорируем повторы тех ID, которые уже забрали из других пулов. Обработка такого случая может быть разной в зависимости от целей: либо идём читать пул дальше, пока не наберём именно 10 постов, либо останавливаемся на 8 уже выбранных. А может быть, придумаем в дальнейшем более сложный метод, включающий в себя внеочередной запрос к хранилищу.
Пулы отлично подходят и для того, чтобы бизнес мог оперировать конкретными величинами: вот такой топ у нас набирается, вот такие анекдоты из таких пулов интересны пользователям и так далее. Благодаря фабрике пулов мы можем довольно просто добавлять и удалять их из кода в зависимости от текущих потребностей. Всё, что нам необходимо — это описать новый запрос в ElasticSearch.
type poolKey string
func (k poolKey) String() string {
return string(k)
}
type Pooler interface {
PoolBuilder
PoolGetter
}
type PoolBuilder interface {
Build(ctx context.Context) (int, error)
}
type PoolGetter interface {
GetItems(amount uint64) ([]Items, error)
Len() uint64
}
type Manager struct {
repo somewhere.Repo
metrics metrics
pools map[poolKey]Pooler
}
func (m *Manager) runPoolBuilding(ctx context.Context, k poolKey, p Pooler) {
ctx, cf := context.WithTimeout(ctx, m.cfg.GetValue(ctx, config.PoolBuildTimeout).Duration())
defer cf()
n, err := p.Build(ctx)
if err != nil {
logger.ErrorKV(ctx, "build failed", "error", err)
m.metrics.poolBuildFailed(k)
return
}
}
Выше приведён небольшой и довольно простой пример сборки пулов (всё-таки мы тут программированием занимаемся) для большей наглядности. Главным здесь является структура Manager
, отвечающая за хранение пулов, доступ к ним, взаимодействие с репозиториями (в нашем случае слоем ElasticSearch) и метриками.PoolKey
— это просто название конкретного пула для его дальнейшей идентификации по ключу (топ, новейшие и так далее). Pooler
— интерфейс для работы с каждым из пулов. Через метод runPoolBuilding
, использующийся в каком-нибудь цикле метода верхнего уровня, имеющего и список poolKey
, и инициализированные объекты, соответствующие интерфейсу Pooler
, мы сможем использовать метод Build
для каждого из объектов, чтобы создать столько пулов, сколько необходимо.
Что важно, пулы превосходно охватываются всевозможными метриками: сколько сейчас в каждом пуле элементов, с какими запросами касательно пулов ходит в целевой сервис мастер-система, менял ли кто-то настройки пулов. Не устаю повторять, что метрики — очень важная часть сервиса. А ещё потрясающий элемент взаимодействия бизнеса и разработчиков, потому что менеджеры, глядя на борду в той же Grafana, могут сами делать выводы по объёму и наполнению пулов, менять вынесенные наружу настройки и наблюдать за изменениями поведения пользователей в реальном времени. Подобное решение позволяет не отвлекать группу разработки, а также придаёт аналитике большую самостоятельность. Поэтому не пренебрегайте метриками. Они не раз спасали крепкий сон дежурного.
Пока что выходит такая архитектура:
Довольно простая на первый взгляд. Однако, её можно усложнить как минимум двумя путями.
Предположим, что некто в компании решил, что стоит создать собственный топ анекдотов, обновляемый раз в месяц. Это абсолютно субъективный набор анекдотов, понравившихся одному человеку или небольшой группе редакторов или модераторов. Что с этим можно сделать? Создадим простой сервис, в область ответственности которого входит только одно: хранить выбор редакции в памяти, выдавая его мастер-системе по запросу не полностью и в случайном порядке. Назовём такой сервис redaction. В принципе, довольно неплохая идея, только вот теперь мы имеем целых два потока анекдотов, текущих в мастер-систему. Ввиду этого определение повторов из разных потоков дополнительно ложится на неё.
Если компания прокачана, то в ней может появиться отдел магии. Так я называю экспертов в data science. Они в силах наваять алгоритмы, предсказывающие то, какие анекдоты каким пользователям могут зайти до колик в животе от смеха. Мне кажется, что подобный путь развития очень выгоден с точки зрения повышения интереса пользователей к сервису, от чего получаем обновлённую схему, куда добавляется новый блок — ML:
Вы можете задать закономерный вопрос: зачем нам тогда все эти приседания с алгоритмами ранжирования, хранилищем и так далее? Ведь архитектура, описанная выше, решает, по сути, ту же задачу, что и machine learning в одиночку, явно находясь в тренде. Да, верно. Только вот способы решения задачи сильно разнятся.
Начнём с того, что алгоритмы машинного обучения настраиваются продолжительное время. Результаты не с первого и не со второго раза будут удовлетворять потребностям бизнеса, а также отражать реальные потребности пользователей. По опыту взаимодействия с отделом магии, вся их работа — это даже более трудоемкий и долгий процесс, чем разработка. Прибавьте к этому и то, что подготовка данных занимает ресурсы и время, а результаты очень сложно использовать в реальном времени. Ведь пока по полученным данным машинное обучение производит расчёты, а предоставленные им прошлые выгрузки тают на глазах (а может и кончаются за полдня), надо же что-то ещё показывать! Тут и кроется ответ: алгоритмы ранжирования, реализованные в описанных выше сервисах, позволяют добиться взаимозаменяемости, отказоустойчивости и гибкости системы в целом. К тому же мы получаем два способа составления ленты, которые можно сравнивать между собой и тюнить. А на основании полученных данных, изменяющих метаинформацию внутри наших основных сервисов, мы можем подготавливать первичные данные для дальнейших магических расчётов.
В работе с «машинкой» возникает проблема: как разработчикам взаимодействовать с отделом магии? Могу сказать, что ребята из data science уважают HDFS — распределённую файловую систему, в которой им удобно хранить большие объёмы данных. Туда-то они и скидывают подсчитанные результаты выгрузок каждый божий день. А нам оттуда их забирать, а также предоставлять данные для новых расчётов, то есть статистику за прошедший день: что показывалось и кому, какова актуальная стоимость у анекдотов и так далее.
HDFS поддерживает многопоточную запись. Мы стремимся раскидать данные по файлам, а затем записать метаинформацию в отдельный файлик, которым затем будет оперировать data science, понимая, что выгрузка для него закончилась и пора бы забирать их. Как же синхронизировать между собой поды сервиса, записывающего файлы в HDFS? Тут нам пригодится старый товарищ Redis с не совсем тривиальным решением. Так как его иногда используют для синхронизации объектов, мы тоже реализуем простую, но рабочую схему. Идея в следующем: поды нашего сервиса тревожит некоторый другой сервис, льющий через нас данные в HDFS. Поды набирают полные буферы данных и сбрасывают в файловую систему. Буферы ограничены, а раз одни буфер соотносится с одним файлом, то из-за ограничения количество файлов может оказаться любым. При этом в Redis создаются объекты для синхронизации: флаг и счётчик. Флаг говорит нам о том, что выгрузка началась (значение 1), а затем — что она закончилась (значение 0). Каждый под, начиная принимать информацию произвольным образом, копит буфер, увеличивая при этом счётчик подов, задействованных в загрузке. В тот момент, когда заканчивается передача данных от стороннего сервиса, у подов может возникнуть проблема: необходимость по окончании загрузки заполнять метаинформацию для машинного обучения, при этом понимая, что надо слить незаполненный буфер. Синхронизация нужна именно для этого. После окончания передачи информации флаг устанавливается инициирующим сервисом в 0, а поды, отслеживающие его изменение, скидывают свои неполные буферы, записывая необходимую метаинформацию по процессу и уменьшая счётчик задействованных подов. Когда он становится равным 0, это значит, что все поды скинули полученную ранее информацию и мы можем валидировать данные и завершать процесс. Конечно, можно заморочиться и предусмотреть с вызывающего сервиса сигнал окончания выдачи информации в каждый из подов. Но… Проще и быстрее действовать по описанному сценарию.
Куда же в свою очередь льются данные из HDFS, когда произошла выгрузка от ML? Для этого уже подойдёт PostgreSQL. Его можно шардировать, предусмотреть cron для удаления старых выгрузок; в общем, настроить работу по своему вкусу. Однако, возникает вопрос: кто переливает данные, а кто отдаёт их по запросу мастер-системе? Ответить можно так: создаётся два сервиса — Uploader и MLFeed. Первый подключается к HDFS, чтобы провалидировать данные, а затем через контракт с MLFeed передаёт их в базу данных по cron, а также загружает данные для новых расчётов в HDFS. Оттуда этот же сервис MLFeed будет забирать данные при обращении к нему мастер-системы.
Получается уже третий путь для попадания анекдотов в ленту пользователя. На неё же и возлагается ответственность за передачу полученных анекдотов с каждого потока в ленту, проверку на дублирование и конечное замешивание всего разнообразия в персональную ленту.
В конце концов получается нечто подобное:
У нас вышла простая и стройная схема ранжирования анекдотов, которая в состоянии обеспечить пользователей десятками и сотнями потрясающих анекдотов. Конечно, во время разработки возникают различные непредвиденные обстоятельства и не совсем ожидаемые паттерны поведения, но в целом это материал на отдельную статью, касаться этого мы тут не будем.
На этом я предлагаю закончить. Мы познакомились с одним из подходов ранжирования для ленты специализированного сервиса. Всем спасибо за прочтение!