ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ClickHouse Map类型实战:从底层原理到性能优化,避开全表扫描大坑

ClickHouse Map类型实战:从底层原理到性能优化,避开全表扫描大坑 先分享一个我踩过的大坑当时给用户画像表设计标签存储图省事把几十个标签全部塞进一个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. 底层存储原理两个数组撑起一个 Map2.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 标签的用户也会被处理为内层判断不会因反正不匹配而出错但在做“键存在性判断”时你必须改用mapContainsSELECT 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 JOINSELECT 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;建一张枚举维表codenamedescription1cpuCPU 使用率2mem内存使用率3disk磁盘使用率写入侧把业务字符串键翻译成枚举 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 JOINSELECT 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 tableevents看 Map 对应的物理子列分析查询计划EXPLAIN indexes 1 SELECT ...判断是否全表扫描看压缩效率SELECT name, data_compressed_bytes / data_uncompressed_bytes FROM system.parts WHERE tableevents判断锁存是否合理实时慢查询system.query_log里找 longest_query定位慢查询源头这套组合拳打下来大部分 Map 性能问题都能正确定位。我自己现在写新表时会把“哪些键要拆列”“哪些键放 Map”“键用什么类型”作为字段设计的三步标准动作很少再遇到那种上线后才发现的性能事故。最后分享一个小经验Map 这个类型好不好用90% 取决于设计期的分寸感。我一向对团队说一句话能用固定列解决的问题永远不要为了“灵活”用 Map而真的是动态键场景也别矫枉过正去建一堆稀疏列。拆高频键、留长尾键、用整数键、规范键格式这四件事做好了ClickHouse 的 Map 就能成为你工具箱里一个顺手又高效的工具。如果你正在搭建一套从 MySQL/Flink 同步到 ClickHouse 的管道Map 会是你解析 JSON 字段的好帮手但务必在同步端就把数据格式清洗规整别把脏键带到 ClickHouse。真到排查性能和脏数据的时候你一定会感谢当初多花的这十分钟。
返回列表