Тип данных 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] возвращает default value для типа значения, например 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; -- то же самое, что mapKeys(m)
SELECT m.values FROM tab; -- то же самое, что 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'], с диска читается только бакет, содержащий этот ключ, а все остальные бакеты пропускаются.
Включение сериализации Map по бакетам
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:
- Среднее количество ключей на строку вычисляется по статистике блока.
- Количество бакетов определяется настроенной стратегией (см. Настройки).
- Каждая пара ключ-значение назначается бакету путём хеширования ключа:
bucket = hash(key) % num_buckets. - Каждый бакет сохраняется как независимый подпоток со своими ключами, значениями и смещениями.
- Поток метаданных
buckets_infoхранит количество бакетов и статистику.
Когда запрос читает конкретный ключ (m['key']), оптимизатор преобразует выражение в подстолбец ключа (m.key_<serialized_key>).
Уровень сериализации вычисляет, к какому бакету относится запрошенный ключ, и читает с диска только этот бакет.
Когда читается весь Map (например, SELECT m), считываются все бакеты и заново собираются в исходный Map. Это медленнее, чем сериализация basic, из-за накладных расходов на чтение и слияние нескольких подпотоков.
Количество бакетов может различаться между частями. Когда части с разным количеством бакетов сливаются, количество бакетов в новой части пересчитывается по объединённой статистике. Части с сериализацией basic и with_buckets могут сосуществовать в одной таблице и прозрачно сливаться.
Настройки
| Настройка | По умолчанию | Описание |
|---|---|---|
map_serialization_version |
basic |
Формат сериализации для столбцов Map. basic хранит данные в виде одного потока массива. with_buckets разбивает ключи по бакетам для более быстрого чтения по одному ключу. |
map_serialization_version_for_zero_level_parts |
basic |
Формат сериализации для частей нулевого уровня (создаются при INSERT). Позволяет использовать basic для вставок, чтобы избежать накладных расходов на запись, тогда как слитые части используют with_buckets. |
max_buckets_in_map |
32 |
Верхняя граница числа бакетов. Фактическое число зависит от map_buckets_strategy. Максимально допустимое значение — 256. |
map_buckets_strategy |
sqrt |
Стратегия вычисления числа бакетов на основе среднего размера Map: constant — всегда использовать max_buckets_in_map; sqrt — использовать round(coefficient * sqrt(avg_size)); linear — использовать round(coefficient * avg_size). Результат ограничивается диапазоном [1, max_buckets_in_map]. |
map_buckets_coefficient |
1.0 |
Множитель для стратегий sqrt и linear. Игнорируется, если выбрана стратегия constant. |
map_buckets_min_avg_size |
32 |
Минимальное среднее число ключей в строке для включения разбиения по бакетам. Если среднее значение ниже этого порога, используется один бакет независимо от остальных настроек. Установите 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) |
~2x медленнее | ~2x медленнее | ~2x медленнее | ~2x медленнее | Нужно считать и заново собрать все бакеты. |
| 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, если запросы часто обращаются к отдельным ключам. Для lookup по одному ключу ускорение составляет 4–8x. - Большие Map (100+ ключей): Используйте
with_buckets. Lookup по одному ключу выполняются в 16–49x быстрее. Рассмотритеmap_serialization_version_for_zero_level_parts = 'basic', чтобы сохранить скорость вставки близкой к исходному уровню. - Если в рабочей нагрузке преобладают полные сканирования Map: Оставьте
basic. Сериализация Map по бакетам добавляет ~2x накладных расходов при полном сканировании. - Смешанная рабочая нагрузка (часть lookup по ключам, часть полных сканирований): Используйте
with_buckets, установив для zero-level parts значениеbasic. ОптимизацияPREWHEREсчитывает только релевантный бакет для фильтра, а затем читает полную Map только для совпавших строк, что дает заметное суммарное ускорение.
Альтернативные подходы
Если сериализация Map по бакетам не подходит для вашего сценария, есть два альтернативных способа повысить производительность доступа на уровне ключей:
Использование типа данных JSON
Тип данных JSON хранит каждый часто используемый путь в виде отдельного динамического подстолбца. Пути, превышающие лимит max_dynamic_paths, попадают в общую структуру данных, где для оптимизации чтения отдельных путей может использоваться сериализация advanced. Подробный обзор сериализации advanced см. в статье блога.
| Аспект | Map с бакетами |
JSON |
|---|---|---|
| Чтение одного ключа | Считывается один бакет (он может содержать и другие ключи). Десериализуются все пары ключ-значение в этом бакете. | Часто используемые пути читаются напрямую из динамических подстолбцов. Редко используемые пути попадают в общие данные; при сериализации advanced считываются данные только для точного пути. |
| Типы значений | Все значения имеют один и тот же тип V |
Каждый путь может иметь собственный тип. Для путей без подсказки типа используется Dynamic. |
| Поддержка индекса пропуска данных | Работает с некоторыми типами индексов, созданных на mapKeys/mapValues |
Индекс пропуска данных можно создать только для подстолбцов конкретных путей, но не сразу для всех путей/значений. |
| Чтение полного столбца | Примерно в 2 раза медленнее, чем 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 с бакетами |
Ручное сегментирование |
|---|---|---|
| Простота использования | Прозрачно — обрабатывается движком хранения | Требует логики маршрутизации на уровне приложения для вставок и выборок |
| Вертикальное слияние | Не поддерживается — все бакеты относятся к одному столбцу | Поддерживается — каждый столбец Map является независимым столбцом и может сливаться вертикально |
| Изменения схемы | Число бакетов автоматически подстраивается для каждой части | Изменение числа сегментов требует перезаписи данных или добавления новых столбцов |
| Синтаксис запроса | m['key'] работает напрямую |
Нужно вычислить правильный столбец: m0['key'], m1['key'] и т. д. |
| Гранулярность бакетов | Для каждой части, подстраивается под статистику данных | Фиксируется при создании таблицы |
Ручное сегментирование полезно, когда Вертикальное слияние важно для снижения использования памяти при слиянии таблиц с большим количеством столбцов, или когда число сегментов должно быть фиксированным и явно контролироваться. Для большинства сценариев автоматическая сериализация с бакетами проще и вполне достаточна.
См. также
- функция map()
- функция CAST()
- комбинатор -Map для типа данных Map