Row groups: 122 880 строк как единица хранения
В прошлом уроке мы увидели, что файл DuckDB разбит на блоки, а данные таблицы лежат в блоках как колоночные сегменты. Но между «таблицей» и «колоночным сегментом» есть ещё один уровень структуры — горизонтальная нарезка таблицы на куски строк. Эти куски называются row groups, и у них есть характерный размер: примерно 122 880 строк. Число выглядит произвольным, но оно выведено из устройства движка и связывает воедино storage-формат и векторизованное исполнение. Этот урок объясняет, откуда взялось 122 880 и зачем вообще резать таблицу по строкам.
Две оси нарезки таблицы
Таблицу можно резать в двух направлениях. Вертикально — по колонкам: каждая колонка хранится отдельным потоком значений. Это и есть колоночное хранение, фундамент аналитических СУБД. Горизонтально — по строкам: таблица делится на группы подряд идущих строк.
DuckDB режет в обоих направлениях. Сначала горизонтально — таблица делится на row groups, группы примерно по 122 880 строк. Затем внутри каждой row group — вертикально: каждая колонка этой группы хранится своим колоночным сегментом. Получается двумерная сетка: строки разбиты на горизонтальные полосы (row groups), внутри каждой полосы данные разложены по вертикальным колоночным сегментам.
Зачем вообще горизонтальная нарезка, если хранение колоночное? Три причины. Первая — параллелизм: row group естественно становится единицей работы, которую можно отдать отдельному потоку. Один поток сканирует row group 0, другой — row group 1, и они не мешают друг другу. Вторая — пропуск данных: у каждой row group своя статистика по каждой колонке (min/max), и если фильтр запроса заведомо не попадает в диапазон row group, всю группу можно пропустить, не читая. Третья — управление памятью: движок обрабатывает данные row group за row group, не загружая всю таблицу в память сразу, что и делает возможным работу с данными больше RAM.
Откуда взялось число 122 880
122 880 — не круглое число и выглядит случайным. На самом деле это произведение: 122880 = 2048 * 60. Чтобы понять обе части, нужно вспомнить про вектор.
DuckDB — векторизованный движок. Он обрабатывает данные не построчно и не всей таблицей сразу, а батчами фиксированного размера. Размер этого батча — STANDARD_VECTOR_SIZE, и он равен 2048. Вектор — это колоночный массив из 2048 значений одного типа; за один «такт» движок прогоняет через оператор именно такой батч. Число 2048 выбрано так, чтобы батч помещался в кэши процессора и хорошо ложился на SIMD-инструкции (это подробно разбирается в модуле про векторизованный движок). Здесь важен сам факт: 2048 — фундаментальный квант обработки.
Теперь row group. Логично сделать так, чтобы row group состоял из целого числа векторов — тогда сканирование группы раскладывается ровно на N батчей по 2048, без неудобного «хвоста» в полвектора на каждой границе. DuckDB берёт 60 векторов на row group. Отсюда: 60 * 2048 = 122880 строк.
Почему именно 60, а не 30 или 120? Это компромисс между двумя крайностями. Слишком маленькая row group (мало векторов) — много мелких групп, на каждую свои метаданные и своя статистика, накладные расходы растут, и кусок работы для потока становится слишком мелким, чтобы окупить диспетчеризацию. Слишком большая row group — грубая гранулярность: пропуск данных по статистике работает хуже (отбрасываются только очень крупные блоки целиком), и параллелизм страдает на средних таблицах, где групп получается слишком мало, чтобы занять все ядра. 60 векторов — выверенная середина: группа достаточно крупная, чтобы метаданные на неё амортизировались, и достаточно мелкая, чтобы и пропуск данных, и параллелизм работали тонко.
Связка 2048 -> 60 -> 122880 — хороший пример того, как в DuckDB одно проектное решение протягивается через все слои. Размер вектора задаёт квант исполнения. Row group — это целое число векторов. Дальше, в модуле про параллелизм, вы увидите, что и единица параллельной работы (morsel) тоже кратна вектору. Один и тот же квант 2048 связывает исполнение, хранение и параллелизм.
Как увидеть row groups в реальной таблице
Системная функция pragma_storage_info, с которой мы познакомились в прошлом уроке, показывает разбиение таблицы на row groups напрямую — через колонку row_group_id.
duckdb rg.duckdb
CREATE TABLE events AS
SELECT range AS id, range % 100 AS bucket
FROM range(500000);
CHECKPOINT;
-- Сколько row groups и сколько строк в каждой
SELECT row_group_id, count(DISTINCT column_name) AS columns,
max(count) AS rows_in_group
FROM pragma_storage_info('events')
GROUP BY row_group_id
ORDER BY row_group_id;
Вывод:
row_group_id columns rows_in_group
0 2 122880
1 2 122880
2 2 122880
3 2 122880
4 2 8480
Таблица в 500 000 строк разложилась на пять row groups. Первые четыре — полные, ровно по 122 880 строк. Последняя, пятая (row_group_id = 4), — неполная: 8480 строк. Проверим арифметику: 4 * 122880 + 8480 = 491520 + 8480 = 500000. Сходится точно.
Это иллюстрирует обычное поведение: все row groups, кроме последней, заполнены до 122 880 строк, а последняя содержит остаток. Движок не «добивает» последнюю группу пустыми строками и не растягивает её — сколько строк осталось, столько и лежит в финальной row group.
ROW_GROUP_SIZE: когда размер можно менять
122 880 — значение по умолчанию, и в подавляющем большинстве случаев его трогать не нужно. Но размер row group настраивается. При создании или присоединении базы доступен параметр ROW_GROUP_SIZE:
-- Присоединить новую базу с уменьшенным размером row group
ATTACH 'fine.duckdb' (ROW_GROUP_SIZE 12288);
Здесь 12288 = 2048 * 6 — row group по 6 векторов вместо 60. Размер row group задаётся в строках и кратен размеру вектора по той же причине, что и значение по умолчанию: чтобы группа раскладывалась на целое число батчей.
Зачем вообще менять. Меньший row group даёт более тонкий пропуск данных по статистике — фильтр отбрасывает меньшие куски, и на сильно отсортированных данных с очень избирательными фильтрами это может ускорить запросы. Цена — больше метаданных и больше мелких сегментов. Больший row group уменьшает накладные расходы на метаданные, но огрубляет пропуск данных и может ослабить параллелизм на средних таблицах. Это тонкая настройка под конкретную нагрузку; менять её стоит, только измерив, что узкое место именно в гранулярности row group. По умолчанию 122 880 — правильный выбор почти всегда.
Почему row group — естественная единица параллелизма
Row group — это не только единица хранения, но и удобная единица работы. Группы подряд идущих строк независимы друг от друга: чтобы обработать row group 2, не нужно ничего знать про row group 0 и 1. Каждая несёт свои сегменты, своё сжатие, свою статистику.
Поэтому при сканировании таблицы DuckDB раздаёт row groups (а внутри них — куски ещё мельче) разным потокам. Поток A читает и фильтрует row group 0, поток B — row group 1, поток C — row group 2, и они работают параллельно, не синхронизируясь между собой. Чем больше row groups в таблице, тем больше независимых кусков работы, тем лучше загружаются все ядра процессора.
Отсюда практическое следствие. На очень маленькой таблице, которая целиком умещается в одну row group, параллелизм сканирования просто негде развернуть — кусок работы один. На большой таблице из десятков и сотен row groups движку есть что раздать всем потокам. Это одна из причин, почему DuckDB особенно хорошо показывает себя на крупных аналитических сканах: там много row groups, и параллелизм работает в полную силу. Детально механику параллельного сканирования и диспетчеризации кусков работы разбирает модуль про morsel-driven execution; здесь достаточно понять, что фундамент этого параллелизма заложен прямо в storage-формате — в нарезке таблицы на row groups.
Попробуй сам
Разберитесь с row groups на собственных таблицах.
- Создайте таблицу ровно в одну row group:
CREATE TABLE small AS SELECT range AS n FROM range(122880);иCHECKPOINT. Проверьте черезpragma_storage_info('small'): сколько различныхrow_group_id? Должна быть одна группа. - Добавьте одну строку:
INSERT INTO small VALUES (999999);иCHECKPOINT. Сколько row groups теперь? Одна 122 881-я строка вынуждает создать вторую, почти пустую группу. - Создайте таблицу в 1 000 000 строк, сделайте
CHECKPOINT. Сосчитайте число row groups черезSELECT count(DISTINCT row_group_id) FROM pragma_storage_info('big');. Совпадает ли оно сceil(1000000 / 122880)? - Посмотрите размер последней (неполной) row group: сколько в ней строк? Проверьте, что
(число полных групп) * 122880 + (остаток) = 1000000. - Присоедините новую базу с уменьшенным размером:
ATTACH 'tiny_rg.duckdb' (ROW_GROUP_SIZE 12288);, создайте в ней таблицу-копию на 1 000 000 строк и сравните число row groups с обычной базой. Во сколько раз их стало больше и почему?
Этот эксперимент показывает живьём связь «количество строк -> количество row groups» и то, как ROW_GROUP_SIZE меняет гранулярность.
Parquet row groups: та же концепция, другой контекст