Партиционированная запись: COPY … PARTITION_BY и Hive-layout
В прошлом уроке COPY писала результат в один файл. Для умеренных объёмов этого достаточно. Но когда данных много и их будут многократно читать с фильтрами, один монолитный файл — не лучшая раскладка. Лучше разложить данные на диске по партициям, так чтобы запрос с фильтром мог пропустить целые куски, даже не открывая их. Этот урок — про COPY ... PARTITION_BY и Hive-layout.
Партиционированная запись — это не «ещё одна опция COPY», а способ заранее организовать данные под будущие чтения. Понимание этой темы напрямую связывает запись данных с производительностью запросов.
Проблема монолитного файла
Представьте таблицу events за три года — сотни миллионов строк, один Parquet-файл. Типичный запрос смотрит один месяц: WHERE event_date BETWEEN '2026-03-01' AND '2026-03-31'.
Что делает движок? Открывает единственный файл и сканирует его. Filter pushdown и zonemap-статистика Parquet помогут пропустить часть row group — но только если данные внутри файла удачно упорядочены по дате. Если строки за март разбросаны по всему файлу, пропустить почти нечего: придётся прочитать большую часть файла, чтобы выловить мартовские строки.
Корень проблемы: физическая раскладка данных не отражает то, как их фильтруют. Запросы режут по дате, а файл по дате никак не структурирован.
Идея партиционирования: раскладка под фильтр
Партиционирование решает это так: данные физически раскладываются по отдельным файлам/каталогам по значению некой колонки — колонки партиционирования. Все строки за март лежат в одном месте, все за апрель — в другом, и так далее.
Теперь запрос WHERE event_date в марте может полностью пропустить каталоги всех остальных месяцев — даже не открывая эти файлы. Читается только то, что относится к нужной партиции. Это называется partition pruning (отсечение партиций).
COPY … PARTITION_BY
DuckDB пишет партиционированные данные опцией PARTITION_BY:
COPY events
TO 'events_dataset' -- путь к каталогу набора данных
(FORMAT parquet, PARTITION_BY (year, month));
Здесь events_dataset — это не файл, а каталог. DuckDB создаст внутри него структуру подкаталогов по уникальным комбинациям year и month, и в каждый положит Parquet-файлы со строками соответствующей партиции.
Можно партиционировать по результату выражения — например, выделить год и месяц из даты прямо в COPY:
COPY (
SELECT *,
year(event_date) AS year,
month(event_date) AS month
FROM events
)
TO 'events_dataset'
(FORMAT parquet, PARTITION_BY (year, month));
Выбор колонки партиционирования критичен. Хорошая колонка — та, по которой часто фильтруют, и с умеренным числом уникальных значений (год, месяц, регион, категория). Плохой выбор — колонка высокой кардинальности (user_id, timestamp с точностью до секунды): получатся миллионы крошечных файлов, и это убьёт производительность сильнее, чем монолит. Правило: партиций должно быть достаточно для отсечения, но не настолько много, чтобы каждая стала микрофайлом.
Hive-layout: соглашение об именах каталогов
DuckDB именует партиционные каталоги по соглашению Hive partitioning — стандарту, пришедшему из экосистемы Apache Hive и понятному множеству инструментов (Spark, Presto/Trino, Arrow, pandas).
Суть проста: каталог называется имя_колонки=значение. Для PARTITION_BY (year, month) структура на диске выглядит так:
events_dataset/
├── year=2024/
│ ├── month=1/
│ │ └── data_0.parquet
│ ├── month=2/
│ │ └── data_0.parquet
│ └── ...
├── year=2025/
│ ├── month=1/
│ │ └── data_0.parquet
│ └── ...
└── year=2026/
└── ...
Ключевая выгода Hive-layout: значение партиции закодировано прямо в пути. Каталог year=2026/month=3 сам говорит, что внутри — данные за март 2026. Это даёт две вещи:
- Колонки партиционирования не дублируются в файлах.
yearиmonthне хранятся в Parquet-файлах — они и так в пути. Экономия места. - Движок выводит значение партиции из пути. Читая
year=2026/month=3/data_0.parquet, DuckDB знает: все строки здесь имеютyear=2026, month=3, и подставляет эти колонки автоматически.
Чтение партиционированных данных и pruning
Записанный с PARTITION_BY набор читается так же просто, как обычный файл — через glob по каталогу:
-- читаем весь партиционированный набор
SELECT count(*) FROM 'events_dataset/**/*.parquet';
-- DuckDB распознаёт Hive-структуру: year и month доступны как колонки,
-- хотя в файлах их нет
SELECT year, month, count(*)
FROM 'events_dataset/**/*.parquet'
GROUP BY year, month;
Главное происходит при фильтре по колонке партиционирования:
SELECT count(*)
FROM 'events_dataset/**/*.parquet'
WHERE year = 2026 AND month = 3;
-- DuckDB прочитает ТОЛЬКО каталог year=2026/month=3,
-- остальные партиции пропущены целиком — partition pruning
DuckDB видит из путей, какие партиции существуют, сопоставляет фильтр year = 2026 AND month = 3 с именами каталогов и открывает только подходящий. Все файлы других месяцев и лет даже не читаются. На наборе за три года запрос одного месяца коснётся примерно 1/36 данных вместо всего объёма.
Это можно увидеть в плане:
EXPLAIN
SELECT count(*) FROM 'events_dataset/**/*.parquet'
WHERE year = 2026 AND month = 3;
В выводе PARQUET_SCAN (или READ_PARQUET) покажет, что в чтение попал лишь ограниченный набор файлов — следов всех 36 партиций там не будет.
Partition pruning срабатывает, только если фильтр идёт по колонке партиционирования. Запрос WHERE year = 2026 отсекает партиции; запрос WHERE amount > 1000 (amount не колонка партиционирования) отсечь партиции не может — придётся читать все, и внутри них работать уже filter pushdown по zonemap. Поэтому партиционировать имеет смысл именно по тем колонкам, по которым фильтруют чаще всего.
Партиционирование как часть дизайна данных
Партиционированная запись — это решение, которое принимают на этапе проектирования датасета, заранее зная профиль будущих запросов. Несколько практических ориентиров.
Партиционируйте по колонкам частых фильтров. Дата (год/месяц), регион, категория, тип события — типичные удачные кандидаты. Профиль запросов первичен: смотрите, по чему режут чаще всего.
Следите за размером партиций. Идеал — партиции, дающие файлы разумного размера (условно — от десятков до сотен мегабайт каждый). Слишком крупная партиция теряет смысл отсечения; слишком мелкая порождает проблему «множества крошечных файлов», когда накладные расходы на открытие каждого файла перевешивают выгоду.
Не партиционируйте по высококардинальным колонкам. user_id, точный timestamp, order_id — по ним получится столько партиций, что каждая станет микрофайлом. Это антипаттерн.
Несколько уровней — для иерархичных фильтров. PARTITION_BY (year, month) оправдан, когда фильтруют и по году, и по году+месяцу. Лишние уровни без нужды только дробят данные.
| Колонка партиционирования | Удачно? | Почему |
|---|---|---|
| year, month | да | частый фильтр, умеренная кардинальность |
| region, country | да | частый фильтр, мало уникальных значений |
| event_type, category | обычно да | если по ним фильтруют и значений немного |
| user_id | нет | высокая кардинальность -> миллионы микрофайлов |
| точный timestamp | нет | почти уникальна -> партиция на строку |
Партиционирование и дозапись
Партиционированный набор — не «слепок раз и навсегда». В него можно дописывать новые данные, и Hive-layout делает это естественным.
Когда приходит новая порция данных — скажем, события за свежий месяц, — повторный COPY ... PARTITION_BY с этими данными добавит новые каталоги партиций (year=2026/month=6/...), не трогая существующие. Каждая партиция — это отдельный набор файлов в своём каталоге, поэтому добавление новой партиции не требует переписывать старые. Это удобно для инкрементальных пайплайнов: каждый запуск дописывает партицию за новый период.
Тонкость касается дозаписи в уже существующую партицию. Если новые данные относятся к месяцу, для которого каталог уже есть, в этот каталог просто добавятся новые файлы рядом со старыми. Партиция при этом может накопить много файлов — и здесь возникает та же проблема мелких файлов, но уже внутри одной партиции. Лечится она периодической перезаписью партиции: прочитать всё её содержимое и записать обратно в меньшее число файлов покрупнее (операция compaction).
Партиционированная запись особенно хорошо сочетается с временными данными: партиция на период (день, месяц) — и каждый инкрементальный запуск пайплайна дописывает свою партицию, не трогая историю. Старые партиции при этом легко и удалять — это просто удаление каталога. Управление жизненным циклом данных «по периодам» становится операцией над каталогами файловой системы.
Попробуй сам
- Создай таблицу с датами за несколько лет:
CREATE TABLE events AS SELECT range AS id, DATE '2024-01-01' + INTERVAL (range % 900) DAY AS d, random()*100 AS amount FROM range(2000000); - Добавь колонки партиционирования и запиши партиционированный набор:
COPY (SELECT *, year(d) AS y, month(d) AS m FROM events) TO 'ev_ds' (FORMAT parquet, PARTITION_BY (y, m)); - Посмотри структуру каталога
ev_dsв файловой системе. Видишь ли подкаталоги видаy=2024/m=1/? Это Hive-layout. - Прочитай набор и проверь, что
yиmдоступны как колонки:SELECT y, m, count(*) FROM 'ev_ds/**/*.parquet' GROUP BY y, m;Хотя в файлах этих колонок нет. - Выполни
EXPLAINдляSELECT count(*) FROM 'ev_ds/**/*.parquet' WHERE y = 2025 AND m = 6;и дляSELECT count(*) FROM 'ev_ds/**/*.parquet' WHERE amount > 50;Сравни, сколько файлов попадает в чтение в каждом случае. Объясни разницу через partition pruning.