从一次写入延迟从 10ms 飙到 100ms+ 的现场复盘,到可复用的工程决策框架
摘要
增量物化视图(Incremental Materialized View)是 ClickHouse 实时数仓的核心构件,但当其查询中关联一张千万级维度表时,每一次 INSERT block 触发 MV 计算都会重新读取整张维度表,写入延迟从 10ms 量级跃升至 100ms 以上,吞吐随之塌陷。本文以该现象为切入点,系统拆解 ClickHouse 中三条主流解法——外部字典(Dictionary)、Join 表引擎(Join Engine)、维度内嵌(把维度字段冗余进 MV 目标表或源表)——的底层执行机制、内存模型与一致性语义,结合官方文档、社区实践与近年公开研究,构建一条“以维度变更频率与一致性容忍度为横轴、以维度规模与写入吞吐为纵轴”的选型主线。文章给出可复现的压测方法、参数配置清单与灰度迁移步骤,并对字典热更新的原子性边界、Join 引擎的持久化陷阱、宽表冗余的存储放大等易踩坑点逐一评述。笔者认为,多数生产场景的最优解并非三选一,而是“字典兜底 + 关键维度内嵌”的混合架构。
目录
一、问题现场:一次 INSERT 为何要重读整张维度表
先把场景还原清楚。业务侧有一张事实表 events,每秒写入若干批次;一张维度表 dim_user,约两千万行,每天全量刷新一次。为了在写入时即完成打宽,团队建了一个增量物化视图:
CREATE MATERIALIZED VIEW mv_events_enriched
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id)
AS
SELECT
e.event_time,
e.user_id,
e.event_type,
d.city,
d.user_level,
d.register_channel
FROM events AS e
LEFT JOIN dim_user AS d ON e.user_id = d.user_id;
上线初期一切正常,单批写入延迟约 10ms。随着维度表从百万级涨到两千万级,写入延迟开始爬升,最终稳定在 100ms 以上,高峰期甚至出现写入队列积压。用 system.query_log 抓取 MV 触发的内部查询,可以看到每次 INSERT block 都会派生一条形如 SELECT ... FROM events JOIN dim_user 的子查询,而 dim_user 的读取行数几乎等于全表行数。
这里的关键认知是:增量物化视图并不是“只处理增量数据”的魔法,它只是把“触发器”限定在新写入的 block 上,而查询计划本身仍然是完整的一条 SELECT。如果这条 SELECT 里包含对维度表的 JOIN,那么每个 block 都要独立执行一次 JOIN 的右表构建或探测。ClickHouse 官方文档在 Materialized View 章节明确说明,MV 本质是“insert 触发器 + 目标表写入”,其计算逻辑与普通 SELECT 无异(ClickHouse Docs, "Materialized Views", 2024)。本文评述:很多团队把 MV 当成流处理引擎来用,却忽略了它没有跨批次的状态缓存,维度表在每个 block 上都是“冷”的,这正是延迟放大的根源。
一个常被忽视的细节:ClickHouse 对 JOIN 的默认策略是 hash,即把右表整体加载进内存构建哈希表。对于两千万行的维度表,这个构建过程本身就是主要开销,而它每个 block 都要重来一次。
1.1 延迟构成拆解
把 100ms 拆开看,大致由三部分组成:右表扫描与反序列化、哈希表构建、探测与结果写出。下表是基于公开基准与社区复现数据整理的典型占比(模拟数据,基于 2000 万行、6 个 String/Int 字段的维度表,单机 32 核 128GB 环境估算):
可以看到,超过 75% 的开销集中在“重复构建右表”上。这就把问题定义得非常清楚:优化的本质不是让 JOIN 更快,而是让维度数据“只加载一次、多次复用”。三条候选路线,本质上是对“复用载体”的三种不同选择——字典把复用载体放在进程内的专用结构里,Join 引擎把复用载体放在一张常驻内存的表里,维度内嵌则干脆取消运行时关联、把复用提前到写入前。
二、机制解剖:增量 MV 的执行链路与维度读取放大
要选对路线,必须先看清 MV 的执行链路。ClickHouse 的 INSERT 进入后,会按 max_insert_block_size(默认 1048576 行)切分 block,每个 block 依次触发挂在其上的 MV。MV 的执行由 InterpreterInsertQuery 派生出的查询管道完成,其中 JOIN 由 JoinStep 处理。
2.1 为什么没有跨 block 的缓存
ClickHouse 的查询执行是“无共享状态”的流水线设计,每个查询(包括 MV 派生的子查询)拥有独立的执行上下文。JOIN 的哈希表生命周期绑定在单次查询内,查询结束即释放。这意味着即便连续两个 block 关联的是同一张维度表,哈希表也会被重建两次。社区 issue #35360、#41234 均讨论过这一行为,官方立场是“这是查询隔离性的必然代价”(ClickHouse GitHub Issues, 2022–2023)。
笔者认为,这一设计在 OLAP 交互式查询场景下完全合理——查询间共享状态会带来内存泄漏与并发干扰风险;但在高频小批量写入的 MV 场景下,它就成了性能杀手。理解这一点,才能明白为什么“调大 block size”只是缓解而非根治:block 越大,单位数据摊薄的重建成本越低,但单次延迟的绝对值反而更高,且受 max_insert_block_size 与客户端批次大小双重约束。
2.2 维度读取放大的量化模型
设维度表行数为 N,单 block 事实行数为 b,每秒 block 数为 k,则每秒维度表被重复扫描的行数为 k×N。当 N=2×10⁷、k=10 时,每秒要扫描 2 亿行维度数据——这已经超过很多集群的聚合扫描能力。用公式表达单位事实行的维度读取放大系数:
放大系数 A = (k × N) / (k × b) = N / b
例:N = 2e7,b = 1e5 → A = 200
即每处理 1 行事实,平均要触碰 200 行维度数据。
这个模型解释了一个反直觉现象:把小批次合并成大 block,放大系数 A 会线性下降。所以第一优先级永远是“减少 block 数量”,其次才是换关联方式。很多团队跳过这一步直接上字典,结果发现收益不如预期,原因就在这里。关于批次合并的工程手段(客户端攒批、async_insert、Buffer 表),可参考 ClickHouse 官方异步插入文档:Asynchronous Inserts。
三、路线一:外部字典 Dictionary 的能力边界
字典是 ClickHouse 为“小表广播式关联”准备的原生结构。它以 key-value 形式常驻内存(或 SSD),支持多种布局(flat、hashed、complex_key_hashed 等)与多种数据源(ClickHouse 表、MySQL、HTTP、文件)。在 MV 中用 dictGet 替代 JOIN,是社区最常推荐的解法。
3.1 典型配置与写法
CREATE DICTIONARY dim_user_dict (
user_id UInt64,
city String,
user_level UInt8,
register_channel String
)
PRIMARY KEY user_id
SOURCE(CLICKHOUSE(
HOST 'localhost' PORT 9000
USER 'default' TABLE 'dim_user' DB 'default'
))
LIFETIME(MIN 300 MAX 600)
LAYOUT(HASHED());
-- MV 中改写
SELECT
e.event_time, e.user_id, e.event_type,
dictGet('dim_user_dict', 'city', e.user_id) AS city,
dictGet('dim_user_dict', 'user_level', e.user_id) AS user_level
FROM events AS e;
改写后,每个 block 不再扫描维度表,而是直接对内存哈希结构做点查,单次查找通常在百纳秒级。官方文档指出,HASHED 布局适用于 key 为 UInt64 且行数在数千万以内的场景(ClickHouse Docs, "Dictionaries", 2024)。
3.2 三个必须知道的边界
边界一:LIFETIME 不是实时。字典按 MIN/MAX 区间随机刷新,刷新期间旧值仍可服务,因此存在最长 MAX 秒的陈旧窗口。对用户等级这类缓慢变化维度可接受,对风控标签这类强一致维度则不可接受。本文评述:很多团队误以为字典刷新是“推送式”,实际是“拉取 + 定时”,理解这一点才能正确设置 LIFETIME。
边界二:刷新是整表替换,不是增量。每次刷新会重建整个字典结构,期间内存占用翻倍。两千万行的 HASHED 字典在刷新瞬间可能占用数 GB 额外内存。社区实践建议对超大维度使用 SSD_CACHE 或 COMPLEX_KEY_SSD_CACHE 布局,把冷数据放盘上(ClickHouse Blog, "Dictionaries in ClickHouse", 2023)。
边界三:不支持任意 JOIN 语义。字典只支持按主键点查,无法表达范围连接、多键不等值连接、一对多展开。如果维度关联逻辑复杂(例如按时间区间匹配费率表),字典无能为力。这一点在官方文档的 "Dictionary" 章节有明确限制说明。
3.3 适用性判断
笔者认为,字典适合满足以下全部条件的维度:单键等值关联、行数在千万级以内、变更容忍分钟级延迟、字段以标量为主。一旦维度需要“一对多”或“区间匹配”,就应转向其他路线。关于字典布局选择的详细对比,可参考 官方字典文档 与社区教程 ClickHouse Dictionary 实战讲解(视频)。
四、路线二:Join 表引擎的内存与持久化真相
Join 表引擎的思路是:把维度表以 Join 引擎物化到内存中,MV 查询时与这张“内存表”做 JOIN。由于 Join 引擎表常驻内存,右表构建只发生一次(或按需增量更新),从而消除重复构建开销。
4.1 基本用法
CREATE TABLE dim_user_join (
user_id UInt64,
city String,
user_level UInt8
)
ENGINE = Join(ANY, LEFT, user_id);
-- 定期或实时灌入
INSERT INTO dim_user_join SELECT user_id, city, user_level FROM dim_user;
-- MV 中直接 JOIN
SELECT e.event_time, e.user_id, d.city
FROM events AS e
LEFT JOIN dim_user_join AS d ON e.user_id = d.user_id;
4.2 持久化陷阱与重启风险
Join 引擎表的数据默认只存在内存中,服务重启后内容清空。官方文档明确提示 "Join engine tables are not persistent"(ClickHouse Docs, "Join Table Engine", 2024)。这意味着必须配套一个“启动后重新灌入”的机制,否则重启窗口内的 MV 计算会全部关联失败,产生 NULL 维度。社区常见做法是用一个普通 MergeTree 表存全量维度,启动时通过物化视图或定时任务同步到 Join 表。
本文评述:这是 Join 引擎最大的工程隐患。它把“一致性”责任从数据库转移给了运维流程,一旦同步任务失败或延迟,错误会静默地写进 MV 目标表,且难以事后追溯。相比之下,字典的 LIFETIME 机制至少保证了“最终会刷新”,而 Join 表一旦漏灌就是永久性缺口。
4.3 与字典的对比
笔者认为,Join 引擎的真正价值在于“需要完整 JOIN 语义但又要避免重复构建”的场景,例如多键关联或需要保留一对多关系的维度。如果只是单键点查,字典在运维简洁性上明显更优。Join 引擎的官方说明见 Join Table Engine 文档。
五、路线三:维度内嵌与宽表冗余的代价核算
第三条路线最“朴素”:既然每次都要关联,那就在写入前把维度字段直接带进事实数据,MV 里不再 JOIN。实现方式有两种——在客户端/接入层完成打宽,或在 MV 的源表侧用字典预计算后写入一张宽表,MV 只做轻量转换。
5.1 存储放大核算
冗余的代价是存储。设事实表日增 R 行,冗余字段平均字节数为 s,压缩比 c,则日增存储为 R×s/c。以日增 5 亿行、冗余 3 个 String 字段(平均 20 字节)、压缩比 5 估算:
日增存储 ≈ 5e8 × 20 / 5 = 2e9 字节 ≈ 2 GB/天
年增 ≈ 730 GB(单副本)
注:以上为模拟估算,实际取决于字段基数与编码方式。
对于低基数字段(如 user_level、register_channel),ClickHouse 的 LowCardinality 编码可以把放大压到很低;对于高基数字段(如 city 若细分到区县),放大则显著。本文评述:冗余的可行性高度依赖字段基数,不能一概而论。工程上应先做字段画像,再决定哪些字段值得内嵌。
5.2 一致性代价
内嵌意味着“写入时刻的维度快照”被固化。若维度后续变更(用户等级提升、城市迁移),历史事实不会自动更新。这在很多分析场景下反而是正确语义(记录当时状态),但在“当前维度画像”类查询中会造成偏差。因此内嵌路线必须明确回答:业务要的是“事件发生时的维度”还是“查询时的维度”?前者适合内嵌,后者适合字典/Join 实时关联。
5.3 混合写法示例
-- 源表侧预打宽(低基数、语义为“当时状态”的字段)
CREATE TABLE events_wide (
event_time DateTime,
user_id UInt64,
event_type LowCardinality(String),
city LowCardinality(String), -- 内嵌
user_level LowCardinality(String) -- 内嵌
) ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id);
-- MV 只做字段裁剪与轻量转换,不再 JOIN
CREATE MATERIALIZED VIEW mv_events_wide
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id)
AS
SELECT event_time, user_id, event_type,
dictGet('dim_user_dict','city',user_id) AS city,
dictGet('dim_user_dict','user_level',user_id) AS user_level
FROM events;
注意这个写法仍然用了字典,但字典只在内嵌时点查一次,且内嵌结果被持久化,后续查询不再依赖字典。这实际上是“字典 + 内嵌”的组合,也是笔者认为最值得推广的形态。关于 LowCardinality 的编码收益,可参考 官方文档。
六、决策主线:一张选型矩阵与三条判定规则
把三条路线放在同一坐标系里,横轴是“维度变更频率与一致性容忍度”,纵轴是“维度规模与关联复杂度”。这条主线贯穿全文:没有绝对最优的路线,只有与维度特征匹配的路线。
6.1 三条判定规则
规则一:先减 block,再换路线。如果 A=N/b 的放大系数仍然很高,说明批次太小,应先做攒批或异步插入。这一步的收益往往大于换关联方式,且零改造成本。
规则二:能用点查就不用 JOIN。只要维度关联是单键等值,字典几乎总是优于 Join 引擎,因为后者引入了持久化运维负担。只有当关联语义超出点查能力时,才考虑 Join 引擎。
规则三:语义决定是否内嵌。如果业务问的是“当时是什么”,内嵌是正解;如果问的是“现在是什么”,则必须保留实时关联。这条规则不是性能问题,而是数据语义问题,优先级最高。
七、工程落地:压测方案、参数清单与灰度步骤
7.1 可复现的压测方案
压测的目标是量化三条路线在“相同 block 规模、相同维度规模”下的写入延迟与吞吐。建议用 clickhouse-benchmark 或自写脚本,固定事实行数与维度行数,分别测三种 MV 定义。关键指标:P50/P99 写入延迟、每秒 block 数、维度相关内存占用、MV 目标表写入速率。
# 示例:固定 10 万行/block,测 100 个 block 的写入延迟
clickhouse-benchmark \
--query "INSERT INTO events SELECT ... FROM numbers(100000)" \
--iterations 100 \
--concurrency 1
# 观察 MV 派生查询
SELECT query, query_duration_ms, read_rows
FROM system.query_log
WHERE query LIKE '%mv_events_enriched%'
ORDER BY event_time_microseconds DESC LIMIT 20;
需要说明的是,上述命令为方法示意,具体表结构与数据生成需按实际 schema 调整;本文不提供虚构的压测结果,读者应以自测数据为准。
7.2 关键参数清单
7.3 灰度迁移步骤
建议按以下顺序灰度,每一步都可回滚:
- 建立维度表画像:行数、基数、变更频率、字段类型,形成决策输入。
- 先做 block 合并优化,观察延迟变化,确认是否已足够。
- 新建字典,用影子 MV 并行写入一张对照表,比对结果一致性。
- 切换主 MV 到字典写法,保留旧 MV 一段时间做双写校验。
- 对低基数、快照语义字段追加内嵌,逐步减少运行时关联。
- 若仍需完整 JOIN 语义,再引入 Join 引擎,并配套启动灌入任务。
这套步骤的核心思想是“先观测、再切换、后收敛”,避免一次性大改带来的不可控风险。关于 MV 双写校验的实践,可参考社区文章 Using Materialized Views in ClickHouse。
八、前沿观察:从 JOIN 重写到查询计划级优化
ClickHouse 社区近两年在 JOIN 优化上动作频繁。2023 年引入的 join_algorithm 支持 grace_hash、partial_merge 等策略,可在内存受限时把哈希表溢写到磁盘(ClickHouse Docs, "Join Algorithms", 2024)。这对大维度 JOIN 的内存压力有缓解,但并未改变“每 block 重建”的本质。
更值得关注的是查询计划缓存与“参数化视图”方向的讨论。社区 issue #56432 提出为高频 MV 子查询缓存 JOIN 构建结果的设想,但截至本文写作时仍处于讨论阶段,尚无稳定实现。本文评述:在官方提供跨查询 JOIN 缓存之前,字典与内嵌仍是生产环境最可靠的两条路。学术界方面,SIGMOD 与 VLDB 近年关于“物化视图增量维护”的研究(如 2022–2024 年多篇关于 delta-maintenance 的论文)更多聚焦于聚合类 MV 的增量更新,对“维度关联放大”这一工程问题着墨不多,这也说明该问题目前主要靠工程手段而非理论突破来解决。
另一个前沿方向是“维度服务化”:把维度查询下沉到独立的低延迟服务(如 Redis、专用字典服务),MV 通过 dictGet 的 HTTP 源或远程字典访问。这在维度跨集群共享的场景下有价值,但引入了网络往返,需权衡。相关讨论可参考 外部字典数据源文档。
九、结论与行动清单
回到开头那个 10ms 到 100ms+ 的现场。问题的本质不是“JOIN 慢”,而是“维度表被每个 block 重复加载”。三条路线分别对应三种复用策略,选择依据应回到维度本身的特征,而非性能直觉。笔者认为,多数生产系统的最优形态是“字典兜底 + 关键维度内嵌”:字典负责实时关联与高基数维度,内嵌负责低基数、快照语义字段,二者组合既控制了延迟,又保留了语义正确性。
行动清单
- 用
system.query_log量化当前维度读取放大系数 A。 - 优先做 block 合并,观察延迟基线变化。
- 为单键维度建字典,影子 MV 校验一致性。
- 对低基数、快照语义字段做内嵌,减少运行时关联。
- 仅在完整 JOIN 语义必要时引入 Join 引擎,并配套灌入任务。
- 建立维度画像文档,作为后续选型的长期依据。
主要参考文献
[1] ClickHouse Documentation. Materialized Views. 2024. https://clickhouse.com/docs/en/sql-reference/statements/create/view
[2] ClickHouse Documentation. Dictionaries. 2024. https://clickhouse.com/docs/en/sql-reference/dictionaries
[3] ClickHouse Documentation. Join Table Engine. 2024. https://clickhouse.com/docs/en/engines/table-engines/special/join
[4] ClickHouse Documentation. Join Algorithms. 2024. https://clickhouse.com/docs/en/guides/join
[5] ClickHouse Blog. Using Materialized Views in ClickHouse. 2023. https://clickhouse.com/blog/using-materialized-views-in-clickhouse
[6] ClickHouse GitHub Issues #35360, #41234, #56432. 2022–2024. https://github.com/ClickHouse/ClickHouse/issues
[7] ClickHouse Documentation. Asynchronous Inserts. 2024. https://clickhouse.com/docs/en/optimize/asynchronous-inserts
[8] ClickHouse Documentation. LowCardinality Data Type. 2024. https://clickhouse.com/docs/en/sql-reference/data-types/lowcardinality
[9] 社区实践与公开基准讨论汇总(含 GitHub Discussions、Stack Overflow 相关问答),2022–2024。文中模拟数据基于 2000 万行维度表、单机 32 核 128GB 环境估算,非真实生产数据。
文章声明
本文内容仅为作者学习、思考、经验、笔记的总结,仅供技术交流与参考。文中观点仅代表笔者个人思辨,不构成任何学术建议、商业建议或专业建议。所有数据来源已标注,引用时请以原始文献为准。

