
Когда вы видите баннер, кликаете по рекламе или указываете, что вас не интересует тот или иной товар, — за кулисами происходит немало вычислений. Система поведенческого таргетинга, отвечающая за персонализацию рекламы в Яндексе, получает эти события, обновляет ваш профиль, а затем использует его, чтобы в следующий раз показать что‑то более подходящее.
Сама по себе задача кажется очевидной: собирать события, обновлять профили, обеспечивать быстрое считывание информации. Но если заглянуть под капот, начинается настоящее инженерное приключение. Сотни тысяч событий в секунду, требование обработки в режиме exactly‑once, жёсткие ограничения по времени отклика, компромисс между скоростью и экономией ресурсов, и всё это — на фоне необходимости работать надёжно и с горизонтальным масштабированием.
Меня зовут Руслан Савченко, в Yandex Infrastructure я руковожу разработкой динамических таблиц YTsaurus — системы, в которой поведенческий таргетинг хранит данные. В этой статье я подробно разберу кейс поведенческого таргетинга с динтаблицами: почему таблицы в памяти иногда тормозят из‑за аллокатора, зачем мы внедрили xdelta, как именно устроены агрегатные колонки и что пришлось сделать, чтобы миллисекунды отклика в 99,9 перцентиле стали реальностью.
Эта статья написана на основе материалов, рассказанных Егором Хайруллиным и Булатом Гайфуллиным. Егор — архитектор поведенческого таргетинга поверх динтаблиц, и многое из того, что мы затрагиваем в статье, придумано им.
Зачем нужен поведенческий таргетинг
Перед поведенческим таргетингом стоит ключевая задача: собирать большое количество событий (поведение пользователя на сайте, показы баннеров, клики по ним), обрабатывать их, и на основе этого оперативно обновлять пользовательские профили. Эти профили затем используются для персонализации рекламы.
Написали что‑то в поисковом запросе, нажали «не показывать такую рекламу» на баннере — поведенческий таргетинг зафиксирует это, обновит профиль, и при следующем посещении сайта с рекламным блоком учтёт новый сигнал при выборе креатива.
Под капотом — полноценная система обработки и хранения больших данных: события пишутся в логи, проходят обработку и попадают в надёжное key‑value хранилище, из которого затем считывает данные рантайм показа рекламы.
Какую роль в этой архитектуре играют динтаблицы и какие их возможности оказались особенно важны для поведенческого таргетинга, расскажем дальше.
Безопасность
Конечно же, нельзя обойти стороной вопрос приватности и сохранности персональных данных. Данные поведенческого таргетинга хранятся в динтаблицах обезличено — по ним нельзя понять, о ком речь. Для таргета рекламы даже не используются персональные данные как таковые: вместо них оценивается само поведение с помощью алгоритмов машинного обучения. Ключ для доступа к профилю тоже не содержит чувствительных данных — это просто некоторый идентификатор (строчка из 20 символов). Можно считать, что поведенческий таргетинг получает этот идентификатор и использует его при работе с данными.
Кроме того, доступ к таблицам существенно ограничен с помощью гибкой системы прав. В YTsaurus таблицы собираются в иерархическое дерево, доступ к узлам которого может быть ограничен до отдельных пользователей или групп. Список тех, кому предоставить доступ до таблиц с данными поведенческого таргетинга, жёстко регламентируется службой безопасности, и проходит регулярный аудит.
Exactly-once обработка событий
Представьте: вам нужно обрабатывать поток событий. Их очень много, и требуется строгая exactly‑once‑семантика — каждое событие должно быть обработано ровно один раз, не больше и не меньше. Не самая простая задача. Большой объём данных требует распределения нагрузки по множеству машин, а это сразу поднимает массу сложных вопросов:
Что делать, если узлы выходят из строя?
Как быть, если машина теряет связь с координатором?
Как гарантировать, что каждое событие будет обработано ровно один раз — не минимум, не максимум, а ровно?
Для начала нужно зафиксировать, что именно мы считаем «обработкой».
Несколько машин независимо могут что‑то посчитать с одними и теми же входными данными, но важно то, что результат этих вычислений должен быть записан в хранилище ровно один раз.
В нашем случае данные поступают из распределённой очереди — топика, разбитого на шарды. Каждый шард — это линейно упорядоченная последовательность сообщений, и каждое сообщение в нём можно однозначно идентифицировать с помощью пары: номер шарда + оффсет (порядковый номер внутри шарда).
Результаты обработки отправляются в таблицу, которая играет роль надёжного хранилища. Именно туда мы хотим положить каждый результат — строго один раз.

Рассмотрим простой обработчик: он читает несколько сообщений из очереди, обрабатывает их и записывает результат в выходную таблицу. Чтобы гарантировать exactly‑once семантику, можно завести отдельную таблицу обработанных оффсетов и записывать в неё прогресс обработки в той же транзакции, что и результат. Это решает сразу две задачи.

Во‑первых, у нас всегда есть актуальное состояние обработки: по таблице оффсетов можно восстановить, какие сообщения уже были учтены, а с каких — продолжать.
Во‑вторых, и это критически важно, такая схема защищает от дублирующей записи: если по каким‑то причинам одновременно запустятся два экземпляра обработчика, транзакцию сможет закоммитить только один. Второй получит явный сигнал о конфликте по оффсету и будет вынужден откатиться. Таким образом, даже при сбоях и гонках система остаётся консистентной.
Поскольку и таблицы, и очереди — распределённые, то в общем случае потребуется выполнить распределённые транзакции, причём между разными таблицами. Сейчас распределёнными транзакциями уже мало кого удивишь. Но в 2017 году доступные широкой аудитории распределённые СУБД только начинали появляться, и в Яндексе единственной зрелой технологией, на которой можно было построить такой production‑сценарий, были динтаблицы в YTsaurus.
Чтение данных в реальном времени
Результат обработки нужен рантайму движка показа рекламы, и времени у него совсем немного — несколько сотен миллисекунд, из которых на сам поведенческий таргетинг приходится 50–100 мс. Для составления ответа поведенческий таргетинг должен несколько раз сходить в динтаблицы и потратить время на обработку. Поэтому от динтаблиц требуется не просто стабильная работа, но и ультранизкая латентность в чтении. В идеале — несколько миллисекунд в среднем и меньше десятка миллисекунд в высоких перцентилях. Добиться таких задержек удалось благодаря нескольким приёмам, которые применяются в динтаблицах. Расскажем о них по порядку.
Первое и самое важное — фиксация таблиц в памяти. Динтаблицы позволяют выделить заранее определённый объём оперативной памяти и «прибить» туда таблицы — то есть всегда держать их в горячем кэше. При этом, конечно, данные продолжают записываться и на диск, чтобы сохранялась отказоустойчивость, но все чтения идут только из памяти — что и обеспечивает низкую задержку.
Второе — хранение в памяти в разжатом виде. Таблиц, у которых критична скорость доступа, можно хранить в памяти сразу в разжатом виде. Чтобы данные хорошо сжимались, нужно кодировать большими блоками — сразу по много строк. Получается, что для чтения одной строчки из такого блока нужно распаковать сразу много строк — это долго и дорого. Хранение в памяти сразу в разжатом виде устраняет лишние и долгие операции декодирования и ещё больше снижает время ответа.
Третье — хеш‑индекс по ключу. Для таких таблиц можно включить хеш‑индексацию по ключу, чтобы поиск был мгновенным — через хеш‑таблицу. Без индекса, напомним, поиск бы шёл по skip‑list или бинарным деревьям, что медленнее.
Эти меры в совокупности и позволили получить необходимые миллисекундные задержки в продакшене поведенческого таргетинга — притом что данные читаются в реальном времени и с высокими требованиями к стабильности.

Все описанные выше приёмы отлично работают на бенчмарках. Но в реальности приходится учитывать ещё один фактор: вместе с чтением одновременно происходит запись в таблицу. При записи структура данных меняется. В динамических таблицах для хранения используется подход Log‑Structured Merge Tree (LSM‑tree). При постоянной записи появляются новые большие блоки данных — чанки (в терминологии YTsaurus), которые могут пересекаться по ключам с уже существующими.
Чтобы найти нужный ключ, система должна проверить все чанки, в диапазоны которых он попадает. Стоимость операции чтения в таком случае линейно зависит от толщины пересечения — количества чанков, которые нужно прочитать. Чем она больше, тем хуже время отклика.
В LSM‑tree есть фоновый процесс — compaction, который периодически объединяет несколько чанков в один. Для BigB мы добавили дополнительную настройку: форсировать компакшн, когда толщина приближается к заданной константе. Это дало предсказуемое время чтения, хотя и увеличило фоновую нагрузку на процесс компакшна.

Даже с описанными выше приёмами на высоких перцентилях всё ещё наблюдаются всплески задержек. Их природа оказывается довольно низкоуровневой.
YTsaurus — это современное C++‑приложение с активным использованием умных указателей и массивной объектной моделью. Всё это сильно нагружает систему аллокации памяти. А когда данные держатся в оперативной памяти и постоянно обновляются (в таблицу идёт устойчивый поток записей), ситуация становится особенно интересной: аллокации и освобождения происходят крупными блоками, что приводит к системным вызовам mmap/munmap.
Дальше — хуже: ядро не успевает разбирать освобождённые страницы, и в результате система начинает попадать в direct reclaim, из‑за чего время отклика скачет.
Чтобы побороться с этой проблемой, мы написали собственный аллокатор — YTAlloc
. Он при выделении памяти просил ядро сразу выделять страницы, что позволяло избежать дорогостоящих операций на горячем пути.
Кроме того, в ядро Linux был добавлен новый вызов, позволяющий агрессивнее и фоново очищать очередь освобождённых страниц, чтобы минимизировать вероятность direct reclaim.
Позднее мы перешли с собственного аллокатора на TCMalloc
, но механизм фонового реклейма памяти остался и продолжает использоваться — он по‑прежнему даёт ощутимую выгоду при пиковых нагрузках.
Сжатие данных и xdelta
Пора поговорить о том, как устроено хранение профилей и как они соотносятся со схемой таблиц. Сам по себе профиль содержит множество полей: это могут быть разные сигналы и статистики с множества сервисов Яндекса. Эти поля могут обновляться независимо друг от друга, а при чтении — использоваться выборочно. При этом в логике обработки профиль удобно представлять как единый объект — например, protobuf или JSON с большим числом полей.
На первый взгляд может показаться логичным разложить такой объект по колонкам таблицы — завести по колонке на каждое поле профиля. Но тут возникает важный нюанс — данные в нашей таблице хранятся в памяти и в разжатом виде. В этом формате таблица с множеством колонок начинает занимать существенно больше памяти, чем таблица, где есть одно поле с сериализованным профилем.
Если же сериализованный protobuf сжать на стороне клиента, например, с помощью zstd и заранее обученного словаря, то получится сэкономить оперативную память — а она у нас весьма дорогая. К тому же снижается нагрузка на журнал и трафик между клиентом и YTsaurus.
При этом, несмотря на сжатие, нагрузка на диск при сохранении табличных данных останется сопоставимой с классическим вариантом: YTsaurus всё равно сам применяет сжатие при записи на диск, так что по сути мы просто переносим сжатие ближе к источнику и тем самым оптимизируем ресурсы на горячем пути.

Хранить в таблице сжатые клиентом protobuf действительно очень выгодно, но есть одно «но»: при изменении даже одного поля нужно перезаписать весь объект. А поскольку большинство обновлений профиля затрагивают лишь небольшую часть данных, на практике это приводит к избыточному трафику — мы вроде как хотели сэкономить, а получилось наоборот.
Чтобы решить эту проблему, мы начали использовать xdelta — библиотеку для построения бинарных диффов. Теперь при обновлении в таблицу можно записывать не весь protobuf, а только дельту: бинарное изменение, полученное с помощью xdelta.

При чтении достаточно наложить все накопившиеся дельты на оригинальный protobuf — и мы получаем актуальную версию объекта. Благодаря тому, что YTsaurus гарантирует строгую консистентность, порядок применения дельт будет корректным, и восстановленный профиль всегда будет валидным.
Конечно, выполнять такое наложение на клиенте — не самая приятная задача (особенно если учесть, что данные ещё и сжаты с предобученным словарём). Здесь в дело вступают агрегатные колонки — особенность динтаблиц, которая оказалась очень кстати.
Агрегатные колонки позволяют не перезаписывать значение, а добавлять апдейт, который будет агрегироваться с текущим значением. Самый простой пример — колонка‑сумма: туда можно просто складывать числа. Для наших целей агрегатом становится применение дельты к текущему значению.
Получается, что профили в виде сжатых protobuf»ов и их дельты естественно ложатся на агрегатные колонки: достаточно задать, что агрегирующая операция — это «применить xdelta», и всё остальное YTsaurus сделает сам.
Теперь можно писать в таблицу только дельту, а при чтении сразу получать актуальный, уже восстановленный и сжатый protobuf — без лишней работы на клиенте.

Шардирование и решардирование
Обсудим подробнее, как устроена работа обработчиков событий. Чтобы обработать новое событие, воркер сначала читает профиль пользователя, к которому это событие относится, затем производит необходимые вычисления и записывает обновлённый профиль. Всё это происходит в рамках транзакции — это важно для того, чтобы гарантировать целостность данных: никакое событие не потеряется, и обновления профиля не будут друг друга перетирать.
Без транзакций два обработчика, работающие параллельно, могли бы одновременно прочитать один и тот же профиль, независимо его обновить и перезаписать изменения друг друга. Транзакции позволяют этого избежать: система сериализует изменения, а конфликтующие попытки записи отклоняются.
Однако у транзакций есть цена: при большом потоке данных неуспешные транзакции становятся узким местом — их нужно перезапускать, и это тормозит всю обработку.

Чтобы свести число конфликтов к минимуму, обработчики поведенческого таргетинга должны работать на разных данных — то есть обновлять разные профили. Для этого нужно решить две задачи:
Равномерно распределить профили между обработчиками.
Доставлять каждому обработчику только те события, которые относятся к его профилям — ведь изначально в очереди события идут в произвольном порядке, без группировки по пользователю.
Проще всего шардировать равномерно распределённые объекты. Например, если у нас есть множество точек, равномерно распределённых в интервале [0,264)[0,264), достаточно просто разделить этот интервал на равные диапазоны — границы каждого шарда легко вычисляются по формуле.
Но что делать, если идентификаторы объектов не обладают таким свойством? Здесь на помощь приходит хеширование: вместо шардирования по самому идентификатору, мы шардируем по хешу от идентификатора. Этот приём широко используется в разных системах и поддерживается, в том числе в динтаблицах.
При создании таблицы можно указать, от каких колонок нужно считать хеш — он будет использоваться как первая компонента ключа. Если использовать одну и ту же хеш‑функцию и при шардировании обработчиков BigB, и в динтаблице, куда они пишут, получится важное соответствие: каждому обработчику соответствует свой диапазон строк в таблице.

Таким образом, воркеры:
не пересекаются по профилям (что и было нашей целью),
фактически пишут каждый в свой шард.
Это даёт нам бонус: транзакции можно выполнять в пределах одного шарда, что гораздо эффективнее — не требуется сетевого взаимодействия с другими шардами и согласования коммита между участниками. При этом система остаётся корректной даже в пограничных случаях — редкие отклонения будут обработаны менее эффективно, но без ошибок.
Теперь вернёмся к тому, как воркеры получают данные. Раньше каждый воркер читал из общей очереди, где события по разным профилям шли вперемешку. Но теперь, если мы хотим, чтобы каждый воркер обрабатывал только «свои» профили, нужно обеспечить, чтобы очередь входящих сообщений уже содержала только нужные ему события.
Для этого мы добавляем предварительную стадию переупорядочивания событий — решардер. Задачи решардера:
читать сообщения из общей входной очереди (напомним, туда попадают события из логов сервисов Яндекса);
и записывать их в другую очередь, разбитую на шарды, строго соответствующие воркерам (число шардов фиксировано и равно числу воркеров).
Важно: каждое событие при этом должно быть обработано ровно один раз.

В этом помогают сразу две особенности YTsaurus:
во‑первых, в YTsaurus есть собственные очереди — это упорядоченные динтаблицы;
во‑вторых, запись в такие таблицы может происходить в рамках обычной транзакции.
Благодаря этому можно построить exactly‑once‑решардер, который в одной транзакции записывает событие в новую очередь и фиксирует свой прогресс.
Может возникнуть вопрос: а не столкнётся ли решардер с теми же проблемами конфликтов при записи, что и обычные обработчики?
На удивление — нет. Это связано с тем, как устроена запись в упорядоченные динтаблицы. Каждый шард такой таблицы — это, по сути, очередь строк, в которую просто добавляются новые записи в конец. Никаких блокировок при этом нет: в момент коммита к очереди атомарно добавляется пачка строк из транзакции.
Если одновременно в один шард пишут несколько транзакций, порядок строк может быть либо произвольным, либо согласованным по времени коммита — в зависимости от настроек таблицы. Но в любом случае конфликты исключены, и транзакции не будут абортироваться из‑за параллельной записи.
Вычислимые колонки и шардирование по хешу
Бесконфликтная запись в них с помощью отложенной сериализации
Междатацентровая доступность
На момент создания поведенческого таргетинга с хранением профилей в динтаблицах в Яндексе существовало три крупных дата‑центра. Считалось, что сервисы должны продолжать работу даже при внезапном отказе любого из них. Поскольку задержка между дата‑центрами могла достигать десятков миллисекунд, к хранению профилей предъявлялись следующие требования:
В каждом дата‑центре должна быть локальная копия обработанных данных — для использования в рантайме.
Копии должны быть идентичны между собой, допускается лишь небольшое отставание во времени.
После восстановления дата‑центра его копия должна догнать и подтянуть все накопившиеся изменения.
Процессинг должен работать с наиболее актуальной версией данных, не обязательно локальной, поскольку к нему не предъявляется жёстких требований по времени отклика (в отличие от рантайма).
Чтобы удовлетворить эти условия, в динтаблицах был реализован специальный тип — реплицированные таблицы.
Реплицированная динтаблица работает как очередь репликации: данные записываются в неё один раз, и система сама доставляет их во все заданные реплики — обычные динтаблицы. При этом гарантируется, что во всех репликах будет записана одна и та же последовательность операций. Это обеспечивает их идентичность.

В каждом дата‑центре находится отдельная таблица‑реплика с профилями пользователей. Запись в них осуществляется через одну реплицированную динтаблицу, которая размещена в специальном кросс‑датацентровом кластере YTsaurus.
Этот кластер сконфигурирован так, чтобы оставаться работоспособным при отказе любого дата‑центра — по сути, это тот же отказоустойчивый механизм, который позволяет YTsaurus переживать сбои отдельных физических машин.
Возникает естественный вопрос: если уже есть кросс‑ДЦ кластер, зачем вообще нужны отдельные реплики в каждом дата‑центре? Почему бы не использовать этот кластер для всего?
К сожалению, кросс‑ДЦ конфигурация не даёт мгновенных локальных ответов — данные физически могут находиться в другом дата‑центре, и это увеличивает задержки. Кроме того, во время обновления такого общего кластера будут периоды его полной недоступности, пусть и кратковременные.
Вот тут и вступают в игру таблицы‑реплики: даже если основной кластер недоступен, реплики в локальных дата‑центрах остаются доступны для чтения, что достаточно для работы рантайма. Таким образом, система сохраняет как доступность на чтение, так и согласованность данных, не жертвуя ни тем ни другим.
Реплицированные динтаблицы для репликации между кластерами YTsaurus
Автоматическое шардирование
При шардировании, особенно если в системе много таблиц, со временем почти неизбежно возникает дисбаланс нагрузки: на одних нодах оказывается больше данных, чем на других. А поскольку данные фиксируются в оперативной памяти, а память — ресурс дорогой и ограниченный, даже небольшой перекос может привести к тому, что отдельные ноды начинают обслуживать больше данных, чем могут уместить в памяти. В итоге появляются ошибки вроде «данные не поместились в память».
Чтобы таких ситуаций не возникало, в динтаблицах реализован фоновый механизм автоматического перешардирования и перебалансировки. Системный процесс отслеживает размер шардов, при необходимости делит их на более мелкие и перераспределяет по нодам, стараясь уравновесить нагрузку.
Важно, что этот механизм работает отказоустойчиво: нет единой точки, сбой в которой мог бы оставить таблицу в подвешенном или полурабочем состоянии. Можно представить, что балансировка организована как распределённая система акторов, каждый из которых отвечает за свою часть работы и не блокирует остальных.
Во время переезда шарда между нодами неизбежно возникает короткий период недоступности: требуется время, чтобы отключить шард на старой ноде, запустить на новой и — самое ресурсоёмкое — подгрузить все его данные в оперативную память. Такая «пауза» может длиться десятки секунд (сейчас мы активно работаем над новой механикой переезда, которая должна сократить видимый даунтайм до менее чем секунды).
Сами по себе такие задержки не критичны: переезды происходят нечасто и только для отдельных шардов, а рантайм может временно сходить за данными в соседний датацентр и уложиться в SLO даже на высоких перцентилях.
Но если в разных датацентрах одновременно начнётся перебалансировка одних и тех же шардов — возникнет полная недоступность. Такое мы, конечно, допустить не можем.
Чтобы избежать синхронных переездов в разных кластерах, у процесса балансировки есть гибкая настройка расписания, похожая на familiar cron‑выражения, знакомые системным администраторам. Например, можно задать в конфиге выражение: (hours * 60 + minutes)% 40 == 10
— это значит, что балансировка будет запускаться каждые 40 минут, со сдвигом в 10 минут. Если на другом кластере в том же выражении указать сдвиг 30, периодичность сохранится, но время запуска будет разнесено, что как раз и решает проблему одновременных перебалансировок.
Интеграция с Map-Reduce
YTsaurus — система сильно более широкого профиля, чем просто динамические таблицы. В YTsaurus есть map‑reduce, который используется почти всеми в Яндексе. В начале своего развития динтаблицы никак не были проинтегрированы с map‑reduce, но уже самые первые использования продемонстрировали пользу от интеграции. Архитектурно выполнить такую интеграцию позволило то, что и динтаблицы, и таблицы, с которыми происходит работа в map‑reduce, хранят данные в чанках в едином слое чанкового хранения (внутренний аналог HDFS или S3).

Пользователю, конечно, не так важно знать, как устроена интеграция. Для пользователя она выглядит как возможность работать с одним объектом — динамической таблицей — совершенно разными способами: как небольшими, но очень быстрыми транзакциями (OLTP-сценарий), так и map‑reduce операциями, что больше похоже на аналитику.

Прежде всего, map‑reduce даёт надёжный и проверенный механизм бэкапа распределённой таблицы. Можно перегнать динамическую таблицу в статическую — именно с такими таблицами работает map‑reduce — и хранить её в неизменном виде в системе, которая уже зарекомендовала себя как очень надёжное хранилище. Если нужна ещё большая сохранность, бэкап можно скопировать на другой кластер YTsaurus — для этого тоже есть готовые механизмы внутри map‑reduce.
Восстановление из такого бэкапа — простая и быстрая операция: статическую таблицу можно превратить в динамическую без перекладывания данных. Более того, можно заранее подготовить таблицу с данными через map‑reduce, а затем сделать её динамической и сразу получить возможность читать и изменять отдельные ключи в реальном времени.
Есть и другой важный сценарий — доступ к данным динамической таблицы через map‑reduce для сложной аналитики. Например, это может понадобиться при порче данных из‑за неудачного релиза. Для поведенческого таргетинга этот кейс особенно важен.
Представим ситуацию: поведенческий таргетинг замечает проблемы с данными — скажем, один из хостов падает, потому что на него приходят битые профили или обработка этих данных приводит к исчерпанию оперативной памяти. С помощью map‑reduce можно быстро проанализировать все профили, прилетающие на проблемный хост, и понять, в чём причина. Если выяснится, что данные действительно повреждены, map‑reduce позволяет написать обработчик, который с максимальной параллельностью пройдётся по таблице и исправит некорректные профили. Это даёт возможность быстро закрыть инцидент и восстановить работу системы.
Возможность указывать динтаблицы в качестве входных данных в Map-Reduce операциях
Поддержка NVMe SSD
Всё вышеперечисленное — уже достаточно зрелые и давно работающие механизмы. А вот в 2020 году мы сделали важное открытие: профили можно переложить из памяти на NVMe SSD, практически не теряя в производительности. Измерения показали, что как время отклика, так и количество IOPS, которое обеспечивают современные NVMe‑диски, вполне подходят для хранения рекламных профилей и, что особенно важно, для их чтения в рантайме.
Для переноса (или как мы говорим, отселения) больших значений из памяти на диск используется специальный режим — ханки. Колонку в динамической таблице можно пометить особым атрибутом, после чего все достаточно большие значения будут автоматически записываться отдельно — в специальное хранилище, называемое хранением ханков (chunk store for hunks).
Таким образом, основной объём таблицы остаётся в памяти и работает быстро, а тяжёлые куски данных живут на NVMe, не занимая дорогую оперативную память, но при этом оставаясь доступными на чтение с низкими задержками.

Чтобы при чтении таких данных не терять в задержках, используется специальный интерфейс ядра — io_uring
. Это современное API, предназначенное для асинхронного ввода‑вывода, которое позволяет одновременно запускать большое количество I/O‑операций с очень низкими накладными расходами и минимальной латентностью. Благодаря io_uring чтение ханков с NVMe‑дисков остаётся почти столь же быстрым, как и чтение из памяти — и этого достаточно, чтобы обеспечить требования рантайма. Подробнее про производительность NVMe SSD и различных интерфейсов доступа можно почитать в нашем исследовании.
Ещё один интересный эффект от использования ханков, который особенно понравился пользователям — это возможность обменять write amplification на space amplification.
Практически любая СУБД генерирует заметно больший поток данных на диск, даже если отключить репликацию и журнал. Причина в том, что структуры данных вроде B‑дерева или Log‑Structured Merge Tree периодически переписывают данные: в B‑дереве — при перебалансировке, в LSM‑дереве — при фоновом компакшне. На практике коэффициент увеличения записи может быть значительно больше единицы.
Ханки тоже нужно периодически компактить. Например, при удалении ссылки в ханковом чанке образуется «дырка» — участок памяти, который просто занимает место. Чтобы избавиться от этих дырок, ханки нужно перезаписать в новый ханковый чанк уже без пустот.

Ханки используют немного другую логику компакшна, и её можно запускать сильно реже. При этом удалённые данные продолжают занимать место, но за счёт того, что теперь ханки перезаписываются компакшном сильно реже — можно заметно уменьшить write amplification, заплатив за это большим расходом дискового пространства. Поскольку ханки лежат на NVMe SSD, дисковое пространство дешевле, чем оперативная память, и в некоторых сценариях разумно сделать такой размен. Получается, что фича, сделанная с одной целью (отселение большого объёма данных из оперативной памяти на SSD), нашла неожиданное важное применение и в других задачах (сокращение потока записи на SSD).
Хранение мелких блобов на NVMe с быстрым доступом и высоким IOPS
Возможность уменьшить write amplification за счёт space amplification
Выводы
Мы начали с простой задачи — хранения и обновления профилей пользователей. Со временем оказалось, что за ней стоит целый стек инженерных решений: от сжатия данных на клиенте и применения бинарных дельт до шардирования и отказоустойчивого масштабирования на десятки тысяч профилей.
Мы обсудили, как устроено хранение в динамических таблицах YTsaurus, как использовать агрегатные колонки для применения diff‑ов, как избежать конфликтов при параллельной записи через решардинг, и даже как вытеснить часть данных на SSD без потери производительности — с помощью ханков и io_uring.
Профили — лишь один из примеров. Подобная архитектура может применяться к самым разным потокам данных, где есть обновляемое состояние, высокая нагрузка и требования к latency. А ещё — где важна масштабируемость и хочется оставаться в пределах разумных затрат на ресурсы.
Описанный подход давно работает в продакшне и позволяет решать реальные задачи. Если хочется построить систему, способную обрабатывать миллиарды событий в день — использование возможностей динамических таблиц YTsaurus может быть хорошей отправной точкой.
Дополнительные материалы
Если вы хотите ещё глубже разобраться в том, как делается подготовка данных в реальном времени в Рекламе, и о динамических таблицах YTsaurus, предлагаем вам посмотреть доклады авторов этих систем:
Егор Хайруллин — Как перейти от batch к streaming на примере рекламной контент‑системы
Юрий Печатнов — Exactly once‑передача данных без материализации
Максим Бабенко — Как мы адаптировали динамические таблицы YTsaurus для хранения блобов
А также два моих доклада:
И заглядывайте в комьюнити‑чат YTsaurus за новыми идеями и лайфхаками использования платформы.
Комментарии (2)
jaker
21.08.2025 11:02А что происходит, если я нажимаю: пожаловаться-(мне это неприятно, мошенники или политическая реклама)?
CitizenOfDreams
"Этот парень купил утюг! Значит, он любит покупать утюги, давайте в следующие полгода показывать ему рекламу утюгов!"
"А этот парень уже лет пятнадцать покупает стройматериалы в магазине "Кирпич"! Давайте показывать ему баннеры этого магазина, а то вдруг он про него забудет".
"А вот этот парень просмотрел объявление о продаже машины Тойота Кукуха 1990 года выпуска! Давайте покажем ему рекламу автосалона, пусть возьмет в лизинг БМВ Х9 за 100500 рублей в день".