Перейти к основному содержимому
Перейти к основному содержимому

Map(K, V)

Тип данных Map(K, V) хранит пары «ключ–значение».

В отличие от других баз данных, в ClickHouse элементы типа Map не обязаны быть уникальными, то есть Map может содержать два элемента с одинаковым ключом. (Причина в том, что Map внутренне реализован как Array(Tuple(K, V)).)

Вы можете использовать синтаксис m[k], чтобы получить значение для ключа k в Map m. Также операция m[k] последовательно сканирует Map, то есть время выполнения линейно зависит от размера Map.

Параметры

  • K — тип ключей Map. Произвольный тип, за исключением Nullable и LowCardinality, совмещённых с типами Nullable.
  • V — тип значений Map. Произвольный тип.

Примеры

Создайте таблицу со столбцом типа Map:

CREATE TABLE tab (m Map(String, UInt64)) ENGINE=Memory;
INSERT INTO tab VALUES ({'key1':1, 'key2':10}), ({'key1':2,'key2':20}), ({'key1':3,'key2':30});

Чтобы выбрать значения по ключу key2:

SELECT m['key2'] FROM tab;

Результат:

┌─arrayElement(m, 'key2')─┐
│                      10 │
│                      20 │
│                      30 │
└─────────────────────────┘

Если запрошенный ключ k отсутствует в Map, m[k] возвращает значение по умолчанию для типа значения, например 0 для целочисленных типов и '' для строковых типов. Чтобы проверить, существует ли ключ в Map, можно использовать функцию mapContains.

CREATE TABLE tab (m Map(String, UInt64)) ENGINE=Memory;
INSERT INTO tab VALUES ({'key1':100}), ({});
SELECT m['key1'] FROM tab;

Результат:

┌─arrayElement(m, 'key1')─┐
│                     100 │
│                       0 │
└─────────────────────────┘

Преобразование Tuple в Map

Значения типа Tuple() можно привести к значениям типа Map() с помощью функции CAST:

Пример

Запрос:

SELECT CAST(([1, 2, 3], ['Ready', 'Steady', 'Go']), 'Map(UInt8, String)') AS map;

Результат:

┌─map───────────────────────────┐
│ {1:'Ready',2:'Steady',3:'Go'} │
└───────────────────────────────┘

Чтение подстолбцов Map

Чтобы избежать чтения всего столбца Map, в некоторых случаях можно использовать подстолбцы keys и values.

Пример

Запрос:

CREATE TABLE tab (m Map(String, UInt64)) ENGINE = Memory;
INSERT INTO tab VALUES (map('key1', 1, 'key2', 2, 'key3', 3));

SELECT m.keys FROM tab; --   same as mapKeys(m)
SELECT m.values FROM tab; -- same as mapValues(m)

Результат:

┌─m.keys─────────────────┐
│ ['key1','key2','key3'] │
└────────────────────────┘

┌─m.values─┐
│ [1,2,3]  │
└──────────┘

Сериализация по бакетам Map в MergeTree

По умолчанию столбец Map в MergeTree хранится как единый поток Array(Tuple(K, V)). Чтение одного ключа через m['key'] требует сканирования всего столбца — всех пар «ключ-значение» для каждой строки — даже если нужен только один ключ. Для map с большим числом различных ключей это становится узким местом.

Сериализация по бакетам (with_buckets) разбивает пары «ключ-значение» на несколько независимых подпотоков (бакетов) по хешу ключа. Когда запрос обращается к m['key'], с диска считывается только бакет, содержащий этот ключ, а все остальные бакеты пропускаются.

Включение сериализации по бакетам

CREATE TABLE tab (id UInt64, m Map(String, UInt64))
ENGINE = MergeTree ORDER BY id
SETTINGS
    map_serialization_version = 'with_buckets',
    max_buckets_in_map = 32,
    map_buckets_strategy = 'sqrt';

Чтобы не замедлять вставки, вы можете оставить сериализацию basic для частей нулевого уровня (создаваемых во время INSERT) и использовать with_buckets только для частей после слияния:

CREATE TABLE tab (id UInt64, m Map(String, UInt64))
ENGINE = MergeTree ORDER BY id
SETTINGS
    map_serialization_version = 'with_buckets',
    map_serialization_version_for_zero_level_parts = 'basic',
    max_buckets_in_map = 32,
    map_buckets_strategy = 'sqrt';

Как это работает

Когда часть данных записывается с сериализацией with_buckets:

  1. Среднее количество ключей в строке вычисляется по статистике блока.
  2. Количество бакетов определяется настроенной стратегией (см. Settings).
  3. Каждая пара «ключ-значение» распределяется по бакету с помощью хеширования ключа: bucket = hash(key) % num_buckets.
  4. Каждый бакет хранится как независимый подпоток со своими ключами, значениями и смещениями.
  5. Поток метаданных buckets_info содержит количество бакетов и статистику.

Когда запрос читает специфичный ключ (m['key']), оптимизатор переписывает выражение в подстолбец ключа (m.key_<serialized_key>). Слой сериализации вычисляет, к какому бакету относится запрошенный ключ, и читает с диска только этот бакет.

Когда читается весь map (например, SELECT m), все бакеты считываются и собираются обратно в исходный map. Это медленнее, чем сериализация basic, из-за накладных расходов на чтение и слияние нескольких подпотоков.

Количество бакетов может различаться между частями. Когда сливаются части с разным количеством бакетов, количество бакетов в новой части пересчитывается по объединённой статистике. Части с сериализацией basic и with_buckets могут сосуществовать в одной таблице и объединяться прозрачно.

Настройки

НастройкаПо умолчаниюОписание
map_serialization_versionbasicФормат сериализации для столбцов Map. basic хранит данные как один поток массива. with_buckets разбивает ключи по бакетам для более быстрого чтения по отдельным ключам.
map_serialization_version_for_zero_level_partsbasicФормат сериализации для частей нулевого уровня (создаваемых командой INSERT). Позволяет использовать basic для вставок, чтобы избежать дополнительных накладных расходов на запись, тогда как части после слияния используют with_buckets.
max_buckets_in_map32Верхняя граница количества бакетов. Фактическое количество зависит от map_buckets_strategy. Максимально допустимое значение — 256.
map_buckets_strategysqrtСтратегия вычисления количества бакетов на основе среднего размера map: constant — всегда использовать max_buckets_in_map; sqrt — использовать round(coefficient * sqrt(avg_size)); linear — использовать round(coefficient * avg_size). Результат ограничивается диапазоном [1, max_buckets_in_map].
map_buckets_coefficient1.0Множитель для стратегий sqrt и linear. Игнорируется, если используется стратегия constant.
map_buckets_min_avg_size32Минимальное среднее количество ключей на строку для включения разбиения по бакетам. Если среднее значение ниже этого порога, независимо от других настроек используется один бакет. Установите 0, чтобы отключить этот порог.

Компромиссы по производительности

В таблице ниже кратко показано, как with_buckets влияет на производительность по сравнению с сериализацией basic при разных размерах map (от 10 до 10 000 ключей на строку). Количество бакетов определялось по стратегии sqrt с ограничением в 32. Точные значения зависят от типов ключей и значений, распределения данных и оборудования.

Операция10 ключей100 ключей1 000 ключей10 000 ключейПримечания
Поиск по одному ключу (m['key'])в 1.6–3.2 раза быстреев 4.5–7.7 раза быстреев 16–39 раз быстреев 21–49 раз быстрееСчитывается только один бакет, а не весь столбец.
Поиск по 5 ключам~1xв 1.5–3.1 раза быстреев 2.9–8.3 раза быстреев 4.5–6.7 раза быстрееДля каждого ключа считывается свой бакет; некоторые бакеты могут пересекаться.
PREWHERE (SELECT m WHERE m['key'] = ...)в 1.5–3.0 раза быстреев 2.9–7.3 раза быстреев 5.3–31 раз быстреев 20–45 раз быстрееФильтр PREWHERE считывает только один бакет; map целиком читается только для совпавших строк. Ускорение зависит от селективности: чем меньше совпавших гранул, тем меньше I/O при полном чтении map.
Полное сканирование map (SELECT m)примерно в 2 раза медленнеепримерно в 2 раза медленнеепримерно в 2 раза медленнеепримерно в 2 раза медленнееНужно считать и заново собрать все бакеты.
INSERTв 1.5–2.5 раза медленнеев 1.5–2.5 раза медленнеев 1.5–2.5 раза медленнеев 1.5–2.5 раза медленнееДополнительные затраты на хеширование ключей и запись в несколько подпотоков.

Рекомендации

  • Небольшие map (в среднем < 32 ключей): Оставьте сериализацию basic. Для небольших map накладные расходы на разбиение по бакетам не оправданы. Значение по умолчанию map_buckets_min_avg_size = 32 обеспечивает это автоматически.
  • Средние map (32–100 ключей): Используйте with_buckets со стратегией sqrt, если запросы часто обращаются к отдельным ключам. Для поиска по одному ключу это дает ускорение в 4–8 раз.
  • Большие map (100+ ключей): Используйте with_buckets. Поиск по одному ключу выполняется в 16–49 раз быстрее. Рассмотрите map_serialization_version_for_zero_level_parts = 'basic', чтобы сохранить скорость вставки близкой к базовому уровню.
  • Если в нагрузке преобладает полное сканирование map: Оставьте basic. Сериализация по бакетам добавляет при полном сканировании примерно 2x накладных расходов.
  • Смешанная нагрузка (часть запросов — поиск по ключам, часть — полное сканирование): Используйте with_buckets, а для частей нулевого уровня задайте basic. Оптимизация PREWHERE считывает только бакет, относящийся к фильтру, а затем считывает полную map только для совпадающих строк, что в итоге дает заметное ускорение.

Альтернативные подходы

Если сериализация Map по бакетам не подходит для вашего варианта использования, есть два альтернативных подхода для повышения производительности доступа к отдельным ключам:

Использование типа данных JSON

Тип данных JSON хранит каждый часто используемый путь как отдельный динамический подстолбец. Пути, превышающие лимит max_dynamic_paths, помещаются в общую структуру данных, где для оптимизации чтения отдельных путей может использоваться сериализация advanced. Подробное описание сериализации advanced см. в статье блога.

AspectMap с бакетамиJSON
Чтение одного ключаСчитывается один бакет (который может содержать и другие ключи). Все пары ключ-значение в бакете десериализуются.Часто используемые пути считываются напрямую из динамических подстолбцов. Редко используемые пути попадают в общие данные; при сериализации advanced считываются только данные нужного пути.
Типы значенийВсе значения имеют один и тот же тип VУ каждого пути может быть свой тип. Для путей без подсказки типа используется Dynamic.
Поддержка skip indexРаботает с некоторыми типами индексов, созданных на mapKeys/mapValuesSkip indexes можно создавать только для подстолбцов конкретных путей, но не сразу для всех путей/значений.
Чтение всего столбца~2x медленнее, чем basic, из-за повторной сборки бакетовНакладные расходы из-за кодирования типа Dynamic и восстановления путей.
Накладные расходы на хранениеМинимальные дополнительные метаданныеВыше из-за кодирования типа Dynamic, хранения имён путей и дополнительных метаданных в сериализации advanced.
Гибкость схемыФиксированные типы ключей и значений при создании таблицыПолностью динамическая — ключи и типы значений могут различаться от строки к строке. Для известных путей можно заранее объявить типизированные пути для прямого доступа к подстолбцам.

Используйте JSON, когда для разных ключей требуются разные типы значений, когда набор ключей существенно различается между строками или когда часто используемые ключи известны заранее и могут быть объявлены как типизированные пути для прямого доступа к подстолбцам.

Ручное сегментирование одного Map по нескольким столбцам

Вы можете вручную разбить один Map на несколько столбцов по хешу ключа на уровне приложения:

CREATE TABLE tab (
    id UInt64,
    m0 Map(String, UInt64),
    m1 Map(String, UInt64),
    m2 Map(String, UInt64),
    m3 Map(String, UInt64)
) ENGINE = MergeTree ORDER BY id;

При вставке направляйте каждую пару «ключ-значение» в столбец m{hash(key) % 4}. При выполнении запросов читайте из нужного столбца: m{hash('target_key') % 4}['target_key'].

АспектMap с бакетамиРучное сегментирование
Удобство использованияПрозрачно — обрабатывается движком храненияТребуется логика маршрутизации на уровне приложения для операций INSERT и SELECT
Вертикальное слияниеНе поддерживается — все бакеты относятся к одному столбцуПоддерживается — каждый столбец Map является независимым столбцом и может сливаться вертикально
Изменение схемыКоличество бакетов автоматически подстраивается для каждой частиИзменение количества сегментов требует перезаписи данных или добавления новых столбцов
Синтаксис запросаm['key'] работает напрямуюНужно вычислить нужный столбец: m0['key'], m1['key'] и т. д.
Гранулярность бакетовНа уровне части, подстраивается под статистику данныхФиксируется при создании таблицы

Ручное сегментирование полезно, если вертикальные слияния важны для снижения потребления памяти при слиянии таблиц с большим количеством столбцов, или если количество сегментов должно быть фиксированным и явно контролироваться. Для большинства сценариев использования автоматическая сериализация по бакетам проще и вполне достаточна.

См. также