MySQL做报表太慢?引入ClickHouse列式存储,同一个查询快240倍

大家好,我是数据库小学妹 👋

上个月,一家零售企业的客户问我。他们的报表系统越跑越慢,按地区和月份汇总一次要八分多钟。

我查了执行计划。索引用了。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数据库成熟了,可以考虑替换。

避坑清单

  1. 别上来就换数据库。 先用EXPLAIN看执行计划,确认瓶颈真的是IO和扫描量,再考虑引入列存。很多时候加个索引、改个SQL就够了。
  2. ClickHouse的ORDER BY字段顺序要认真选。 把最常用的查询过滤条件放第一位,不然稀疏索引形同虚设。我一开始随便排了个顺序,查询性能差了5倍,后来改成按实际查询模式排序才解决。
  3. CDC同步链路要监控。 Canal或Flink CDC偶尔会因为网络抖动或binlog格式问题丢数据。上线后一定要加监控,比如对比MySQL和ClickHouse的行数差异,发现不一致及时补数据。

总结

报表慢的根本原因可能不是SQL写错了,是存储引擎选错了。行式存储天生不适合大批量聚合分析,列式存储才是这个场景的正确工具。MySQL做OLTP没问题,加上ClickHouse做OLAP,用CDC做数据同步,这套架构我亲测有效。

但数据库选型不是选一个全能的,而是选对的组合。根据业务场景选最合适的引擎,比追求一个数据库解决所有问题更务实。

朋友,你的报表查询慢是怎么解决的?加了索引还是换了分析引擎?欢迎在评论区聊聊。

我是数据库小学妹,咱们下篇见 👋

相关推荐
隔壁阿布都2 小时前
ClickHouse完整指南
服务器·数据库·clickhouse
—Miss. Z—5 小时前
计算机二级MySQL——基本操作题
数据库·mysql·oracle
️学习的小王5 小时前
Python + MySQL 学生信息管理系统实战教程
android·python·mysql
eddietao5 小时前
MySQL+SQLServer数据库-笔记
数据库·笔记·mysql
宠友信息5 小时前
消息撤回与已读状态如何在即时通讯源码中统一管理
java·spring boot·websocket·mysql·uni-app
宠友信息6 小时前
从重复请求到订单同步看内容社区源码的处理思路
java·spring boot·redis·后端·mysql·spring
LaughingZhu6 小时前
Product Hunt 每日热榜 | 2026-07-17
前端·数据库·人工智能·经验分享·mysql·chatgpt·html
韩楚风16 小时前
【参天引擎】事务生命周期 / MVCC / Undo / ACID / 分布式事务 功能域整体解析
数据库·分布式·mysql·架构·cantian
愿做无知一猿17 小时前
Nacos连接MySQL异常?DataGrip竟成救星
数据库·mysql