Вложенный тип STRUCT: устройство и доступ
Реляционная модель в чистом виде — это плоские таблицы: строка, набор скалярных колонок. Но реальные данные часто иерархичны. У заказа есть адрес доставки с городом и индексом; у события — вложенный объект с метаданными. Классический реляционный ответ — разнести иерархию по отдельным таблицам и собирать джойнами. Это работает, но не всегда удобно, и не отражает структуру данных, которая пришла, скажем, из JSON-API.
DuckDB поддерживает вложенные типы — STRUCT, LIST, MAP, UNION — которые позволяют хранить иерархию прямо в колонке.
Trino: сложные типы ARRAY, MAP, ROW, JSON STRUCT — самый базовый из них: запись с фиксированным набором именованных полей. В этом уроке разберём, как STRUCT устроен внутри, почему его колоночное хранение не убивает производительность, и как обращаться к полям.
Что такое STRUCT
STRUCT — это значение, состоящее из именованных полей, каждое со своим типом. Аналог — структура в C, запись, объект с фиксированным набором атрибутов. Тип STRUCT объявляется перечислением имён полей и их типов:
-- Колонка address — это STRUCT с тремя именованными полями
CREATE TABLE customers (
id INTEGER,
name VARCHAR,
address STRUCT(city VARCHAR, zip VARCHAR, country VARCHAR)
);
-- Литерал STRUCT: фигурные скобки, поля как ключ-значение
INSERT INTO customers VALUES
(1, 'Анна', {'city': 'Москва', 'zip': '101000', 'country': 'RU'}),
(2, 'Петр', {'city': 'Казань', 'zip': '420000', 'country': 'RU'});
Ключевая характеристика STRUCT — фиксированная схема. Имена полей и их типы заданы в определении типа и одинаковы для всех значений колонки. У всех address ровно три поля city, zip, country именно этих типов. Этим STRUCT отличается от MAP (где у каждого значения свой набор ключей) и от JSON (где структура вообще произвольна). STRUCT — это типизированная запись с известной заранее формой.
Как STRUCT хранится: колонки внутри колонки
Вот самое важное для понимания производительности. Наивно можно представить, что колонка STRUCT хранится построчно — каждая запись {city, zip, country} лежит единым куском, записи идут друг за другом. Это было бы строчное хранение, и оно убило бы колоночные преимущества DuckDB.
DuckDB хранит STRUCT иначе. Колонка STRUCT физически разбита на отдельные под-колонки — по одной на каждое поле. Все значения city лежат сплошным массивом, отдельно от него все значения zip сплошным массивом, отдельно — все country. То есть STRUCT(city, zip, country) хранится как три независимые колонки, объединённые логически.
Из такого устройства следуют два важных эффекта.
Первый — projection pushdown работает внутрь STRUCT. Если запрос обращается только к address.city, DuckDB читает с диска только под-колонку city и полностью пропускает zip и country. Чтение поля STRUCT стоит ровно столько, сколько чтение одной колонки — а не «прочитать весь STRUCT и достать поле». Вложенность не штрафует за неиспользуемые поля.
Второй — каждое поле сжимается независимо своей оптимальной схемой. Под-колонка country с повторяющимися значениями 'RU' сожмётся словарным кодированием или RLE; под-колонка zip — схемой для строк. Если бы STRUCT хранился построчно, всю запись пришлось бы сжимать одной схемой, и она была бы хуже для каждого поля.
Колоночное хранение вложенных типов — фундаментальное свойство аналитических движков. Тот же принцип лежит в основе формата Parquet: вложенные структуры там тоже «расшиваются» (shredding) на отдельные колонки-листья. Поэтому DuckDB и Parquet прекрасно стыкуются: вложенный STRUCT из DuckDB ложится в Parquet без потери колоночности.
Доступ к полям через точку
Главный способ достать поле STRUCT — точечный оператор, как доступ к атрибуту объекта:
-- Достать отдельные поля STRUCT
SELECT
name,
address.city AS city,
address.country AS country
FROM customers;
address.city обращается к полю city структуры address. Под капотом это вызов функции struct_extract(address, 'city'), но точечная форма читаемее. Поскольку обращение к полю — это чтение отдельной под-колонки, такой SELECT читает с диска только нужные под-колонки.
STRUCT можно вкладывать друг в друга, и точечный доступ работает на любую глубину:
-- STRUCT внутри STRUCT
-- event STRUCT(type VARCHAR, payload STRUCT(user_id INT, source VARCHAR))
SELECT
event.type,
event.payload.user_id, -- спуск на два уровня
event.payload.source
FROM events;
event.payload.user_id спускается через два уровня вложенности. Каждый уровень — это ещё одно разбиение на под-колонки, и user_id в итоге — это отдельный сплошной массив где-то в глубине физической структуры. Чтение event.payload.user_id всё равно стоит как чтение одной колонки.
Фильтровать и группировать по полям STRUCT можно так же, как по обычным колонкам:
-- WHERE и GROUP BY по полям STRUCT
SELECT address.country, count(*) AS n
FROM customers
WHERE address.city = 'Москва'
GROUP BY address.country;
Это прямое следствие колоночного хранения. Фильтр WHERE address.city = 'Москва' для движка ничем не отличается от фильтра по обычной колонке city: под-колонка city существует физически, она плотный массив, и DuckDB сканирует именно её. Более того, на под-колонку STRUCT распространяются те же оптимизации, что и на обычные колонки, — в частности, zonemap-статистика (min/max на блок данных), по которой движок при фильтрации пропускает блоки, заведомо не содержащие искомое значение. Поле STRUCT — это полноценная колонка во всём, кроме того, что логически оно сгруппировано с соседними полями под общим именем.
Стоит отметить и обратную сторону. Поскольку доступ к полю — это всегда address.city (точка плюс имя), имя поля должно быть известно при написании запроса, на этапе компиляции. Нельзя «вычислить имя поля динамически» и подставить его — для STRUCT это невозможно, и это правильно: фиксированная схема STRUCT именно для того и нужна, чтобы поля были статически известны и типобезопасны. Если требуется именно динамический доступ — «достань поле, чьё имя лежит в переменной» — это сигнал, что нужен не STRUCT, а MAP, где ключи являются данными и доступ по ключу-выражению — норма. Эта развилка детально разбирается в следующем уроке про LIST и MAP.
Создание и преобразование STRUCT
Собрать STRUCT из обычных колонок можно несколькими способами. Литеральная форма с фигурными скобками уже показана. Есть функции struct_pack (поля передаются по имени) и row (поля передаются позиционно):
-- Собрать STRUCT из колонок
SELECT struct_pack(city := city_col, zip := zip_col) AS addr
FROM raw_addresses;
-- Та же сборка с фигурными скобками
SELECT {'city': city_col, 'zip': zip_col} AS addr
FROM raw_addresses;
Полезный приём — STRUCT от *: выражение tbl.* можно упаковать в один STRUCT, содержащий все колонки строки. Это удобно, когда нужно передать «всю строку» как единое значение, например в качестве элемента списка.
STRUCT особенно полезен на стыке с полуструктурированными источниками. Когда вы читаете JSON-файл, объект естественно отображается в STRUCT: ключи объекта становятся полями. Это даёт типизированный, колоночно хранимый, быстро сканируемый аналог JSON — с тем отличием, что схема STRUCT фиксирована и проверяется, тогда как JSON бесформен.
Когда STRUCT, а когда отдельные плоские колонки? Если поля концептуально образуют одну сущность (адрес: город + индекс + страна) и почти всегда используются вместе или приходят вместе из источника — STRUCT логично группирует их и отражает структуру данных. Если поля независимы и используются порознь — обычные плоские колонки проще. На производительность чтения выбор почти не влияет: и STRUCT-поле, и плоская колонка читаются как отдельная под-колонка.
STRUCT и плоские колонки: что выбрать
Поскольку STRUCT хранится по-колоночно, разница между «STRUCT из трёх полей» и «три плоские колонки» — в основном про моделирование, а не про скорость:
| Аспект | STRUCT | Три плоские колонки |
|---|---|---|
| Физическое хранение | Три под-колонки | Три колонки |
| Скорость чтения одного поля | Как одна колонка | Как одна колонка |
| Группировка иерархии | Поля логически связаны | Связь только в голове разработчика |
| Соответствие JSON-источнику | Прямое (объект -> STRUCT) | Требует разворачивания |
| Добавление поля | Меняет тип колонки | Добавляет колонку |
Есть один случай, где разница между STRUCT и плоскими колонками всё же проявляется, — эволюция схемы. Добавить новое поле в STRUCT означает изменить тип колонки целиком: STRUCT(city, zip) превращается в STRUCT(city, zip, country) — это операция над определением колонки. Добавить отдельную плоскую колонку — более локальное изменение. На практике это редко критично, но если набор «полей» сущности активно и непредсказуемо растёт, стоит задуматься: либо это плоские колонки, либо, если набор не просто растёт, а вообще не фиксирован, — это уже не STRUCT, а MAP. STRUCT оправдан именно для устойчивой, заранее известной структуры.
Главный вывод урока: вложенность в DuckDB — это удобство моделирования, а не компромисс по производительности. STRUCT хранится как набор обычных колонок, projection pushdown работает внутрь него, каждое поле сжимается оптимально. Вы получаете иерархию данных, не платя за неё скоростью сканирования.
Попробуй сам
Создайте таблицу:
CREATE TABLE events AS
SELECT * FROM (VALUES
(1, {'type': 'click', 'payload': {'user_id': 10, 'source': 'web'}}),
(2, {'type': 'view', 'payload': {'user_id': 20, 'source': 'app'}}),
(3, {'type': 'click', 'payload': {'user_id': 10, 'source': 'app'}}),
) t(id, event);
Задания:
- Через точечный доступ выведите
event.typeиevent.payload.user_idдля всех строк. - Отфильтруйте строки, где
event.type = 'click', и сгруппируйте их поevent.payload.sourceсо счётчиком. - Объясните своими словами, почему запрос, читающий только
event.type, не тратит время на чтениеevent.payload. - Соберите новый
STRUCTиз литералов через фигурные скобки и черезstruct_pack. Убедитесь, что результаты идентичны. - Выполните
SELECT event FROM events— посмотрите, как DuckDB отображает целикомSTRUCT-значение. Сравните сSELECT event.payload FROM events.