先分享一个我踩过的大坑:当时给用户画像表设计标签存储,图省事把几十个标签全部塞进一个Map(String, String)列里,写入倒是方便了,可线上跑查询的时候傻眼了——单条标签过滤要全表扫描,几个亿行数据硬扫下来十几秒起。后来我把高频标签拆成独立列、低频标签继续用 Map 兜底,查询才回落到毫秒级。这个经历让我认真把 ClickHouse 的 Map 类型从头到尾摸了一遍,包括它的存储布局、索引边界和性能瓶颈。这篇文章就把这轮研究与实践沉淀下来,从底层原理讲到建表、写入、查询和性能优化,既有可抄作业的示例,也有我实际踩过和修复过的坑。
先说结论:Map 在 ClickHouse 里不是鸡肋,但它的定位非常明确——适合存那些“行内稀疏、键不固定、不会高频作为过滤条件”的附加属性。你要是把它当成万能键值对,什么数据都往里塞,那后面大概率要为性能和存储冗余买单。
1. 先用真实场景把 Map 的定位盘清楚
1.1 它到底适合干什么
ClickHouse 每一列存储时,单行里除了一个固定的标量值外,还可以存储一组键值对,这个类型叫Map(K, V)。它解决的是典型的“宽表膨胀”问题:如果你有几百个可选属性,每个属性大多数行都用不到,把这些属性都建成独立列,空值会吃大量存储,而且表结构会非常难维护。Map 可以把这些稀疏属性打包进一列,按行动态扩展键。
日常开发里最常见的适用场景我列几个:
- 用户画像标签:每个用户身上的标签集合差异很大,标签总量可能有几百上千种,但单个用户通常只有几十个。
- 事件上报中的扩展字段:埋点事件除了公共字段外,会有各种业务自定义参数。
- 配置类数据:比如商品的不同售卖渠道价格、不同地区的差异化规则。
- 从 JSON 日志中提取动态字段:把 JSON 里的动态 key 序列化成 Map,比直接在 ClickHouse 里存原始 JSON 字符串更利于后续分析。
这类场景的共同点是什么?键的集合不可预知、每行需要的键稀疏、而且查询更多是“遍历这一行的全部属性”或者“读取某几个高频键”。这是 Map 的主场。
1.2 和 JSON、Nested、普通列怎么选型
很多人在 Map、JSON、Nested 三选一的时候纠结。我直接给一个经验矩阵:
| 类型 | 存储形态 | 适合场景 | 主要瓶颈 |
|---|---|---|---|
| 普通列 | 固定字段,每行必存 | 高频过滤、排序、聚合字段 | 列越多表越宽,空值浪费存储 |
| Map | 行内键值对,键可变 | 稀疏动态属性、低频点查 | 键无法走进主索引,查询需扫行 |
| JSON | 原始文本 | 一次性导入、极少分析或仅查原文 | 查询必须解析,列能力完全丢失 |
| Nested | 行内嵌套数组 | 子记录级别的存储和展开 | 使用复杂,写 SQL 要 ARRAY JOIN |
有过一段实测经验:同样一批物体识别结果数据,我分别用 JSON 字符串和 Map 建了两张表。JSON 列做过滤时,每次都要对整段文本做解析正则,查一条要全表扫一遍;Map 列虽然也要扫行,但至少是结构化数组,mapContains和map['key']的代价远低于解析文本。差异不是一星半点,尤其行数上亿时更明显。
Map 也很适合做数据同步的中间层。比如用 Flink 把 MySQL 里的动态扩展字段凑成 Map 写入 ClickHouse,比在 ClickHouse 里为 MySQL 每一列动态建列靠富士。我后面会专门写一节从同步角度讲的实操方案。
2. 底层存储原理:两个数组撑起一个 Map
2.1 Map 在列存储里到底是什么形态
很多人以为 Map 是一列里存了一个复杂结构,实际上 ClickHouse 底层是把 Map 列拆成两个普通数组列来处理的:一个 keys 数组,一个 values 数组,它们在物理存储上是独立的子列。你可以把概念形象地理解为一张二字段的子表嵌进了主表的每一行里。
定义attrs Map(String, UInt32)时,物理上会变成两个子列。查询的时候你可以直接用attrs.keys和attrs.values拿到数组:
SELECT attrs.keys, attrs.values FROM user_profile LIMIT 3;我在生产上见过有人利用这一点写遍历:
SELECT user_id, keys.1 AS key, values.1 AS val FROM ( SELECT user_id, attrs.keys, attrs.values FROM user_profile LIMIT 3 ) ARRAY JOIN attrs.keys AS keys, attrs.values AS values;这种拆列存储带来两个直接影响。一是压缩率高,因为零点值数组按列存储,Key 如果是同一枚举集合,压缩效果和整列重复度正相关。二是访问时如果只取某个键,物理上依旧要把该列整个 keys 数组的子列读进来,无法只读“某一键”的连续存储片段。
2.2 键是有序且唯一的,这点极其重要
ClickHouse 为了提供更快的单键查找,在写入时就维护了键的唯一性和有序性。同一行内同一个键不会出现两次,键会按二进制序排好。这意味着你在内部分配map['biz']时,底层并不会线性扫整个数组,而是做一个类似二分查找的定位,复杂度大约 O(log n)。
不过别高兴太早,这个“有序+二分”只在行内定位起作用。它不会帮你把过滤条件下推成索引,因为整行读入前,没人知道这一行 Map 里有没有你要的键。这就是 Map 查询最容易被低估的边界:行内定位快,行间过滤慢。
2.3 缺失键返回默认值:隐患在“看起来没报错”
Map 有一个容易踩坑的语义:读取 Map 中不存在的键时,不会抛异常,而是返回该值类型的默认值。字符串返回空串,数值返回 0,数组返回空数组。这逻辑本身为了兼容性没问题,但实际统计时你可能被坑得很惨。
比如说统计“有多少用户 vip 标签值为 1”:
SELECT countIf(attrs['vip'] = '1') FROM user_profile;缺失 VIP 标签的用户也会被处理为'',内层判断不会因反正不匹配而出错,但在做“键存在性判断”时你必须改用mapContains:
SELECT countIf(mapContains(attrs, 'vip') AND attrs['vip'] = '1') FROM user_profile;新版本里还提供了更安全的mapGetOrElse(attrs, 'vip', 'default'),直接给定缺省值。如果你的运行版本支持,强烈建议用它来消除默认值语义带来的歧义。
3. 建表、写入、查询的完整实操流
3.1 建表:分区、排序键和 Map 列怎么搭配
一个生产环境常用的表设计长这样:
CREATE TABLE events ( event_id String, event_date Date, region String, user_id UInt64, attrs Map(String, String), ... ) ENGINE = MergeTree PARTITION BY toYYYYMM(event_date) ORDER BY (event_date, region, user_id) SETTINGS index_granularity = 8192;注意这里的排序键没有包含任何 Map 字段。Map 列本身不适合放进 ORDER BY,原因有两个:第一,Map 长度多行不一致,排序意义不大;第二,排序键决定主索引的构建,而 Map 内部键值无法为主索引提供可用的范围信息。
3.2 写入方式:INSERT 到底怎么写
写入最直接的方式就是通过map()函数构造:
INSERT INTO events VALUES ( 'e_001', '2024-05-20', 'ap-northeast-1', 10001, map('env', 'prod', 'os', 'linux', 'device', 'android') );从表中批量插入也一样:
INSERT INTO events SELECT event_id, event_date, region, user_id, map('env', env_val, 'os', os_val) FROM raw_logs;如果你用的是 Flink 同步 MySQL 到 ClickHouse,最常见的做法是把 MySQL 的 JSON 字符串或动态字段在 Flink 端解析成 Map 结构,再写入 Map 列。这里有一个真实工程技巧:约束好 key 的规范格式,尽量全小写、统一分隔符,不要一会儿user_id,一会儿userId。因为 Map 键必须保持唯一且有序,不同类型写进去就是两套键,后端查询条件写得乱,很容易出现“看着有数据,就是查不到”的问题。
3.3 查询:点查和遍历的写法各不相同
点查某个规则固定键:
SELECT user_id, attrs['vip'] FROM user_profile WHERE user_id = 12345;单行内的这个查询底层走二分,代价很低,单行内取 10 个键也就是 10 次二分查找。问题永远出在过滤键上:
SELECT count() FROM events WHERE attrs['env'] = 'prod';这种 SQL 会把每一行的 attrs 子列整体读取,再对每一行做map['env']二分查找并比较。查询框架完全无法利用主索引做 prewhere 裁剪,只能硬扫。真实表现就是你发现这个查询跑得越来越慢,加多少索引都不管用。
遍历整列的键值组合,通常配合ARRAY JOIN:
SELECT event_id, k AS attr_key, v AS attr_val FROM events ARRAY JOIN attrs.keys AS k, attrs.values AS v WHERE event_date = '2024-05-20';这种遍历就是 Map 的正确打开方式——把行内数组展开成多行,后面可以做聚合、炒股,甚至写入另一张宽表。我遇到很多分析需求是用这种展开后再粒度的。不要想着在 Map 原列上用一堆高阶函数做复杂聚合,先用 ARRAY JOIN 展开,查询写起来更天然,优化器也能正确处理。
4. 性能优化:实测下来最见效的几个手段
4.1 高频键拆列,物理拆,不要逻辑拆
这是我在生产上做过收益最大的一次改造。业务方频繁用attrs['vip']过滤用户,Map 查询每次都是全表扫描。我们干脆把 vip 提前拆出来做独立列,甚至直接放进排序键。改造后的表:
CREATE TABLE user_profile_v2 ( user_id UInt64, is_vip String DEFAULT '0', attrs Map(String, String), ... ) ENGINE = MergeTree ORDER BY (is_vip, user_id);注意,如果业务只查很少几个高频键,拆出 1 到 3 个就够了,不要把所有键都拆出来,否则你等于回到了宽表的老路。拆列后,常用过滤可以走索引或者至少走列裁剪,Map 里继续存长尾属性,两全其美。
还有一个小技巧,这个依赖主表已有 Map 列的情况:
ALTER TABLE user_profile_v2 ADD COLUMN vip_value String MATERIALIZED attrs['vip'];物化列在插入时自动从 Map 提取对应键,物理上落盘,查询根本不需要再碰 Map 子列。它适合“键存在且为字符串”等简单场景,但请注意物化列字段写公式时每次写入都会计算,存在一定写入开销。如果写入量极大,可以考虑普通默认列加异步批处理填充。
4.2 键的编解码和类型设计决定压缩率和速度
Map 键的类型直接决定性能上限。我自己做过对比:一张表 4000 万行,把 Map 的键从 String 改成 UInt16 枚举键后,行内平均存储从约 180 字节降到 70 字节,压缩后总大小只有原来的三成左右。原因很简单,String 键每行都要存储完整的字节序列,即使有压缩,基数很高的长键压缩率也很不理想;而整数键定长编码后,存储紧凑,二分查找时整数比较也比字符串比较快得多。
实际落地手法:
CREATE TABLE metric_tags ( ... tag_map Map(UInt16, Float64) ) ENGINE = MergeTree;建一张枚举维表:
| code | name | description |
|---|---|---|
| 1 | cpu | CPU 使用率 |
| 2 | mem | 内存使用率 |
| 3 | disk | 磁盘使用率 |
写入侧把业务字符串键翻译成枚举 code 再构造 Map,查询侧用 JOIN 或字典还原语义。这样脏键、大小写、长尾部问题一并解决。如果你舍不得这层转换,至少先把键从全串改成固定长度的枚举字符串。
4.3 全 Map 扫描和点查大键集合的取舍
如果查询经常需要过滤某个业务规则,但又舍不得用 Map,那么谨慎做法是分层:把常用规则键写在排序列里,把扩展的学习属性放到 Map。这里有一个判断维度,按“查询频率 × 筛选价值”来分组:
- 高频过滤 + 键固定:物理独立列,不要用 Map。
- 低频过滤 + 键固定:可以留在 Map,但必须能接受全扫或多粒度块扫描。
- 高频聚合 + 键动态:保留 Map,但把聚合结果写在物化视图中,避免实时展开全表。
- 低频聚合 + 键动态:Map 是最优选,直接结合 ARRAY JOIN 即可。
这块容易被忽略的细节是,Map 的子列在读取时是按块加载的,ClickHouse 每个数据块内含 8192 行(由 index_granularity 控制)。如果你只查其中一个键,依旧要把整个 8192 行的 attrs 子列数据块搬进内存。这个固定开销,在小分区表上不明显,大分区上几百个块累加就非常可观。
4.4 处理跨行、跨键的关联查询
我喜欢把 Map 展开后与维表做关联,展开时用ARRAY JOIN:
SELECT t.biz_key, countDistinct(t.user_id) FROM events AS e ARRAY JOIN e.attrs.keys AS biz_key, e.attrs.values AS biz_val WHERE e.event_date = '2024-05-20' AND biz_val = 'activate' GROUP BY biz_key;这个写法在分析场景中性能还可以,因为它在“行展开”后已经变成普通列上的聚合,ClickHouse 可以并行处理。但要注意,ARRAY JOIN 会把一行的多个键展开成多行,行数膨胀几十倍后,后续聚合的内存压力会随之上涨。遇到超大结果集时,可以分段处理或提前写物化视图,不要硬着头皮一个 SQL 把全量键展开。
4.5 和 Doris 这类 OLAP 的横向选型对比
很多团队在 ClickHouse 和 Doris 之间做选型,这里我也简单聊聊 Map 维度上的差异。Doris 同样提供了 Map 类型,底层存储方式大致类似,也是结构化的数组对。但从我的使用体验看,Doris 对动态子列的更新支持更友好,如果业务有大量单键增改需求,Doris 的更新模型会比 ClickHouse 的 MergeTree 更顺手;而 ClickHouse 在原生高并发分析、超大规模列裁剪上通常更有优势。也就是说,如果你们的 Map 使用场景偏“写入后分析”,ClickHouse 会省心;如果偏“频繁准确更新部分键”,Doris 可能会让你少走些弯路。当然,具体选型永远要结合集群规模、查询模式、实时性要求综合考虑,不能单看一个类型。
5. 高频踩坑与性能问题排查技巧
5.1 为什么我的点查询这么慢
最常见的误区是把“行内单键读取”和“全表点查”混为一谈。你写WHERE attrs['env'] = 'prod'时,数据块要先读出来才能判断键值,主索引在该键上是盲区。排查时用 EXPLAIN 看看扫描范围:
EXPLAIN indexes = 1 SELECT count() FROM events WHERE attrs['env'] = 'prod';输出如果显示最终扫描的分区级主键裁剪范围很宽,就说明查询在硬扫。解决路径就是前面说的拆列。如果拆列确实拆不出来,那就把查询粒度下沉到分区或者按时间段批量跑,至少别让线上查询天天全表扫。
5.2 为什么键值读出来是空的
先确认键的大小写和空格。Map 键是二进制序,区分大小写,mapContains(attrs, 'VIP')和mapContains(attrs, 'vip')是完全不同的。再用attrs.keys和arrayJoin把它们能读出来的实际键打出来核对:
SELECT user_id, attrs.keys FROM events WHERE user_id = 123456;这就是老生常谈的“有数据但查不到”问题。我在 Flink 同步场景里遇到过最典型的坑:上游 JSON 里的 key 带空格或不可见字符,同步到 Map 后肉眼看不出来,条件永远匹配不上。解决方案是提前对 key 做 trim 和规范校验。
5.3 为什么写入量一大内存就扛不住
Map 的写入路径比普通列更耗内存——每行都要构造键数组、保证有序性。如果一条记录里有几十上百个键,且建表语句中还有多个 Map 列,写入缓冲区的消耗会成倍上涨。我的建议是:
- 控制单行键数量,超过几十个键的考虑用 Nested 或其他结构。
- 批量写入时不要一次性堆太大 batch,比如 Flink 同步场景建议每个批次控制在几万行以内。
- 不要在 MergeTree 里频繁
ALTER UPDATE改 Map 内容,每一次变更都会触发重写,极其昂贵。
5.4 排查工具箱
| 手段 | 命令/写法 | 作用 |
|---|---|---|
| 查看表结构 | SHOW CREATE TABLE events | 确认子列与压缩编码 |
| 查看子列存储 | SELECT name, type FROM system.columns WHERE table='events' | 看 Map 对应的物理子列 |
| 分析查询计划 | EXPLAIN indexes = 1 SELECT ... | 判断是否全表扫描 |
| 看压缩效率 | SELECT name, data_compressed_bytes / data_uncompressed_bytes FROM system.parts WHERE table='events' | 判断锁存是否合理 |
| 实时慢查询 | system.query_log里找 longest_query | 定位慢查询源头 |
这套组合拳打下来,大部分 Map 性能问题都能正确定位。我自己现在写新表时,会把“哪些键要拆列”“哪些键放 Map”“键用什么类型”作为字段设计的三步标准动作,很少再遇到那种上线后才发现的性能事故。
最后分享一个小经验
Map 这个类型好不好用,90% 取决于设计期的分寸感。我一向对团队说一句话:能用固定列解决的问题,永远不要为了“灵活”用 Map;而真的是动态键场景,也别矫枉过正去建一堆稀疏列。拆高频键、留长尾键、用整数键、规范键格式,这四件事做好了,ClickHouse 的 Map 就能成为你工具箱里一个顺手又高效的工具。
如果你正在搭建一套从 MySQL/Flink 同步到 ClickHouse 的管道,Map 会是你解析 JSON 字段的好帮手,但务必在同步端就把数据格式清洗规整,别把脏键带到 ClickHouse。真到排查性能和脏数据的时候,你一定会感谢当初多花的这十分钟。