系列目录
- MySQL 索引与慢查询:B+ 树如何减少扫描
- 声明式事务之下:InnoDB 的 MVCC 与锁
- 接入层:Nginx 反向代理与 OpenResty 的边界
- Web 容器与 Netty:线程模型之下的 IO 模型
- Redis(上):缓存用法与单线程模型的限制
- Redis(下):超出缓存用途的用法:锁、队列与排行榜
- 数据库访问层:连接池与 MyBatis 的显式 SQL
- Kafka(上):吞吐的来源是顺序 IO
- Kafka(下):生产端、broker 与消费端的可靠性配置
- RPC 框架:像本地调用一样调远程的代价
- 消息语义:按 at-least-once 设计业务代码
- 熔断与限流中间件:把失败当作正常状态管理
- 唯一 ID 中间件 Leaf:号段模式与雪花模式
- RocketMQ 的事务消息与延迟消息
- 分布式任务调度:同一时刻只跑一份
- ZooKeeper/etcd:小数据强一致的协调服务
- 分库分表:用应用层复杂度换数据库容量
- 分布式事务:2PC 的代价与业务补偿模式
- Elasticsearch 的近实时模型与误用为数据库的后果
- 链路追踪:Dapper 模型与埋点的取舍
- 实时 OLAP:列存与预聚合的两条路线(本篇)
MySQL 执行看板查询的限制
ClickHouse 和 Druid 我都没有在生产中接入过。下面的判断基于官方文档,以及在本地以 ClickHouse 22.x、Druid 0.22 单节点验证列存读取、压缩、MergeTree 排序键跳颗粒和 Druid rollup 后行数缩减。生产中的多节点集群运维、摄入背压、查询限流和冷热分层没有经过验证,相关内容会显式标注。
目标场景是数据看板类需求:按小时维度的订单量、按渠道的 GMV、按省份的活跃用户数。这类查询通常扫描大量行(几千万到几亿),聚合少量列(几个维度加度量),返回少量结果(几十行)。
MySQL 用 B+ 树按行组织,MySQL 索引与慢查询:B+ 树如何减少扫描讲过 B+ 树用有序性换查询速度,但看板查询往往没有能命中的索引:分组字段多、过滤条件组合多变,即使建立多个联合索引也无法全部覆盖,多数查询退化为全表扫描。读取一行时需要从磁盘读入整行,即使只使用三列,其余几十列仍占用 IO。
Elasticsearch 也不适合此类需求。Elasticsearch 的近实时模型与误用为数据库的后果讲过 ES 的近实时写入模型和倒排索引,它擅长多字段任意词命中;看板查询主要进行数值聚合(sum/count/avg)和按维度分组。ES 聚合仍按文档遍历并在 shard 间归并,大批量聚合的吞吐低于专门的列存引擎;此外,每个文档要维护 _source、倒排索引、doc values 多份数据结构,存储放大明显,用于亿级数据的聚合看板时成本偏高。
此类需求适合使用为「扫大量行、聚合少量列」优化的存储。ClickHouse 和 Druid 分别采用明细查询和预聚合两种路线。
列存适合分析型查询的原因
行存把一行的各列连续存放,读一行一次顺序 IO 即可;列存把一列的所有值连续存放,读一列一次顺序 IO 即可。OLTP 请求(点查、范围查单行或几行)按行读,行存占优;OLAP 请求(聚合某几列跨千万行)按列读,列存占优。这是两种存储布局的根本差异,后面的压缩、向量化执行都建立在按列连续存放之上。
按列读取。 看板查「按渠道汇总 GMV」,只需要读 channel 和 amount 两列。行存要扫全行(订单表一行几十列),实际用到的列占比很小,浪费在无用列上的 IO 是几倍到几十倍。列存只读这两列,IO 量按实际使用的列数走。查询用的列越少、表越宽,列存的优势越大。
压缩率与字典编码。 同一列的数据类型一致、取值往往重复(如渠道名、省份、状态枚举),列存对每列单独编码。字符串列用字典编码:值映射成整数,存储只存整数编码加字典。状态字段只有几种取值,一列几千万行可能一两个字节就能存下一个值。行存里一行跨多个类型,没法对整行做这种编码。列存的压缩率通常是行存的数倍,相应地磁盘读取量和内存占用都低。
向量化执行与延迟物化。 列存引擎按列读取数据后按批处理,一次处理一批值而不是逐行。同列数据类型相同,可以塞进 CPU SIMD 指令一次算多个值,聚合运算(sum/count/比较)的吞吐远高于逐行解释执行。延迟物化指查询计划里过滤和聚合先在需要的列上做完,最后才把命中的行需要的其他列拼出来;行存是先把整行物化再过滤。延迟物化减少了无效列的读取和拼装开销。
这三点都建立在「按列连续存放」上。列存的限制包括单行点查和高频随机写入:单行点查(按主键取一条)需要分别定位每一列,比 B+ 树按行定位慢;写入时一行要拆到多个列文件,写入放大比行存大。列存适用于扫描聚合;点查和高频随机写入通常使用行存。
ClickHouse 明细查询与 Druid rollup 预聚合
列存降低单次扫描的成本,但原始数据量仍决定扫描量。一亿行的原始数据,按渠道汇总 GMV 仍要扫一亿行 amount 列。ClickHouse 保留全部明细,通过排序键和稀疏索引减少扫描量;Druid 在摄入时做 rollup 预聚合,将明细聚合成时间粒度内的汇总行,查询时读取聚合后的行数。
ClickHouse:MergeTree 通过排序键和稀疏索引减少扫描。 MergeTree 是 ClickHouse 的常用表引擎。建表时 ORDER BY (channel, event_time) 定义排序键,数据按这个键有序存储。它不建 B+ 树那种每行一个索引项的密集索引,而是每隔 8192 行(默认 granularity)记一个稀疏索引项,指向这批行的起始位置。查询按 channel 过滤时,稀疏索引跳过不匹配的颗粒,只读命中的颗粒。排序键的前缀匹配(类似联合索引最左前缀)决定过滤能跳过多少颗粒:channel 在前,按 channel 过滤能大幅跳过;按 event_time 过滤但没带 channel,稀疏索引仍接近全表扫描。
-- ClickHouse MergeTree:排序键决定稀疏索引能跳过哪些颗粒
CREATE TABLE orders_rt (
channel String,
event_time DateTime,
amount Decimal(18,2),
province LowCardinality(String)
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (channel, event_time); -- 排序键即稀疏索引的前缀排序键会影响 ClickHouse 建表后的查询范围:高频过滤字段放前面,范围查询字段放后面。排序键与查询条件不匹配时,查询会退化为近全表扫描。PARTITION BY 按时间分区,分区裁剪能跳过整月数据,并与排序键共同减少扫描量。
Druid:摄入时 rollup 聚合明细。 Druid 的数据源(datasource)按时间分片成 segment,每个 segment 是一段时间内的列存数据。摄入时如果开启 rollup(rollup: true),相同维度组合加同一段时间粒度内的多条明细会聚合成一行,存储聚合后的 count 和 sum,原始明细被丢弃。一秒内同一个渠道同一个省份来 1000 条订单,rollup 后只存一行(count=1000,sum_amount=…)。查询时扫描行数从 1000 降到 1。
// Druid 摄入 spec 片段:开启 rollup,按维度聚合
{
"type": "index",
"dataSource": "orders_rt",
"granularitySpec": {
"segmentGranularity": "HOUR",
"queryGranularity": "MINUTE",
"rollup": true
},
"dimensionsSpec": {
"dimensions": ["channel", "province"]
},
"metricsSpec": [
{"name": "count", "type": "count"},
{"name": "sum_amount", "type": "doubleSum", "fieldName": "amount"}
]
}queryGranularity 是 rollup 的时间粒度(如分钟),比它细的时间差异不再保留。dimensions 列表决定哪些字段作为聚合维度,不在列表里的度量字段被汇总。开启 rollup 后,无法查询原始明细,只能查询聚合后的值。
明细查询与预聚合的适用范围。 ClickHouse 保留明细,支持任意维度组合、任意时间粒度的查询,前提是扫描量可以接受;查询需要扫描原始行数,扫描量随数据量线性增长。Druid 在摄入时聚合,查询扫描聚合后的行数(可能小一两个数量级);代价是损失明细,维度组合变动时需要重新摄入。
ClickHouse 也能通过物化视图预聚合,Druid 也能关闭 rollup 存储明细,但两者的默认用法不同:ClickHouse 通常用于明细查询,Druid 通常用于预聚合。选择取决于业务是否能接受丢失明细,以及维度组合是否稳定。需要下钻到单条记录(如排查异常订单)时使用 ClickHouse;只查询汇总指标且维度固定(如渠道报表)时使用 Druid。也可以将明细存入 ClickHouse,并将预聚合看板存入 Druid。
使用限制
-
OLAP 存储对更新和删除的限制。 ClickHouse 的
ALTER TABLE ... UPDATE/DELETE是异步 mutation,重写整个列存文件,代价远高于 OLTP 的原地更新;生产里通常用版本标记加后台合并或删除分区重灌绕开。Druid 的 segment 是不可变的,更新要重新摄入覆盖对应时间段的 segment。高频更新场景通常在 OLTP 中更新,再同步到 OLAP。 -
实时摄入中的重复和乱序。 ClickHouse 的 ReplacingMergeTree 靠排序键去重,但合并是异步的,查询时刻可能仍能看到重复行,需要
FINAL或去重查询,带性能代价。Druid 的 Kafka 摄入走 supervisor 加索引任务,靠 Kafka 自身的 partition 与 offset 机制保证摄入侧恰好一次;跨 segment 的重复要靠重新摄入覆盖对应时间段。对于乱序与迟到数据,ClickHouse 不设置拒绝窗口,接受写入后每次形成一个按排序键排好的新 part,查询时跨 part 归并;Druid 按事件时间分到对应 segment,ioConfig 的 lateMessageRejectionPeriod 配置迟到拒绝窗口,早于窗口的消息被丢弃。两个引擎的实时摄入都需要在摄入层确认「恰好一次」语义,并承担相应的性能代价。 -
排序键和维度需要匹配查询模式。 ClickHouse 排序键不匹配查询前缀时,稀疏索引失效,查询退化为近全表扫描;Druid 维度组合变动时,原有的预聚合数据需要重新摄入。建模阶段需要确定主要查询模式,事后修改排序键或维度需要重建数据。
-
列存不适合单行点查和高频随机写入。 点查走列存需要分别定位每列,速度低于 B+ 树;随机写入要拆到多列文件,写入放大比行存大。ClickHouse 通过批量写入加后台 merge 缓解,Druid 通过 segment 不可变加摄入批处理缓解。两者均面向批量追加写入,不适用于 OLTP 式的逐行随机写。
-
查询并发受内存和 CPU 限制。 列存查询可以高吞吐扫描,但单个查询占用的内存(聚合中间状态)和 CPU(向量化执行)不低,高并发查询会互相挤占。ClickHouse 默认并发查询数不高(单实例几十并发),靠分片加副本扩并发;Druid 靠 historical 节点扩并发。将 OLAP 用于几千并发的在线接口,可能超过内存和并发上限。看板类查询的并发通常不高;超过这个范围时,需要评估资源限制。
经验范围与验证限制
ClickHouse 和 Druid 我都没有生产使用经历。上面的判断来自官方文档,以及在本地用 ClickHouse 22.x、Druid 0.22 单节点验证列存读取、压缩、MergeTree 排序键跳颗粒和 Druid rollup 后行数缩减。生产里的多节点集群运维、摄入背压、查询限流、冷热数据分层、物化视图与 rollup 的长期维护成本,我没有实操经验,相关判断仅基于文档。Druid 的 coordinator 与 overlord 调度、ClickHouse 的副本与分布式协调依赖 ZK(22.x 可用 ClickHouse Keeper 作为 Raft 替代,但我未在多节点环境验证过 Keeper 与 ZK 的实际差异),我只了解概念,没有读源码验证。
参考资料
- ClickHouse 22.x 官方文档(MergeTree 引擎、排序键、稀疏索引、granularity、ReplacingMergeTree、分区裁剪)
- Druid 0.22 官方文档(rollup、segment、datasource、segmentGranularity、queryGranularity、Kafka 摄入)
- Dremel 论文(Google,2010)——列存与嵌套数据查询的早期参照
- MonetDB/X100(Boncz 等,2005)——向量化执行与延迟物化的来源
- MySQL 索引与慢查询:B+ 树如何减少扫描(B+ 树有序性与索引失效,行存对照)
- Elasticsearch 的近实时模型与误用为数据库的后果(ES 聚合与列存的边界对照)
