
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/Oattr[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_versioniOS端可能上报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做全量的键值分析比如统计哪些标签热度最高可以用mapKeysarrayJoin展开成平表再聚合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亿行上都不可能快。另外一个常见需求是按值过滤比如“找出所有osandroid的用户”。这里容易犯的错误是把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拉成固定列并加LowCardinalityprops只保留真正动态的低频属性排序键从(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] 1ClickHouse通常会对每一行的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不是洪水猛兽用对了就是一个很强的武器。