ClickHouse学习笔记(一):从订单报表理解列式存储与OLAP

订单系统刚上线时,运营只想知道今天卖了多少钱。一条 SUM 查询就能解决。

后来,报表要按城市、渠道、小时拆分,还要统计支付人数、客单价和最近一年的趋势。订单越积越多,每次打开报表都要扫描大量记录,业务数据库的压力也越来越大。

这时需要先想清楚:创建一笔订单和分析一亿笔订单,对数据库的要求有什么不同?

ClickHouse 就是从这个问题开始学习的。

**系列约定:**以 ClickHouse 25.8 LTS 系列为学习基线,Docker 实操固定使用 clickhouse/clickhouse-server:25.8.1.5101,方便复现;版本可在官方发布记录核对。这是实验基线,不代表最新版本。示例使用单节点、自管理部署,时间统一为 Asia/Shanghai。

系列导航:

  1. 列式存储与 OLAP
  2. Docker 与 SQL 入门实战
  3. MergeTree、分区与索引
  4. 物化视图与聚合状态
  5. 查询优化与常见问题

一、ClickHouse 是什么?

一句话理解:

ClickHouse 是面向分析查询的列式数据库,擅长对大量记录进行筛选、分组和聚合。

“分析查询”可以是每天的订单金额,也可以是接口延迟分位数、用户行为趋势、日志中的错误比例。它的典型输入是不断产生的明细数据,输出是经过统计的结果。官方介绍将它定位为列式 OLAP 数据库。

这里要关注的是工作负载,而不是“哪一种数据库更高级”。

1.1 OLTP 与 OLAP

维度 OLTP:在线事务处理 OLAP:在线分析处理
典型问题 订单 1001 现在是什么状态? 最近一个月各城市卖了多少钱?
单次访问 通常涉及少量记录 可能涉及大量记录
常见操作 点查、插入、更新、事务 扫描、筛选、分组、聚合
主要关注 业务一致性、并发与响应时间 扫描效率、吞吐与聚合成本
数据组织 常按业务实体建模 常按分析维度和指标建模

MySQL 也能做报表,ClickHouse 也能查询一条记录。这个表格描述的是各自更常见的优化目标,不能理解成 SQL 能力的绝对边界。

1.2 在订单系统中放在哪里?

可以先看一个常见的数据流:

1
2
3
4
5
6
7
8
9
用户提交订单
↓
业务服务 → MySQL:保存业务状态、完成事务
↓
CDC / 事件管道 / 批量导入
↓
ClickHouse:分析明细
↓
报表、监控、统计接口

如果使用 Kafka,Kafka 负责传递事件,ClickHouse 负责保存和查询分析数据。这个数据流是示意,实际接入还需要选择连接器、消费服务和同步策略。

报表的数据新鲜度取决于整条链路。 MySQL 中已经支付成功,ClickHouse 中还没看到这笔支付,可能是同步延迟,需要先查数据到达进度。


二、列式存储:为什么只查几列会有优势?

假设订单明细有这些字段:

1
order_id | user_id | city | channel | amount | paid_at | address | remark

现在要统计每个城市的支付金额:

1
2
3
SELECT city, sum(amount)
FROM order_payments
GROUP BY city;

查询主要需要 city 和 amount,地址、备注等字段不参与计算。

2.1 行式与列式的直观区别

1
2
3
4
5
6
7
8
9
10
行式组织的概念示意:
[1001, 101, 上海, app, 120, ...]
[1002, 102, 北京, web, 80, ...]
[1003, 101, 上海, app, 200, ...]

列式组织的概念示意:
order_id → [1001, 1002, 1003]
user_id → [101, 102, 101]
city → [上海, 北京, 上海]
amount → [120, 80, 200]

列式组织让引擎可以主要读取需要的列。当表很宽、查询只涉及少量列时,能减少无关数据的读取和解码。官方列式存储说明解释了这种设计对分析负载的意义。

这张图只是逻辑示意。ClickHouse 的实际文件组织还与数据 part 的格式有关,不能简单背成“每一列永远对应一个文件”。

2.2 为什么同一列更容易压缩?

同一列的数据类型相同,也更容易出现重复或相近的值:

1
2
channel → [app, app, app, web, web, web]
city → [上海, 上海, 上海, 北京, 北京, 北京]

如果通过排序让相似值靠近,重复模式会更明显。压缩后需要从磁盘读取的数据通常更少,但解压也有 CPU 成本,最终效果仍取决于数据分布、编码和查询模式。

因此,排序键不仅影响过滤,也可能影响压缩。 后面学习 ORDER BY 时,这一点很重要。官方建模指南也讨论了排序与压缩的关系。


三、向量化执行:成批计算有什么意义?

这里的“向量化”指对一批列值进行计算,不是把文本转成 Embedding。

例如,要判断金额是否大于 100:

1
2
输入金额:[120, 80, 200, 50]
筛选结果:[true, false, true, false]

如果每处理一行都重复调用一套判断逻辑,会产生较多的函数调用、分支和对象处理开销。成批处理可以减少这些开销,并给 CPU 的缓存和 SIMD 指令提供利用机会。

官方架构说明介绍了 ClickHouse 按列和数据块执行的方式。不要把向量化理解成“所有算子都一定使用同一种 SIMD 指令”,具体执行方式与函数、类型和平台有关。

把列式存储和批量执行放在一起看:

1
2
3
4
5
6
7
只读需要的列
↓
对列数据解压、解码
↓
成批过滤与计算
↓
聚合出较小的结果集

少读数据和高效处理数据,是两个相互配合的方向。 如果查询本来只需要读一小部分,却因为建模不当扫描整张表,仅靠更快的计算也无法消除这部分浪费。


四、先认识几个核心名词

名词 一句话理解 本系列中的用途
Database 表的命名空间 clickhouse_learning
Table Engine 决定表如何存储和处理数据 MergeTree、AggregatingMergeTree
Part 一批写入形成的数据片段 后台合并的对象
Partition 按表达式划分的数据范围 按支付月份组织数据
ORDER BY 表内数据的排序规则 按城市、时间、订单 ID 排序
Primary Index 帮助排除不相关数据范围的稀疏索引 减少需要读取的 granule
Materialized View 将源表的新写入加工后写到目标表 按天汇总支付指标

这里的物化视图特指本系列使用的增量物化视图。ClickHouse 还提供按调度重新执行查询的可刷新物化视图,两种机制不同。官方视图指南给出了它们的区别。

4.1 表引擎为什么值得单独学习?

同样执行 INSERT,不同引擎对数据的处理方式可能不同:

  • MergeTree:保存明细,后台合并数据片段。
  • ReplacingMergeTree:合并时按排序键处理重复版本。
  • AggregatingMergeTree:合并相同排序键对应的聚合状态。

这些引擎都属于 MergeTree 家族,但“合并”不等于同一种业务行为。具体语义分别见MergeTree、ReplacingMergeTree和AggregatingMergeTree文档。


五、分析之前,先确定一行代表什么

这是学习数据库时很容易跳过的一步。

下面两种表都能带上 order_id 和 amount,但统计方式完全不同:

表的含义 一笔订单可能有几行? 直接求和有什么风险?
支付成功事实表 本例约定恰好一行 重复导入会重复计算
订单状态事件表 创建、支付、取消等多行 每个状态都带金额时会多次求和

比如订单 1001 的创建和支付事件都带上 120 元:

1
2
1001 | CREATED | 120
1001 | PAID | 120

直接执行 sum(amount) 会得到 240 元。数据库计算正确,业务口径却错了。

本系列的 order_payments 是支付成功事实表:一笔订单只记录一次成功支付,金额为人民币元。 示例不包含拆单支付、退款、取消和重复投递;真实业务需要另外定义这些规则,也不能将这里的支付金额直接当作扣除退款后的净收入。

第二篇会用八条固定记录练习 SQL,第四篇再用独立的小表理解增量聚合。先把统计口径说清楚,后面的查询才有可核对的答案。


六、学习这一篇后,应该能回答什么?

可以用三个问题自测:

  1. 查询最近一年的城市销售趋势,为什么可能适合放到分析数据库?
  2. 一张有五十个字段的表,查询只需要三个字段,列式存储的优势在哪里?
  3. 订单状态事件表中,每条记录都带金额,为什么不能直接求和?

如果能够结合“读取哪些数据、计算多少记录、指标是什么含义”回答,就已经建立了一个有用的起点。

下一篇搭建本地环境,通过建表、插入和查询,把这些概念落到具体 SQL 上。