广告实时链路把曝光、点击、转化事件搬进 Kafka、用 Flink 清洗对齐之后,钱和效果最终要变成可交互的报表:广告主按 campaign × 媒体 × 地域 × 设备切一刀,要在秒级看到花费、点击和 CTR,还要追问结算去重基数(UV)为什么对不上。当看板卡在十几分钟前转圈时,这件事就必须落在现代 OLAP(在线分析处理,Online Analytical Processing) 身上——而现代 OLAP 的地基,几乎全是列式存储。这条阅读路径共 4 篇:列存引擎怎么扫得动 → 实时数仓分层怎么让口径长期不漂 → 报表怎么问得起、算得准 → 系统指标用时序库怎么存;本篇作为开篇,先把「为什么列存、怎么加速、ClickHouse 与 Doris 怎么选」讲透。
本文是 实时数仓与 OLAP 系列的第 1 篇(开篇 · 列式存储)。 全系列 4 篇:
- OLAP · 列式存储原理与 ClickHouse/Doris(本篇)
- 实时数仓 · 分层设计与 Lambda/Kappa 架构
- 广告多维报表与漏斗分析实战
- 时序数据库 InfluxDB 深挖 · 数据模型、TSM 与降采样
一句话定位:OLAP 要用列存,是因为分析查询通常「读很少几列、却扫很多行」;编码压缩、向量化、MPP 把这条路径从分钟级压到秒级,ClickHouse 与 Doris 是这条路上最常见的两块工程积木。
TL;DR
- 行存 vs 列存:行存按行连续落盘,对点查与更新友好;列存按列连续落盘,只读相关列、同列易压缩、且对向量化极其友好——这是宽表聚合的默认形态。
- 广告效果表是列存的教科书场景:面对数十乃至上百列维度的明细宽表,报表却常常只需
GROUP BY少数几列再去SUM(cost)。强行按行存扫描会白白读取大半行无关数据,而列存只会扫描命中的相关列。 - 编码与压缩是加速的第一层:字典编码(Dictionary,适用于低基数列)、游程/差分编码(RLE/Delta,适用于连续或有序数据)、LZ4/ZSTD(块压缩)以及稀疏/跳过索引(Skip/MinMax)协同作用,能把 I/O 扫描字节砍掉整整一个数量级。
- 向量化执行:打破 Volcano 模型迭代器的瓶颈,按「列块(Batch)」一批一批算,吃满 SIMD 指令集与 CPU 缓存,执行效率远高于「一行一算」。
- MPP:Coordinator 节点拆分查询计划,Worker 节点本地扫描各自分片后再进行 shuffle 汇总,使得扫描吞吐量能够随节点数量近乎线性扩展。
- ClickHouse:以
MergeTree家族为核心,采用节点对等架构加Distributed表路由,扫表明细极强、引擎生态丰富,其运维心智偏向「本地表优先」。 - Doris:采用 FE/BE 分离架构,全面拥抱 MySQL 协议,物化视图(MV)与流批导入体验极其友好,报表语义更贴近传统数仓产品。
- 选型:若追求极致的大规模扫描吞吐与高度可控的引擎运维,选 ClickHouse;若团队渴望 MySQL 协议生态、FE/BE 解耦且希望借助物化视图省心构建预聚合,选 Doris。对于 AdTech 来说,二者均能胜任实时 ADS 层的重任。
- 分区键与排序键先于压缩算法调优:同时要警惕头部广告主造成的 MPP 分片倾斜,必要时需引入盐值或租户隔离策略。
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 列存如何对症下药
列式存储选择按列连续落盘。这种物理布局的改变带来了立竿见影的好处:
- 只读相关列:针对上面的 SQL,引擎只需读取
campaign_id、media_id、cost、clk与event_date这五列,其余 70 多列完全不会触发任何磁盘 I/O,直接从源头削减了数据读取量。 - 同列相邻带来极高压缩率:由于同一列的数据类型完全一致、取值空间相近,应用字典编码、游程编码(RLE)以及差分编码(Delta)后,压缩比远远超过混杂着各种数据类型的行存块。
- 向量化友好:连续排列的同类型数组,天然适合利用 CPU 的 SIMD 指令集进行批量加速运算。
同一份广告效果数据:行存按行连续落盘(适合点查),列存按列连续落盘(适合宽表聚合)。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 的分区分桶策略同理):
- 分区键通常选择
event_date(或小时):因为广告报表查询几乎总是带有时间范围,利用分区裁剪能够直接跳过无关日期的历史分区。 - 排序键 / 主键序应当优先匹配最高频的等值/范围过滤条件与
GROUP BY前缀:如果广告看板热路径上 80% 的查询都是按「某广告主下的计划 × 媒体」进行过滤,那么将排序键设计为(advertiser_id, campaign_id, media_id, event_time)的收益,要比把高去重基数(UV)的imp_id放在最前面大得多。 - 稀疏索引粒度(index_granularity)过细会导致元数据膨胀,过粗则会降低跳过数据块的概率:建议初期采用默认值,后续再通过慢查询日志中的「读取粒度标记数」进行反推调优。
一条铁律是:先保证「时间分区 + 业务过滤前缀位列排序键最左侧」,再去谈论更换压缩编解码器。 许多开发者在遇到性能瓶颈时,一上来就试图调高 ZSTD 压缩等级,却任由查询长期执行全分区扫描,这无疑是典型的优化顺序颠倒。
2.2 宽表要不要拆列族?
由于列式存储在物理底层已经是按列连续落盘的,因此在业务层不必像早期行存宽表那样,为了性能强行将维度和指标垂直拆分成多个物理表。在 AdTech 实践中,更常见的设计模式是:保留一张明细宽表,再配合投影(Projection)或物化视图(Materialized View,MV)来覆盖热路径上的高频预聚合查询;同时,将极少访问的排障列单独剥离到旁路表中,以避免这些庞大的长尾字段拖累主表的 parts 合并(merge)效率与内存缓存命中率。可以说,如今拆表的收益往往来自于生命周期与权限管控,而不是误以为「列存也必须像行存那样手动切分列簇才能扫得动」。
3. 向量化执行:一批一批算,而不是一行一算
传统的数据库执行器多采用 Volcano 迭代器模型,即通过 next() 方法逐行处理数据。这种方式充斥着大量的虚函数调用与条件分支,且对 CPU 缓存极不友好。相比之下,现代向量化引擎在计算模式上进行了彻底重构:
- 每次从存储层拉取一个 Batch(列块),通常包含数千行的连续同类型数组;
- 算子在进行 Filter、Project 和 Aggregate 操作时,均以整列为单位向前推进;
- 通过紧凑的数据结构吃满 CPU 缓存,并在热路径上充分利用 SIMD(单指令多数据流)指令集批量执行运算,从而极大减少了解释开销。
对于 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)架构,其分布式查询的分工相对朴素:
- 首先,Coordinator 节点负责解析 SQL、生成分布式执行计划,并将扫描任务拆解下发给各个 Worker 节点。
- 接着,各个 Worker 只负责扫描本地负责的独立分片(shard 或 tablet),并利用向量化引擎完成本地过滤与局部预聚合。
- 最后,通过 Shuffle 与二次聚合,将局部结果按
GROUP BY的 key 归约汇总,最终将秒级结果返回给客户端。
编码压缩减 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 + 本地表优先
- 存储引擎核心:以
MergeTree家族(涵盖ReplacingMergeTree、AggregatingMergeTree、CollapsingMergeTree等)为基石,数据严格按照主键序落盘,内置稀疏索引,并由后台线程异步执行 parts 的合并(merge)操作。 - 节点架构大体对等:任意节点均可接收查询请求,跨节点的分布式查询主要依赖
Distributed表路由至底层的本地表。系统没有单独的 FE 元数据大脑层,分片与副本的协同往往依赖外部 Keeper 机制。 - 核心强项:拥有极致的扫描吞吐量与丰富的 SQL 函数生态,能够依靠强大的投影(Projection)机制,直接用海量宽表明细扛住高并发的查询流量。
- 运维心智:工程师心智极强。业务方在建表时,必须将排序键、分区策略、跨机架副本以及
Distributed路由规则设计得清清楚楚。
5.2 Apache Doris:FE / BE 分离
- FE(Frontend)前端节点:专职负责 SQL 解析、查询优化、元数据管理与分布式调度,对外提供极其友好的 MySQL 协议入口。
- BE(Backend)后端节点:专注于列式存储、向量化执行以及 Tablet 分片副本的物理落地。
- 核心强项:提供了成熟的 Stream Load 与 Routine Load 导入体系,内置强大的物化视图机制,能够与传统数仓和 BI 工具无缝对接。
- 运维心智:更像一款完备的数仓产品。表模型抽象、分桶规则与副本调度均由 FE 统筹管理,让报表研发团队的上手成本明显更低。
ClickHouse 偏「强大引擎 + 本地表心智」;Apache Doris 偏「FE/BE 产品化 + 报表友好」。
5.3 选型对照
| 维度 | ClickHouse | Apache Doris |
|---|---|---|
| 架构 | 对等节点 + Distributed | FE / BE 分离 |
| 协议 | HTTP / native | MySQL 协议友好 |
| 建模 | 宽表 + 排序键为主 | 明细 + 物化视图/聚合表常见 |
| 导入 | 批量/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。面对潜在的口径漂移风险,常见的对策包括:
- 使用
ReplacingMergeTree或主键去重模型:在查询侧挂载FINAL关键字,或者根据状态记录按最新版本过滤。由于这种查询时去重的开销极大,必须谨慎使用,严禁滥用。 - 在写入链路前置幂等校验:在 Flink 的 Sink 端依据业务主键进行拦截,或者利用专门的去重表引擎来消化重复数据。
- 放宽数据对账 SLA,接受短暂重复:让热路径上的报表优先读取预聚合视图,底层明细中的短暂冗余会在 parts 异步合并后自动被消除。
说到底,去重策略必须与业务 SLA 强绑定:业务看板的去重基数(UV)能否容忍异步合并前略微偏高?广告主的结算账单又是否允许哪怕 1–2% 的误差?只要业务场景能够接受这种近实时可见过程中的轻微不一致,就能直接省下海量的在线精确去重算力。
6.2 副本与高可用
此外,列式存储集群的分片副本远不止是灾备的手段:查询流量可以通过副本路由进行分散,而写入操作则需要权衡 quorum 协议与异步复制下的可见性延迟。在生产环境中,必须确保分片副本跨机架部署、定期冻结不可变分区并备份至对象存储,且真实演练过单节点宕机后的副本重建流程。毕竟,当底层 OLAP 引擎遭遇故障限流时,前端广告看板应该弹出合理的降级提示,绝不能直接向客户抛出白屏崩溃。
7. 什么时候不该上列存 OLAP
需要保持清醒的是,并非所有带有「分析」标签的需求都应该盲目引入 ClickHouse 或 Apache Doris:
- 强依赖事务控制、按行高频原地更新的广告预算水位与账户状态,理应留在 OLTP 数据库或 Redis 缓存中处理。
- 针对单个
imp_id详情的极低延迟点查,行式存储或 KV 引擎显然更加对口。 - 如果团队缺乏分布式列存的运维经验,且表数据日增量仅在百万级别,那么依托 PostgreSQL 加上合理的索引也许就能满足业务 SLA。
总而言之,列式引擎的真正甜蜜点在于:以追加写入为主、拥有庞大维度的宽表、单次查询需要扫描海量行数但只聚合极少数列,并且能够接受秒级的查询可见延迟。 广告效果明细报表几乎完美契合了这些特征,而实时竞价决策链路中的「在线特征点查」却绝不在此列。
参考
- ClickHouse Docs. What is ClickHouse?:
MergeTree、稀疏索引与列存执行的官方入口。 - Apache Doris Docs. Architecture:FE/BE、Tablet 与物化视图概念。
- D. Abadi et al. Column-Stores vs. Row-Stores:列存优势的经典论文视角。
- ClickHouse. Compression in ClickHouse:编码与编解码器实践。