Learning Platform
Глоссарий Troubleshooting
Урок 05.04 · 22 мин
Средний
type-systemlistmapnested-types

LIST и MAP: отличия и выбор

STRUCT из предыдущего урока хранит фиксированный набор именованных полей — запись известной формы. Но иерархичные данные бывают и другими. Иногда в одной ячейке нужно хранить не запись, а коллекцию однотипных значений переменной длины: теги статьи, цены в корзине, координаты маршрута. Иногда нужен словарь — набор пар ключ-значение, где сами ключи заранее неизвестны.

Для этих двух случаев у DuckDB есть LIST и MAP. Они похожи тем, что оба — коллекции переменного размера, и оба часто путают. Но устроены и предназначены они по-разному. В этом уроке разберём оба типа, их физическое хранение и — главное — однозначное правило, когда что выбирать.


LIST: упорядоченная коллекция одного типа

LIST — это упорядоченная последовательность значений одного типа, переменной длины. Аналог — массив или список в языке программирования. Тип записывается как тип_элемента[]:

-- Колонка tags — список строк, scores — список целых
CREATE TABLE articles (
    id   INTEGER,
    tags VARCHAR[],
    scores INTEGER[]
);

INSERT INTO articles VALUES
    (1, ['sql', 'duckdb', 'olap'], [5, 4, 5]),
    (2, ['python'],                [3]),
    (3, [],                        []);

Три определяющих свойства LIST. Первое — однородность: все элементы списка одного типа. VARCHAR[] — только строки, INTEGER[] — только целые. Список разнотипных значений невозможен. Второе — упорядоченность: у элементов есть позиции, tags[1] — первый, tags[2] — второй; порядок значим и сохраняется. Третье — переменная длина: один список может содержать три элемента, другой — один, третий — ноль; длина не фиксирована.

LIST подходит, когда в ячейке логически лежит «несколько однотипных значений, порядок которых имеет значение или хотя бы стабилен»: список тегов, последовательность измерений, набор идентификаторов.


Как LIST хранится: значения плюс смещения

Физическое устройство LIST элегантно. Наивно список можно было бы хранить так: каждая ячейка — отдельный кусок памяти переменного размера. Но это плохо для колоночного движка — куски разного размера, нет сплошного массива.

DuckDB хранит колонку LIST двумя массивами. Первый — child array: все элементы всех списков колонки, склеенные в один сплошной массив. Если в трёх строках лежат списки [a,b,c], [d], [], то child array — это [a,b,c,d], пять… нет, четыре значения подряд. Второй массив — offsets: для каждой строки он хранит, где в child array начинается её список и какой он длины.

LIST: один сплошной массив элементов плюс смещения
Строки: [a,b,c], [d], []Логически три ячейки, в каждой список своей длины: три элемента, один, ноль.
хранится как
child array: [a, b, c, d]Все элементы всех списков склеены в один сплошной массив — обычная колонка, к которой применимо колоночное сжатие.
offsets: (0,3), (3,1), (4,0)Для каждой строки — смещение начала и длина её списка в child array. Список строки 1 — это элементы с 0 по 3.

Преимущество такого устройства: child array — это обычная плотная колонка значений одного типа, к ней применимы все колоночные оптимизации и схемы сжатия. Список строки восстанавливается как срез child array по смещению и длине. Длинные списки и короткие хранятся одинаково эффективно — разница лишь в числах в offsets.

Над LIST работает богатый набор функций: len, list_contains, list_sort, list_transform, list_filter, агрегат list (собрать столбец в список). Достать элемент — индексацией tags[1] (нумерация с 1), достать подсписок — срезом tags[2:3] (это разбиралось в модуле про friendly SQL). Развернуть список в строки — функцией UNNEST:

-- UNNEST: одна строка со списком -> несколько строк
SELECT id, UNNEST(tags) AS tag FROM articles;
-- строка с tags=['sql','duckdb','olap'] даст 3 строки

MAP: словарь ключ-значение

MAP — это коллекция пар ключ-значение. Аналог — словарь, hash map, ассоциативный массив. Тип записывается как MAP(тип_ключа, тип_значения):

-- Колонка attributes — словарь строка -> целое
CREATE TABLE products (
    id   INTEGER,
    attributes MAP(VARCHAR, INTEGER)
);

INSERT INTO products VALUES
    (1, MAP {'weight': 500, 'width': 20}),
    (2, MAP {'weight': 300, 'height': 15, 'depth': 8});

Ключевое отличие MAP от STRUCT — набор ключей не фиксирован и может различаться от строки к строке. У продукта 1 ключи weight, width; у продукта 2 — weight, height, depth. Это невозможно для STRUCT, где схема одна на всю колонку. MAP нужен ровно тогда, когда множество ключей заранее неизвестно или варьируется: атрибуты товаров из разных категорий, произвольные пользовательские настройки, разреженные характеристики.

Доступ к значению MAP — по ключу, в квадратных скобках. Важно: результат всегда LIST значений (на случай повторяющихся ключей), даже если ключ один:

-- Доступ по ключу возвращает LIST
SELECT attributes['weight'] AS w FROM products;
-- w = [500] для продукта 1 — это список, не скаляр

-- Достать скаляр — взять первый элемент
SELECT attributes['weight'][1] AS w FROM products;

Над MAP работают функции map_keys (список ключей), map_values (список значений), map_contains (есть ли ключ), cardinality (число пар).

WARNING

Доступ к несуществующему ключу MAP возвращает не ошибку, а пустой список. attributes['nonexistent'] для строки без такого ключа даст []. Это значит, что attributes['nonexistent'][1] вернёт NULL, а не упадёт. Учитывайте это: «ключа нет» и «значение по ключу равно NULL» в MAP дают похожий результат при индексации первым элементом.


STRUCT, LIST, MAP: главное правило выбора

Три вложенных типа закрывают три разные формы данных. Путаница между ними — частая ошибка моделирования. Однозначное правило:

Форма данныхТипПризнак
Фиксированный набор именованных полей разных типовSTRUCTПоля известны заранее, схема одна на колонку
Коллекция однотипных значений, порядок значимLISTОдин тип, переменная длина, есть индексы
Пары ключ-значение, ключи варьируются по строкамMAPНабор ключей заранее неизвестен или разный

Развилка STRUCT против MAP — самая важная и самая частая. Оба похожи на «объект с полями», но: STRUCT — фиксированная схема, поля как часть типа, доступ через точку, поля могут быть разных типов. MAP — динамический набор ключей, ключи как данные, доступ по ключу-значению, все значения одного типа, все ключи одного типа.

STRUCT против MAP: фиксированная схема против динамических ключей
STRUCT(city VARCHAR, zip VARCHAR)Схема — часть типа. У всех значений колонки ровно эти поля. Поля могут быть разных типов. Доступ через точку.
когда ключи известны заранее
address.cityТочечный доступ. Имя поля известно на этапе компиляции запроса.
MAP(VARCHAR, INTEGER)Ключи — это данные, а не часть типа. У каждой строки свой набор ключей. Все ключи одного типа, все значения одного типа.
когда ключи варьируются
attributes['weight']Доступ по ключу-значению. Ключ — это выражение, может приходить из данных.

Практический ориентир: если вы можете перечислить все возможные «поля» при проектировании таблицы и они одинаковы для всех строк — это STRUCT. Если поля приходят из данных, их множество открыто или разное у разных строк — это MAP. Хранить в MAP то, что на самом деле фиксировано, — значит терять типизацию (STRUCT проверяет поля, MAP нет) и читаемость. Хранить в STRUCT то, что варьируется, — невозможно в принципе.

Вложенные типы свободно комбинируются: LIST от STRUCT (список заказов, каждый — структура), STRUCT с полем-LIST, MAP со значениями-STRUCT. Это позволяет смоделировать почти любую иерархию прямо в одной колонке.

TIP

Если данные совсем бесформенны — структура произвольна, типы значений не предсказуемы, — ни STRUCT, ни MAP не подходят: STRUCT требует схему, MAP требует единый тип ключей и единый тип значений. Для по-настоящему произвольных полуструктурированных данных в DuckDB 1.5 появился тип VARIANT — он разбирается в отдельном уроке этого модуля.


Попробуй сам

Создайте таблицы:

CREATE TABLE articles AS
  SELECT * FROM (VALUES
    (1, ['sql', 'duckdb', 'olap']),
    (2, ['python', 'sql']),
  ) t(id, tags);

CREATE TABLE products AS
  SELECT * FROM (VALUES
    (1, MAP {'weight': 500, 'width': 20}),
    (2, MAP {'weight': 300, 'height': 15}),
  ) t(id, attributes);

Задания:

  1. Через UNNEST разверните tags в отдельные строки. Посчитайте, сколько раз встречается каждый тег по всем статьям.
  2. Достаньте первый тег каждой статьи индексацией tags[1] и проверьте через list_contains, есть ли в списке тег 'sql'.
  3. Для каждого продукта выведите map_keys(attributes) и map_values(attributes). Обратите внимание, что наборы ключей у строк разные.
  4. Достаньте attributes['weight'] — посмотрите, что результат это LIST, а не скаляр. Затем достаньте скаляр через attributes['weight'][1].
  5. Запросите несуществующий ключ attributes['nonexistent'] и attributes['nonexistent'][1]. Объясните, почему первое даёт пустой список, а второе NULL.
Массивы PostgreSQL: хранение и операции с коллекциями Nested Arrays в ClickHouse: схожий подход к хранению вложенных данных
Проверка знанийKnowledge check
Чем LIST отличается от MAP по устройству и назначению, и как выбрать между STRUCT и MAP?
ОтветAnswer
LIST — это упорядоченная коллекция значений одного типа переменной длины: все элементы однотипны (VARCHAR[] — только строки), у них есть позиции (tags[1] — первый), длина не фиксирована. Физически колонка LIST хранится двумя массивами: child array — все элементы всех списков, склеенные в один сплошной массив (обычная плотная колонка, к которой применимо колоночное сжатие), и offsets — смещение начала и длина списка каждой строки. MAP — это коллекция пар ключ-значение, словарь; все ключи одного типа, все значения одного типа, но набор ключей не фиксирован и может различаться от строки к строке. Доступ к MAP по ключу всегда возвращает LIST значений (на случай повторов), а несуществующий ключ даёт пустой список, а не ошибку. LIST применяют, когда в ячейке несколько однотипных значений с осмысленным порядком (теги, измерения); MAP — когда нужны пары ключ-значение с заранее неизвестным набором ключей. Развилка STRUCT против MAP: STRUCT имеет фиксированную схему — имена и типы полей заданы в типе, одинаковы для всех строк, поля могут быть разных типов, доступ через точку. MAP имеет динамический набор ключей — ключи это данные, а не часть типа, у каждой строки свой набор, доступ по ключу. Правило выбора: если все 'поля' можно перечислить при проектировании и они одинаковы для всех строк — STRUCT (он ещё и проверяет типизацию); если поля приходят из данных и их множество открыто или разное у строк — MAP. Хранить фиксированное в MAP — терять типизацию, хранить варьирующееся в STRUCT — невозможно.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Как DuckDB физически хранит колонку типа LIST?

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

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

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

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