全文共 4,245 字 预计阅读 13 分钟
bg

bg10.olap

  • OLTP(On-Line Transaction Processing,联机事务处理):面向事务的负载。典型动作是"按主键读一行 / 改一行",高并发、低延迟、强一致。你熟的 MySQL 就是代表。
  • OLAP(On-Line Analytical Processing,联机分析处理):面向分析的负载。典型动作是"扫过几亿行、只取其中几列、做聚合",单条查询重、并发不高、以读为主。你天天用的云数仓就是干这个的。

这两种负载对存储和执行的要求几乎是相反的。用一张表按维度对照(这是它们最容易被混为一谈的地方,值得逐维度列):

维度 OLTP(MySQL) OLAP(云数仓)
典型查询 WHERE id = 123,点查/点改 GROUP BY city 扫全表聚合
触碰的行 极少数(靠索引直达) 一大片甚至全表
触碰的列 整行(行存,取一行=取所有列) 少数几列
存储布局 行存 + B+ 树索引 列存 + 稀疏索引
并发 高(上千 QPS 小查询) 低(几十并发,单查询重)
写入 频繁行级增删改 批量追加为主,少更新
事务 强(ACID、行锁) 弱(以读为主)
  • 加工引擎(05 Spark 批 / 07 Flink 流):做 ETL。把原始数据清洗、JOIN、聚合成宽表和汇总层,面向吞吐,能容忍分钟级甚至小时级延迟。它的产物是一张张算好的表,落在存储(03)上。
  • 查询引擎(本篇 OLAP 引擎):面向的交互式查询。直接读加工好的表,做即席分析(ad-hoc),追求低延迟——你点一下看板,它要在几秒内出结果。
                     加工(慢而稳,面向吞吐)        查询(快而轻,面向交互)
原始日志/DB ──ETL──►  Spark 批 / Flink 流  ──写──► 数仓宽表/汇总表 ──►  OLAP 查询引擎 ──► BI/看板/即席查询
                     (05 / 07)                    (列存, 03/04)         (本篇, 秒级)

它出现前的方案:早期做分析,要么用 OLTP 库硬扛(行存扫全表,慢到不可用),要么用 MapReduce / Hive 跑批(05 讲过,一个查询几分钟到几十分钟,靠落盘 shuffle 换容错)。前者扫不动,后者太慢——**没有一样能支撑"人坐在看板前点一下、几秒出结果"**的交互式分析。

它主要解决什么问题:让分析师能像查 MySQL 一样"即席地问数据",但面对的是几十亿行的规模,还要求秒级返回。

为此付出的代价(先把结论摆这,后面逐条兑现因果链):

  1. 放弃高并发点查:引擎为"扫大片数据"优化,不为"精准取一行"优化。
  2. 放弃灵活更新:数据以不可变文件为主,行级增删改很贵。
  3. 更高的资源开销:一条查询就能把多台机器的 CPU/内存/网络吃满,不适合海量小查询挤在一起。

记住这三条代价——它们不是 bug,是这台引擎为了"秒级"主动做的取舍。

秒级分析靠两根支柱撑着:横向把活分给很多机器(MPP),纵向把每台机器的 CPU 榨干(向量化)。

定义:MPP(Massively Parallel Processing,大规模并行处理)是一种 shared-nothing(无共享) 的分布式架构——每个节点有自己的 CPU、内存和本地数据,互不共享;一条查询被拆成很多份,每个节点只处理自己那份数据,节点之间只在必要时通过网络交换中间结果。

拿你熟的东西对照(类比,近似):

  • MySQL 是单机 SMP(对称多处理,共享内存)——所有 CPU 抢同一份内存和磁盘,加机器加不上去。
  • MPP 是把很多台"独立的小库"横着摆一排,数据切成片分给它们,大家同时算,最后汇总。加机器就能加算力(近似线性扩展)。

定义:向量化执行(vectorized execution)指查询算子每次处理一批(a batch of)列值,而不是一次处理一行。一"批"通常是几千到几万行的某一列,在内存里就是一段连续的数组

它要对照的是老模型——火山模型(Volcano model,又名 iterator model,火山模型是 Graefe 1994 提出的经典执行模型):算子组成一棵树,每个算子暴露一个 next(),每调一次吐一行,根节点不停调 next(),一行一行地把数据从叶子"拉"上来。

  • 模拟输入:1 亿行订单,filter status='paid'
  • 运行结果(量级对比):火山模型触发约 1 亿次 next() 调用链;向量化把 1 亿行切成约 1500 个 block(1 亿 / 6.5 万),每个 block 内是一个紧凑 for 循环。
  • 一句话解释:同样的活,一个按"次数=行数"付开销,一个按"次数=批数"付开销,批数比行数小四个数量级——这就是向量化省下来的东西,下一节拆它的因果链。

三者如何咬合:列存 + MPP + 向量化

它们不是三个独立优化,而是互为前提的一套设计:

列存(04)         ─── 提供了"一列连续的字节" ───►  向量化才有连续数组可批处理、才吃得到 CPU cache
分区/存储(02/03) ─── 提供了"可切分、可裁剪的数据片" ─►  MPP 才能把片分给各节点、少读无关数据
MPP              ─── 提供了"多节点并行 + exchange" ──►  向量化的算力才能横向堆叠起来

换句话说:没有列存,向量化无从下手;没有分区与不可变文件,MPP 不好切分;没有 MPP,单机向量化也堆不出秒级。

"秒级返回"这种结论,规范要求给因果链和代价,不接受只甩结论。这里逐条兑现。同一个查询贯穿始终:

-- 查 7/27 已支付订单,各城市的 UV 和 GMV,取 GMV 前十
SELECT city, count() AS uv, sum(amount) AS gmv
FROM   dwd_order
WHERE  dt = '2026-07-27' AND status = 'paid'
GROUP  BY city
ORDER  BY gmv DESC
LIMIT  10;
-- 模拟:dwd_order 单日分区 2 亿行、80 个字段;查询只用到 city / amount / status 三列

机制①:列存 + 编码,先把"要读的数据"砍到最小(承接 04)

  • 列裁剪:80 个字段只读 3 列,IO 直接降到约 3/80。
  • 分区裁剪(02):dt='2026-07-27' 只扫一个分区,不碰历史数据。
  • 编码/压缩(04):status 这种低基数列用字典 + RLE 编码后,2 亿行可能压到几 MB;city 同理。读的字节数再降一个数量级。

因果:查询延迟≈读的数据量 ÷ 带宽 + 计算。列存把"读的数据量"这一项从"2 亿×整行"压到"3 列的压缩字节",分母级别地砍。 代价:列存写入更重(一行要拆到多列分别落盘、编码);更新一行要动多列文件——这就是后面"更新弱"的根源之一。

机制②:MPP 把查询拆到多节点并行,数据本地计算,节点间 exchange

2 亿行不压在一台机器上,而是切成很多片(shard / part),分布在多个节点。引擎把查询拆成分布式执行计划,让每个节点扫自己本地的那片数据(数据本地性,省掉搬运),各自先算出局部结果,再通过 exchange 交换。

  • exchange(交换算子):节点间按某个 key 重新分发数据。当按 GROUP BY city 的 city 做 hash 重分布时,它干的事和 Spark 的 shuffle(05)完全同构——把"同一个 city 的数据汇到同一个节点"。
  • 和 shuffle 的关键差别:MPP 的 exchange 尽量走内存流水线(pipeline),数据边算边发、不落盘;Spark 批的 shuffle 默认落盘

因果:并行度 = 节点数 × 每节点核数,10 台 16 核就是 160 路同时扫;数据本地计算省掉全量搬运;exchange 走内存省掉磁盘往返。三者叠起来,把"单机串行几分钟"压成"多机并行几秒"。 代价:exchange 不落盘 = 不留中间结果,某个节点挂了通常整条查询重跑(而不是像 Spark 那样从 shuffle 文件断点续算)。低延迟是拿容错换的——这也是 OLAP 引擎一般不适合跑长达小时级大作业的原因。

机制③:向量化——按列批量处理,榨干 CPU cache 与 SIMD

同样扫这 3 列,火山模型逐行处理慢在哪?向量化快在哪?拆开看(这是"更快"结论必须给的机制):

  1. 摊薄每行的固定开销:火山模型每行都要走一遍算子链的虚函数调用、类型判断、算子间传递。向量化把这些摊到一整批上——一次 nextBatch() 处理几万行,固定开销除以批大小,几乎归零。
  2. 吃满 CPU cache(局部性):向量化对一列的连续数组做紧凑循环,数据顺序进 L1/L2 cache,cache 命中率高;代码也就那几行指令,指令 cache 也友好。火山模型逐行在算子树里跳来跳去,数据和指令都难以驻留 cache。
  3. 用上 SIMD:SIMD(Single Instruction Multiple Data,单指令多数据)指一条 CPU 指令同时处理一个向量寄存器里的多个值——比如 AVX2 的 256 位寄存器能一次算 8 个 int32(近似:把"标量循环"变成"每 8 个一拍")。连续数组的紧凑循环正好能被编译器/引擎向量化成 SIMD 指令,单核吞吐直接翻几倍。

因果:延迟里的"计算"这一项,被"摊薄固定开销 + cache 命中 + SIMD 并行"三连击压下去。这也是为什么向量化必须配列存——它要的就是"一列连续的值"。 代价:向量化要在内存里物化一批批中间结果,吃内存;算子实现比逐行复杂得多。另一条技术路线是代码生成(codegen / JIT),例如 Spark SQL 的 WholeStageCodegen 把一段算子编译成一个紧凑函数——它和向量化是"殊途同归"的两种榨 CPU 思路,不展开。

机制④:预聚合 / 物化视图 / 跳数索引,把"算"提前或跳过

前三条是"把每条查询算快",这条是"让查询少算甚至不算":

  • 跳数索引(skip index / data-skipping index):每个数据块(granule)存一份 min/max(或 bloom filter)。查 WHERE amount > 1000 时,min/max 已经小于 1000 的块整块跳过,连读都不读。ClickHouse 的主键是稀疏索引(默认 index_granularity = 8192,即每 8192 行一个索引标记),外加可选的二级 data-skipping 索引(minmax / set / bloom_filter 等)。这承接 03/04 的存储与索引。
  • 预聚合 / 物化视图(materialized view):把常用的聚合结果预先算好存下来。查询命中物化视图时,读的是几千行汇总,而不是几亿行明细。
    • 代价:物化视图要在写入时同步维护(增加写开销),且有数据新鲜度问题(可能滞后);存储也翻倍。空间和写入换查询延迟,这是典型取舍。

一句话小结:①②③把"每一条查询"算快,④让"高频查询"根本不用重算。四条叠加,才有你体感的"秒级"。

一条 SQL 在 MPP 引擎里怎么跑

flowchart TD
    A["客户端提交 SQL"] --> B["解析 Parse: SQL -> AST"]
    B --> C["优化 Optimize: 逻辑计划 -> 分布式物理计划<br/>(列裁剪/分区裁剪/JOIN 重排/在 exchange 处切分 stage)"]
    C --> D["调度: 把各 stage 的 fragment 分发到多个计算节点"]
    subgraph P["各节点并行执行(shared-nothing)"]
      E1["节点1: 扫本地列存<br/>分区+跳数索引裁剪<br/>局部聚合 partial GROUP BY city"]
      E2["节点2: 扫本地列存<br/>...<br/>局部聚合"]
      E3["节点N: 扫本地列存<br/>...<br/>局部聚合"]
    end
    D --> E1 & E2 & E3
    E1 & E2 & E3 --> F["Exchange: 按 city 做 hash 重分布<br/>(= 05 的 shuffle,同一 city 汇到同一节点)"]
    F --> G["最终聚合 final GROUP BY city:<br/>合并各节点的 partial 结果"]
    G --> H["ORDER BY gmv DESC + LIMIT 10"]
    H --> I["汇总回协调节点 -> 返回客户端"]

关键点串讲:

  1. 解析 + 优化:和 MySQL 优化器同类,但多一件事——生成分布式物理计划,并在需要重分布数据的地方(GROUP BY / JOIN)插入 exchange,把计划切成多个 stage
  2. 两段聚合(partial → final):每个节点先在本地算局部聚合(partial),把 2 亿行压成"每节点 × 每 city 一条 partial 结果";exchange 只搬这些已经变小的中间结果,而不是 2 亿行明细。这是 MPP 省网络的核心技巧——能在本地先算的,绝不上网
  3. exchange = shuffle:同一个 city 的 partial 结果被 hash 到同一节点,做 final 聚合。数据搬运量 ≈ 节点数 × city 基数,可能就几千条,秒级搬完。

这套架构不是某一家的专利,业界主流开源实现:

  • ClickHouse:向量化执行的标杆,单机性能极强,列存 + 稀疏索引。CNCH / ByConity 与它同源。
  • StarRocks / Apache Doris:MPP + 向量化,主打实时数仓,对高并发和多表 JOIN 优化更好。你在画像里提到"用过 StarRocks 不懂原理"——它的底子就是本篇讲的 MPP + 向量化。

ByteHouse CNCH:把前面各层收束到主力工具 ByteHouse 云数仓版是火山引擎(字节跳动)的云原生数据分析仓库。官方原文:它"全面继承了开源 ClickHouse 的高性能和强大的分析能力",并按云原生理念重构,实现了"容器化、存储计算分离、多租户管理和读写分离"。

  • CNCH = Cloud Native ClickHouse(字节内部对这套云原生 ClickHouse 引擎的代号,开源版本为 ByConity)——此命名与对应关系请以官方文档核实
  • 一个可佐证的细节:云数仓版的表引擎是 CnchMergeTree,系统表以 system.cnch_* 命名(如 system.cnch_parts)——"Cnch" 前缀与上面的说法一致(此为文档可见的客观信息)。

存算分离的 MPP 数仓:各层怎么收束

用一张分层图,把前几篇的内容收到这个产品里(组件名尽量用官方词):

                 ┌─────────────────────────────────────────────┐
   你写的 SQL ──►│  Server 层(专属 Server): 接入 / 解析 / 优化 / 协调 │  ← 生成分布式计划、管理元数据
                 └──────────────────────┬──────────────────────┘
                                        │ 下发 fragment
                 ┌──────────────────────▼──────────────────────┐
                 │  计算组 Virtual Warehouse(无状态计算节点)      │  ← MPP 并行 + 向量化(第三/四节)
                 │  弹性伸缩 · 读写分离 · 多租户 · 本地盘做缓存      │     算力可随时增减,算完即走
                 └──────────────────────┬──────────────────────┘
                                        │ 读列存数据
                 ┌──────────────────────▼──────────────────────┐
                 │  对象存储(共享存储层): 列存文件 + 分区          │  ← 03 存储 + 04 列存 + 02 分区
                 │  数据不可变、廉价、可无限扩,所有计算组共享一份    │     存储与计算各自独立扩缩
                 └─────────────────────────────────────────────┘
    (旁路)元数据 / catalog 服务: 管理表结构、分区、part 位置 ── 我推断由 KV 存储承载,需核实

"存算分离"为什么是这套架构的关键(给因果链):

  • 存储层是共享的对象存储(承接 03),数据只存一份、不可变;计算层(计算组)是无状态的,不绑定数据。
  • 因果:计算和存储解耦后,可以各自独立弹性伸缩——大促要算力就临时拉起几个大计算组,算完释放;存储照旧按对象存储计费。多个计算组还能读写分离(写入用一组、查询用另一组,互不抢资源)。这正是官方强调"弹性""读写分离""多租户"的底层原因。
  • 代价:计算节点本地没有数据,首次查询要从对象存储拉数据(有网络往返),因此要靠本地盘缓存来弥补——冷查询会比热查询慢,这是存算分离的固有代价。

收束:你在云数仓里体验到的一切——建表时写的 PARTITION BY(02)、底层的列式文件与编码(04)、放在对象存储上的数据(03)、由平台调度的计算组弹性伸缩(09)——在 CNCH 这里被拼成了一台完整的、你天天用的 OLAP 引擎。前 9 篇讲的每一层,都在这张图里各就各位。

边界

  • 高并发点查:不要拿它当在线业务库。

    • 机制:MPP 每条查询都要解析、生成分布式计划、调度多节点——这套开销对"扫大片数据"可忽略,但对 WHERE id=? 的单行点查是纯浪费。MySQL 靠 B+ 树主键点查是 O(log n)、一两次 IO 就返回;OLAP 引擎却要走调度 + 扫块。
    • 反模式:把 OLAP 引擎直接挂到高并发 C 端 API 后面当主存储。上千 QPS 点查会把计算组资源打满。点查高并发交给 MySQL/Redis。
  • 频繁更新 / 删除:更新是弱项。

    • 机制:数据是列存 + 不可变文件(03/04),没有 OLTP 的行级事务。一次 UPDATE/DELETE 往往要重写文件或写"标记删除",再靠后台 merge(MergeTree 的合并)慢慢生效,代价远高于 MySQL 的行级改。
    • 反模式:用它存需要高频行级更新的状态(如订单实时状态机)。要频繁改的数据留在 OLTP 库,数仓里以追加/批量覆盖为主。
  • 大表 × 大表 JOIN:小心 shuffle 爆内存。

    • 机制:分布式 JOIN 通常要按 JOIN key 做 exchange/shuffle(第五节)。大表 × 大表会搬运海量数据、吃满内存,甚至 OOM。
    • 缓解:合理设计**分桶(bucket)/ 同分布(colocate)**让 JOIN 双方按同 key 预先分布,避免大 shuffle;或把维表做成可广播的小表。
  • 资源开销粗放:不适合海量小查询。

    • 机制:一条大查询可能独占一个计算组的 CPU/内存/网络,缺乏 OLTP 那种细粒度并发控制。
    • 反模式:让成百上千个小查询挤在同一计算组里互相抢资源。

一句话判据:要"快速地问一大片数据"→ 用它;要"精准高频地读写单条记录"→ 别用它,那是 OLTP 的活。

走到这里,整个系列的拼图凑齐了。回头看,10 篇其实在回答一个问题的不同层面:海量数据,怎么存得下、算得动、查得快?

  • 存得下:数据落在廉价、可无限扩、不可变的分布式存储上(03 存储),按分区组织、保证一致性(02 分区与一致性),用列式格式 + 编码压缩把体积和读取代价压到最小(04 列式存储),再用湖表格式补上 ACID 与时间旅行(08 Iceberg)。
  • 算得动:批处理引擎扫全量、面向吞吐(05 Spark),流处理引擎处理无界数据、面向低延迟(07 Flink),中间靠消息管道解耦与缓冲(06 Kafka)。
  • 查得快:OLAP 查询引擎用 MPP + 向量化,对着算好的数据做秒级交互式分析(本篇 10)。
  • 贯穿始终:资源与时序由调度层统一编排(09 调度),而这一切的全景在开篇已经铺开(01 全景)。

一张精简分层图,把"存储 → 格式 → 数据组织 → 计算 → 查询",以及横贯其间的调度,收成一个整体:

┌───────────────────────────────────────────────────────────────────┐
│  应用层        BI 看板 · 即席查询 · 数据服务                          │
├───────────────────────────────────────────────────────────────────┤
│  查询层 (10)   OLAP MPP 引擎(CNCH)  ── 列存+MPP+向量化,秒级交互分析  │
├───────────────────────────────────────────────────────────────────┤
│  计算层 (05/07) 批 Spark(吞吐) · 流 Flink(低延迟) ── 把数据"算好"     │  ┐
│  接入层 (06)   Kafka  ── 实时数据管道 / 缓冲                         │  │ 调度层(09)
├───────────────────────────────────────────────────────────────────┤  ├─ YARN/K8s + 工作流
│  组织层 (02/08) 分区 · 一致性 · 湖表 Iceberg(ACID / 时间旅行)         │  │  贯穿计算与查询,
│  格式层 (04)   列式存储 Parquet/ORC + 编码压缩                       │  │  统管资源与时序
│  存储层 (03)   HDFS / 对象存储 ── 廉价、可扩、不可变文件             │  ┘
└───────────────────────────────────────────────────────────────────┘
        (全景总览见 01;越往下越"存",越往上越"用")

从这张图往回看你就会发现:上层的每一项能力,都建立在下层的取舍之上。 OLAP 引擎能秒级返回,是因为下面有列存把数据压小、有分区让它少扫、有存算分离让算力弹性伸缩;而它换来的代价——更新弱、点查弱——也正来自这些下层设计。这就是本系列反复强调的那件事:没有免费的快,每一层的"更快/更省"都在别处付了账。 看懂了这条因果链,你手里那台天天用的云数仓,就不再是黑盒了。

Back to Blog