Перейти к содержанию
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 из 7