ClickHouse 踩坑合集:7 月遇到的 8 个让人头疼的生产问题

发布时间:2026/7/27 11:34:50

ClickHouse 踩坑合集:7 月遇到的 8 个让人头疼的生产问题 ClickHouse 踩坑合集7 月遇到的 8 个让人头疼的生产问题ClickHouse 快是真快但坑也是真坑。朱大喜 7 月生产环境实战排雷笔记每一个坑都踩到过半夜。一、ClickHouse 的快不是白给的先泼个冷水ClickHouse 的快是有代价的。它牺牲了事务、更新能力和即席查询的灵活性换来的是列存 向量化 并行处理的极致 OLAP 性能。这个月接手了一套日均写入 20 亿条、总存储 50TB 的日志分析系统算是把 ClickHouse 的暗面给摸了一遍。二、8 个生产问题逐一击破坑 1ORDER BY 键选错了查询直接慢了 100 倍最惨的一次一个 5 亿行的表查询某个user_id的最近行为跑了 30 秒没出来。一看建表语句-- ❌ 错误示范ORDER BY 键没有把常用过滤条件放前面 CREATE TABLE user_events_bad ( event_time DateTime, user_id UInt64, event_type String, page_url String, duration UInt32 ) ENGINE MergeTree() ORDER BY (event_time, event_type); -- event_time 放第一位按 user_id 查询要全表扫描ClickHouse 的主键索引是稀疏索引只在ORDER BY的前几列生效。修正后-- ✅ 正确做法把最常用的等值过滤条件放 ORDER BY 最前面 CREATE TABLE user_events_good ( event_time DateTime, user_id UInt64, -- 最常用的过滤条件 event_type LowCardinality(String), -- 低基数列用 LowCardinality page_url String, duration UInt32 ) ENGINE MergeTree() PARTITION BY toYYYYMM(event_time) -- 按月分区加速时间范围裁剪 ORDER BY (user_id, event_type, event_time) -- user_id 在最前面 SETTINGS index_granularity 8192; -- 默认粒度一般够用 -- 查询验证同样的 SQL原来 30s → 现在 0.05s SELECT count(), avg(duration) FROM user_events_good WHERE user_id 1234567 AND event_time 2026-07-01 AND event_type page_view;坑 2批量插入太小merge 速度跟不上写入速度7 月初发现系统 CPU 长期 80%排查下来是每 100 条就 INSERT 一次。ClickHouse 的推荐做法是每批次至少几千到几万条一组提交。from clickhouse_driver import Client import json from typing import List, Dict client Client(hostlocalhost, port9000, databaselogs) def batch_insert_optimized(events: List[Dict], batch_size: int 10000): ClickHouse 最佳实践攒够 batch_size 条再写入 生产环境实测batch_size10000 时写入效率是逐条插入的 ~200 倍 buffer [] for event in events: buffer.append(event) if len(buffer) batch_size: # 一次性提交整批数据ClickHouse 会在后台异步 merge client.execute( INSERT INTO user_events VALUES, [(e[event_time], e[user_id], e[event_type], e[page_url], e[duration]) for e in buffer] ) buffer.clear() # 别忘了处理剩余数据 if buffer: client.execute( INSERT INTO user_events VALUES, [(e[event_time], e[user_id], e[event_type], e[page_url], e[duration]) for e in buffer] )坑 3Nullable 列猛如虎一看性能原地杵给字段加了Nullable(String)以为只是允许为空。实际上 ClickHouse 对 Nullable 列会用额外的 UInt8 掩码存储查询时需要多一步 null 判断聚合性能直接打折。7 月份的教训能用默认值替代的就别用 Nullable。为什么 ClickHouse 的 Nullable 开销比一般数据库大得多在 MySQL 中Nullable 只是在行格式里多一个 bit 标记对聚合影响很小。但 ClickHouse 是列存——每次读取 Nullable 列时要先读 UInt8 掩码列判断每个值是不是 null然后再读实际数据列。这意味着原本的读一列变成了读两列 逐行条件判断完全破坏了 ClickHouse 赖以成名的向量化执行SIMD 批量操作。更致命的是如果在 GROUP BY 里用了 Nullable 列ClickHouse 无法使用某些哈希优化因为 null 值的哈希处理需要特殊路径。实测在我们的 5 亿行表上同样的聚合查询Nullable 版本比非 Nullable 版本慢3-5 倍。-- ❌ 不推荐所有可能为空的字段都加 Nullable CREATE TABLE orders_bad ( order_id UInt64, user_id UInt64, coupon_code Nullable(String), -- 大部分是 null refund_reason Nullable(String) -- 大部分是 null ) ENGINE MergeTree() ORDER BY order_id; -- ✅ 推荐用空字符串 代替 null或建两个表 CREATE TABLE orders_good ( order_id UInt64, user_id UInt64, coupon_code String DEFAULT , -- 空串替代 null refund_reason String DEFAULT -- 空串替代 null ) ENGINE MergeTree() ORDER BY order_id;坑 4JOIN 了张大右表内存直接 OOMClickHouse 的 JOIN 默认是 hash join右表必须能装进内存。右表 2000 万行直接炸了。解决办法-- ❌ 右表太大必定 OOM SELECT a.*, b.user_name FROM events a LEFT JOIN users b ON a.user_id b.user_id; -- users 表 2000 万行 -- ✅ 方案1用字典适合维度表 CREATE DICTIONARY user_dict ( user_id UInt64, user_name String ) PRIMARY KEY user_id SOURCE(CLICKHOUSE(DB dim TABLE users)) LAYOUT(FLAT()) -- Flat 布局适合 key 连续的字典 LIFETIME(MIN 300 MAX 600); -- 5-10分钟刷新一次 SELECT a.*, dictGet(user_dict, user_name, a.user_id) AS user_name FROM events a; -- ✅ 方案2把大表做子查询缩小再 JOIN SELECT a.*, b.user_name FROM events a LEFT JOIN ( SELECT user_id, user_name FROM users WHERE user_id IN (SELECT DISTINCT user_id FROM events WHERE event_date 2026-07-27) ) b ON a.user_id b.user_id;坑 5SELECT * 在生产环境是定时炸弹一个实习生写了SELECT * FROM events WHERE event_date today()表有 120 列、当日 3 亿条数据。这条 SQL 跑了 2 分钟后 OOM 了。ClickHouse 的列存让大宽表的全列查询代价极高。坑 6物化视图没有 TO 子句数据丢失了也不知道-- ❌ 裸物化视图一旦源表删数据视图数据也会丢 CREATE MATERIALIZED VIEW mv_daily_stats ENGINE SummingMergeTree() ORDER BY (event_date, event_type) AS SELECT toDate(event_time) AS event_date, event_type, count() AS cnt FROM events GROUP BY event_date, event_type; -- ✅ 带 TO 子句独立的目标表数据安全 CREATE TABLE daily_stats_target ( event_date Date, event_type LowCardinality(String), cnt UInt64 ) ENGINE SummingMergeTree() ORDER BY (event_date, event_type); CREATE MATERIALIZED VIEW mv_daily_stats_safe TO daily_stats_target -- 数据写入独立表 AS SELECT toDate(event_time) AS event_date, event_type, count() AS cnt FROM events GROUP BY event_date, event_type;坑 7TTL 删除 ≠ 即时删除给表设了TTL event_time INTERVAL 30 DAY以为到期自动删。实际上 ClickHouse 的 TTL 是在 merge 时触发的如果分区一直没 merge数据就一直留着。7 月发现 4 月的数据都还在排查后才明白。解决办法定期执行OPTIMIZE TABLE xxx FINAL或者在查询侧用 WHERE 过滤代替依赖 TTL。为什么 TTL 只在 Merge 时触发而不是实时删除ClickHouse 的 MergeTree 引擎是基于不可变数据块Parts设计的——每个 Insert 产生一个新的 PartMerge 把多个小 Part 合并成大 Part。TTL 的删除逻辑是在 Merge 过程中用过滤掉过期行的方式实现的而不是修改已存在的 Part。如果你的表写入量很小比如每天只有几万行Merge 可能几周都不触发一次TTL 就形同虚设。更隐蔽的坑OPTIMIZE TABLE xxx FINAL会强制触发全表 Merge耗时可能很长在业务高峰期执行等于自己锁自己的表。更好的方案是在查询侧加WHERE event_time now() - INTERVAL 30 DAY做逻辑过滤把 TTL 当作最后的兜底清理而非主要的淘汰手段。坑 8分布式表的distributed_product_mode雷区默认配置下分布式表做GLOBAL IN子查询数据会被拉到发起节点再分发。两张 5000 万行的分布式表做IN直接全集群 OOM。正确做法要么改用GLOBAL JOIN要么预先聚合。三、ClickHouse 监控清单这个月搭了一套 ClickHouse 的基础监控四个维度四、踩坑后的最佳实践速查表场景错误做法正确做法建表ORDER BY 随便写把高频过滤列放第一插入逐条/小批量10,000 一条批量字段类型滥用 Nullable用默认值代替 nullJOINJOIN 大右表字典 / 缩小右表 / Broadcast物化视图无 TO 子句必须 TO 独立表删除频繁 DELETE用 TTL 分区 DROP分布式查询跨分片 INGLOBAL IN / 预聚合数据类型String 通吃LowCardinality / FixedString五、总结 踩坑提醒ORDER BY (event_time, user_id) 在时间范围查询时比 (user_id, event_time) 更优——但必须结合你的查询模式选如果你的 90% 查询都是WHERE user_id 12345 AND event_time ...user_id 放前面没错。但如果你大量查询是最近 1 小时所有用户的行为只有时间过滤没有 user_id 过滤把 event_time 放第一列反而更好——因为 ClickHouse 会按 ORDER BY 的第一列做分区裁剪。没有放之四海皆准的 ORDER BY 顺序必须基于实际查询日志做统计分析。OPTIMIZE TABLE xxx FINAL在生产环境相当于锁表操作FINAL 修饰符强制等待所有后台 Merge 完成对大表可能耗时数小时期间写入INSERT可能正常但查询性能会受影响因为 Parts 在重组中。建议只在维护窗口期执行而且只对特定分区执行OPTIMIZE TABLE xxx PARTITION 202607而非全表。物化视图不回溯历史数据当你创建物化视图CREATE MATERIALIZED VIEW ... TO target_table AS SELECT ... FROM events时它只对创建之后写入 events 表的数据生效。如果 events 表里已有 30 天的历史数据这些数据不会自动进入物化视图。必须手动执行一次INSERT INTO target_table SELECT ... FROM events WHERE event_date today()把历史数据补齐。ClickHouse 不是 MySQL 的替代品它是 OLAP 场景的特化武器。用好了是倚天剑用不好就是七伤拳。这个月的核心认知是ClickHouse 的优化不是在 SQL 层面加个索引那么简单而是从建表那一刻就开始的存储层设计。ORDER BY、分区策略、字段类型、物化视图、TTL —— 这五件事做好了大部分性能问题根本不会有。8 月计划深挖一下 ClickHouse Keeper 和副本同步机制到时候再写续集。

相关新闻