实时 OLAP:列存与预聚合的两条路线

📅
2 分钟阅读
·

系列目录

  1. MySQL 索引与慢查询:B+ 树如何减少扫描
  2. 声明式事务之下:InnoDB 的 MVCC 与锁
  3. 接入层:Nginx 反向代理与 OpenResty 的边界
  4. Web 容器与 Netty:线程模型之下的 IO 模型
  5. Redis(上):缓存用法与单线程模型的限制
  6. Redis(下):超出缓存用途的用法:锁、队列与排行榜
  7. 数据库访问层:连接池与 MyBatis 的显式 SQL
  8. Kafka(上):吞吐的来源是顺序 IO
  9. Kafka(下):生产端、broker 与消费端的可靠性配置
  10. RPC 框架:像本地调用一样调远程的代价
  11. 消息语义:按 at-least-once 设计业务代码
  12. 熔断与限流中间件:把失败当作正常状态管理
  13. 唯一 ID 中间件 Leaf:号段模式与雪花模式
  14. RocketMQ 的事务消息与延迟消息
  15. 分布式任务调度:同一时刻只跑一份
  16. ZooKeeper/etcd:小数据强一致的协调服务
  17. 分库分表:用应用层复杂度换数据库容量
  18. 分布式事务:2PC 的代价与业务补偿模式
  19. Elasticsearch 的近实时模型与误用为数据库的后果
  20. 链路追踪:Dapper 模型与埋点的取舍
  21. 实时 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 预聚合

行存与列存的布局对照,以及 ClickHouse 明细查询与 Druid 预聚合两条路线的取舍位置

列存降低单次扫描的成本,但原始数据量仍决定扫描量。一亿行的原始数据,按渠道汇总 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。

使用限制

  1. OLAP 存储对更新和删除的限制。 ClickHouse 的 ALTER TABLE ... UPDATE/DELETE 是异步 mutation,重写整个列存文件,代价远高于 OLTP 的原地更新;生产里通常用版本标记加后台合并或删除分区重灌绕开。Druid 的 segment 是不可变的,更新要重新摄入覆盖对应时间段的 segment。高频更新场景通常在 OLTP 中更新,再同步到 OLAP。

  2. 实时摄入中的重复和乱序。 ClickHouse 的 ReplacingMergeTree 靠排序键去重,但合并是异步的,查询时刻可能仍能看到重复行,需要 FINAL 或去重查询,带性能代价。Druid 的 Kafka 摄入走 supervisor 加索引任务,靠 Kafka 自身的 partition 与 offset 机制保证摄入侧恰好一次;跨 segment 的重复要靠重新摄入覆盖对应时间段。对于乱序与迟到数据,ClickHouse 不设置拒绝窗口,接受写入后每次形成一个按排序键排好的新 part,查询时跨 part 归并;Druid 按事件时间分到对应 segment,ioConfig 的 lateMessageRejectionPeriod 配置迟到拒绝窗口,早于窗口的消息被丢弃。两个引擎的实时摄入都需要在摄入层确认「恰好一次」语义,并承担相应的性能代价。

  3. 排序键和维度需要匹配查询模式。 ClickHouse 排序键不匹配查询前缀时,稀疏索引失效,查询退化为近全表扫描;Druid 维度组合变动时,原有的预聚合数据需要重新摄入。建模阶段需要确定主要查询模式,事后修改排序键或维度需要重建数据。

  4. 列存不适合单行点查和高频随机写入。 点查走列存需要分别定位每列,速度低于 B+ 树;随机写入要拆到多列文件,写入放大比行存大。ClickHouse 通过批量写入加后台 merge 缓解,Druid 通过 segment 不可变加摄入批处理缓解。两者均面向批量追加写入,不适用于 OLTP 式的逐行随机写。

  5. 查询并发受内存和 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 聚合与列存的边界对照)

631 字 · 58 段落
ximing

Follow onGitHub

相关文章