Learning Platform
Глоссарий Troubleshooting
Урок 07.03 · 22 мин
Средний
storage-formatrow-groupsinternals

Row groups: 122 880 строк как единица хранения

В прошлом уроке мы увидели, что файл DuckDB разбит на блоки, а данные таблицы лежат в блоках как колоночные сегменты. Но между «таблицей» и «колоночным сегментом» есть ещё один уровень структуры — горизонтальная нарезка таблицы на куски строк. Эти куски называются row groups, и у них есть характерный размер: примерно 122 880 строк. Число выглядит произвольным, но оно выведено из устройства движка и связывает воедино storage-формат и векторизованное исполнение. Этот урок объясняет, откуда взялось 122 880 и зачем вообще резать таблицу по строкам.

Две оси нарезки таблицы

Таблицу можно резать в двух направлениях. Вертикально — по колонкам: каждая колонка хранится отдельным потоком значений. Это и есть колоночное хранение, фундамент аналитических СУБД. Горизонтально — по строкам: таблица делится на группы подряд идущих строк.

DuckDB режет в обоих направлениях. Сначала горизонтально — таблица делится на row groups, группы примерно по 122 880 строк. Затем внутри каждой row group — вертикально: каждая колонка этой группы хранится своим колоночным сегментом. Получается двумерная сетка: строки разбиты на горизонтальные полосы (row groups), внутри каждой полосы данные разложены по вертикальным колоночным сегментам.

Двумерная нарезка таблицы
Row group 0: строки 0 ... 122879Первая горизонтальная полоса таблицы. Внутри неё каждая колонка хранится отдельным колоночным сегментом со своей статистикой.
следующие 122 880 строк
Row group 1: строки 122880 ... 245759Вторая полоса. Полностью независима от первой: своё сжатие на каждый сегмент, своя min/max статистика.
и так далее
Row group N: хвост таблицыПоследняя полоса. Обычно неполная — содержит остаток строк, меньше 122 880.

Зачем вообще горизонтальная нарезка, если хранение колоночное? Три причины. Первая — параллелизм: 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 векторов — выверенная середина: группа достаточно крупная, чтобы метаданные на неё амортизировались, и достаточно мелкая, чтобы и пропуск данных, и параллелизм работали тонко.

TIP

Связка 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.

Таблица 500 000 строк -> 5 row groups
RG 0Полная группа: ровно 122 880 строк = 60 векторов по 2048.
RG 1Полная группа: 122 880 строк.
RG 2Полная группа: 122 880 строк.
RG 3Полная группа: 122 880 строк.
RG 4Неполная финальная группа: остаток 8480 строк. 4*122880 + 8480 = 500000.

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 на собственных таблицах.

  1. Создайте таблицу ровно в одну row group: CREATE TABLE small AS SELECT range AS n FROM range(122880); и CHECKPOINT. Проверьте через pragma_storage_info('small'): сколько различных row_group_id? Должна быть одна группа.
  2. Добавьте одну строку: INSERT INTO small VALUES (999999); и CHECKPOINT. Сколько row groups теперь? Одна 122 881-я строка вынуждает создать вторую, почти пустую группу.
  3. Создайте таблицу в 1 000 000 строк, сделайте CHECKPOINT. Сосчитайте число row groups через SELECT count(DISTINCT row_group_id) FROM pragma_storage_info('big');. Совпадает ли оно с ceil(1000000 / 122880)?
  4. Посмотрите размер последней (неполной) row group: сколько в ней строк? Проверьте, что (число полных групп) * 122880 + (остаток) = 1000000.
  5. Присоедините новую базу с уменьшенным размером: ATTACH 'tiny_rg.duckdb' (ROW_GROUP_SIZE 12288);, создайте в ней таблицу-копию на 1 000 000 строк и сравните число row groups с обычной базой. Во сколько раз их стало больше и почему?

Этот эксперимент показывает живьём связь «количество строк -> количество row groups» и то, как ROW_GROUP_SIZE меняет гранулярность.


Parquet row groups: та же концепция, другой контекст
Проверка знанийKnowledge check
Откуда берётся характерный размер row group в 122 880 строк и почему DuckDB вообще режет колоночную таблицу ещё и горизонтально, по строкам?
ОтветAnswer
Число 122 880 — это произведение 2048 на 60. 2048 это STANDARD_VECTOR_SIZE, размер вектора: векторизованный движок DuckDB обрабатывает данные батчами ровно по 2048 значений, и этот квант выбран так, чтобы батч помещался в кэши CPU и ложился на SIMD. Row group делается из целого числа векторов, чтобы его сканирование раскладывалось на N полных батчей без неудобного хвоста; DuckDB берёт 60 векторов на группу, отсюда 60*2048 = 122880. Число 60 — компромисс: слишком мелкая группа раздувает метаданные и даёт слишком мелкие куски работы, слишком крупная огрубляет пропуск данных и ослабляет параллелизм. Горизонтальная нарезка нужна, даже при колоночном хранении, по трём причинам: row group — естественная независимая единица параллельной работы для потоков; у каждой row group своя min/max статистика по каждой колонке, что позволяет целиком пропускать группы, не подходящие под фильтр; и обработка группа за группой даёт управление памятью, делая возможной работу с данными больше RAM. Все row groups кроме последней заполнены до 122 880, последняя содержит остаток.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Откуда берётся характерный размер row group в 122 880 строк?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7