Skip to content
Charles Shao
Go back

OLAP · 列式存储原理与 ClickHouse/Doris

–views

广告实时链路把曝光、点击、转化事件搬进 Kafka、用 Flink 清洗对齐之后,钱和效果最终要变成可交互的报表:广告主按 campaign × 媒体 × 地域 × 设备切一刀,要在秒级看到花费、点击和 CTR,还要追问结算去重基数(UV)为什么对不上。当看板卡在十几分钟前转圈时,这件事就必须落在现代 OLAP(在线分析处理,Online Analytical Processing) 身上——而现代 OLAP 的地基,几乎全是列式存储。这条阅读路径共 4 篇:列存引擎怎么扫得动 → 实时数仓分层怎么让口径长期不漂 → 报表怎么问得起、算得准 → 系统指标用时序库怎么存;本篇作为开篇,先把「为什么列存、怎么加速、ClickHouse 与 Doris 怎么选」讲透。

本文是 实时数仓与 OLAP 系列的第 1 篇(开篇 · 列式存储)。 全系列 4 篇:

  1. OLAP · 列式存储原理与 ClickHouse/Doris(本篇)
  2. 实时数仓 · 分层设计与 Lambda/Kappa 架构
  3. 广告多维报表与漏斗分析实战
  4. 时序数据库 InfluxDB 深挖 · 数据模型、TSM 与降采样

一句话定位:OLAP 要用列存,是因为分析查询通常「读很少几列、却扫很多行」;编码压缩、向量化、MPP 把这条路径从分钟级压到秒级,ClickHouse 与 Doris 是这条路上最常见的两块工程积木。

TL;DR

Table of contents

Open Table of contents

1. 为什么 OLAP 绕不开列存

1.1 OLTP 与 OLAP 的访问模式完全相反

缓存、交易库与订单系统关心的是:按主键取一行、改几列、事务提交——这是典型的 OLTP 场景。行式存储(以 InnoDB 为代表)选择把同一行的所有列连续存放在一起,一次磁盘 I/O 就能拿到完整的一行记录,对点查极为友好。

然而,广告多维报表与漏斗分析关心的是截然不同的另一种问法:扫过去 N 天的数据、按若干维度分组、聚合少数几个指标。典型的 SQL 如下:

SELECT campaign_id, media_id, sum(cost), countIf(clk = 1)
FROM ad_effect
WHERE event_date BETWEEN today() - 7 AND today()
GROUP BY campaign_id, media_id;

一张效果明细表可能包含 80 多列(涉及创意、出价、设备、OS、地域、流量类型等),但这条查询真正用到的列只有少数几个。如果强行按行存扫描,磁盘 I/O 和内存缓存会被大量无关列填满,导致查询在队列中阻塞等待。这就是为什么业务初期常会遇到「宽表聚合扫不动」的痛点。

1.2 列存如何对症下药

列式存储选择按列连续落盘。这种物理布局的改变带来了立竿见影的好处:

  1. 只读相关列:针对上面的 SQL,引擎只需读取 campaign_id、media_id、cost、clk 与 event_date 这五列,其余 70 多列完全不会触发任何磁盘 I/O,直接从源头削减了数据读取量。
  2. 同列相邻带来极高压缩率:由于同一列的数据类型完全一致、取值空间相近,应用字典编码、游程编码(RLE)以及差分编码(Delta)后,压缩比远远超过混杂着各种数据类型的行存块。
  3. 向量化友好:连续排列的同类型数组,天然适合利用 CPU 的 SIMD 指令集进行批量加速运算。

行式存储与列式存储对比示意图。左侧行存面板展示一张广告效果表(列:imp_id、campaign、ad_id、media、cost、clk)的三行数据,每一整行用琥珀色高亮,说明磁盘一次 I/O 扫过整行全部列,点查友好但宽表扫表浪费。右侧列存面板把同样六列竖着排列,仅将查询相关的 campaign、cost 两列蓝色高亮,说明 SQL 只需这两列时只扫相关列,同列相邻利于编码压缩与向量化。底部注解指出广告效果表往往几十到上百列,报表却常 GROUP BY 少数维度再 SUM,列存使只读相关列成为默认路径。

同一份广告效果数据:行存按行连续落盘(适合点查),列存按列连续落盘(适合宽表聚合)。OLAP 默认站在右边。

心智切换:把「取一行对象」换成「取几列连续的数组再进行聚合」。一旦接受了这条设定,后续的编码压缩、向量化与大规模并行处理(MPP),本质上都是在把这条聚合路径从分钟级压到秒级。

2. 编码、压缩与跳过:少扫一字节是一字节

列式存储并非仅仅将数据按列连续落盘就大功告成。在成熟的工业实现中,引擎通常会将一列切分成多个数据块(granule / page / segment),在数据块内部执行具体的编码与压缩策略,并在块级别挂载跳过索引等元信息。

技术适用作用
Dictionary低基数字符/枚举(媒体、OS)存字典 ID,体积骤降
RLE大量连续重复值存「值 × 出现次数」
Delta / DoubleDelta有序时间戳、自增 ID存差分,整数更小更好压
LZ4 / ZSTD通用块压缩进一步砍传输与缓存占用
MinMax / 稀疏索引任意可比较列谓词下推时整块跳过

回到广告场景,media_id、os、country 这类枚举维度几乎总是适合字典编码(Dictionary);event_time 则天然适合有序存放并挂载跳过索引。相比之下,对于 imp_id 这种高基数列,优化重点只能是依靠分区裁剪,而不是指望字典压缩。正因如此,分区键与排序键(ORDER BY / 主键序)的选型,直接决定了一次报表查询到底能跳过多少数据块。这也解释了为什么排在下一节的排序键,必须先于压缩算法定下来。

2.1 分区键与排序键:比压缩算法更早定

以 ClickHouse 的 MergeTree 引擎为例(Apache Doris 的分区分桶策略同理):

一条铁律是:先保证「时间分区 + 业务过滤前缀位列排序键最左侧」,再去谈论更换压缩编解码器。 许多开发者在遇到性能瓶颈时,一上来就试图调高 ZSTD 压缩等级,却任由查询长期执行全分区扫描,这无疑是典型的优化顺序颠倒。

2.2 宽表要不要拆列族?

由于列式存储在物理底层已经是按列连续落盘的,因此在业务层不必像早期行存宽表那样,为了性能强行将维度和指标垂直拆分成多个物理表。在 AdTech 实践中,更常见的设计模式是:保留一张明细宽表,再配合投影(Projection)或物化视图(Materialized View,MV)来覆盖热路径上的高频预聚合查询;同时,将极少访问的排障列单独剥离到旁路表中,以避免这些庞大的长尾字段拖累主表的 parts 合并(merge)效率与内存缓存命中率。可以说,如今拆表的收益往往来自于生命周期与权限管控,而不是误以为「列存也必须像行存那样手动切分列簇才能扫得动」。

3. 向量化执行:一批一批算,而不是一行一算

传统的数据库执行器多采用 Volcano 迭代器模型,即通过 next() 方法逐行处理数据。这种方式充斥着大量的虚函数调用与条件分支,且对 CPU 缓存极不友好。相比之下,现代向量化引擎在计算模式上进行了彻底重构:

对于 SUM(cost) 这类广告指标聚合,向量化意味着对一段连续的 float/int 数组进行紧密的循环累加。它不仅规避了逐行解释的开销,还能将吞吐量提升一个数量级。这也是为什么 ClickHouse、Apache Doris 乃至 Spark 的 Adaptive 执行计划,最终都殊途同归地选择了向量化。

3.1 谓词下推与延迟物化

在向量化执行的基础上,为了进一步将宽表聚合扫描压至秒级,分析引擎还会深度结合列存特性进行两项关键优化。首先是谓词下推,对于 WHERE event_date = today() AND campaign_id = 42 这类条件,引擎会将过滤逻辑尽量下推到存储扫描阶段,直接通过稀疏索引跳过无关数据块,大幅减少进入上层算子的数据量。其次是延迟物化(late materialization),引擎会先利用排序键或索引列过滤出命中的存活行号,随后才去拉取 cost 等较宽的指标列,从而避免了「先把所有相关列全解压再进行过滤」的无谓消耗。

广告明细宽表极度依赖这套机制:查询时先依靠 advertiser_id 与 campaign_id 的谓词下推,将扫描范围精准缩小到一小撮数据块(granule),然后再通过延迟物化去解压实际需要的指标列。这远比一上来就强行解码 80 多列要高效得多。

4. MPP:一查询打满多台机器

即便单机性能被压榨到极致,也依然扛不住「半年历史明细 × 全站广告主」的暴力扫表。此时必须引入大规模并行处理(MPP)架构,其分布式查询的分工相对朴素:

  1. 首先,Coordinator 节点负责解析 SQL、生成分布式执行计划,并将扫描任务拆解下发给各个 Worker 节点。
  2. 接着,各个 Worker 只负责扫描本地负责的独立分片(shard 或 tablet),并利用向量化引擎完成本地过滤与局部预聚合。
  3. 最后,通过 Shuffle 与二次聚合,将局部结果按 GROUP BY 的 key 归约汇总,最终将秒级结果返回给客户端。

列存加速三件套示意图。自上而下三层面板。① 列内编码与压缩:Dictionary、RLE/Delta、LZ4/ZSTD、Skip Index。② 向量化执行:列块 Batch → Filter/Project → Aggregate SIMD。③ MPP:Coordinator 分发 → Worker 本地扫列 → Shuffle 汇总 → 回客户端。

编码压缩减 I/O → 向量化抬单核 → MPP 扩集群:OLAP 性能的三层叠塔。

需要强调的是,数据局部性直接决定了 MPP 的执行效率。如果分片键与排序键能够对齐高频过滤条件,大部分聚合操作就可以在 Worker 本地完成,从而消除了昂贵的网络 shuffle。反之,如果分片策略随意切分,而业务查询却总是按特定广告主进行过滤,就会将整个集群拖入全员 shuffle 的性能泥潭。

4.1 分片策略与广告主热点

在真实的 AdTech 业务中,广告流量分布天然存在倾斜:少数头部广告主往往贡献了绝大部分的广告曝光。如果简单将分片键设置为 cityHash(advertiser_id),头部客户的数据就会集中砸进少数几个分片副本中,出现集群整体空闲、个别节点冒烟的惨状——这种物理分片倾斜与我们在 Flink 架构篇中讨论的热 key 倾斜,本质上是同一种物理限制。

针对这种倾斜,常规的缓解手段包括在分片键中掺入高基数字段(如 impression 的散列盐值)打散热点数据,代价是查询仍按广告主过滤时,需要接受一定程度的额外扫描开销;或者从业务架构层面实行租户隔离,将超级大客户拆分到独立表或专属的高可用集群中;也可以利用实时分层的思想,将指标预聚合到 DWS 层的物化视图中,避免看板查询总是直接穿透去扫描最底层的明细数据。

因此,MPP 绝不是「只要加机器就能线性变快」的银弹。如果分片策略与查询模式未能有效对齐,盲目扩容只会让 shuffle 网络变得更加拥堵。

5. ClickHouse vs Doris:两套架构心智

在开源界,ClickHouse 与 Apache Doris 都具备了列存、向量化与 MPP 这性能三件套,但它们在「集群状态如何组织」与「数据该如何建模」上,体现出了截然不同的架构心智。

5.1 ClickHouse:MergeTree + 本地表优先

5.2 Apache Doris:FE / BE 分离

ClickHouse 与 Apache Doris 架构对比。左侧 ClickHouse:客户端 → 各节点对等(本地表 + Distributed)→ MergeTree 分片副本 → 极致扫表。右侧 Doris:FE 解析调度 → 多个 BE 列存与 Tablet → MySQL 协议与物化视图友好。

ClickHouse 偏「强大引擎 + 本地表心智」;Apache Doris 偏「FE/BE 产品化 + 报表友好」。

5.3 选型对照

维度ClickHouseApache Doris
架构对等节点 + DistributedFE / BE 分离
协议HTTP / nativeMySQL 协议友好
建模宽表 + 排序键为主明细 + 物化视图/聚合表常见
导入批量/Kafka 引擎多Stream/Routine Load 产品化
扫表明细极强强
运维心智更「引擎可控」更「数仓易用」

AdTech 实践:其实对于明细实时写入 + 秒级多维看板的核心诉求,两套引擎目前都能很好地胜任。如果团队已有深厚的 ClickHouse 经验,那就继续深挖投影与物化视图的潜力;如果希望 BI 与数据分析师能用标准 MySQL 工具自助查询,且不想让业务层过多接触底层的 Distributed 表,那么选择 Apache Doris 会更顺滑。不必在「谁才代表正宗 OLAP」上空辩——最终拉开差距的,往往是团队现有的工程生态与技术掌控力。

6. 写入、合并与「实时」的边界

为了保障极致的扫描速度,列式存储在写入端通常会采用追加小文件或小批次,随后交由后台异步合并的策略(即 ClickHouse 的 merge 操作与 Apache Doris 的 compaction 机制)。这种设计机制不可避免地带来了两个现实约束:首先是写入吞吐与数据可见延迟必须折中,如果写入批次过小,会导致小文件泛滥并引发 merge 风暴或 compaction 积压;如果批次过大,又会显著拉升实时链路的写入可见性延迟。其次是更新与删除沦为二等公民,引擎通常只能依靠标记删除后回收、记录版本列或引入专门的替换引擎来间接处理更新,完全做不到 OLTP 那种高效的原地覆盖写(upsert)。

正因如此,广告明细的写入通常被设计为只做追加写(append),再配合查询侧的去重或替换语义(后续在收官篇还会深入探讨这类问题)。在广告实时数仓架构中,标准的端到端链路往往是由 Flink Exactly-Once 负责增量写出,最终在 OLAP 层达到近实时可见的状态。这里的「近实时」意味着数据从事件发生到报表可见的延迟通常在秒级至数十秒之间,绝非交易库所追求的毫秒级强一致同步。

6.1 重复数据与 Replacing / 主键模型

在遭遇上游重放修数或者 Kafka 触发「至少一次」消费语义时,同一个 imp_id 难免会被多次写入 OLAP。面对潜在的口径漂移风险,常见的对策包括:

说到底,去重策略必须与业务 SLA 强绑定:业务看板的去重基数(UV)能否容忍异步合并前略微偏高?广告主的结算账单又是否允许哪怕 1–2% 的误差?只要业务场景能够接受这种近实时可见过程中的轻微不一致,就能直接省下海量的在线精确去重算力。

6.2 副本与高可用

此外,列式存储集群的分片副本远不止是灾备的手段:查询流量可以通过副本路由进行分散,而写入操作则需要权衡 quorum 协议与异步复制下的可见性延迟。在生产环境中,必须确保分片副本跨机架部署、定期冻结不可变分区并备份至对象存储,且真实演练过单节点宕机后的副本重建流程。毕竟,当底层 OLAP 引擎遭遇故障限流时,前端广告看板应该弹出合理的降级提示,绝不能直接向客户抛出白屏崩溃。

7. 什么时候不该上列存 OLAP

需要保持清醒的是,并非所有带有「分析」标签的需求都应该盲目引入 ClickHouse 或 Apache Doris:

总而言之,列式引擎的真正甜蜜点在于:以追加写入为主、拥有庞大维度的宽表、单次查询需要扫描海量行数但只聚合极少数列,并且能够接受秒级的查询可见延迟。 广告效果明细报表几乎完美契合了这些特征,而实时竞价决策链路中的「在线特征点查」却绝不在此列。

参考


–views
Share this post on:

Previous Post
实时数仓 · 分层设计与 Lambda/Kappa 架构
Next Post
MySQL 内核深挖 · 主从复制链路与 HA 高可用