大家好,我是数据库小学妹 👋
上个月,一家零售企业的客户问我。他们的报表系统越跑越慢,按地区和月份汇总一次要八分多钟。
我查了执行计划。索引用了。JOIN也优化了。但扫描的数据量就是那么大。一千万行数据,按地区、按产品、按时间维度汇总,MySQL的行式存储天生不擅长这种场景。
后来引入了ClickHouse做分析。同一个查询,从八分多钟降到两秒。差距不是一个量级。
今天我把列式存储的原理、ClickHouse的核心引擎、和MySQL的实际对比写出来。朋友,如果你的报表也越跑越慢,这篇或许能给你一个新思路。
行式存储为什么不适合做报表?
先看MySQL是怎么存数据的。
行式存储:
行1: [id=1, name=张三, region=华东, amount=100, date=2026-01-01]
行2: [id=2, name=李四, region=华南, amount=200, date=2026-01-02]
行3: [id=3, name=王五, region=华东, amount=150, date=2026-01-03]
...
所有列存在一起。读一行很快。但如果只算amount的总和呢?MySQL必须把每一行的所有列都读进内存,然后从中提取amount字段。其他列(name、region、date)也读了,但没用到。IO浪费了。数据量小时无所谓。一千万行时,这个问题被放大了无数倍。
行式存储的设计目标是OLTP(在线事务处理)。 它的优势是增删改快,单行查询快,事务支持好。报表分析不是它的主场。
列式存储是怎么做到快的?
ClickHouse用的是列式存储。同一个列的数据存在一起。
列式存储:
id列: [1, 2, 3, 4, 5, ...]
name列: [张三, 李四, 王五, 赵六, ...]
region列: [华东, 华南, 华东, 华北, ...]
amount列: [100, 200, 150, 300, ...]
date列: [2026-01-01, 2026-01-02, ...]
执行SELECT SUM(amount)时,只需要读amount这一列。其他列完全不碰。IO量直接减少了N倍(N是列数)。
这还不是全部。列式存储还有两个杀手锏。
数据压缩。 同一列的数据类型相同,值域相近,压缩率极高。amount列全是数字,region列重复值多。列存压缩后通常只有行存的1/5到1/10。
压缩率对比(同一份数据):
行式存储(MySQL InnoDB):约 12GB
列式存储(ClickHouse): 约 2.4GB
压缩比:5:1
向量化执行。 传统数据库一行一行算。ClickHouse一批一批算。这里面的关键是CPU的SIMD指令。
SIMD全称Single Instruction Multiple Data。单指令多数据。普通CPU指令一次处理一个值。SIMD指令一次处理8到16个值。ClickHouse把查询编译成向量化算子,每个算子直接调用SIMD指令。
传统执行(逐行):
for row in rows:
result += row.amount ← 每次处理1个值
向量化执行(批量):
result = SIMD_SUM(amount_chunk) ← 每次处理8-16个值
我一开始不理解这有什么用。后来我用perf看了ClickHouse的执行profile,发现SUM操作走的是AVX2指令集,一个循环处理256位数据。64位的DECIMAL值一次能算4个。这就是为什么列存的SUM能比行存快几十倍。数据挨在一起,CPU可以批量加载到向量寄存器里,一条指令搞定。
但向量化加速有个前提:数据必须连续。行式存储里,amount字段和其他列混在一起,CPU要先跳过无关列才能取到数据。列式存储里,amount值连续存放,CPU直接批量加载。列存和向量化是天生一对。
向量化的加速效果取决于CPU。支持AVX2指令集的CPU,向量化SUM可以快4到8倍。
理解了列式存储和向量化,再来看ClickHouse的表引擎就顺了。ClickHouse有很多表引擎,MergeTree家族是最核心的。MergeTree的工作原理是:把数据按主键排序后分成多个"part"(数据块),每个part独立压缩存储,查询时只扫描相关的part。
MergeTree数据组织:
part_1: [主键范围 1-10000] ← 压缩存储,有索引
part_2: [主键范围 10001-20000]
part_3: [主键范围 20001-30000]
...
查询 WHERE id BETWEEN 5000 AND 15000:
→ 只需扫描 part_1 和 part_2
→ part_3 及之后的part跳过
MergeTree有两个核心特性。
主键索引(稀疏索引)。 不是B+树,而是一个稀疏的标记。每隔一定行数(默认8192行,ClickHouse叫index_granularity)记录一个主键值和它对应的mark文件偏移。查询时先查索引标记,通过mark文件定位到具体的数据块,只读那个块。8192这个默认值和ClickHouse的存储设计有关------每个granule存8192行数据,一个索引标记对应一个granule,索引体积小但足以定位。如果有大量点查(WHERE id = xxx)的需求,可以调小index_granularity来加密索引,代价是索引体积变大。
数据合并(Merge)。 新写入的数据先放到一个小part。后台自动把小part合并成大part。合并时重新排序、去重、压缩。这个过程对用户透明。
创建一个MergeTree表:
sql
CREATE TABLE orders (
order_id UInt64,
customer_id UInt64,
region String,
product_id UInt32,
amount Decimal(12, 2),
order_date Date,
status UInt8
) ENGINE = MergeTree()
ORDER BY (order_date, region, customer_id) -- 排序键(也是主键)
PARTITION BY toYYYYMM(order_date); -- 按月分区
ORDER BY在这里不是排序操作,是定义主键(排序键)。数据按这个顺序存储,决定了查询能用哪些索引。ORDER BY的字段顺序很关键------把查询最常用的过滤条件放前面,这样WHERE条件才能命中索引。我一开始把customer_id放第一位,后来发现大多数查询都是按时间范围过滤,改成(order_date, region, customer_id)后,扫描的granule少了很多。
PARTITION BY把数据按月分成独立的分区。查询时如果带了时间范围条件,ClickHouse会先用分区裁剪(partition pruning)跳过不相关的分区,只扫描目标分区。查三个月的数据就只读三个分区,不碰其他月份。分区粒度要权衡:太细(按天)会导致part数量太多,Merge压力大;太粗(按年)裁剪效果差。按月分区是比较稳妥的折中。
实际对比:同一个查询,MySQL vs ClickHouse
准备同样的数据。一千万条订单记录。
MySQL(行式存储):
sql
-- MySQL 表结构
CREATE TABLE orders (
order_id BIGINT PRIMARY KEY,
customer_id BIGINT,
region VARCHAR(20),
product_id INT,
amount DECIMAL(12,2),
order_date DATE,
status TINYINT,
INDEX idx_date_region (order_date, region)
) ENGINE=InnoDB;
-- 查询:按地区和月份汇总销售额
SELECT region,
DATE_FORMAT(order_date, '%Y-%m') AS month,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM orders
WHERE order_date >= '2026-01-01'
AND order_date < '2027-01-01'
GROUP BY region, DATE_FORMAT(order_date, '%Y-%m')
ORDER BY month, region;
执行结果:
12 rows in set (8 min 34 sec)
8分34秒。用了索引,但GROUP BY需要全表扫描符合条件的数据。
ClickHouse(列式存储):
sql
-- ClickHouse 表结构
CREATE TABLE orders (
order_id UInt64,
customer_id UInt64,
region String,
product_id UInt32,
amount Decimal(12, 2),
order_date Date,
status UInt8
) ENGINE = MergeTree()
ORDER BY (order_date, region)
PARTITION BY toYYYYMM(order_date);
-- 同样的查询
SELECT region,
toYYYYMM(order_date) AS month,
count() AS order_count,
sum(amount) AS total_amount
FROM orders
WHERE order_date >= '2026-01-01'
AND order_date < '2027-01-01'
GROUP BY region, month
ORDER BY month, region;
执行结果:
12 rows in set. Elapsed: 2.145 sec.
Processed 10.00 million rows, 120.00 MB
2.1秒。快了约240倍。
为什么差距这么大?
| 维度 | MySQL InnoDB | ClickHouse MergeTree |
|---|---|---|
| 存储方式 | 行式 | 列式 |
| 查询扫描量 | 读取整行(所有列) | 只读取查询涉及的列 |
| 数据压缩 | 无/低 | 高压缩比(5:1) |
| 执行方式 | 逐行 | 向量化批量 |
| IO量 | 约2.5GB | 约120MB |
| 用时 | 8分34秒 | 2.1秒 |
但ClickHouse不是万能的
我用了一段时间ClickHouse后,发现它有几个明显的短板。
不适合高频单行更新。 ClickHouse的UPDATE是异步的重写操作。不是原地的行级修改。执行一条UPDATE,它要重写整个数据part。更新频率高会导致Merge永远追不上写入。
sql
-- ClickHouse的UPDATE(异步重写,不是原地修改)
ALTER TABLE orders UPDATE amount = 600 WHERE order_id = 1;
-- MySQL是原地修改,ClickHouse是标记旧行删除+插入新行
-- 下次Merge时才会真正清理
我在一个电商项目里踩过这个坑。运营同事要频繁修改订单状态,每改一条就触发一次异步重写。上线两周后,小part越来越多,后台Merge跟不上写入速度,查询时要扫大量小part,性能反而变差。后来我把高频变更的字段留在MySQL,只把最终状态同步到ClickHouse,问题才解决。
不支持事务。 ClickHouse没有ACID事务。不能做并发写入的一致性保证。它的设计假设是:数据批量写入后基本不变。如果往ClickHouse并发写入同一批数据,没有任何机制保证一致性。所以写入端要做好幂等设计。
JOIN能力有限。 ClickHouse能做JOIN,但实现方式和MySQL不同。它默认用hash join,把右表整个加载到内存里构建哈希表。右表小的时候没问题。右表一大就容易OOM。虽然可以通过join_algorithm参数选择partial_merge_join来控制内存,但性能会打折扣。复杂的多表关联查询,还是MySQL更擅长。ClickHouse的强项是单表聚合分析,不是多表关联。
我一开始以为ClickHouse能完全替代MySQL。结果上线后发现它不是万能的------单行查询不擅长,并发写入没有一致性保证。ClickHouse解决的是"大批量数据算得慢"的问题,不是"所有数据库问题"。正确的做法是互补,不是替代。MySQL管交易,ClickHouse管分析。
MySQL(OLTP) ClickHouse(OLAP)
↓ ↑
业务写入 → CDC同步 → 分析查询
在线交易 报表汇总
高频更新 批量分析
CDC是这套架构的关键。我用过两种方案:
Canal + Kafka。 Canal伪装成MySQL从库,监听binlog,把变更事件发到Kafka,再由消费者写入ClickHouse。延迟通常在秒级。优点是不侵入业务代码,MySQL只要开了binlog就能用。缺点是多了一个Kafka组件,运维成本高一些。
Flink CDC。 Flink直接读MySQL的binlog,做简单的ETL转换后写入ClickHouse。延迟也很低,而且Flink本身能做数据清洗和转换。缺点是需要额外部署Flink集群,学习曲线陡。
我个人偏好Canal方案,组件少,链路清晰。如果你的数据同步需求复杂(比如需要跨库聚合、格式转换),Flink CDC更灵活。
说了这么多ClickHouse,但列式存储不是它的专利。
越来越多的数据库在融合行存和列存。HTAP(混合事务/分析处理)架构试图用一个引擎同时做好OLTP和OLAP。TiDB的TiFlash是比较典型的例子------写入走TiKV(行存),通过Raft协议把数据实时同步到TiFlash(列存)副本,分析查询直接走列存。一份数据两个引擎,对业务层透明。OceanBase也在做类似的行列混存。
国产数据库在HTAP方向上也有探索。有的通过行列混存实现一份数据同时服务交易和分析,有的通过存算分离架构实现独立扩展。这条路还在走,现阶段的HTAP数据库在纯分析场景的性能和ClickHouse还有差距,纯交易场景的事务能力也不如传统行存数据库。但方向是对的------如果一个数据库能同时搞定交易和分析,运维复杂度确实能降不少。
现阶段,行列分离、各司其职仍然是最稳妥的方案。ClickHouse做分析,MySQL做交易,CDC做同步。等HTAP数据库成熟了,可以考虑替换。
避坑清单
- 别上来就换数据库。 先用EXPLAIN看执行计划,确认瓶颈真的是IO和扫描量,再考虑引入列存。很多时候加个索引、改个SQL就够了。
- ClickHouse的ORDER BY字段顺序要认真选。 把最常用的查询过滤条件放第一位,不然稀疏索引形同虚设。我一开始随便排了个顺序,查询性能差了5倍,后来改成按实际查询模式排序才解决。
- CDC同步链路要监控。 Canal或Flink CDC偶尔会因为网络抖动或binlog格式问题丢数据。上线后一定要加监控,比如对比MySQL和ClickHouse的行数差异,发现不一致及时补数据。
总结
报表慢的根本原因可能不是SQL写错了,是存储引擎选错了。行式存储天生不适合大批量聚合分析,列式存储才是这个场景的正确工具。MySQL做OLTP没问题,加上ClickHouse做OLAP,用CDC做数据同步,这套架构我亲测有效。
但数据库选型不是选一个全能的,而是选对的组合。根据业务场景选最合适的引擎,比追求一个数据库解决所有问题更务实。
朋友,你的报表查询慢是怎么解决的?加了索引还是换了分析引擎?欢迎在评论区聊聊。
我是数据库小学妹,咱们下篇见 👋