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:
Чтобы выбрать значения по ключу key2:
Результат:
Если запрошенный ключ k отсутствует в Map, m[k] возвращает значение по умолчанию для типа значения, например 0 для целочисленных типов и '' для строковых типов.
Чтобы проверить, существует ли ключ в Map, можно использовать функцию mapContains.
Результат:
Преобразование Tuple в Map
Значения типа Tuple() можно привести к значениям типа Map() с помощью функции CAST:
Пример
Запрос:
Результат:
Чтение подстолбцов Map
Чтобы избежать чтения всего столбца Map, в некоторых случаях можно использовать подстолбцы keys и values.
Пример
Запрос:
Результат:
Сериализация по бакетам Map в MergeTree
По умолчанию столбец Map в MergeTree хранится как единый поток Array(Tuple(K, V)).
Чтение одного ключа через m['key'] требует сканирования всего столбца — всех пар «ключ-значение» для каждой строки — даже если нужен только один ключ.
Для map с большим числом различных ключей это становится узким местом.
Сериализация по бакетам (with_buckets) разбивает пары «ключ-значение» на несколько независимых подпотоков (бакетов) по хешу ключа.
Когда запрос обращается к m['key'], с диска считывается только бакет, содержащий этот ключ, а все остальные бакеты пропускаются.
Включение сериализации по бакетам
Чтобы не замедлять вставки, вы можете оставить сериализацию basic для частей нулевого уровня (создаваемых во время INSERT) и использовать with_buckets только для частей после слияния:
Как это работает
Когда часть данных записывается с сериализацией with_buckets:
- Среднее количество ключей в строке вычисляется по статистике блока.
- Количество бакетов определяется настроенной стратегией (см. Settings).
- Каждая пара «ключ-значение» распределяется по бакету с помощью хеширования ключа:
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) | примерно в 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 см. в статье блога.
| Aspect | Map с бакетами | JSON |
|---|---|---|
| Чтение одного ключа | Считывается один бакет (который может содержать и другие ключи). Все пары ключ-значение в бакете десериализуются. | Часто используемые пути считываются напрямую из динамических подстолбцов. Редко используемые пути попадают в общие данные; при сериализации advanced считываются только данные нужного пути. |
| Типы значений | Все значения имеют один и тот же тип V | У каждого пути может быть свой тип. Для путей без подсказки типа используется Dynamic. |
| Поддержка skip index | Работает с некоторыми типами индексов, созданных на mapKeys/mapValues | Skip indexes можно создавать только для подстолбцов конкретных путей, но не сразу для всех путей/значений. |
| Чтение всего столбца | ~2x медленнее, чем basic, из-за повторной сборки бакетов | Накладные расходы из-за кодирования типа Dynamic и восстановления путей. |
| Накладные расходы на хранение | Минимальные дополнительные метаданные | Выше из-за кодирования типа Dynamic, хранения имён путей и дополнительных метаданных в сериализации advanced. |
| Гибкость схемы | Фиксированные типы ключей и значений при создании таблицы | Полностью динамическая — ключи и типы значений могут различаться от строки к строке. Для известных путей можно заранее объявить типизированные пути для прямого доступа к подстолбцам. |
Используйте JSON, когда для разных ключей требуются разные типы значений, когда набор ключей существенно различается между строками или когда часто используемые ключи известны заранее и могут быть объявлены как типизированные пути для прямого доступа к подстолбцам.
Ручное сегментирование одного Map по нескольким столбцам
Вы можете вручную разбить один Map на несколько столбцов по хешу ключа на уровне приложения:
При вставке направляйте каждую пару «ключ-значение» в столбец m{hash(key) % 4}. При выполнении запросов читайте из нужного столбца: m{hash('target_key') % 4}['target_key'].
| Аспект | Map с бакетами | Ручное сегментирование |
|---|---|---|
| Удобство использования | Прозрачно — обрабатывается движком хранения | Требуется логика маршрутизации на уровне приложения для операций INSERT и SELECT |
| Вертикальное слияние | Не поддерживается — все бакеты относятся к одному столбцу | Поддерживается — каждый столбец Map является независимым столбцом и может сливаться вертикально |
| Изменение схемы | Количество бакетов автоматически подстраивается для каждой части | Изменение количества сегментов требует перезаписи данных или добавления новых столбцов |
| Синтаксис запроса | m['key'] работает напрямую | Нужно вычислить нужный столбец: m0['key'], m1['key'] и т. д. |
| Гранулярность бакетов | На уровне части, подстраивается под статистику данных | Фиксируется при создании таблицы |
Ручное сегментирование полезно, если вертикальные слияния важны для снижения потребления памяти при слиянии таблиц с большим количеством столбцов, или если количество сегментов должно быть фиксированным и явно контролироваться. Для большинства сценариев использования автоматическая сериализация по бакетам проще и вполне достаточна.
См. также
- функция map()
- функция CAST()
- комбинатор -Map для типа данных Map