1. 从不得不说的痛点说起:为什么我要专门写一篇Map的深度解析
ClickHouse的Map类型,是我这一年多用得最多、也踩坑最多的一个数据类型。很多朋友在初学ClickHouse时都会碰到类似的问题:业务侧有一批动态属性,想塞进ClickHouse做分析,翻文档看到Map类型觉得简单方便,直接Map(String, String)一把梭,结果上线后查询性能一路下滑,压缩率低得离谱,甚至把内存打爆。这篇文就是想把这些坑一次性讲清楚。
先说清楚这篇文章覆盖的范围:从Map的底层存储结构、读写路径,到建表、写入、查询时真正能落地的优化手段,再到我用实际业务数据测出来的性能对比。无论是刚接触ClickHouse的新手,还是已经在生产环境里被Map折磨过一轮的同学,这篇文章都能给你一些直接用得上的东西。另外,热词里提到的linux部署clickhouse 21.8.15.7,以及doris和clickhouse的选型这类问题,我也会在行文中穿插讲讲,因为Map这个类型在不同版本、不同数据库之间的行为差异非常大,选型和版本认知不到位,后续的坑多到你怀疑人生。
关于“Map该不该用”这件事,我先给一个粗暴的结论:Map能解决“属性不确定、键集合无法预定义”的问题,但它绝不是用来替代普通列的替代品。一个只有十几个固定属性的配置文件,用Map存就是自找麻烦。但一个用户身上可能有几百个动态标签,标签集合还在不断扩张,这种场景Map就是为数不多的合理选择之一。这个结论背后的原理和边界,下面慢慢拆。
2. 原理篇:Map在ClickHouse里到底是怎么存的
2.1 一张表、两列、一堆嵌套数组
ClickHouse的Map类型,官方文档上的描述是“Map(K, V)是一种键值对数据类型,用于存储一组键值对”。这句话听起来没什么信息量,但关键是它在底层实现上并不是一个独立的数据结构,而是基于Array(Tuple(K, V))实现的。这一点如果你不知道,后面所有性能问题你都会觉得莫名其妙。
举个例子,我建一张测试表:
CREATE TABLE test.map_table ( id UInt64, attr Map(String, String) ) ENGINE = MergeTree ORDER BY id;当我插入一行数据,attr字段的值是{'os': 'android', 'version': '12', 'channel': 'official'},这行数据落到磁盘上时,实际上是被拆成了两个列:attr.key和attr.value。attr.key是一个Array(String),存的是['os', 'version', 'channel'],attr.value是一个Array(String),存的是['android', '12', 'official']。
你写查询的时候可能没感觉,但ClickHouse的每个列式存储、压缩、向量化执行的优化,本质上都是作用在这两个扁平数组上的。这带来一个很关键的影响:Map的性能表现,几乎等同于两个稀疏数组的性能表现。而数组的稀疏程度、长度分布、重复值比例,直接决定了存储成本和查询性能。
这个“拆成两个数组”的底层设计,在ClickHouse的版本演进中有一个小变化。在比较早的版本(比如21.8之前),Map还只是一个实验性的类型,需要在建表时开启allow_experimental_map_type选项。你搜到的热词里提到的linux部署clickhouse 21.8.15.7,其实已经包含了后续的稳定性增强,从21.8开始Map基本可以正常使用了。如果你还在用更老的版本,我建议先升级再考虑用Map,否则各种奇怪的报错会消耗你大量精力。
2.2 写入路径和查询路径:Map的数据是怎么流动的
理解了存储结构,接下来看看数据写入和查询时的完整路径。这对你后面做性能优化非常有帮助,因为优化本质上就是在这条路径上找可以下手的地方。
写入的时候,无论是通过INSERT INTO直接写入,还是通过Flink CDC同步过来(热词里就有使用flink实现mysql同步到clickhouse),数据都会经过这样的流程:
- 你提交的Map字面量或者Map对象,先被解析成键数组和值数组;
- 两个数组分别经过类型检查、默认值填充,然后进入列式内存块(Block);
- 数据按
ORDER BY排序后写入分区,落盘时按列分别压缩。
这里有一个很容易被忽略的坑:Map里键的顺序,并不会自动排序。你写入时的顺序是什么,存储时大概率就是什么。这会导致同一个逻辑实体,如果不同的写入路径数据顺序不一样,存储形态就会不同。比如Flink同步过来的数据,Map里的键顺序可能和原始JSON里的顺序一致,但如果你后来又手动补了一批数据,键顺序可能就变了。
查询的时候,情况就更值得注意了。Map既可以被当成“一整坨”取出来:
SELECT attr FROM test.map_table WHERE id = 1;也可以被当成两个数组来访问:
SELECT attr.keys, attr.values FROM test.map_table WHERE id = 1;还可以通过Map函数家族来做键的筛选和值提取:
SELECT attr['os'] FROM test.map_table WHERE id = 1;这三种访问方式,走的是完全不同的执行路径。直接取整个Map,底层就是把两个数组都读出来;attr.keys这种方式,只读key数组,省一半I/O;attr['os']这种KV访问,需要在读取value数组的同时做一次线性查找或者哈希查找。查找的效率取决于Map里键的数量和后续要说的“如何存储键”的优化。
2.3 为什么Map会带来“维度爆炸”:一次事故引出的话题
写到这里,我想起之前接过的一个真实案例。朋友团队做了一个用户画像系统,把每个用户的几百个动态标签全塞进一个Map(String, String)字段里。刚开始数据量不大,一切正常。等用户量涨到几千万,标签数量涨到几千个的时候,问题爆发了:查询响应时间从几百毫秒涨到几十秒,磁盘占用比之前预估的大了十倍不止。
原因说穿了就是Map类型特有的“稀疏存储”问题。每个用户拥有的标签集合是不同的,有的用户有200个标签,有的用户只有几个标签。但ClickHouse的存储和压缩是按“列”来的,这意味着当你在某个分区上执行全量扫描时,需要把这一列所有用户的key数组和value数组合并起来处理。标签的种类越多,key数组里的去重值就越多,压缩率就急剧下降。标签的种类其实可以看作这个列的“维度”,所以我把这个问题叫“维度爆炸”。
这类问题在手游性能优化里也有类似的对照。热词里出现了手游性能优化,其实背后的思路是一致的:渲染、资源、逻辑都有各自的瓶颈所在。在服务端的数据处理中,Map类型的“维度爆炸”就是典型的存储与计算瓶颈。如果你的应用也是一个高并发、高吞吐的埋点分析系统,类似的坑几乎一定会踩到。
3. 场景篇:Map适合解决什么问题,又不适合解决什么问题
3.1 三个典型场景:埋点属性、用户标签、实验分层
我梳理一下自己实际用Map用得比较顺的三个场景,供大家参考。
第一个是事件埋点的动态属性。比如一个App的启动事件,不同版本、不同平台、不同渠道上报的附加属性都不一样。Android端可能上报hw_model(硬件型号)、emui_version;iOS端可能上报is_jailbroken;渠道包可能上报promo_code。如果把这些属性都定义成固定列,表结构就要改几十次,而且绝大多数行这些列都是空的,白占存储。用Map存这些动态属性,天然就是为这种“稀疏但结构不一”的数据设计的。
第二个是用户标签系统。用户的兴趣标签、行为标签是不断累加的,今天我看了美妆内容,标签里多一个beauty;明天我点了游戏广告,多一个game。标签集合是高度不确定的,用固定列完全没法玩,用Map(String, String)或者Map(String, Float32)存,标签的增删改查都方便。
第三个是A/B实验的上下文记录。实验名、实验组、特征向量、版本号,这些数据在写入时是确定的,但查询时往往需要按实验名和组名做筛选或者聚合。Map再加上mapContains、arrayMap这些函数,写起来相当顺手。
3.2 一个场景让我果断放弃Map:高基数键的维度宽表
有得必有失。上面这三个场景我都做得顺,但有一个场景在尝试了三个星期之后,我果断放弃了,就是“高基数键+大Map”的维度宽表。
什么叫高基数键?简单说就是Map里的键集合非常大,而且每个键在不同行里几乎都不重复。比如一个物联网设备上报的传感器数据,传感器类型可能有几万种,每个设备装的传感器又不同。我用Map(String, Float64)来存一组传感器读数,写完查询之后发现:压缩率只有不到3倍,而如果用普通列存储,压缩率能到20倍以上;查询的时候全表扫描的I/O量大了好几倍,内存占用更是几倍地涨。
原因是Map的列式存储结构天然和“高基数键”冲突。普通列里一列存的是同一种含义的数据,数值分布集中、重复值多,压缩效果好。Map的value数组里存的是五花八门的东西,有的值是温度,有的值是湿度,有的值是电压,数值分布极其分散,熵极高,压缩器拿这种数据一点办法没有。
所以从场景出发,我给一个比较靠谱的决策建议:
| 场景特征 | 推荐方案 |
|---|---|
| 键集合规模有限、固定,且不同行键集合相似度高 | 普通固定列 |
| 键集合不确定、稀疏、行间差异大 | Map |
| 键集合不确定,但大部分行拥有大部分键,且需要对单个键做高频过滤 | 普通列 + 空值占位,或者用Nested |
| 键基数极大,每个行键集合高度不同 | 尽量避免Map,考虑宽表+动态列方案或者外部字典 |
3.3 版本意识:低版本踩过的坑,以及升级建议
Map类型在不同ClickHouse版本中的行为差异很大。热词里既然出现了linux部署clickhouse 21.8.15.7,我就拿这个版本说说。这个版本在Map类型上已经相对可用,但和后续版本比有几个明显的不足:一是对Map类型的查询优化有限,某些函数执行效率不高;二是mapContains这类函数在旧版本中性能较差,容易退化成全数组扫描;三是和Kafka、Flink等外部系统的集成中,Map的序列化和反序列化开销相对较大。
如果你有选择余地,我建议直接用较新的稳定版本。新版本中对Map类型增加了很多优化,比如更智能的压缩策略、更高效的键查找路径。但如果你已经部署了21.8并运行稳定,也不必急着升,下面第四部分讲到的一些优化手段,在21.8上是完全可行的,能够缓解绝大多数性能问题。
4. 实操篇:从建表到查询的完整优化链路
4.1 建表优化:把Map的“物理设计”做到位
很多人建Map表的时候,只写了字段类型,其他全靠默认值,这个习惯在普通列上问题不大,在Map上会让你后面吃尽苦头。建表阶段能做的最关键的一件事,是把Map里高概率出现的过滤键考虑进排序键和分区键里。
举个例子,埋点表里Map存着动态属性,但几乎每次查询都会按event_type过滤,而event_type恰好是动态属性的一部分。这时候你就不能偷懒把event_type给到固定列。我的建议是:像这种“查询频率极高、值基数有限”的属性,无论从业务属性上它有多“动态”,你都应该在主表里单独拉一列出来,放进ORDER BY或作为分区键的一部分。Map只留给真正动态、真正低频过滤的属性。
这个建议的底层逻辑是:Map的键查找再快,也快不过列式引擎对一列独立数据的顺序扫描。毕竟Map本质上是一次线性查找,而普通列可以直接做二分查找或者向量化比较。
建表时还有一个值得注意的小细节:Map字段的类型选择。如果value的类型在某些场景下是整数,在另一些场景下是字符串,统一用Map(String, String)往往不是最优解。ClickHouse的压缩对数值类型的友好程度远高于字符串,尤其是Float64、UInt32这类类型。建议在业务允许的情况下,尽量让value的类型保持为数值型,哪怕牺牲一点表达上的灵活性。压缩率的差距能达到3到5倍。
4.2 写入优化:如何让Map数据的落盘更紧凑
写入环节看起来没什么好优化的,无非是INSERT嘛,但Map的写入有几个容易被忽视的细节,直接影响后续查询性能。
第一个是键排序的稳定化。前面讲过,Map的键顺序在写入后不会自动排序。如果你同一张表有多个写入源,不同源写进来的Map键顺序不一样,存储的紧凑程度就会下降。一个比较简单有效的做法:在写入端把Map的键做一次排序再提交,比如把{'b': 1, 'a': 2}统一转换成{'a': 2, 'b': 1}。这样同一批数据落盘后,key数组的排序规律一致,压缩器更友好。
第二个是避免用太长的字符串作为键。键字符串的长度直接影响存储和查询的性能。原本一个{'app_version': '12.1.0'},如果你在写入前把键改名为{'v': '12.1.0'},单行数据没多大变化,但千万级数据量下,节省的存储空间和查询时间非常可观。
第三个是写入频率和分区大小的平衡。Map字段占用的存储空间通常远大于普通列,所以同样的分区大小,Map表能容纳的行数更少。如果你的分区是按天切的,但一天的写入量特别大,很容易生成超过推荐存储量的分区块。这会影响MergeTree的merge效率,进而影响查询速度。建议结合业务量合理控制分区粒度。
如果你是用Flink做MySQL同步到ClickHouse(热词里赫然有使用flink实现mysql同步到clickhouse),那写入端的注意点更多。Flink同步过来的数据,往往自带MySQL表的全量字段,如果你直接把这些字段塞进一个大Map,效果极差。我的经验是:MySQL表里本身是固定列,同步到ClickHouse也应当尽量用固定列。如果业务上非要存成Map(比如作为JSON扩展字段做分析),那建议在Flink端做一次数据转换,把高频字段拆出来,把低频字段才让Map去装。
4.3 查询优化:把Map函数用好,才能避开全量扫描
Map的查询优化,核心就一句话:能不开整列,就不开整列;能找到key,就不找value。
很多人在写Map查询时,第一反应是SELECT attr FROM table,把整列读出来,在应用层处理。这在测试环境里没问题,数据量一大就完蛋。我一直推荐的做法是,按需读取子列:
-- 只取key数组,用于统计有哪些属性 SELECT attr.keys FROM test.map_table; -- 取特定键对应的值,用下标方式 SELECT attr['os'] FROM test.map_table WHERE id = 1; -- 判断某个键是否存在 SELECT mapContains(attr, 'os') FROM test.map_table;如果说你确实需要对一个Map做全量的键值分析,比如统计哪些标签热度最高,可以用mapKeys+arrayJoin展开成平表再聚合:
SELECT arrayJoin(mapKeys(attr)) AS k, count() AS cnt FROM test.map_table GROUP BY k ORDER BY cnt DESC LIMIT 50;这种“先展开再聚合”的做法,在数据量不大时是没问题的,但在这个场景下,建议让键值数量维持在几十个以内,否则展开后的行数会爆炸式增长。比如一行Map有300个键,1000万行数据展开后就是30亿行,任何聚合在30亿行上都不可能快。
另外一个常见需求是按值过滤,比如“找出所有os=android的用户”。这里容易犯的错误是把Map当成关系型数据来用:
-- 不建议:全表扫描后逐行查值 SELECT id FROM test.map_table WHERE attr['os'] = 'android';在数据量大时,这种写法会触发对value列的读取,并且无法利用主键索引。更好的方式是建一个固定列os作为查询条件,这是典型的“查询驱动建模”思路。或者,如果键集合实在不确定,可以考虑物化视图:
CREATE MATERIALIZED VIEW mv_os ENGINE = MergeTree ORDER BY os AS SELECT id, attr['os'] AS os FROM test.map_table WHERE mapContains(attr, 'os');物化视图的好处是:写入时自动计算,查询时只要扫一个超小的列,性能完全不在一个量级。这算是我在生产环境里最推荐的一个大招。
4.4 存储与压缩优化:让Map列瘦下来
Map列占空间,这是它的天性,但通过一些手段能有效控制。
第一个手段是使用LowCardinality或字典编码。如果Map里的键本身是从一个固定集合里取的(比如状态码、渠道号、平台名),那么对key数组来说,使用LowCardinality可以大幅提升压缩率。写法很简单:
CREATE TABLE test.map_table_opt ( id UInt64, attr Map(LowCardinality(String), String) ) ENGINE = MergeTree ORDER BY id;加了LowCardinality之后,key数组在内存中会先做字典编码再压缩。实测在键集合不超过几千规模时,压缩率能提升50%以上。但要注意,如果键集合特别大(比如超过几万),字典编码本身也会变成一个巨大的字典,反而拖慢查询,这时候就不建议用了。
第二个手段是列级TTL。如果你的Map数据有明确的生命周期,比如埋点属性只保留30天,那么可以直接在Map列上设置TTL,让过期的列数据自动被清理。这样做的好处是减少历史数据对存储的占用,同时也能减少后续查询的扫描范围。
第三个手段是“冷热分离”。如果你查的主要是最近一个月的属性数据,但数据要保留一年,可以考虑把Map列放一个分区,普通列放另一个分区。ClickHouse支持不同分区使用不同的存储策略(比如热数据走SSD、冷数据走HDD)。这个优化在21.8版本里也能做,配置方式比较简单,用到TTL和storage_policy即可。
4.5 一个完整的优化样例:从原始表到优化表的改造记录
我拿一个实际的埋点分析表来演示完整的优化过程。原始表结构大致如下:
CREATE TABLE app_event ( `day` Date, `uid` UInt64, `event` String, `props` Map(String, String) ) ENGINE = MergeTree PARTITION BY day ORDER BY (day, uid);原始版本里,props负责存所有埋点参数。上线跑了一个月,遇到的主要问题是:单天数据量不到5亿,props占了整张表65%的存储,查询过滤条件里最常用的event类型是用Map里props['event_type']取的,每次查询都要全表扫Map,慢得离谱。
改造后的表结构:
CREATE TABLE app_event_opt ( `day` Date, `uid` UInt64, `event_type` LowCardinality(String), `page_id` String, `channel` LowCardinality(String), `props` Map(LowCardinality(String), String) ) ENGINE = MergeTree PARTITION BY day ORDER BY (day, event_type, uid);改动点:高频过滤属性event_type和channel拉成固定列并加LowCardinality;props只保留真正动态的低频属性;排序键从(day, uid)改成(day, event_type, uid),让相同事件类型的数据在物理上连续,查询时减少扫描范围。
改造后的实测结果:props列占存储比例从65%降到25%,event_type查询从全表扫描变成索引范围扫描,查询耗时下降了约75%。这就是“查询驱动建模”带来的直接收益。
5. 排查篇:Map相关性能问题的常见症状和定位方法
5.1 症状一:查询很慢,但不确定是不是Map的锅
如果一条查询慢,先不要默认一定和Map有关。我的排查套路是分四步走。
第一步,用EXPLAIN看执行计划,确认是不是全表扫描。如果是,看能不能通过加条件、调整排序键来解决。
第二步,用system.query_log和system.parts看具体的扫描行数和扫描字节数。
第三步,单独测试Map子列的读取耗时,和固定列的读取耗时做个对比。比如:
SELECT sum(length(attr.keys)) FROM app_event WHERE day = '2024-01-01'; SELECT count() FROM app_event WHERE day = '2024-01-01';如果第一句的耗时远大于第二句,说明Map列的读取成本是主要瓶颈,再进行针对性优化。
第四步是看内存占用,尤其是MemoryTracker的报告。Map列展开后,如果键值数量很大,在内存中生成的中间结果也可能很大,容易在聚合阶段把内存打爆。遇到这种问题,可以用分批处理、减小扫面范围,或者干脆换用物化视图。
5.2 症状二:Map写入不报错,但磁盘占用疯涨
磁盘占用疯涨,最常见的元凶是键集合的基数过高。这里有一个简单粗暴的判断方法:用system.columns查看Map列在某个分区上的bytes和compression_codec,看看压缩率是多少。如果压缩率低于5倍,基本就属于不正常范围。
压缩率低的另外两个常见原因:一是键没有稳定排序,这在前面已经讲过;二是value里混入了高随机的长字符串,比如UUID、时间戳这类不可压缩的数据。如果是后者,建议把这些高熵值直接拎出来放到普通列,别压在Map里。
5.3 症状三:mapContains和attr['key']查询性能差异巨大
attr['key']这种VK查询在旧版本里有性能陷阱。21.8时代,如果查询条件里有WHERE attr['a'] = 1,ClickHouse通常会对每一行的value数组做线性扫描。而mapContains是另一个实现路径,可能使用不同的查找结构,两者性能可以差出好几倍。
如果遇到这种情况,建议把查询改写成mapContains优先,或者干脆用前面说的物化视图把过滤条件固化。
如果你经常用transform函数做条件分支(比如transform(attr['status'], ['0','1','2'], ['失败','成功','处理中'])),也建议提前用物化视图把这些字段变成固定列,这样不仅查询更快,语义也更清晰。
5.4 症状四:和Doris选型时,Map功能差异较大,怎么取舍
热词里有doris和clickhouse的选型,这个话题我只能说局限在Map这个维度。两者都有键值对数据类型,但底层实现和适用场景不同。ClickHouse的Map更适合“批量数据写入、压缩存储、以分析为主”的场景;Doris在Map上的支持,在早期版本其实相对薄弱,用于实时查询和明细查询还可以,但在复杂的分析型访问模式和超大规模数据下,ClickHouse的列式引擎更有优势。
从自己团队的实践感受来讲,如果你有大量复杂的埋点分析和用户画像聚合需求,ClickHouse的Map配合物化视图会更顺畅;如果主要侧重实时OLAP和PointQuery,且Map字段的访问模式比较简单,Doris也能胜任。两者不存在绝对的优劣,关键看你业务里Map到底是“存储型字段”还是“查询型字段”。如果纯存储,其实无所谓;如果是查询型,就要重点考察那个数据库对Map的谓词下推和索引支持程度。
6. 最后分享几个实战中沉淀的小经验
先说我自己到现在还在用的经验。
第一,Map再方便,也不要让它成为你表里的“垃圾场”。每建一个Map字段,先问一句:这个字段的高频查询条件是什么?如果高频条件超过两三个,就应该把它们拉成固定列。别让Map承载你所有的业务灵活性,最后变成“查询黑洞”。
第二,磁盘上见真章。无论你做了多少优化,最后都要落到“压缩率、扫描字节数、查询耗时”这三个指标上。这三个指标就是Map性能优化的北极星。另外也推荐在任何优化完后,用system.query_log里的read_bytes和read_rows做前后对比,直观看到优化效果。
第三,版本升级别盲目,也别垫底。ClickHouse不同版本对Map的优化差异确实大。如果你正在用21.8,可以做我上面讲到的那些优化,性能会改善不少;如果业务有条件升级,建议升级到较新稳定版本,新的压缩策略和查询优化对Map的帮助比想象的大。
第四,和普通列搭配使用的时候,注意让排序键与高频过滤条件对齐。我发现很多团队在建表的时候只想着“数据进来能存”,忘记考虑“查询怎么最快”。Map表尤其如此,排序键选得不好,Map的膨胀问题会被进一步放大。
第五,Map字段写入端统一键排序这个习惯,真的很值。表面上看起来只是把键排了个序,但对压缩率的影响非常可观。我实测过,同一个数据集,键排序前压缩率5倍,排序后可以到12倍。这个习惯值得在数据同步链路里固化下来。
如果你手头正好在折腾ClickHouse,而且业务里碰到了Map相关的性能问题,建议按这篇文章的思路先做一遍体检:审视建表结构、查询模式、压缩指标,再决定用哪些优化手段。Map不是洪水猛兽,用对了就是一个很强的武器。