Все предыдущие статьи этого цикла крутились вокруг одной темы: как сделать OLTP предсказуемым. Мы разбирали p99 latency и почему оно уезжает при смешанной нагрузке. Писали про API-контракты между слоями ядра, про WAL-before-data как фундамент корректного recovery, про Buffer Pool с Clock-sweep и BufferRing, который изолирует полные сканы от горячего рабочего набора. Всё это фундаментальная инженерная работа для предсказуемого OLTP-ядра.
Но есть одна вещь, которую мы намеренно не трогали в предыдущих статьях.
С самого первого дня архитектура нашей базы закладывалась как HTAP-ready (Hybrid Transactional/Analytical Processing). Не «добавим аналитику потом», а «в ядре с нуля есть место для векторизованного выполнения». Это решение влияло на выбор типов данных, формат батчей, структуру планировщика. Эта статья про это направление.
Что реализовано на момент публикации: PhysicalType и колоночные батчи; SelectionVector; RowToColumnBridge и BatchToRowBridge; векторизованные VectorSeqScan и VectorIndexScan; SIMD-ускоренный lower-bound на листовых страницах B-Tree (под отдельным feature-флагом сборки на nightly Rust); автовекторизуемые компилятором фильтрационные кернелы для i64/f64 колонок на стабильной toolchain; векторизованный Hash Join и хэш-агрегации. Что ещё не готово: планировщик пока не умеет автоматически выбирать между VectorSeqScan и VectorIndexScan на основе актуальной статистики; колоночное хранилище на диске (данные сейчас row-store, конвертация происходит через RowToColumnBridge на лету); колоночные хэш-кернелы по типам уже есть, но в горячий путь Hash Join подключаются отдельным шагом.
Проблема: почему построчное исполнение плохо считает
Исторически OLTP-базы строились вокруг модели Volcano-итератора, или tuple-at-a-time execution. Каждый оператор плана выполнения передаёт следующему данные по одной строке таблицы. Для коротких транзакционных запросов вроде SELECT * FROM users WHERE id = 1 это работает отлично: нашли одну строку, вернули, закончили.
Проблема проявляется, когда нужно посчитать агрегацию по миллиону строк. Построчная модель начинает ощутимо проигрывать по нескольким причинам:
-
Косвенный вызов (dispatch) на каждую строку. Каждое
next()в классическом Volcano — это обращение через vtable, и процессор плохо предсказывает такие ветвления (branch misprediction) при высоком темпе чередования операторов. -
Плохая утилизация кэшей L1/L2. Строки в row-store лежат вперемежку с разными полями, а нам для агрегации часто нужна только одна колонка.
-
Нет возможности применить SIMD. Процессор умеет обрабатывать сразу несколько значений одной SIMD-операцией, но tuple-at-a-time pipeline не даёт ему такой возможности: данные поступают поштучно.
Для транзакционных запросов всё это незаметно: запрос затрагивает 1–10 строк. Но для аналитических сценариев (агрегации, соединения, сканирование диапазонов) векторизованный подход снижает накладные расходы по сравнению с построчным исполнением на тех же данных.
От Value к PhysicalType: как выглядят данные внутри векторизованного батча
В классическом построчном исполнителе каждое значение обычно представляется как enum, примерно вот так:
enum Value { Int(i64), String(String), Null,}
Удобно для разработчика. Но такой формат плохо подходит для плотной кэш-локальности и SIMD-обработки: массив значений в памяти выглядит как решето с разным выравниванием, тегами перечислений и указателями на heap. Никакого SIMD на таком массиве не сделаешь.
Мы перешли на PhysicalType. Векторизованный батч хранит данные не по строкам, а по колонкам. Колонка Int64 — это непрерывный массив Vec<i64>; для горячих SIMD-путей выравнивание обеспечивается отдельно. Это позволяет загружать несколько значений в SIMD-регистры и обрабатывать их одной операцией, что повышает пропускную способность на подходящих архитектурах.
Батч в памяти имеет фиксированный верхний предел строк (по умолчанию 1024) и состоит из трёх частей:
-
Набора типизированных колоночных векторов. Для каждой колонки свой непрерывный массив нужного типа.
-
Null bitmap.
Vec<u64>, где 1 бит отвечает за 1 строку (1 значит «значение есть», 0 значитNULL). Упаковка в слова по 64 бита позволяет работать с целыми группами строк через bitwise-операции вместо побитового цикла. -
SelectionVector.
Почему 1024 строки? Это компромисс между накладными расходами на вызов оператора и локальностью данных. Для колонки Int64 такой вектор занимает 8 КБ, то есть несколько горячих колонок и служебные структуры всё ещё помещаются в L1/L2 на типичных x86-64 CPU. Размер можно менять через настройку vector_batch_size, но верхняя граница 1024 оставлена как консервативное значение по умолчанию для кэшей и предсказуемого потребления памяти.
На последнем пункте стоит задержаться, потому что он нетривиален.
SelectionVector: как фильтры работают без копирования данных
Допустим, у нас батч из 1024 строк, и мы применяем фильтр WHERE age > 18. После фильтрации подходит, скажем, 300 строк. Как передать результат дальше?
Наивный вариант: удалить неподходящие строки из колонок, сдвинуть элементы. Это O(N) копирований на каждый фильтр, и при цепочке из пяти операторов мы платим за копирование пять раз.
Другой вариант: битовая маска (bitmask). Один бит на строку. Но при последующих операциях (хэшировании, проекции) каждый оператор вынужден распаковывать маску перед работой с данными.
Мы выбрали список индексов. SelectionVector — это Vec<u16>, который содержит индексы строк, прошедших фильтрацию: [0, 3, 7, 8, ...]. При последующих операциях следующий оператор итерируется по плотному массиву индексов, напрямую обращаясь к нужным элементам в колонках, без промежуточного распаковывания.
Этот выбор особенно хорош при умеренной и низкой селективности, когда после фильтра остаётся небольшая доля строк. При высокой плотности прошедших строк, например 900 из 1024, список индексов становится длинным, а доступ к колонкам через него хуже плотного последовательного прохода. В текущей реализации всегда используется SelectionVector; адаптивный выбор между SelectionVector и bitmask по наблюдаемой селективности — задача следующей итерации.
RowToColumnBridge: мост между row-store и векторизованным пайплайном
Наши данные на диске хранятся в формате строк таблицы (row-store). Это стандартное решение для OLTP: точечные чтения и запись по первичному ключу там оптимальны. Но как их передать векторизованному исполнителю?
Для этого есть оператор RowToColumnBridge. Он читает строки с диска (уже после проверки MVCC-видимости, так что векторизованному исполнителю не нужно ничего знать о транзакциях) и транспонирует их в колоночные батчи нужного формата. Проверку видимости делает scan-слой, который читает heap-страницы под snapshot: невидимые версии он отбрасывает до передачи строки в мост.
Да, здесь есть оверхед на конвертацию. Но даже с учётом этого транспонирования последующая векторизованная фильтрация и агрегация могут окупать стоимость моста. А ещё это архитектурный задел: когда данные будут лежать в колоночном формате на диске, этот мост заменится на оператор прямого колоночного чтения с минимизацией копирования; конкретная степень зависит от формата хранения, кодеков и структуры страниц. Весь остальной пайплайн (фильтры, соединения, сортировки) при этом останется неизменным.
RowToColumnBridge работает в потоковом режиме. Строки читаются батчами, каждый батч сразу транспонируется и передаётся вниз по пайплайну. Это позволяет не материализовывать весь результат в памяти и корректно завершать выполнение досрочно при наличии LIMIT.
SIMD в B-Tree: где векторизация помогает, а где нет
Полные сканы таблицы с векторизацией работают хорошо. Но OLTP-нагрузка — это прежде всего точечные запросы и короткие диапазоны по индексам. Можно ли векторизовать обход B-Tree?
Частично. Всё зависит от того, о какой части B-Tree идёт речь.
Спуск по дереву: здесь SIMD не помогает
Когда мы ищем ключ в B-Tree, первая фаза — спуск от корня до листовой страницы. Читаем корневую страницу, находим нужный указатель, спускаемся ниже, и так до листа. Это классический pointer-chasing: процессор упирается в задержку доступа к памяти, ожидая следующий узел дерева. SIMD ускоряет вычисления над плотным массивом данных, а здесь главный расход — ожидание следующей страницы.
Листовые страницы: где SIMD уже работает по данным
Листовая страница содержит плотный массив ключей. Вместо классического partition_point по одному элементу мы загружаем ключи в SIMD-регистры и одной операцией находим первую позицию, удовлетворяющую границе диапазона (lower-bound). Это ускоряет именно начало диапазонного скана — WHERE created_at BETWEEN x AND y, WHERE id >= ? — а дальше плотный последовательный проход по листу делает остальное. На нашем AVX2-стенде в синтетических тестах с диапазонами шириной от 16 ключей на страницу мы наблюдали ускорение 2–3× по сравнению со скалярным partition_point. Это не универсальная константа: конкретный коэффициент зависит от ISA, ширины регистра, типа данных и ширины диапазона.
Скалярный путь на листе выглядит как цикл по одному ключу:
for key in leaf.keys() { if key >= lower && key <= upper { tids.push(key.tid()); }}
Векторизованный путь делает то же сравнение группами: загружает несколько ключей в SIMD-регистр, получает маску совпадений и определяет позицию первой подходящей строки за одну операцию. Мы не пытаемся «векторизовать B-Tree целиком»; ускоряется именно плотный leaf-scan, где данные уже лежат рядом, и в первую очередь lower-bound поиск, с которого начинается любой диапазонный скан.
В текущей реализации std::simd-путь на листе включается отдельным feature-флагом сборки (он опирается на nightly-Rust API), а сборка по умолчанию использует скалярный fallback на стабильной toolchain. В горячих местах самого батчевого исполнителя (фильтры по i64 и f64 колонкам) мы намеренно полагаемся на autovectorization компилятора: код написан так, чтобы LLVM мог сам сгенерировать AVX2/NEON. Это даёт переносимый stable-build «по умолчанию» и явный nightly-путь там, где он действительно нужен.
VectorIndexScan: как индексный поиск вписывается в векторизованный пайплайн
Интеграция индексов в векторизованный движок потребовала нового оператора. Он называется VectorIndexScan, и его пайплайн работает в четыре шага:
Шаг 1. Потоковый обход листовых страниц. Итератор пробегает по листам B-Tree с применением SIMD-сравнений и собирает батч TID-ов (Tuple Identifier). Итератор ленивый: он не материализует весь результат в память, что позволяет корректно завершать выполнение досрочно при LIMIT.
Шаг 2. Heap fetch. По собранным TID-ам читаем сами строки из табличных страниц. Это случайный ввод-вывод, и его стоимость зависит от того, насколько TID-ы кластеризованы на диске. При низкой кластеризации (данные вставлялись не по порядку) этот шаг может стать узким местом.
Шаг 3. Проверка видимости и транспонирование. Строки проходят snapshot-based MVCC-проверку (здесь тоже есть накладные расходы, особенно при длинных цепочках версий) и транспонируются в колоночный батч через RowToColumnBridge.
Шаг 4. Residual filter. Векторизованный фильтр применяется к условиям, которые не вошли в индекс. Например, при WHERE indexed_col = 5 AND non_indexed_col > 10 индекс покрывает первое условие, а второе применяется уже на батче через SelectionVector.
Более честная оценка стоимости выглядит не как просто O(log N + K), а как O(log N + K × heap_fetch_cost + K × visibility_cost). Если TID-ы хорошо кластеризованы, heap_fetch_cost близок к последовательному чтению страниц; если плохо — это серия случайных обращений. Поэтому итоговая цена часто определяется не самим поиском в индексе, а кластеризацией TID-ов, локальностью heap fetch, длиной version chains и тем, покрывает ли индекс нужные колонки. При K в несколько тысяч строк heap fetch и MVCC-проверка могут составлять большую часть времени выполнения, даже если leaf-scan по индексу отработал быстро.
Когда SeqScan, а когда IndexScan: эвристика планировщика
У планировщика теперь есть осмысленный выбор между двумя операторами:
-
VectorSeqScanчитает всю таблицу последовательно. Ввод-вывод при этом последовательный, что хорошо для дисковых носителей. Сложность O(N). -
VectorIndexScanспускается по B-Tree и делает heap fetch по TID-ам. Ввод-вывод при этом может быть случайным. Стоимость ближе к O(log N + K × heap_fetch_cost + K × visibility_cost), где K количество подходящих строк.
В текущей реализации используется эмпирическая эвристика: на нашем стенде индексный путь начинает выигрывать на низкой селективности, когда запрос затрагивает только малую долю таблицы. При более высокой селективности часто дешевле последовательно прочитать таблицу с векторизованной фильтрацией, потому что последовательный ввод-вывод обычно эффективнее случайного. Точный порог сильно зависит от степени кластеризации данных, характеристик носителя, ширины строки и покрытия индекса, поэтому это не «универсальное правило 5%», а рабочая граница для конкретной конфигурации.
Дальнейшее развитие планировщика предполагает актуальную статистику и выбор на основе оценки стоимости, но для текущего состояния системы такая эвристика работает разумно.
Векторизованный Hash Join и агрегации
Помимо сканирования, на векторизованные операторы переведены соединения и агрегации. Это важно: даже если отдельные сканы быстрые, без векторизованного соединения выигрыш теряется при первом JOIN.
Hash Join разделён на две фазы. На build-фазе меньшая сторона соединения материализуется в хэш-таблицу; для HTAP-сценариев это важное ограничение, потому что build-сторона должна помещаться в доступный лимит памяти или оператор должен перейти в более сложный режим со сбросом на диск. На probe-фазе большая сторона приходит батчами: для каждой строки батча вычисляется ключ соединения, и затем выполняется обычный probe по хэш-таблице. Главный выигрыш здесь не в SIMD-хэшировании в горячем цикле, а в устранении косвенных вызовов между операторами: фильтрация и проекция перед соединением уже отработали колоночно по всему батчу. Реальная пропускная способность probe-фазы дополнительно зависит от структуры хэш-таблицы и локальности обращений к памяти. Колоночные хэш-кернелы для i64/bool/utf8 уже есть в коде, но подключение их в горячий путь Hash Join запланировано отдельным шагом.
Агрегации (GROUP BY, SUM, COUNT, AVG) работают аналогично: операции применяются к колонке целиком, а не к каждому элементу по отдельности. Null bitmap при этом обрабатывается через word-level операции над Vec<u64>, что быстрее побитового цикла, хотя проход по массиву слов всё равно необходим.
Что показывают замеры
Мы прогнали сравнительный бенчмарк на внутренней эталонной базе: локальный NVMe, Linux 6.12, Intel Core i5-12400, 32 ГБ RAM, buffer pool 2 ГБ, vector batch size 1024. Таблица seed_order_items содержит 2 250 000 строк и хранится в row-store. Сценарий ниже — warm-cache: перед серией запросов буферный пул прогрет, drop_caches не использовался. Это не cold-cache измерение «с чистого диска», а сравнение двух путей исполнения на одной и той же базе. Режимы включались перезапуском сервера с execution.mode = "force_row" и execution.mode = "force_vector".
Важно: все три запроса в таблице ниже — последовательные сканы. Это не бенчмарк VectorIndexScan; индексный путь зависит от селективности, кластеризации TID-ов и покрытия индекса, поэтому для него нужен отдельный набор измерений.
Для каждого запроса было 5 прогонов в каждом режиме. В таблице указаны среднее, стандартное отклонение по пяти прогонам и диапазон min–max:
|
Запрос |
force_row |
force_vector |
Ускорение по avg |
|---|---|---|---|
|
SUM(qty) + SUM(unit_price) + COUNT(*) по всей таблице |
avg 1051 мс; σ 58; min–max 988–1129 |
avg 865 мс; σ 12; min–max 854–887 |
1.22× |
|
COUNT(*) WHERE qty > 2 (фильтр без индекса) |
avg 1068 мс; σ 43; min–max 990–1116 |
avg 766 мс; σ 17; min–max 736–788 |
1.39× |
|
GROUP BY product_id + SUM(qty) |
avg 2699 мс; σ 181; min–max 2494–3036 |
avg 1012 мс; σ 47; min–max 954–1088 |
2.67× |
GROUP BY — это то место, где разрыв наиболее заметен: батчевая обработка ключей хэш-агрегации даёт 2.67× на той же row-store базе. Простые полнотабличные агрегаты дают 1.22–1.39×, что честнее отражает ситуацию с row-store на диске: часть выигрыша съедается конвертацией через RowToColumnBridge, чтением row-store страниц и кэш-поведением.
Несколько оговорок, которые важно держать в голове:
-
Данные хранятся в row-store. Когда появится колоночный формат на диске,
RowToColumnBridgeуйдёт и картина изменится в пользу векторизованного пути. -
Порог переключения
VectorSeqScan/VectorIndexScanподобран эмпирически на этом стенде. На другом профиле носителя, при другом размере строк или другой кластеризации TID-ов граница сдвигается. -
Ускорение SIMD на листовых страницах B-Tree зависит от ISA и ширины диапазона. Отдельные замеры по этому пути будут в следующих материалах.
Как это держится вместе
Принципиальный момент в архитектуре: мы не добавляли векторизованное выполнение поверх существующего OLTP-ядра. PhysicalType вместо Value появился не потому что кто-то оптимизировал горячий путь, а потому что такой выбор был сделан на этапе дизайна типов. RowToColumnBridge спроектирован так, чтобы MVCC-слой не знал про колоночный формат вообще: он возвращает строки, а мост транспонирует их дальше. Это позволило держать OLTP-путь (точечные транзакции, pin/unpin, no-steal, WAL) и аналитический путь (батчи, SIMD, SelectionVector) в одном движке без взаимных зависимостей — слои разделены контрактами.
Что пока не сделано: планировщик не использует актуальную статистику для выбора оператора; данные на диске хранятся в row-store (переход на колоночный формат — отдельная задача с собственным форматом страниц и миграцией); оконные функции в векторизованном пайплайне не реализованы. Все эти пункты зафиксированы в дизайн-документах как следующие шаги.
Что проверять дальше
Главный открытый вопрос теперь не в том, можно ли встроить векторизованный пайплайн в OLTP-ядро. Можно. Интереснее посмотреть, как он ведёт себя на реальной смешанной нагрузке: с живыми индексами, неидеальной кластеризацией данных, длинными version chains, разными размерами buffer pool и запросами, которые меняются со временем.
Это направление — отдельный большой блок работы: как собрать воспроизводимый HTAP-бенчмарк, какие метрики смотреть кроме среднего времени запроса, почему один и тот же VectorIndexScan может быть быстрым на одном профиле данных и проигрывать последовательному скану на другом. К этим вопросам мы ещё вернёмся в следующих материалах цикла, по мере того как набираются собственные измерения и наблюдения с реальных стендов.
Закрытое бета-тестирование
Мы рассказываем технические детали и открываем закрытое бета-тестирование, чтобы проверить заявленные в статье решения на реальной нагрузке и не распыляться на «всё для всех». Никаких обязательств по закупкам, никакого enterprise-sales — это техническая проверка с обеих сторон.
Поддерживаемый стек на момент публикации:
-
PostgreSQL wire-протокол: simple query и extended query (Parse/Bind/Execute).
-
Драйверы:
psql,psycopg3(3.3.4),tokio-postgres(0.7.17, simple + extended),sqlx(0.8.6). -
ORM/прикладные системы: Django 5.x на
psycopg3, Odoo 19 (community). -
Сборка: Linux x86_64, glibc ≥ 2.28 (пакеты .rpm и .deb, tarball).
Подходящий профиль команды:
-
сейчас в production стоит PostgreSQL и есть метрики по нему;
-
стек целиком ложится на список выше, без зависимости от хранимых процедур, триггеров и редких расширений;
-
есть инженер, который будет вести тестирование с вашей стороны и сможет договариваться об измерениях.
Сроки и формат:
-
Заявки принимаются до 25 мая 2026.
-
Дальше обсуждаем индивидуально с каждой командой: сроки, сценарий, объём данных, критерии успеха, что именно фиксируем в бенчмарк-сессии под NDA.
Если интересно поучаствовать — напишите на team@angarabase.com: коротко о вашей системе, стеке и сценарии, который хотите проверить.
ссылка на оригинал статьи https://habr.com/ru/articles/1032894/