Как эволюционировала доставка данных для рекламного движка Яндекса
Я работаю в одной из команд, которая занимается поддержкой рекламного движка, а именно доставкой данных под рекламный движок. И сегодня как раз хотелось бы поговорить о том, как наша система эволюционировала на протяжении нескольких лет. Доклад будет построен следующим образом. Сначала я расскажу, как у нас всё было, и после этого мы шаг за шагом откроем идеи, которые привели нас в светлое будущее.
В первую очередь хочется рассказать, как вообще устроена реклама Яндекса хотя бы крупноблочна. Сердцем рекламы Яндекса является рекламный движок. Глобально этот сервис решает задачу рекомендательной системы. У нас есть некоторое большое множество объектов, есть контекст пользовательского запроса, и нужно этому контексту подобрать наиболее подходящее подможество из этих объектов. Под наиболее подходящим я имею в виду топ объектов, проходящих все фильтрации по запросу и отранжированых по какой-либо метрике качества. В нашем случае рекламными объектами являются рекламные объявления, то есть баннера, а контекст запрос состоит из пользовательского запроса, профиля пользователя, описания рекламной площадки и так далее.
Теперь посмотрим архитектурно, как устроен отбор в движке. Когда движок получает запрос за тем, что нужно показать рекламу, нужно все баннера отобрать. Отбор производится по стадии. Стадии занимаются какие-то фильтрациями, то есть смотрят, подходит баннер запрос или нет. А другие стадии занимаются ранжированием, то есть сортируют баннера по какой-нибудь метрике качества и отбрасывают те, которые не проходят в топ. Первые стадии у нас работают десятками тысяч кандидатов, поэтому ресурсы затрачивают на проверку одного, должны быть небольшими. А последние стадии уже финализируют выдачу, поэтому там можем себе позволить более дорогие проверки, потому что они работают уже с сотнями кандидатов.
Отбор и ранжирование производятся на основе атрибутов объектов. Атрибуты бывают разные, и каждый стадий нужны свои атрибуты. По сути, это флажки, числа, строки, массивы, вычислимые поля и поля, которые задают иерархии объектов. Так, например, каждый баннер принадлежит какой-то группе баннеров, группа баннеров — заказу, заказ — клиенту. И все эти объекты имеют свои атрибуты, которые используются для того, чтобы подбирать рекламу на тех или иных стадиях.
Дальше возникает вопрос, а где же вот это все множество объектов хранится? И здесь у нас основных источников данных два:
  • В первую очередь это единая база данных рекламы, там все данные лежат в Protobuf, и это наши основные объекты, баннера, заказы, клиенты.
  • И есть помогательные данные, это небольшие словарики. Например, прогноз погоды на следующие сутки, курсы валют. Они поступают от смежных команд в каком угодно формате из каких угодно источников.
  • Кроме того, про единую базу данных рекламы вчера был прекрасный доклад от моего коллеги Булата. Можете посмотреть в записи, чтобы составить более общее представление про то, как вообще вся реклама работает в целом.
    Дальше поговорим про объемы. Рантайму нужно около миллиарда баннеров. Других объектов, таких как заказы, клиенты, нужны десятки миллионов. Вспомогательные словарики бывают от десятков мегабайт до единиц гигабайт. При этом суммарный объем данных можно оценить где-то в 10 терабайт. Движок отвечает на сотни тысяч запросов в секунду. Соответственно, как мы помним, первым стадиям нужны десятки тысяч кандидатов, а последним единицы и десятки. Поэтому, если это всё перемножить, то получается, что нам на первых стадиях нужны сотни миллионов ключей в секунду, то есть сотни миллионов объектов, а на последних просто миллионы объектов. Отвечать движок должен за сотни миллисекунд, 200-300 обычно.
    Пока мы посмотрели на источники данных и на рантайм. Не хватает еще одного кусочка, который часто называют индексатором. Задача этого сервиса — собирать данные из различных источников, приводить их в единый формат и после этого предоставлять рантайму доступа до этих данных. У нас, конечно же, есть свой индексатор, и он еще называется контент-системой Баннерной Крутилки.
    Давайте теперь пройдемся по его основным концепциям. В первую очередь, как мы видели, у нас на некоторые объекты нужны сотни миллионов ключей в секунду, поэтому ходить в какой-то ремоут сторадж мы себе позволить не можем, и поэтому было когда-то принято решение все данные возить просто под рантаймы и, соответственно, читать их всегда локально, чтобы избежать задержек по сети.
    Дальше мы хотим с ними как-то эффективно работать. Зздесь используем один из самых простых и, тем не менее, оптимальных способов. Это просто все наши данные из таблиц перекладываем в C++-структурки, их записываем друг за другом в блобы, блобы подвозим на машинки, а там поднимаем просто виртуальную память процесса, и, соответственно, работаем с этими структурами, как будто они вот у нас были в коде.
    Соответственно, если мы так сделаем, то все данные в память одной машины, конечно же, не влезут. У нас их очень много. Поэтому мы пользуемся стадийностью отбора и еще делим движок на две роли — Мета и Стат. Статы отвечают именно за базу баннеров и хранят в себе 1/12 базы. Кроме того, такой подход позволяет первые стадии ранжирования проводить параллельно. После этого Статы отвечают Мете своими кандидатами, и Мета уже проводит финализирующие стадии и формирует ответ.
    С рантайм-частью примерно понятно. Как, собственно, данные готовить? У нас объемы это терабайты. Все эти данные нужно скачать из разных источников, распарсить, переложить в какие-то C++-структуры, еще что-нибудь посчитать, разбить по шардам. Для такой задачи долгое время классическим решением был просто MapReduce, запускаемый по шедулеру раз в сколько-нибудь. Это "сколько-то" должно быть таким, чтобы успевать процессить данные. В нашем случае это было раз в чуть больше часа. После этого MapReduce собирает все эти блобы в индексы и они доставляются под runtime.
    Таким образом, итоговая схема того, что было раньше, выглядит следующим образом. У нас есть источники — это наши различные таблички в единой базе рекламы, либо же из сторонних источников. Из каждой такой таблички нужно сварить индекс, и этим занимается контент-система. Она с помощью шедулера запускает MapReduce, который перекладывает данные в C++-структуры. Эти структуры довозятся под нужный рантайм, там отображаются в память и используются.
    Казалось бы, отличная схема, зачем вообще что-то менять?
  • В первую очередь изменились требования к скорости доставки данных. Хочется, чтобы теперь цены на баннерах менялись быстро, чтобы после того, как в интернет-магазине появился какой-то новый товар, его нужно начать рекламировать как можно быстрее, снизить период между перерасчетом ML-модели и использованием ее в продакшене при ранжировании. Поэтому доставлять за часы уже не круто, хочется что-то быстрее.
  • Такая схема не очень хорошо масштабируется, потому что мы данные все складываем в память, а объем данных все время растет. Появляются новые баннера, появляются новые фичи, которым нужны новые поля. Все это постоянно тратит память на Статах, и мы не раз подходили к границам по памяти, когда вертикально масштабироваться было уже особо некуда. И приходилось что-то кастомное выдумывать: либо подрезать количество объектов по какой-нибудь сложной стратегии, либо искать легаси и фичи, удалять для них поля. Это все каждый раз требовало достаточно много времени, чтобы разобраться.
  • И, наконец, в такой схеме есть несколько издержек для разработчиков. В первую очередь из-за того, что у нас индексы — это C++-структуры, то они не обладают свойством обратной совместимости. То есть у нас индекс, который сгенерирован двумя разными версиями контент-системы, несовместимы друг с другом. Таким образом, нужно тестировать и релизить движок БК — рантайм, — вместе с индексатором всегда. А это достаточно большие накладные расходы. Кроме того, для откатов нужно всегда держать полностью рабочую предыдущую версию, чтобы она генерировала индексы, на которой можно быстро откатиться, что достаточно дорого по железу. Также для такой тривиальной задачи, как прокинуть поля для рантайма, нужно идти писать код, который заводит поле в структуре, после этого перекладывать что-то, что тоже достаточно много сил для такой простой задачи.
  • Проблемы понятны. Теперь давайте начнем придумывать нашу новую контент-систему. В первую очередь хочется побороться с проблемой формата данных. Во-первых, хочется получить обратную совместимость на индексах, чтобы индексы, сгенерированные разными версиями контент-системы, могли доставляться под любой рантайм. Ещё хочется облегчить жизнь разработчикам, чтобы не нужно было руками перекладывать поля.
    В первую очередь мы пошли, посмотрели, а что там вообще сейчас в базах лежит. Получилось так, что всякие сложные индексы — деревья, множества, — уже достаточно сильно устарели для нас, а для обработки стадиями достаточно просто по идентификатору объекта уметь получить набор его атрибутов. Таким образом, на самом деле, в новой контент-системе достаточно обойтись просто статической хэш-мапой из ключа в какое-то сериализованное представление объекта.
    Дальше возникает вопрос: что класть в качестве значения? Здесь, как я говорил, нужна обратная совместимость при мутации схемы этих объектов. Кроме того, у старых баз было прекрасное свойство, что мы их подвозим под рантайм, там отображаем в память и сразу же используем, то есть не делаем никакой десериализации и каких-нибудь других вычислений. Это значит, что это свойство очень хорошее, потому что с нашим объемом данных мы себе не можем ничего позволить парсить, потому что тогда начнет тормозить движок в эти моменты. И это просто долго.
    И вот для задачи вот бы иметь что-то такое обратно совместимое, как Protobuf в нашей единой базе данных рекламы, но при этом чтобы парсить еще не надо было, уже есть готовое решение, это формат называется FlatBuffers. Здесь можно даже без велосипедов обойтись. Этот формат очень похож на прото. Там объекты также описываются в схеме. Типы очень привычные — есть скаляры, стройки, массивы, вложенные структуры. И, соответственно, по этой схеме генерируется код, в нашем случае на C++, так же, как и в проте, который позволяет читать и писать объекты. А основной особенностью как раз является то, что записанные данные из буфера не нужно никак парсить, чтобы прочитать.
    Чтобы дальше понимать плюсы и минусы этого формата, давайте посмотрим, как он устроен под капотом. Сериализация во FlatBuffers представляет собой запись данных в буфер в таком формате, который потом позволит без парсинга читать данные в рантайме. Скаляры пишутся, как есть просто в буфер, а сложные объекты, такие как строки и вложенные структуры, пишутся с помощью оффсетов. Оффсет — это такое число, которое нужно прибавить к виртуальному адресу, по которому оффсет записан, чтобы получить целевой виртуальный адрес. На самом деле, это получается просто реализация указателя, которая не привязана к конкретному адресу, и это позволяет замапить FlatBuffers в любой участок памяти и работать с ним без дополнительных вычислений. Похоже на C++-структуры — скаляры лежат как есть, а какие-то сложные типы лежат по ссылочному типу.
    Также появляется новая структура Vtable. Она содержит мета-информацию о сообщении, которая и позволяет достигать прямой и обратной совместимости при мутациях схемы. Каждый объект имеет Vtable, каждый объект начинается с указателя на Vtable. Под капотом Vtable — это несколько двухбайтовых чисел, записанных друг за другом. В первую очередь идет размер Vtable, а дальше идут отступы, которые нужно сделать внутри сообщения, чтобы прочитать поле. Как можно заметить, сами поля в сообщении могут идти в любом порядке, а вот внутри Vtable они идут в том же порядке, в котором они объявлены в схеме. Это связано с тем, что оффсеты внутри самого Vtable являются константами в сгенерированном коде, с которыми мы работаем сообщениями.
    Таким образом, если мы хотим прочитать какое-нибудь поле, например, RegionID, то мы сначала по адресу сообщения находим его Vtable, а после этого по константному оффсету идем и читаем отступ, который нужно сделать уже внутри самого сообщения, чтобы найти наше поле.
    Если мы удаляем какое-то поле, тогда мы можем в Vtable просто ноль на его позиции записать. После этого старый код, который еще продолжает использовать это поле, будет видеть, что оно не инициализировано и возвращает дефолт. Если же мы добавляем новое поле, то оно всегда добавляется в конец схемы, нельзя в середину добавлять. И когда мы пытаемся его прочитать, нам нужен размер Vtable в начале. Мы оффсет всегда сравниваем с размером Vtable перед этим. Если он выходит за границы Vtable, то тоже считаем, что поле неинициализировано, и возвращаем дефолт. Таким образом, получается и прямая, и обратная совместимость при разрешенных мутациях схемы, и при этом не нужно ничего партить.
    Плюсы понятны. К минусам относится обычно то, что у FlatBuffers не такие удобные интерфейсы, как у Protobuf. В первую очередь, оффсеты всегда направлены по возрастанию адресов, а это значит, что сериализовывать объект нам нужно с конца. И если у нас какая-то большая вложенность, то все не очень хорошо выглядит. Кроме того, записанный в буфер объект уже сложно модифицировать, потому что все строки выделены, но нас это особо не касается, потому что у нас все индексы read-only, и мы их в рантайме никак не модифицируем — один раз записали и используем.
    Для нас еще было проблемой то, что нет Has-методов таких, как в Protobuf 2. Хоть это и не лучшая практика, но у нас был достаточно много мест, где сложно с этого мигрировать. И, наконец, у Protobuf есть такое прекрасное свойство, что если какая-то вложенная структура не инициализирована, то при обращении к ней возвращается синтетический объект, который для всех своих полей вернет дефолты, а во FlatBuffers вернется нулевой указатель. Поэтому если мы обращаемся к какому-то глубокому полю, то чтобы безопасно это сделать, надо написать трехэтажный if, который проверит, что каждая промежуточная структура инициализирована, что тоже выглядит не очень.
    Также есть проблемы с производительностью. Конечно, мы ничего не парсим, но с другой стороны теперь, чтобы прочитать какое-то поле, нам нужно сделать четыре чтения и еще два ветвления — в первую очередь проверить, что мы за границы Vtable не выходим, а во вторую, что сам оффсет не нулевой. Для процессора это все выглядит просто как много чтений, причем зависимых друг от друга, то есть мы не можем сделать следующее, пока не завершим предыдущее. И поэтому при доступе к особо глубоким полям в особо горячих местах это все начинает тупить по сравнению с C++-структурами и чуть-чуть просаживать нам производительность.
    Еще остается проблема, что если раньше разработчикам нужно было просто на C++ код писать, то теперь нужно в структуру какую-то новую докинуть поле, после этого с не очень удобными интерфейсами написать перекладывание, и потом еще и в рантайме все сложнее стало использовать.
    Нужно еще что-то придумать. На самом деле FlatBuffers же генерирует свой код. Почему бы нам еще больше код генерации не накрутить? У нас есть схема объекта в нашем едином хранилище, она в Protobuf. На самом деле, так как форматы похожи между собой, ничего нам не мешает взять эту Protobuf-схему, по ней сгенерировать FlatBuffers-схему, и сгенерировать и код, который будет перекладывать Protobuf-объект во FlatBuffers-объекты и назад. Кроме того, так как у нас FlutBuffers — это просто буфер в памяти, то и структура, которая генерируется для его использования — это просто stateless-обёртка, поэтому никто нам не мешает свою stateless-обёртку сгенерировать, использовать её для доступа и добавить туда те фичи, которых нам не хватает именно в рантайме.
    Первый кодогенератор проходится по Protobuf-схеме из единой базы данных рекламы. Те поля, которые хотим видеть в рантайме, отмечаются специальным тегом. Кодогенератор его видит и генерирует FlatBuffers-схему только из этих полей. Кроме того, в начало положим еще битовую маску, через которую потом сделаем Has. Кроме этого, кодогенератор еще и генерирует методы, которые просто рекурсивно по объектам проходят и перекладывают одни поля в другие.
    Второй кодогенератор генерирует аксессоры, по сути. Во-первых, с помощью битовой маски, которую мы сделали на предыдущем этапе, мы генерируем Has-методы, и в ]Get-методы добавляем вот эту проверку на nullptr себя же. Если она выполняется, то для скаляров будем возвращать дефолты, а для сложных объектов будем возвращать такую же обёртку, только от nullptr. Таким образом, мы вот эти все трёхэтажные if спрятали просто в наш сгенерированный код и избавили пользователя от необходимости их писать. Стало чуть красивее.
    Таким образом, получаем, что в нашем конкретном случае используем преимущество от обоих форматов:
  • От Protobuf и от FlatBuffers получаем прямую обратную совместимость при мутациях схемы.
  • От FlatBuffers получаем то, что нам не нужно парсить сообщения, когда мы их подвезли под рантайм.
  • От Protobuf получаем привычный и удобный интерфейс для работы с объектами.
  • Объекты находятся в такой же схеме, что и в единой базе данных рекламы, что тоже упрощает жизнь, потому что, когда есть одна схема, это всё гораздо проще воспринимается.
  • Заплатили мы за это в основном производительностью, но понятно, что мы добавили какую-то новую фичу для формата данных, и за это нужно заплатить каким-то железом. Пока мы всё-таки смирились.
    Теперь понятно, что у нас основной индекс — это статическая хэш-мапа из идентификатора объекта в его FlatBuffers-представление. Но строится это все еще около часа, а мы как бы целимся в задержки 10-15 минут. Здесь хороший способ что-то пооптимизировать — это поискать работу, которую на самом деле можно не делать. В нашем случае такая работа кроется в самом подходе с нашим Full State MapReduce.
    На самом деле, если мы посмотрим на тот же миллиард баннеров наших, то заметим, что за час меняются десятки миллионов баннеров, а если за 10 минут, то вообще миллионы баннеров. Но мы, несмотря на этот простой факт, каждый раз запускаем этот MapReduce, который все скачивает, все парсит, после этого все перекладывает в C++-структуры и везет под рантайм, хотя под рантаймом большая часть данных уже находится в актуальном состоянии. Получается, делаем на порядки больше работы, которые на самом деле не требуются.
    Тогда наша быстрая схема доставки могла бы выглядеть следующим образом. Наша единая база данных рекламы, как и многие базы данных, умеет писать лог обновившихся профилей. Мы их собираем в какие-то дельты для нашего нового индекса, который умеет под рантаймом только дельты на себя применять, обновляться и отвечать рантайму новыми объектами. Кроме того, оценив поток обновлений, мы прикинули, что можно даже не дельтами конкретных полей думать, а целиком перезаписывать значения в хэш-мампе, и настолько маленький будет поток обновлений, что в принципе можем себе позволить.
    Осталось понять только, что же за индекс взять для формата. Здесь нам пришли на помощь наши коллеги из Поиска. Поиск — отчасти рекомендательная система. У нас есть объем документов, есть поисковый запрос пользователей, и нужно построить релевантную выдачу. Задачи решаются, на самом деле, похожие по поиску и использованию этих всех данных Там уже в разработке находилась система Plutonium, которая позволяет строить кусочно обновляемые индексы и доставлять их на множество хостов.
    Давайте теперь пройдемся по основным концепциям этой системы. Как я говорил, нам нужно доставлять на движок не все значения, а только часть, которая скорее всего обновилась. Поэтому нам надо наш рендж значений в нашей хэш-мапе побить на кусочки, чтобы уметь их доставлять. В Plutonium такие кусочки значений называются чанки. Чанки содержат в себе значения хэш-мапы, записанные друг за другом. Здесь формат данных в значении не важен. Важно, что это просто непрерывный блоб в памяти, а здесь FlatBuffers как раз идеально подходит.
    Чтобы это все еще работало как хэш-мапа, нам нужна вторая часть индекса, которая еще называется маппинг. Она содержит отображение из ключа в номер чанка и оффсет внутри чанка, где нам искать эти данные. Внутри реализация — такая же статическая хэш-мапа, как в индексах.
    Пусть у нас есть какая-то дельта. В ней какие-то профили добавляются, какие-то удаляются, какие-то обновляются. Обновление представим как удаление и вставку заново, поэтому будем думать только про удаление и добавление. У нас есть индекс предыдущей версии, в Plutonium еще версии называются снэпшотами. Нам из него нужно перестроить новую версию.
    На самом деле маппинг мы можем себе позволить перестроить полностью. Во-первых, нам нужно куда-то сохранить информацию про те ключи, которые удалились и которые добавились, но это не проблема каждый раз перестраивать и перекачивать, потому что на каждый ключ хранится три числа, и это на порядки меньше данных, чем сами значения наши. А вот значения стараемся перестроить так, чтобы затронуть наименьшее количество чанков. Здесь за это отвечают внутренние алгоритмы Plutonium. Они для нас просто как черный ящик, и они по дельте возвращают новый индекс, в котором отличаются маппинг и наименьшее возможное количество чанков.
    Кроме того, мы еще можем наш индекс и пошардировать сильнее, то есть сделать много независимых индексов в глазах Plutonium, поделив наш поток обновлений. Тогда внутри каждого индекса мало того, что обновления чанками производятся, так еще и каждый индекс раскатывается незаметно. Кроме того, и маппинги уменьшаются еще сильнее. Таким образом, возим совсем маленькие дельточки, что работает быстро. Также можем теперь масштабировать нагрузку на построение этого индекса, просто делать больше либо меньше шардов. А в рантайме по хэшу ключа понимать, в какой же нам подшардик нужно пойти.
    Дальше, собственно, про то, как доставлять обновления, эти дельточки. Здесь Plutonium разрабатывается для доставки произвольного типа индекса, поэтому система чуть более общая. Любое поколение индекса можно представить как какую-то директорию с какими-то файлами. Если бы мы наш старый индекс затаскивали на Plutonium как есть, это был бы каждый раз новый файлик, а новый индекс выглядит как множество файликов — это маппинг и чанки. И тогда перестроенный маппинг и перестроенные чанки — это полноценные файлы, а те чанки, которыми переиспользуем и не трогаем, можно выразить просто как симлинки на файлики, которые отражают предыдущее поколение индекса. И поэтому ядром Plutonium является распределенная файловая система, которая позволяет работать с директориями, файлами и линками.
    Таким образом, схема обновления выглядит примерно следующим образом. У нас есть какой-то воркер. Он из нашей базы набирает дельту, после этого локально перестраивает у себя индекс с помощью фреймворка Plutonium, набирает новый маппинг, новые чанки, и после этого нужно засинкать свое состояние со распределённой файловой системой. Распределенная файловая система состоит из двух частей: это какой-то S3-сторадж, которая хранит только блобы и, соответственно, в нем лежат файлы, и динтаблицы YTsaurus, которые отвечают за мета-информацию файловой системы.
    Таким образом, когда мы создаём новые файлы, сначала загружаем в S3, а после этого в динтаблицах создаем записи про эти новые файлы и создаем линки, просто меняя мета-информацию в динтаблицах. После этого в Plutonium Control Plane попадает информация, что у нас какой-то новый индекс построился. Он понимает, на каких рантаймах нужен этот индекс, и после этого на эти рантаймы приходит сигнал, что нужно заисинкать со распределённой файловой системой новую директорию. Рантайм, в свою очередь, скачивает новые файлы и создает у себя локально линки на те файлы, которые мы переиспользуем. Таким образом, скачали новый индекс, при этом перекачали только маленькую дельту, поэтому сделали это быстро, и в рантайме можем работать с новыми данными.
    Это все приводит к тому, что мы ускоряем доставку с около часа до 10-15 минут из-за того, что стали просто на порядке меньше работы делать. Кроме того, это позволяет нам делать унифицированную инфраструктуру между двумя похожими системами, рекламой и поиском, но заплатили мы это, конечно же, усложнением системы, потому что раньше был понятный MapReduct, а теперь какие-то S3, динтаблицы, стейт у индекса появился, и за ним надо следить и тестировать его, но ускорение все это окупает.
    Мы знаем теперь, что возить и как возить, но у нас остается проблема с местом на Статах. К тому же мы ее немного усугубили, потому что FlatBuffers на самом деле потребляет больше данных, чем просто сериализованные структуры. Здесь решением будет ответ на вопрос, а куда возить данные?
    Давайте посмотрим на те данные, которые нужны на стадиях. Как я говорил, в первой стадии работают десятками тысяч кандидатов, и ресурсы, затрачиваемые на обработку одного кандидата, должны быть максимально небольшие. Поэтому там и данные простые — числа, флаги, небольшие ML-эмбеддинги до 128 байт. Последние стадии — финализирующие. Они уже используют много ML, много эмбеддингов, и данные там чуть более тяжелые. Для победителей нам нужны самые тяжелые ресурсы для отрисовки — картинки, описания, шаблоны и так далее.
    Если посмотреть, как это все распределяется в базах на Статах, то кажется, что большинство данных на Статах нужны не Стату, а Мете. И мы их вынуждены каждый раз передавать по сети для наших кандидатов. Кроме того, Стат еще не знает, какие кандидаты выигрывают, и поэтому большую часть данных, которые мы перекачиваем по сети, мы еще и выбросим потом. Если бы все-таки Мета могла ходить на поздних стадиях в remote-сторадж, то было бы совсем классно, мы бы получали только нужные нам данные. Кроме того, это уже звучит более реально, там же не сотни миллионов ключей в секунду, а просто единицы миллионов. Почему бы и не попробовать? В Plutonium уже была своя реализация индекса повверх remote-стораджа Мы, на самом деле, можем наш поток обновлений поделить на две части, на два индекса — одни индексы локальные, и мы их с помощью Plutonium доставляем до нашего рантайма, а вторые индексы ремоутные, их с помощью Plutonium доставляем до remote-рантайма.
    Remote-рантайм под капотом устроен следующим образом. У нас есть два сервиса, как две части индекса. Есть сервис RS, который отвечает за данные, то есть за чанки, и он по идентификатору файла и оффсету в нём позволяет получить блоб. И есть RS Proxy, которая принимает входные запросы, после этого формирует с помощью маппинга подзапросы в те RS-ы, где лежат данные, и собирает ответ. Получается, что мы можем за батчом ключей сходить параллельно.
    Всё это довольно хорошо масштабируется. Во-первых, на RS-ах чанки уже лежат на диске, а не в памяти. Если у нас растет объем данных, то мы просто доливаем новых RS-ов с новым диском, это уже не такой дорогой ресурс. А если у нас растет нагрузка, то мы масштабируем наши RS Proxy. Их тоже можно шардировать, чтобы каждая RS Proxy отвечала за свой независимый рендж ключей. И тогда движок может делать тоже несколько параллельных запросов. Получается такая снежинка, где движок сначала делает несколько параллельных запросов, потом RS Proxy делает несколько параллельных запросов. Все работает достаточно быстро.
    Кроме того, поход по сети — довольно тяжелая операция, поэтому здесь мы можем еще поискать ненужную нам работу. И она кроется в том, что из всего миллиарда баннеров активно побеждают в текущий момент времени, то есть имеют такие характеристики, которые позволяют им проходить все стадии и побеждать, небольшое подмножество баннеров из десятков миллионов. Таким образом, мы можем сделать просто кэши, которые соберут в себе все эти баннера. Кроме того, из-за того, что кэш вымывается, они еще будут ротироваться. Получается такая схема. На RS-ах на диске хранятся холодные данные, которые там редко требуются, а те горячие данные для активно используемых баннеров оседают в кэшах в памяти, например, на RS Proxy или вообще на движке, поэтому по сети ходить не надо. Таким образом, либо мы вообще не идем по сети, либо нам отвечает RS Proxy сразу же из памяти, и получается за единицы миллисекунд ответ.
    Таким образом, итоговая архитектура индексатора нового следующая. Единая база данной рекламы пишет лог обновлений. Мы его собираем в дельты на воркере. Воркер локально перестраивает свой индекс. После этого с помощью Plutonium, его файловой системы и контроллеров на нужные рантаймы попадают нужные индексы. Локально мы их просто используем как есть, а для remote-индексов мы ходим за ними по сети через 2 кэша.
    Что получилось?
  • Из-за того, что стали качать на порядки меньше данных, получаем ускорение с часа до 10 минут.
  • Из-за того, что вынесли все холодные данные далеко на диск в легко масштабируемое хранилище, освободили кучу места на Статах, и можем теперь про это вообще не беспокоиться.
  • Облегчили жизнь разработчикам за счет формата. Теперь, чтобы поле в рантайм прокинуть, нужно просто в единой базе рекламы на Protobuf на поле поставить тег, и через пару релизов эта поле будет в рантайме.
  • Заплатили мы за это все усложнением системы в целом. Появились новые сервисы, появился поход по сети, а это значит нужно ретраить, хеджировать, быть готовым к тому, что оно не ответит, это все более сложно. Кроме того, у индекса появился стейт — если раньше мы его просто могли сварить, то теперь нужно поддерживать этот стейт, все это надо тестировать, поддерживать, но тем не менее наши ускорения и освобождение памяти с лихвой окупают все эти проблемы.
    Спасибо за внимание.