Это адаптированная для Хабра расшифровка доклада Алексея Дмитриева, директора аналитической платформы YDB DWH, которую создаёт команда Yandex Cloud, — компонента нашей гибридной базы данных YDB для обработки аналитических нагрузок. Когда проект только начинался, у нас было много наработок, которые мы успешно переиспользовали в других проектах. Но оказалось, что OLAP‑нагрузка так сильно отличается от OLTP, что за три года пришлось практически написать по ещё одной реализации многих частей системы. Под катом история о том, почему на рынке так мало гибридных баз данных класса Hybrid Transactional and Analytical Processing (HTAP) и какие сложности стоят на пути их разработки.

Мы в Яндексе начали разработку базы данных YDB более десяти лет назад, чтобы дать пользователям гарантии строгой согласованности на наших больших нагрузках. Кластера YDB являются мультитенантными и могут обслуживать тысячи независимых баз данных, каждая из которых может содержать петабайты данных и обрабатывать миллионы запросов в секунду. У нас много кластеров YDB разных размеров для различных целей. Основной кластер обслуживают тысячи серверов, и суммарный объём хранимых в нём данных приближается к экзабайту.

Архитектурно база данных разделена на два слоя: хранения и вычисления. Слой хранения отвечает за надёжное и распределённое размещение данных. А слой вычисления делает всё остальное: выполняет над данными запросы, балансирует нагрузку в кластере, предоставляет сервисные функции для управления кластером. Посмотрев на иллюстрацию выше, вы можете заметить такое интересное слово как Tablet или, на нашем сленге, «таблетка».
Таблетки — это специальные компоненты YDB, хранящие часть данных таблицы (таблица — table, часть таблицы — tablet, таблетка). К примеру, если в таблице 100 миллионов строк и эту таблицу обслуживает 100 таблеток, то каждая из них будет работать со своим собственным подмножеством строк, примерно по одному миллиону. YDB динамически подстраивает количество обслуживающих таблицу таблеток в зависимости от текущей нагрузки на неё и объёма данных.
Таблетки работают по принципу Replicated State Machine (RSM). У каждой таблетки есть текущее состояние и очередь команд, из которой таблетка транзакционно забирает очередную команду, выполняет её и транзакционно сохраняет новое состояние в слой хранения. Выполняя команду, таблетка может не только читать, обрабатывать и сохранять данные но и, например, отправлять сообщения другим таблеткам.
RSM хорошо подходит для создания отказоустойчивых систем: если сервер вышел из строя, то достаточно пересоздать таблетку на новом месте, подключить к слою хранения и очереди команд — и таблетка восстановит своё состояние со слоя хранения и продолжит работать с того же места, где была прервана её работа. Такая архитектура позволяет процессам YDB оперировать сотнями тысяч легковесных таблеток, которые обмениваются сообщениями друг с другом.

Таблетки обеспечивают не только отказоустойчивость, но и масштабируемость с помощью шардирования. Когда запросов к данным становится много и таблетка понимает, что не справляется со своей частью данных, то она разделяется. Например, была одна таблетка на миллион записей. Это слишком много данных для обработки в один поток. И таблетка разделилась на две по 500 тысяч в каждой. Не хватило — разделилась на четыре по 250 тысяч. После разделения обе таблетки смогут выполняться параллельно: на одном физическом сервере или на разных. А если нагрузки или данных стало меньше, то таблетки начинают снова объединяться в более крупные.

Пока компания маленькая, с аналитическими задачами можно справляться не совсем подходящими инструментами вроде OLTP баз данных. Но по мере роста бизнес понимает, что смог накопить много данных, а как их анализировать — непонятно, так как транзакционные базы данных просто не предназначены для аналитики на больших объёмах данных. И тут на помощь приходят аналитические базы данных и аналитические запросы.
Перед тем, как начать модифицировать YDB для поддержки и ускорения аналитических запросов, команды Яндекса изучили довольно много опенсорс‑решений. Но они либо не работали на наших масштабах, либо у команд уже был негативный опыт с ними. А ещё у любой отдельной аналитической базы данных был врождённый недостаток: в неё нужно было переносить наши 500 терабайт и как‑то обеспечивать копирование новых данных на скорости в пару миллионов RPS. Решаемая, но нетривиальная задача.

Поэтому мы приняли решение взять нашу базу данных YDB и начать превращать её в базу данных для обоих типов нагрузок: OLTP и OLAP. Такие базы данных называют Hybrid Transaction and Analytical Processing (HTAP). Термин придумали в компании Gartner, когда в 2014 году описывали тренды баз данных. Десять лет назад они написали, что в светлом будущем на смену отдельным транзакционным и аналитическим системам придут гибридные решения.
Чудес не бывает, и пока не научились делать всё в одной системе. Под капотом гибридных баз данных находятся по сути две разные базы, каждая со своим форматом хранения данных и движком запросов. Разработчики стремятся к тому, чтобы обе базы гарантированно содержали транзакционно одни и те же данные. А дальше в дело вступает оптимизатор: если запрос похож на транзакционный, то оптимизатор мог бы выполнять его в транзакционной части базы, а если аналитический — то в аналитической. Оптимизатор мог бы даже разбить запрос на OLTP‑ и OLAP‑части, выполнить их по отдельности и объединить результат. И чтобы это всё работало, нужна транзакционная согласованность данных, которой довольно сложно добиться.
Когда начинаешь строить HTAP базу данных, то сталкиваешься с очень интересной дилеммой. Самый простой способ обеспечить согласованность данных — это сложить транзакционные и аналитические данные в одну базу данных. В таком случае данные моментально доступны и для транзакционной, и для аналитической обработки и не нужно ждать синхронизации. Но есть нюанс. Аналитические запросы очень прожорливы по процессору и легко вытесняют транзакционные запросы, которые выполняются на том же сервере. Самый простой способ справиться с этой проблемой — правильно, разнести транзакционные и аналитические данные на разные сервера. И в этот момент появляются задержки, ведь данные приходится передавать по сети между серверами.

Иллюстрацию выше даже не пришлось переделывать: я взял её из презентации, которая была на SIGMOD в 2022 году.

И ещё одна иллюстрация, которую я тоже позаимствовал с SIGMOD из презентации TiDB. Маленький кораблик транзакционной нагрузки и девятый вал аналитической иллюстрируют, как именно аналитическая нагрузка влияет на транзакционную. Их тяжело совмещать.

На SIGMOD рассказывали про семь видов HTAP баз данных. Четыре основных и три экзотических.

Первые гибридные решения были предложены коммерческими базами данных: SQL Server и Oracle. Данные для аналитики просто держались в памяти того же сервера. У такого подхода есть две проблемы. Во‑первых, проблема с изоляцией транзакционных и аналитических запросов: тяжёлая аналитическая нагрузка мешала транзакционной. Во‑вторых, проблема с масштабированием: в те времена память для серверов была дорогая и максимальный её объём ограничивался возможностями одного сервера.

После того, как Sun купила MySQL, а Oracle купила Sun, появился второй вариант. Рядом с транзакционной базой данных устанавливался большой In‑memory кластер, в котором считались аналитические запросы. Взаимовлияние двух отдельных систем получалось минимальным, но за него пришлось заплатить задержками в передаче данных между системами. И ещё заплатить масштабированием, потому что память стоит дорого.

Интересный момент, что относительно современные системы, такие как Snowflake и SAP HANA, подходили к вопросу с другой стороны. К изначально аналитическим системам добавляли расширения для OLTP. Но изоляция нагрузки в таких решениях не была высокой, и девятый вал аналитики всё так же смывает транзакционную нагрузку.

Последний основной вариант — когда есть изначально распределённая транзакционная система и рядом строится вторая такая же, но аналитическая. Обе системы получаются изолированными друг от друга и хорошо масштабируемыми. Но задержка на синхронизацию данных между ними будет самая высокая из всех вариантов: транзакционно собрать данные с двух тысяч серверов и перенести их на четыре тысячи серверов занимает время. Первопроходцем здесь оказался Google, вслед за ним пошли разработчики TiDB, и, посмотрев, как у них получается, мы тоже решили пойти этим путём.

При разработке таких баз данных нужно ответить на четыре основных архитектурных вопроса:
Как эффективно хранить транзакционные и аналитические данные? Использовать одинаковую структуру хранения или разную?
Как передавать данные между транзакционным и аналитическим хранилищем?
Как оптимизировать запросы, которые устроены очень по‑разному для OLTP‑ и OLAP‑нагрузок?
Как выделять мощности под разные типы нагрузок, чтобы они меньше влияли друг на друга?
Организация хранения данных
В этой статье я рассказываю про три вопроса из четырёх: как гибридные базы хранят данные и почему тяжело сделать универсальное хранилище, как запросы оптимизируются и как системы планирования выделяют ресурсы для выполнения запросов.

Все современные системы пришли к тому, что случайный доступ к данным — это дорого. Поэтому для хранения используются LSM‑деревья или B+‑деревья. Основная идея в том, что вместо изменения существующих данных в файл дописываются новые данные. А когда файл становится достаточно большим, то создаётся следующий файл и новые данные продолжают дописываться в него.
При таком подходе данные дублируются, одни и те же ключи находятся в разных файлах и дубликаты занимают много места. Поэтому база данных использует фоновые compaction‑процессы, которые перемалывают данные в файлах и дедуплицируют их, убирают помеченное удалённым и т. п.

Для LSM‑деревьев существуют два классических подхода к compaction. Если мы хотим очень быстро писать данные и готовы жертвовать чтением и местом, то используется Size tiered compaction. У нас будут пересекающиеся диапазоны, гигантские файлы, но писать в них мы будем очень быстро. Если же мы хотим быстро читать данные, то используется Leveled compaction: постоянное удаление лишнего замедляет запись, но в каждом файле формируются компактные, оптимальные для чтения структуры данных.

Изначально делая OLTP‑систему в YDB, мы приняли решение, что для нас важна скорость записи, поэтому пошли по пути Size tiered compaction. В случае транзакционной нагрузки наши пользователи пишут гораздо больше данных, чем читают уже записанных.
С OLAP всё по‑другому. Данные зачастую один раз заливаются, и затем их надо читать в огромных объёмах для выполнения аналитических запросов. Так как данных много, то мы не можем позволить себе дублирование, поэтому для хранения OLAP‑данных мы используем Leveled compaction.
А так как нам нужно поддерживать OLTP‑ и OLAP‑нагрузки, нам приходится иметь реализацию одновременно и Size tiered, и Leveled compaction.

Транзакционные и аналитические запросы различаются не только в физическом хранении данных, но и в их логической организации. OLTP‑запросы обычно приходят большим потоком, но каждый работает с небольшими диапазонами ключей. Для каждого такого запроса базе данных несложно найти нужную таблетку или несколько, сервер, где они сейчас исполняются, и внести изменения.
В случае с OLAP ситуация меняется. Запросы менее частые, но многие зачастую сканируют очень большие объёмы данных, сотни гигабайт, если не больше. Данные должны быть максимально равномерно распределены по кластеру, чтобы обрабатываться параллельно для скорейшего получения результата. Поэтому в распределённых OLAP‑системах используется шардирование по хэшам, которое обеспечивает именно такое распределение данных.
Подводя итог: OLAP‑ и OLTP‑системы имеют совершенно разные сценарии использования. Данные в них хранятся по‑разному и на физическом, и на логическом уровне.
Оптимизация запросов

Вторая часть моей статьи связана с оптимизацией запросов. Те, кто работал с банковскими системами, знают, что когда происходит закрытие квартала, на счёт Налоговой начинают приходить деньги от огромного числа людей. Для того, чтобы выдерживать нагрузку в десятки тысяч RPS на один ключ, время исполнения запроса в ядре базы данных должно составлять десятки микросекунд. Иначе начнёт накапливаться задержка.
А вот аналитические запросы могут выполняться десятки секунд или даже минут. Поэтому мы можем потратить некоторое время и подготовиться к выполнению такого запроса. Все в каком‑то виде это делают: например, Amazon генерирует C++ код запроса и компилирует его с помощью GCC. А мы используем промежуточное представление и компилятор LLVM.
Компиляция запроса в машинный код может занять несколько секунд. Для транзакционных запросов, когда время выполнения составляет десятки микросекунд, а запросы повторяются редко, компилировать невыгодно. Запрос будет компилироваться гораздо дольше, чем затем выполняться скомпилированный код. А вот в случае аналитической нагрузки всё снова наоборот. Запросы выполняются десятки секунд над огромными объёмами данных, поэтому база данных выигрывает от компиляции. Для OLAP‑нагрузок часто используется LLVM‑компиляция и разные варианты оптимизации. Например, можно скомпилировать не весь запрос, а только его часть.
Во многих запросах есть фильтры, которые мы описываем с помощью SQL‑конструкции WHERE. Если читать со слоя хранения только удовлетворяющие фильтру строки, то можно очень сильно сэкономить на объёме читаемых данных и скорости их обработки. Такая оптимизация называется «pushdown предикатов»: передача предикатов вниз, в систему хранения (pushdown).
Так как таблетки YDB однопоточные, а запросов приходит тысячи в секунду, то исполнение в них фильтров к данным заставит слой вычисления ждать. Поэтому для OLTP‑запросов выгодно максимально быстро получить относительно небольшой объём данных совсем без фильтров, а применить фильтры к этим данным уже в распределённом слое вычислений. А для OLAP снова всё наоборот. Чем эффективнее применены фильтры при считывании данных, тем меньше данных (а данных очень много) придётся поднимать со слоя хранения в слой вычислений. Поэтому в OLAP‑базах так распространён pushdown фильтров и предикатов.
OLAP‑запросы эффективно выполнять с помощью векторных вычислений, когда одна инструкция процессора совершает несколько операций сразу над несколькими идущими подряд байтами в памяти. Для этого данные в памяти должны быть выровнены определённым образом, а выравнивать сотни гигабайт для каждого запроса очень накладно. Поэтому наш слой хранения держит данные ровно в том формате, в каком они будут обрабатываться в памяти. Это позволяет при чтении сразу размещать их в памяти в выровненном состоянии без дополнительных преобразований.

Про pushdown предикатов стоит рассказать подробнее. Чтобы узлы хранения могли вернуть только те данные, которые нам нужны, используется SSA‑программа (Single Static Assignment). Так делаем мы, так делают разработчики ClickHouse и большинства компиляторов.

Как подсказывает название, в SSA‑программе каждая переменная может быть использована только один раз. Для такого кода очень легко понять, какие части можно выполнять параллельно, а какие нет. В примере выше все части, связанные с фильтрацией, могут быть выполнены параллельно.
Результатом выполнения SSA‑программы являются колонки. Это логично, так как именно колонки находятся в слое хранения, их можно обрабатывать большими массивами с помощью векторных вычислений и получать на выходе такие же колонки. Если данные не попадают под условия WHERE, то их не нужно передавать дальше. Наша SSA‑программа не может удалить данные, но может на основе условий создать новую временную битовую колонку, которая будет определять, что данные передавать не нужно.

И мы, и ClickHouse в этом плане двигаемся в одном направлении. Мы оперируем готовыми блоками SSA‑выражений. Начиная от простых, которые хорошо ложатся на машинный код, и заканчивая сложными, такими как регулярные выражения и математические функции.
При выполнении SSA‑кода загруженные в память колонки не меняются. Вместо этого для каждого шага создаются новые, временные колонки. Которые существуют только на время вычислений и удаляются после получения результата.

Не все предикаты, которые хочется отправить на слой хранения, можно туда отправить. Синтетический пример — «текущее время». Если на каждом сервере слой хранения применит «текущее время» как фильтр, то это время будет немного отличаться и в результате получится неконсистентный результат.

После применения таких оптимизаций, как векторные вычисления, и pushdown предикатов у нас получилось ускориться втрое. Это можно увидеть на иллюстрации выше, где используется тест ClickBench, в котором порядка 100 миллиардов строк.

Девятый вал аналитической нагрузки может вытеснить транзакционную, даже если вы корректно всё разложили по потокам. Просто за счёт физических свойств процессора, где есть общая шина памяти и диска. OLTP‑запросы оптимизированы на микросекундное выполнение, в то время как OLAP оперируют с огромными массивами и используют все ресурсы серверов, поэтому в обоих случаях нужно планировать доступные вычислительные ресурсы и управлять ими.
Планирование ресурсов и управление ими

В завершение своей статьи хочу рассказать про управление ресурсами. В случае OLTP у системы миллисекунды на всё, и поэтому она оптимистично пытается выполнить запрос на том же узле, на который этот запрос отправил клиент. Каждый OLTP‑запрос обычно потребляет небольшое количество ресурсов, и шансы, что ресурсов хватит, велики. Ну а если не угадали, то просто получим чуть большую задержку, чем обычно.
С OLAP снова всё не так. Запросы потребляют очень много ресурсов, включая самый дорогой — память. Поэтому стоимостной оптимизатор YDB делит запрос на части и оценивает, сколько ресурсов нужно на каждом этапе выполнения. После чего уже раскладывает запрос по кластеру, исходя из доступных ресурсов. Если не угадали, то приходится использовать спиллинг данных на диски и сложные штуки, связанные с нехваткой ресурсов.

Даже с точки зрения физического размещения ресурсов в кластере ситуации очень разные. В случае OLTP‑нагрузки нам важно, чтобы когда запрос пришёл в таблетку, которая является единицей исполнения, она могла сразу же его выполнить. Поэтому мы считаем мощность каждого сервера и прицельно размещаем по ним нужные таблетки.
С OLAP нужна максимальная параллельность выполнения запроса: чем больше серверов выполняют один аналитический запрос, тем быстрее он будет выполнен. Поэтому таблетки должны быть разложены по серверам максимально равномерно. Если на каком‑то сервере окажутся две таблетки, то нужно будет по очереди ждать выполнения на обеих, фактически увеличив время выполнения запроса в два раза.
В итоге, база данных очень по‑разному планирует ресурсы для OLTP и OLAP. Для транзакционных запросов база оптимистично считает, что ресурсов хватит. Большую часть времени так и происходит. Для аналитических запросов проводится оценка мощности и размещения, YDB может даже отправить такой запрос в очередь, если посчитает, что кластер слишком занят, а запрос может и подождать.
Выученные уроки
Начав разрабатывать поддержку OLAP‑запросов в базе данных, мы стали очень хорошо понимать, почему баз, умеющих выполнять OLTP‑ и OLAP‑запросы, немного. OLTP‑ и OLAP‑системы совершенно по‑разному хранят данные. По‑разному оптимизируются запросы. И даже управление ресурсами так сильно различается.

Фактически это приводит к тому, что для решения таких задач разработчики создают две разные базы данных, потому что данные нужно хранить, передавать, обрабатывать и планировать по‑разному.

Создание HTAP‑систем — это не цель, а путь. Стоимостной оптимизатор в таких базах данных очень интересен, и у нас есть множество идей по его улучшению. Например, при планировании запросов можно учитывать, что у нас по факту две базы, между которыми налажена передача данных с задержкой. Если данные ещё не переданы в OLAP‑систему, значит часть запроса для таких данных нужно выполнять в OLTP. А ещё можно учитывать сбои в передаче данных!
Вторая важная задача — это размещение обоих кластеров на одних и тех же серверах. У нас есть примерное понимание, как это сделать, но объём работы огромный, и мы готовы к тому, что будут сложности.
На этом я завершаю свой рассказ и готов обсудить с вами в комментариях разработку баз данных вообще и HTAP‑систем в частности. Наши разработки выложены в опенсорс, есть коммерческая сборка с открытым ядром, и всё, о чём я рассказал выше, вы можете попробовать сами прямо сейчас. А ещё у нас есть Телеграм‑канал, где мы рассказываем про YDB и другие наши разработки. Присоединяйтесь к нашим сообществам!
https://github.com/ydb-platform
Всё о коммерческой сборке для корпоративного использования тут: