Featured image of post 第六话|数据的存与算:Doris 的架构与机制

第六话|数据的存与算:Doris 的架构与机制

一个分析型数据库要回答两件事:数据怎么存,数据怎么算。Doris 的架构分 FE 与 BE 两层——FE 管元数据与查询编排,BE 同时承担存储与计算(存算一体)。正文先铺全貌,再分别把「数据的存」(列存、排序键与前缀索引、分区/分桶、副本与 Compaction)和「数据的算」(MPP 并行、向量化执行、Pipeline/PipelineX、优化器)讲清,然后停在存与算之间的契约——数据模型——上,说明它为什么同时约束存储语义和查询能力,最后把 Stream Load 如何把数据「存」进来走一遍。全文以 Apache Doris 2.1.x 为准,机制均来自 2.1 官方文档;3.x 起的存算分离等新形态不在本文范围内。

系列 GYD(Get Your Distributed) 第 6 / 7 篇
主题 DorisOLAP分布式列式存储MPP

笔者在工作中同时使用过 MySQL 和 Doris。MySQL 是事务型数据库,面向 OLTP(Online Transaction Processing)场景——比如订单处理、用户登录、库存扣减这类需要高并发、低延迟、强一致性的业务操作。Doris 是分析型数据库,面向 OLAP(Online Analytical Processing)场景——比如用户行为分析、销售报表、数据仓库这类需要扫描大量历史数据、按维度聚合统计的分析需求。

Doris 最早是百度在 2017 年开源的(当时叫 Palo),后来捐赠给 Apache 基金会,定位为高并发、低延迟的 MPP(Massively Parallel Processing,大规模并行处理)分析型数据库。它不是 MySQL 的升级版,而是从存储到计算都为分析场景重新设计的系统。这篇文章以 MySQL 为参照系,讲 Doris 的架构与原理。

版本与口径:下面的机制都以 Apache Doris 2.1.x 为准,出自 2.1 官方文档;涉及真实生产使用的地方标【生产经验】。3.x 起的存算分离形态不在此文讨论范围。

一、两种负载:OLTP 与 OLAP

以订单表 orders(字段:order_id BIGINT(主键), user_id BIGINT, region VARCHAR, category VARCHAR, amount DECIMAL, status VARCHAR, created_at DATETIME,几百万行)为例。

业务后端每秒执行的是这类查询:

SELECT order_id, status FROM orders WHERE order_id = 1024;
UPDATE orders SET status = 'paid' WHERE order_id = 1024;

单次查询只涉及一行,走 order_id 索引,毫秒级返回。

分析师每月执行的是这类查询:

SELECT region, category, SUM(amount)
FROM orders
WHERE created_at >= '2026-08-01' AND created_at < '2026-09-01'
GROUP BY region, category;

需要扫描几百万行、按区域和类目分组求和,最后只返回几十行。扫描范围大,聚合计算重。

两者需求差异显著。数据库领域用两个术语区分:

  • OLTP(Online Transaction Processing,在线事务处理):处理「单行数据的正确提交」。重点是事务、并发、单行性能。业务系统普遍采用。
  • OLAP(Online Analytical Processing,在线分析处理):处理「大量数据的扫描与聚合」。重点是扫描、聚合、吞吐。报表和数据分析场景的首选。

主要差异体现在以下几个方面:

维度OLTP(MySQL 的主场)OLAP(Doris 的主场)
典型操作单行/小批量的读取与更新全表/大范围的扫描与聚合
访问方式走索引点查,一次命中目标行只读用到的列,跨大量行扫描
并发高并发,大量短事务大查询少,但单个查询计算量大
一致性强一致,ACID 事务,出错可回滚单批导入原子可见;几乎不做交互式跨行/跨表强事务
写入随时小量写,靠行锁保证并发正确攒批后大批量导入
指标每秒事务数(TPS)、单次延迟每秒扫描行数、整批处理吞吐
数据形态当前状态(正在处理的订单)长期历史(几个月到几年的明细)

InnoDB 适合 OLTP,主要在下面这几处:

存储结构:InnoDB 把整张表按主键组织成一棵 B+ 树(聚簇索引):叶子节点就是存放数据的页(Page,默认 16KB,落在表空间文件 .ibd 里),每行的各列连续存在同一页内,主键即索引、索引即数据。

查询执行:InnoDB 维护一个缓冲池(Buffer Pool),把频繁访问的页缓存在内存中。执行 WHERE order_id = 1024 时,沿 根节点 → 中间节点 → 叶子页 逐层向下,走过的页数等于 B+ 树高度(几百万行通常 3 层左右);根节点和上层索引页一般已命中缓冲池,真正落盘的往往只有叶子页那一次,再从中取出目标行。

二级索引:主键之外的列也建 B+ 树,叶子只存「索引列 + 主键值」,命中后回聚簇索引取整行,这就是回表。联合索引只能按最左前缀用:WHERE col1 = 1 AND col2 = 2 能用 (col1, col2) 索引,WHERE col2 = 2 则不行(官方手册:只有左前缀能被优化器用于查找)。

事务与并发:行级锁只锁被改动的行(官方特性表:锁粒度是 row);多版本并发控制(MVCC)用回滚日志(undo log)保留旧版本,让读不阻塞写。默认隔离级别可重复读(REPEATABLE READ),保证同一事务内前后读取一致。

水平扩展:InnoDB 本质是单机存储引擎,一张表的数据完整在一个实例里,引擎自身不做单表分片(官方特性表:InnoDB 的 Cluster database support 为 No)。要多机承载,只能靠主从复制做高可用与读写分离,或靠中间件分库分表——都不是 InnoDB 单表原生的水平扩展。

分析查询在 MySQL 上的表现,可以逐条对应:

  • 索引失效,只能全表扫描:报表常按日期、状态、类目这些列过滤分组,往往跟主键顺序对不上,B+ 树的快速定位用不上,只能逐页读取所有叶子页。
  • 行存读取整行数据:一行所有列连续存放在同一个页内。即使只查询 amount 字段,也需要将整个页读入缓冲池。
  • 分组、排序需要额外开销:当聚合或 ORDER BY 无法用索引顺序直接满足时,MySQL 要借助内存或临时磁盘文件做排序、分组(官方管这类操作叫 filesort)。
  • 计算资源受限于单机:所有查询共享同一个实例的 CPU、内存和缓冲池。分析大查询会挤占缓冲池空间,影响其他查询的性能。

一条聚合查询可能同时触发多个瓶颈:全表扫描占用大量缓冲池 + 逐页读取产生大量磁盘 I/O + 排序/分组占用内存或临时文件 + 长时间占用实例资源。这是生产环境中「报表查询导致主库 CPU 告警」的典型场景【生产经验】。

边注:MySQL 在隔离级别设计上也考虑了报表场景。官方文档在说明降低一致性以提升性能时,以批量报表为例。MySQL 对分析查询的特性有清晰认识,但其设计优先级是事务处理。

Doris 是分布式的 MPP 分析型数据库,节点分两类:FE(Frontend)与 BE(Backend)。两类节点都多节点部署以消除单点,对外通过兼容 MySQL 协议的接口接入查询(官方架构文档)。这和 MySQL 相反:MySQL 的解析、优化、存储、执行全部在单个实例的进程内完成,也不做这种角色拆分。

FE(Frontend):接收连接、解析并规划查询,并管理集群的元数据。库、表,以及分区、分桶这样的数据分布与副本位置——这一整套元数据只保存在 FE,并持久化到 FE 本地(每次元数据变更顺序写进 edit log,周期性把整份内存元数据落成 image 快照,重启时按快照加日志回放恢复);BE 只存数据本身;节点存活与副本状态由 FE 负责心跳检测与副本修复。FE 分 Master、Follower、Observer 三种角色:Master 写元数据并调度副本,Follower 同步元数据、可在 Master 故障时接替,Observer 只读以分担读压力,多个 FE 由此保证管理层高可用。

BE(Backend):真正存数据、算数据的一方。数据分片以列存文件落在 BE 上、各存多副本;BE 执行 FE 下发的计算算子(扫描、聚合等)。计算是向量化执行——一次处理一批列数据而不是逐行处理(官方口径:列式内存布局 + SIMD 加速);并以 Pipeline 方式调度,把查询切成可由有限线程轮转执行的任务,避免一个查询占满线程。

存算一体:在 2.1 里,存储与计算发生在同一台 BE 上——数据分片存在哪台 BE,相关计算就在哪台 BE 上做,不必先把数据搬到别处(3.x 起的存算分离不在本文范围)。

数据分布:Doris 把一张表分两级切开。第一级是分区(Partition),常按时间这类列做 Range 划分——比如 orders 表按 created_at 每月一个分区,查询限定在某段时间时只扫对应分区、其余跳过。第二级是分桶(Bucket),在分区内再按某个键取哈希打散——比如按 order_id 哈希分成若干桶。两级切到最后,每个桶就是一个 tablet:它是 Doris 存数据、做副本、调度(迁移与均衡)的最小单位。于是一张表被摊成「若干分区 × 每分区若干 tablet」,分布到多台 BE 上、每台各管一部分,tablet 又各存多副本(默认 3 副本)以防节点宕机丢数据——这是 InnoDB 单实例没有的能力,Doris 由引擎自己把单表分布出去。

列式存储:tablet 内部按列存放,查询只读用到的列、跳过无关列,不像行存那样即使只用一列也要把整行读进来。

MPP 并行:Doris 把一个查询切成执行片段,下推到持有相关 tablet 的多个 BE 上并行扫描、聚合,最后汇总,而不是由单台机器串行执行完。

二、数据的存:tablet、列存与版本合并

一条数据从导入到可查询,在 Doris 里走的路径是:按分区、分桶落到某个 tablet;以列存格式写成一个不可变的 rowset,里面是一个个 segment 文件;tablet 的多副本分布在不同 BE 上;多数副本写入成功后,FE 提交事务,数据对外可见。此后每次导入都追加新的 rowset 和版本,由后台的 Compaction 把版本收敛回去。查询则反向使用这些结构:先裁剪分区,再用前缀索引定位数据块,最后只读所需的列。

tablet:数据落在哪台 BE 上

第一章说过,一张表先按分区、再按分桶切开。落到存储层,官方给的关系式是:Tablet 数 = 分区数 × 桶数 × 副本数。以 orders 表为例:按 created_at 每月一个分区、按 order_id 哈希成 10 个桶、默认 3 副本,那么每个月就是 10 × 3 = 30 个 tablet,分散在不同 BE 上。tablet 是 Doris 存数据、做副本、做调度迁移的最小单位——副本往哪台机器放、哪个副本坏了要补,都以它为单位。

这三个乘数落在建表语句里就是三行,本章后面反复用到的这张表长这样:

CREATE TABLE orders (
  created_at DATETIME   NOT NULL,   -- 排序键第 1 列
  region     VARCHAR(32),           -- 排序键第 2 列
  category   VARCHAR(32),           -- 排序键第 3 列
  order_id   BIGINT     NOT NULL,   -- 只做分桶列,不在排序键里
  user_id    BIGINT,
  amount     DECIMAL(10, 2),
  status     VARCHAR(16)
) ENGINE = OLAP
DUPLICATE KEY(created_at, region, category)          -- 排序键:必须是列定义的前 K 列
PARTITION BY RANGE(created_at) (                     -- 分区:决定去哪个分区
  PARTITION p202608 VALUES [("2026-08-01"), ("2026-09-01"))
)
DISTRIBUTED BY HASH(order_id) BUCKETS 10             -- 分桶:决定落在哪个 tablet
PROPERTIES ("replication_num" = "3");                -- 副本:每个 tablet 存 3 份

注意列的顺序和第一章那张表不一样:created_at、region、category 提到了最前面——不是排版偏好,排序键只能取列定义的前 K 列。分区、分桶、副本三行各给出一个乘数,上面这一个分区就是 10 × 3 = 30 个 tablet;SHOW TABLETS FROM orders 能把这 30 个逐条列出来,含每个副本在哪台 BE、当前版本、行数和大小。本文写作时在单机 Docker 里跑的就是这张表,单台 BE 只能存一份副本,那一行写的是 "1",tablet 数相应是每分区 10 个——其余行为与 3 副本一致。

桶数不是随手填的。官方建表最佳实践的参考指标是:单个 tablet 的数据量保持在 1G–10G。太小(桶切得过碎),每片都要单独写文件、单独记元数据,聚合效果差、FE 的元数据管理压力也大;太大(桶不够多),副本迁移和补齐的代价高,改表结构(Schema Change)、建物化视图这类要重写数据的操作,失败重试的代价也跟着单个 tablet 变大。维表这类小表,官方建议 tablet 数控制在 3–5 个,既保住一定并发,又不制造小文件。分区列方面,官方建议事实表用 date/datetime、一般每日一个分区(本例按月是为了数字好算),缓慢增长的维表可以单分区。分桶列要选高基数(取值种类多)且分布均衡的列,比如 order_id;选了低基数列,数据会挤在少数几个桶里,等于白分。不想绑定某个键时也可以用 RANDOM 分桶(不指定分桶列、随机撒),官方口径是它适合整体聚合分析、不适合高并发点查。

segment:按列存放与编码

官方把 tablet 内部的存储层级概括为 Tablet → Rowset → Segment → Column → Page,它们各自的用途是:rowset 是一次导入产出的文件集合;segment 是 rowset 里真正装数据的文件,一个 rowset 可以有一个或多个;column 指 segment 内每一列单独组织,同一列的值连续排在一起;page 是把一列的值按约 64KB 切出的小块,它是读取和压缩的最小单位——查询要么整页跳过,要么整页读进来解压。

Doris 存储层级:Tablet → Rowset → Segment → Column → Page

按列存放带来的第二个好处是:同一列的相邻字节长得很像,可以针对性地换一种更省的记法,这就是编码。行存做不到这点——一行的各列挤在一起,相邻字节一会儿是字符串、一会儿是金额、一会儿是时间,没有规律可榨。编码和压缩是两道工序:编码挑一种适合这一列数据规律的表示方式,压缩在编码后的字节流上再跑一道通用无损压缩。Doris 按列的类型和取值分布自动挑编码:

编码适合什么样的列省在哪
PLAIN没有规律可榨的列(官方口径:一般数值)原样存
DICT(字典)低基数列,即不同取值很少(status 只有几个状态、region 只有几十个地区)建一张「值 → 编号」字典,数据里只存短编号,不再重复存整个字符串
RLE(游程)连续重复值多的列(官方举的例子是布尔列)记「值 + 连续出现次数」,而不是把同一个值写很多遍
BIT_SHUFFLE整数列(order_id、时间戳)把所有值的第 1 个字节放在一起、第 2 个字节放在一起;整数高位往往相同,于是相同字节连成大片,后面压缩特别有效
FOR(frame-of-reference,基准值)日期列只存「减掉一个基准时间后的差值」,用更少的位表示

编码之后才轮到通用压缩,默认 LZ4F,可以换成 ZSTD 等,官方给的口径是 ZSTD 典型能把表压到原来的 1/5 到 1/10;反过来,一道 page 压缩后省不到 10%,Doris 就干脆不压,免得解压白烧 CPU。

收益直接落在读取上:查询只碰涉及列的 page,表越宽、查询涉及的列越少,省得越多。每个 page 带一份 zone map——这一页该列的最大值、最小值、是否含 NULL,条件跟这个范围不沾边的页不解压就整页跳过,这一份是内建的、每列都有。等值查询还想跳得更狠,就得自己加索引了:bloom filter 索引不是自动的,要在建表属性里点名("bloom_filter_columns" = "user_id, order_id" 这类写法),Doris 才为这几列逐 page 各建一个 bloom filter。有了它,= 和 IN 就能先问一句「这一页里可能有这个值吗」,回答「肯定没有」的页直接跳过。bloom filter 只有假阳性、没有假阴性,说「没有」就是真没有,所以拿它跳页是安全的;代价是对高基数列(user_id、订单号这类)跳过率高,对低基数列基本没用,Tinyint、Float、Double 三类还建不了。属性一旦写上,查询用到时自动生效、SQL 不用改写法,但它对 = 和 IN 之外的条件不帮忙。

代价也要如实说:取一整行要把所有列各读一次,每列一次独立 I/O,官方文档直言宽表按主键点查会付出 N 倍 I/O,高频 SELECT * 点查是列存的弱项。

排序键与前缀索引:tablet 内部怎么找到那一段数据

一提到索引,从 MySQL 过来的人默认是 B+ 树:多层节点、叶子之间互相串联、每个二级索引各是一棵树、按查询条件一个个建。Doris 的前缀索引形状简单得多,也单薄得多,它就是一份排好序的定长数组,每项存两样东西:一小段拼接起来的排序键值,和这一段值所在的起始行号。查找时在这个有序数组上二分,定位到目标值落在哪一块,拿到起止行号,其余块不读。它不长成树,也不为每个查询条件各建一套——一张表只有这一套,代价是能力比 B+ 树窄很多。

它挂在「行」这个维度上,容易和上一节的 page 混:page 是一列的值按 64KB 切出来的,每列各切各的;前缀索引的逻辑数据块是按行数划的,跨所有列共同。两者粒度不重合。

排序键就是建表时 Key 子句里那几列,官方明确 Key 列必须是表定义的前 K 列(从第一列开始连续声明),单个 tablet 内的数据按这几列整体排序。有序是这一切的前提:一个条件对应的行必然连续成一段,才谈得上「记住每段起点、只读那一段」;无序就只能是全读之后逐行判断。官方对这套存储结构的说法是「类似 SSTable」——按键有序、写完不改、后台合并的不可变文件格式,Java 生态里 RocksDB、LevelDB、HBase 用的都是这一类。

数组每项存的那段值叫短 key(short key),取法是把排序键按声明顺序拼接,累加到 36 字节为止。上限只到 36 字节,是为了让整份索引小到能常驻内存:一亿行按每块若干行算下来仍有几万条索引项,每项再宽就成了查索引本身要扫一遍的大对象。每块到底多少行,2.1 的文档只写了「按行数划成逻辑数据块」,没给具体数字,这里也就不猜。碰到变长列时规则收紧:VARCHAR 可以进短 key,但只截取它自己的头几个字节,且到此为止,后面的列一概不再进入前缀索引。官方给的两组数正好说明这条的分量:

  • user_id BIGINT, age INT, message VARCHAR(100):短 key = user_id 的 8 字节 + age 的 4 字节 + message 的头 20 字节,凑满 36;
  • user_name VARCHAR(20), user_id BIGINT:短 key 只有 user_name 这 20 字节,没到 36 也停,因为 VARCHAR 之后的 user_id 已经进不来了。

对照 tablet 那节贴出的建表语句还有一个容易找错地方的地方:整份 DDL 里找不到「前缀索引」这个词,也没有任何子句能调它——短 key 完全由 DUPLICATE KEY(created_at, region, category) 那一行推出来,排查「我的前缀索引建成了什么样」时,要看的是表定义,不是索引列表。

这条规则决定了 Key 的排列纪律,也就是最左匹配的实际含义:条件里出现排序键连续的前几列才走得通前缀索引,跳过中间一列,后面的列就匹配不上——这一点和 MySQL 的联合索引同构,直觉可以照搬。所以官方的建议是把最常过滤的定长列(尤其时间列)排在最前面,且不必贪多,超过三四列基本是白写。

前缀索引:在有序的短 key 数组上二分,定位到一段行号

这条纪律落到 orders 表上有个不好听但必须说的结论。前面把它排成 created_at、region、category,created_at 是 DATETIME、定长,进得来;region 是 VARCHAR,只能以头几个字节的形式参与;category 则完全不在前缀索引里,WHERE category = 'food' 走不到它。也就是说这条查询从前缀索引得到的只有时间那一段。剩下两段要靠对某一列单独建的索引来补。另外,排序键放这三列不干涉分桶:明细模型不要求分桶列与 Key 列一致(那是聚合、主键模型的约束),分桶仍然可以用 order_id。

Key 在聚合和主键模型里还兼作聚合键、去重键,那是另一层语义;本章只用到它的排序作用。前缀索引到底过滤掉多少行,可在查询 profile 的 RowsKeyRangeFiltered 指标里核对。

索引体系:哪些内建,哪些要自己建

上一节那份前缀索引是自动来的,建表时不用写任何东西。Doris 还有几种索引必须你点名,实际用表时最常碰到的就是「给某一列开 bloom filter」这类设置。它们和自动的那两种放在一起是这样:

索引建在哪些列上作用粒度怎么建只加速什么
前缀索引排序键(Key)的前 36 字节每个逻辑数据块一项内建,自动覆盖连续最左前缀的等值、范围条件
ZoneMap 索引所有列,无一例外每个 page 一份 min / max / 是否含 NULL内建,自动等值、范围、IS NULL 的整页跳过
BloomFilter 索引你点名的若干列每个 page 一个表属性 bloom_filter_columns只有 = 和 IN 的整页跳过
NGram BloomFilter 索引你点名的字符串列每个 page 一个INDEX ... USING NGRAM_BF,可配 gram_size、bf_size只有字符串 LIKE '%…%' 的整页跳过
倒排索引你点名的字符串、数值、日期、数组列值或词项 → 命中的行号INDEX ... USING INVERTED等值、范围、关键词匹配,能定位到行

这条分界决定了你能优化什么。内建的两种没得选,能调整的只有「数据按什么顺序排」,也就是上一节;要自己建的三种是在排序之外再为个别列补一份结构,所以选择全在「给哪一列、建哪一种」上,代价也全在这里——每补一份,这一列就多占空间、每次写入都要跟着维护,所以每一种都把自己限制在能真正省下读取的那几种条件上,表里「只加速什么」那一栏写的就是这条边界。

要自己建的这三种里,前两种属于跳数索引(skip index,ClickHouse 里同名同义的那个说法)。它不告诉你数据在哪,只对「这一页」回答一个是或否:这里头肯定没有,于是整块跳过。所以它的收益完全取决于数据分布和条件形状。分析型扫描的瓶颈通常是「读了太多不相关的页」而不是「找不到那一行」,这也是分析型表不必像 OLTP 那样按查询模式铺一串二级索引的原因——ZoneMap 是同一思路的内建版本,BloomFilter 是你手动加强的那一版。加强版也有自己的边界:BloomFilter 只对高基数列有意义,低基数列(只有几个取值的 status)几乎每页都含目标值,一个也跳不掉;Tinyint、Float、Double 这三种类型官方直接不让建。

BloomFilter 只认完整值,所以帮不了模糊查询。NGram BloomFilter 补的就是这个洞:把字符串按 N 个连续字符滑窗切开,shanghai 在 gram_size = 3 时切成 sha、han、ang、ngh、gha、hai 这一串片段,每个片段作为一个值塞进这一页的 BloomFilter;查询 LIKE '%ang%' 时把 ang 也照切一遍,逐个问「在不在过滤器里」,只要有一个不在,这一页就整块跳过。官方那条限制由此而来——LIKE 里连续的字符数要够到 gram_size,短于 N 就切不出一个完整片段,索引无从问起。它和上一段那个按完整值建的 BloomFilter 抢的是同一个位置,同一列上两者只能有一个。

倒排索引是这几种里能力最宽的,因为它不走「跳过整块」那条路,而是直接给出哪些行命中。「哪些行含某个值」这张表按值组织,等值、范围、IN / NOT IN 都查得动它,数组列可以按元素过滤。字符串列加上分词器才有全文能力:parser 取 english / chinese / unicode,中文还能选 fine_grained(细粒度)或 coarse_grained(粗粒度);之后 WHERE 里可以写 MATCH_ANY(含任一关键词)、MATCH_ALL(每个关键词都得有,不管顺序)、MATCH_PHRASE(这些词得挨着、且按这个顺序出现)。它也有建不了的时候:聚合模型,以及没开 Merge-on-Write(主键表的一种写入模式,写入时就把同键的行合并掉)的主键表,只有 Key 列能建倒排索引;FLOAT、DOUBLE 因为精度问题不支持。BITMAP 索引官方说已被倒排索引取代,不必再单独学。

它到底能快多少,官方给的对比是关键词检索比等价的 LIKE '%…%' 快 7–13 倍,样本是一百万行 HackerNews 数据,同一份数据建索引本身花了一两秒——这两个数得一起看才知道量级。这个量级我在本地一套单 FE、单 BE 的 2.1.11 上复现过:三百万行,title 平均 121 字节、合计约 363 MB,八个桶,其中 1% 的行含一个生造词。同一个条件,WHERE title LIKE '%那个词%' 的服务端耗时稳定在 36–54 ms,写成 MATCH_ANY 是 5–6 ms,约 7 倍。另外两个对照数才说清这钱花在哪:把 LIKE 里的词换成一个必然不命中的,耗时不变,还是 33 ms,和强制把整列扫一遍的 sum(length(title))(29–35 ms)基本相等——LIKE 的成本就是全列扫描的成本,跟命中多少无关;而把关键词换成每行都出现的 doris,LIKE 28 ms、MATCH_ANY 12 ms,差距缩到两倍出头:命中面铺满时,索引省下的扫描抵不掉它为命中行逐个产出行号的开销——后半句是按机制推的,我测到的只有那两个数本身。倒排索引的收益来自命中少,不是无条件快,选列的时候这条比任何倍数都重要。

最后是怎么补上去。三种索引的建法长在三个不同的地方,对照着 orders 表看一眼就分清了:

-- BloomFilter:表属性,列名列表。没有这一行,这一列就没有 bloom filter
ALTER TABLE orders SET ("bloom_filter_columns" = "user_id, order_id");

-- NGram BloomFilter:INDEX 子句,只服务字符串列的 LIKE
CREATE INDEX idx_region ON orders (region) USING NGRAM_BF
  PROPERTIES ("gram_size" = "3", "bf_size" = "1024");

-- 倒排索引:同一个 INDEX 子句换一种类型;事后加的要对已有数据补建一次
CREATE INDEX idx_status ON orders (status) USING INVERTED;
BUILD INDEX idx_status ON orders;

第一条是表属性,所以它不会出现在任何 CREATE INDEX 里;后两条是 INDEX 子句,ALTER TABLE orders ADD INDEX idx_status (status) USING INVERTED 与 CREATE INDEX 等价。字符串列要做全文检索,再在 PROPERTIES 里加 "parser" = "chinese" 这类分词器,纯等值和范围过滤不用加。

这里有个容易踩的坑。改 bloom_filter_columns 不是改一行元数据就完事,Doris 会为它起一个 schema change 作业——所谓 schema change 是 Doris 的在线改表流程:数据是一个个不可变文件,改结构就得由 FE 调度、BE 把这些文件重写或补齐,所以它是异步的。在 2.1.11 上,改完立刻到 SHOW ALTER TABLE COLUMN 里能看到一个 PENDING 的作业,表停在 SCHEMA_CHANGE 状态,这期间再发 CREATE INDEX 会被直接拒掉(Table[orders]'s state(SCHEMA_CHANGE) is not NORMAL),作业跑完属性才生效;小表是几秒,大表就是分钟级的事。倒排索引建完还要对已有数据跑一次 BUILD INDEX,漏了这一步,只有新写入的数据带着索引。

核对的时候还有一处容易找错:SHOW INDEX FROM orders 只列 INDEX 子句建的东西(上面两个 INVERTED、那个 NGRAM_BF),bloom_filter_columns 不在里面,得回 SHOW CREATE TABLE orders 看表属性。

物化视图:把聚合提前算好

索引省的是「读」,还有一类开销索引帮不上:同样的聚合,每次查询都重算一遍。报表每天按地区、类目出一次销售额,明细有几亿行,每天都要把这几亿行重扫重聚一次。物化视图的思路是把这类查询的结果提前算好存下来,查询来了直接取。Doris 2.1 里它有两种形态,分界在「谁来维护这份预计算数据、什么时候维护」。

同步物化视图:跟着导入走。建法是一条 CREATE MATERIALIZED VIEW,定义里只写想提前算什么:

CREATE MATERIALIZED VIEW mv_region_amt AS
SELECT region, category, sum(amount)
FROM orders
GROUP BY region, category;

本文在 2.1.11 上对 orders 表执行,SHOW ALTER TABLE MATERIALIZED VIEW 里能看到一个 FINISHED 的作业,输出里有一栏 RollupIndexName——它的实现就是基表上的一个预聚合副本,导入时同步更新,官方的口径是「只有同步完成后,相关命令才会结束」,无需人工维护。代价也在这里:每一张同步物化视图都让导入多写一份、多聚一次,官方直言单表上物化视图过多会拖慢导入。

它生效的方式是透明改写:查询照常写基表的名字,Doris 自动匹配能否用物化视图回答。把上一节那条不带时间条件的聚合拿来 EXPLAIN,扫描节点显示 TABLE: demo.orders(mv_region_amt)——语句里写的是 orders,实际读的是物化视图。验证命中与否看的也是 EXPLAIN,文档把改写结果分了三种标记(MaterializedViewRewriteSuccessAndChose 命中且选用、MaterializedViewRewriteSuccessButNotChose 命中但按成本未选、MaterializedViewRewriteFail 未命中),排查「为什么没命中」时到计划里找它们。

透明改写有一条本文实测撞出来的边界。同一条聚合加上 WHERE created_at >= '2026-08-01' AND created_at < '2026-09-01' 之后,计划回落到基表:这份物化视图里没有 created_at 这一列,带时间条件的查询它回答不了。提前算好的口径必须盖得住查询的过滤条件——分组列决定哪些谓词能落到这份预计算数据上,建视图时把常用于过滤的列一并考虑,否则存储白付、改写永不命中。它的能力边界也来自同一个原因:只支持单表(官方明确不支持 JOIN),聚合形式限于 sum 这类常规聚合和 bitmap_union(用它可以把 count(distinct) 变快,官方给的标准做法);它也不像普通表那样能直接 SELECT——本文实测对它查询返回 Unknown table,这是它与异步物化视图最直观的一个区别。

异步物化视图是 2.1 引入的另一种形态:它自己就是一张内表(底层是 Duplicate 模型),定义里可以写多表 JOIN,基表模型不限,数据靠创建时注册的刷新任务重算。本文实测的一条:

CREATE MATERIALIZED VIEW amv_orders_region
BUILD IMMEDIATE REFRESH AUTO ON MANUAL          -- 创建即构建;手动刷新
PROPERTIES ("replication_num" = "1")            -- 单 BE 环境的限制,多 BE 集群不必写
AS SELECT region, sum(amount) AS amt, count(*) AS cnt
FROM orders GROUP BY region;

建完 10 秒内直接 SELECT 已有数据。刷新方式三种,写在建表语句里:ON MANUAL 手动触发(REFRESH MATERIALIZED VIEW … AUTO)、ON SCHEDULE 定时(EVERY 2 HOUR 这类)、ON COMMIT 基表数据一变就刷。它同样支持透明改写(官方称基于 SPJG——SELECT-PROJECT-JOIN-GROUP-BY——模式的改写算法),代价是数据与基表最终一致:官方口径是「无法实时同步」,通常用于对时效性要求不高的场景,「一般是 T+1 的数据」。

两种形态的选型其实是三个问题:口径是单表还是多表——单表才能用同步,多表只能异步;结果要实时还是容忍延迟——导入即更新是同步的语义,异步是最终一致;写入放大付在何时——同步的每个导入都多写一份,异步把代价挪到刷新时刻。它和聚合模型的关系也在这里收拢:聚合模型在建模时就锁定了数据的聚合口径,物化视图是在已有表上给某几个固定口径补一份预计算——两者都是拿空间和写入换查询时间。索引省下的「读」,物化视图省的是「算」。

每次导入产生一个新版本:rowset

Doris 的写入属于 LSM 树那一套路子:写操作从不原地修改已有文件,每次导入产生一个新的、不可变的 rowset,带一个版本号。于是一个 tablet 的数据就是一串按版本排开的 rowset。

代价落在读取上:查询要把这些 rowset 在读取时合并起来看——新版本、被删掉的旧版本,都靠版本号和删除标记判断,这就是读时合并。rowset 少时无所谓,但 Stream Load、Group Commit 这类高频导入会让版本数涨得很快。官方给了硬限制:单 tablet 版本数超过 max_tablet_version_num(默认 500)后导入直接失败,报 -235,官方自己说这是用户群里最常见的生产事故之一。版本堆积还有两笔账:查询要合并的路径变长;被删除的行不会立刻消失,它们以 tombstone(删除标记) 的形式留在旧 rowset 里,每次读都要额外过滤。

Compaction:把版本收敛回去

把一串小 rowset 合并成少数几个大 rowset 的后台动作就是 Compaction。理解它的关键是一条分界线 cumulative point:cumulative compaction 高频、轻量,只合并 cumulative point 以上的新进小 rowset,先把刚灌进来的碎片归拢;base compaction 低频、重量,把 cumulative point 以下的所有 rowset 合并成一个 base rowset(这个 tablet 的底座数据)。cumulative 的合并结果一旦超过 promotion size(默认 1GB),下一轮就有资格并进 base。

rowset 版本累积与 cumulative 合并

BE 给每个 tablet 打一个「乱」的分数来决定先合并谁,分数大致等于查询在这个 tablet 上要走的合并路径数,谁最乱先处理谁。合并完成后,读时合并的路径变短、小文件数回落、tombstone 也随旧 rowset 一起被清掉。

副本:多数派成功才算成功

导入时,数据并行写入各副本所在的 BE,每个 BE 写完后向 FE 确认。FE 统计成功写入的副本数,超过总副本数的一半即视为导入成功——三副本的表,至少 2 个副本写成功。随后 FE 提交导入事务,数据对外可见,这就是第一章说的「单批导入原子可见」的由来。

没写成功的副本不会永久缺数据,由后台异步复制补齐,最终各副本一致;副本的检测与修复由 FE 负责,就是第一章「节点管理」那条职责。

查询怎么用这套结构

回到第一章那条聚合查询。它在存储层依次经过三道收缩:

  1. 分区裁剪:created_at 的范围条件把扫描目标收窄到 8 月这一个分区,其余分区不碰。这一步 EXPLAIN 里看得见:这条查询的计划里写着 partitions=1/3 (p202608),三个分区只有这一个进了扫描范围;
  2. 前缀索引:排序前缀把分区内的扫描范围缩到少数数据块;
  3. 列裁剪与 zone map:只读 region、category、amount 三列的 page,zone map 再把条件不沾边的 page 整页跳过。

读取量从「全表、所有列」收缩到「一个分区里、若干数据块的三列」。数据在磁盘上已经被摆成利于这种扫描的形状:分布开的 tablet、按列存放并逐列编码、按排序键有序、按版本追加再由 Compaction 收敛。

但一条查询要同时在几十上百个 tablet 上执行这三步、再把结果汇总,光靠存储层的组织还不够,得有一整套把计算铺到多机上的执行机制。

三、数据的算:fragment、向量化与 pipeline

存储层摆好数据只是回答了一半。本章沿一条 SQL 的执行过程走完另一半:计划怎么拆成并行单元、单元之间怎么交换数据、算子在什么线程模型上跑、以及这份计划是谁做的决定。主角仍是第一章那条聚合查询,中途引入一张几行的维表 regions 来承载 join 的例子。本章的语句证据都来自作者在本机 Docker 里跑的 2.1.11 单 FE 单 BE 集群,EXPLAIN 的原文为节选、行内注释为本文所加。

先把计划拆开:fragment 与两阶段聚合

拿到 SQL,Doris 不是直接开跑,而是先编译出一份执行计划。计划的基本单位叫 fragment:FE 把一条 SQL 拆成若干个 fragment,每个 fragment 是发给 BE 的最小执行单元;fragment 之间的数据传递经过 exchange(数据交换节点)。这些结构在 EXPLAIN 的输出里全部可见——上一章那张 orders 表、那条八月聚合,计划的关键部分长这样(原文列名后的 [#N] 内部编号已略去):

PLAN FRAGMENT 2
PARTITION: HASH_PARTITIONED: order_id                 -- 这个单元处理的数据分布:分桶列
  0:VOlapScanNode                                     -- 扫描节点
     partitions=1/3 (p202608)                         -- 三个分区只有 8 月进入计划
     tablets=10/10, tabletList=11127,11129, ...       -- 该分区 10 个 tablet 全部参与
  1:VAGGREGATE (update serialize)                     -- 局部聚合:扫完数据就地先加一遍
     output: partial_sum(amount)
     group by: region, category
  STREAM DATA SINK
     EXCHANGE ID: 02
     HASH_PARTITIONED: region, category               -- 部分和按分组键重分布

PLAN FRAGMENT 1
PARTITION: HASH_PARTITIONED: region, category         -- 接收方按同样的键接收
  3:VAGGREGATE (merge finalize)                       -- 最终聚合:把各处的部分和合并
     output: sum(partial_sum(amount))

先看 PARTITION 那一行:fragment 声明自己处理哪一份分布,扫描 fragment 的分布就是分桶列 order_id——第二章说 tablet 按分桶列哈希散开,这一行就是那句话在计划里的样子。partitions=1/3 和 tablets=10/10 也都是第二章的旧相识:分区裁剪和 tablet 列表,FE 在计划阶段就把扫描范围定到了这 10 个 tablet 上。

VAGGREGATE 出现了两次,这件事比看起来重要。聚合被拆成两阶段执行:扫描完每个 tablet,先就地按 region, category 求一遍部分和(update serialize,输出列叫 partial_sum),再把部分和按分组键重分布,由另一个 fragment 合并出最终结果(merge finalize)。为什么要两阶段?分布式的聚合必须有人把散在各处的中间结果收拢,直接把明细行全部传走,网络传的就是全部行;先局部聚合再传,网络上传的是「每个分组一行部分和」。拿本章实测的 bench 表算这笔账:300 万行、8 个桶,若分组数是 1 万,两阶段聚合让网络上跑的是 1 万 × BE 数行,而不是 300 万行——传输量从行数级降到分组数级,这一步省不出意义时(分组数接近行数,比如按用户 ID 分组),优化器可以退回单阶段,这也是它按成本选优的一部分。整个计划还有第三个 fragment(编号 0),负责把各处的最终结果收拢返回客户端,不再展开。

两阶段聚合与 shuffle 的数据流

join 怎么执行:build、probe 与三种数据分发

聚合讲完讲 join。两张表 join 的标准动作是哈希连接:把其中一侧的连接键逐行读出、在内存里建一张哈希表——就是 Java 里 HashMap.put 那一下;另一侧的每行拿着键去查这张表——HashMap.get。前一步叫 build,后一步叫 probe。哪侧 build 不是任意的:build 侧的数据要整体进内存,probe 侧按流逐批读,所以倾向选小的一侧。

多机执行让 join 多了一层问题:两张表的行分散在不同 BE 上,任意两行都可能满足连接条件,得先让相关的行碰面。Doris 有三种让它们碰面的办法,下面三种计划形状都来自本章的实测——broadcast 来自 orders JOIN regions,shuffle 和 bucket shuffle 来自 bench 库里两张 300 万行的大表(posts,分桶列 id;visits,分桶列 uid)之间的 join:

  • broadcast:小表整表复制到每个相关 BE,各处各自完成 join。orders JOIN regions 的计划里写着 INNER JOIN(BROADCAST)。代价是小表大小乘以 BE 数,所以这条路只对维表这类小表成立。
  • shuffle(PARTITIONED):两侧都大、谁都复制不起时,把两侧都按连接键哈希重分布,同一个键的行落到同一台 BE 再 join。用非分桶列的表达式把 posts 和 visits 相连,计划给出 INNER JOIN(PARTITIONED),两侧各挂一个重分布的 exchange——两份数据都得动。
  • bucket shuffle:如果连接键恰好是某张表的分桶列,这张表的数据本来就按连接键分布着,一行都不必移动——让另一侧按它的分桶规则重排即可。把 join 键换成 visits 的分桶列后,计划给出 INNER JOIN(BUCKET_SHUFFLE),大表一侧扫描节点直接接 join、中间没有任何 exchange,小表一侧的输出写着 BUCKET_SHFFULE_HASH_PARTITIONED(SHFFULE 是官方输出的原样拼写)。算一笔账:大表 3 亿行时,broadcast 要把小表复制 BE 数份,普通 shuffle 要把大表 3 亿行按连接键重排一遍,bucket shuffle 只把小表按大表的分桶规则重排一次,大表一行都不动。

第四种是更彻底的:建表时就用 "colocate_with" = "group_name" 声明两张表同分布,数据落盘时对应的分桶就落在同一台 BE 上,join 时连重排都省了(计划里显示 colocate: true)。约束也在这里:同组的表分桶列的类型和数量、桶数、副本数必须一致(分区数不必一致);副本修复或迁移期间组状态不稳定时,会退化成普通 join 并在计划里注明原因。colocate 的分布效果本机单 BE 验证不了,以上按 2.1 官方文档口径陈述。

默认选哪种由优化器定;认为它选错了,可以在 SQL 里用 [broadcast]、[shuffle] 两个 hint 指定 join 右表的分发方式,再用 EXPLAIN SHAPE PLAN 对比前后的计划。

分发解决「碰面」,解决不了「不均」:某个连接键的行数远大于其他键,处理它的任务就成了长尾,其余任务干完只能等它。2.1 引入了 local shuffle:单个 BE 内部、在聚合前把数据在并行任务之间再重排一遍,把不均摊平。它与自适应并行扫描一起,是 2.1.0 发布说明里执行引擎一项的主要改进。

向量化:按批处理,一次 4064 行

上面计划里每个算子都带 V 前缀——VOlapScanNode、VAGGREGATE、VEXCHANGE。V 指 vectorized。要讲清它替代了什么,得先说旧模型。业界通称的火山模型(Volcano)里,每个算子实现成一个迭代器:上层算子调下一层的 next(),一次取一行。这是 Java 工程师最熟悉的 Iterator 接口的形状,它的问题也是同一个:调用太密。一条 SQL 十几个算子、扫描几百万行时,算子之间「一行一次」的函数调用要发生几千万次,真正的计算反而不是时间大头。

Doris 2.1 默认向量化执行,算子改成一次处理一批,批大小默认 batch_size = 4064 行(本文实测的默认值)。一批数据按列连续存放——和第二章的列存一脉相承,同一列的值类型一致、排在一起,CPU 才能上 SIMD 指令:单条指令对一批数据做同一运算(Java 里 Vector API 的设计目标就是把它暴露给应用)。第二章的列裁剪也在这里兑现最后一层收益:查询没引用的列,从扫描那一步起就不进入批次。算一笔调用次数的账:300 万行、十几个算子,火山模型是行数乘算子数量级的 next() 调用,向量化是行数除以 4064 再乘算子数——差了四千倍。

Pipeline:算子谁来跑

fragment 拆好、算子也向量化了,还剩一个问题:这些算子由哪些线程跑。早期的做法是每个 fragment 实例独占一组线程,查询一多线程数跟着涨。2.1 的执行引擎文档把新模型要解决的问题直接写作线程爆炸(thread explosion)。

2.1 起默认执行引擎是 pipeline 模型(本文实测 experimental_enable_pipeline_engine 默认为 true)。它把 fragment 里的算子链按阻塞点切开:聚合、排序这类算子必须收齐全部输入才能产出,算子链在这些位置断开,每段是一个 pipeline。Join 算子拆成 JoinBuildOperator 和 JoinProbeOperator 两段,聚合、排序同理拆成 Sink 与 Source 两段。pipeline 是逻辑概念,真正执行的是它实例化出来的 PipelineTask,全部提交到一个 BE 级的固定线程池——线程数不再随查询数量增长,这是与旧模型的分界线。

断开的位置用依赖(Dependency)接起来:build 侧的 pipeline 建完哈希表,调用 set_ready 唤醒等在依赖上的 probe 侧 pipeline。每个任务跑到时间片用完、或者被依赖挡住时让出线程,线程马上转去执行其他就绪任务——少数线程在大量任务之间流转,而不是大量线程各自守着一个 fragment。

pipeline 执行模型的分工

并行度由 parallel_pipeline_task_num 控制,默认 0,语义是取 BE 核数的一半(2.1 文档口径),在单查询延迟和并发吞吐之间取平衡:16 核的 BE,一个 fragment 默认开 8 个并行任务。2.1 还拆掉了一道历史限制——此前并行度受 tablet 数封顶,桶切少了并行度就上不去;2.1 起对明细模型和 Merge-on-Write 主键模型支持分片内并发读,官方口径的线程上限是 48。本地实测还有一层版本事实:experimental_enable_pipeline_x_engine 也默认为 true。PipelineX 是这一代 pipeline 引擎改进版的名称,local shuffle 和自适应并行扫描都算它的组成部分;开关还挂在 experimental_ 前缀下,这个前缀本身值得读出来——生产上动它之前先想一下。

Nereids:计划是谁做的决定

前面所有的决定——拆几个 fragment、哪一侧 build、聚合要不要两阶段、扫描定到哪些分区——都是优化器做的。Doris 2.1 默认使用新优化器 Nereids:本地实测 experimental_enable_nereids_planner 默认 true,每份 EXPLAIN 的第一行也自报 Explain String(Nereids Planner);enable_fallback_to_original_planner 默认 true,Nereids 处理不了的语句退回旧规划器执行。

Nereids 的工作分四步:把 SQL 文本解析成语法树(AST);做语义分析,检查表、列、函数是否存在、用法是否合法;用一组固定的规则重写计划,这一步称 RBO——列裁剪、谓词下推、分区裁剪都在这里,读者已经见过它们的产物;最后基于成本选优(CBO),在等价的计划集合里逐个估算执行成本,取最低者。RBO 的改写是确定性的,CBO 的估算靠统计信息:表和列的行数、每列不同值的个数(NDV,number of distinct values)、最大最小值、NULL 数。

统计信息的收集默认也是开着的(enable_auto_analyze 默认 true),后台自动对内部表做采样收集,结果存进一张内部表;要手动触发,ANALYZE TABLE orders WITH SAMPLE ROWS 100000;。统计过期或缺失时,CBO 的成本估算只能建立在猜测上——查询突然变慢时,统计信息健康度应该在排查清单的前几项。规划本身也有开销:Nereids 默认 30 秒超时(nereids_timeout_second),因为规划过程要对涉及的表加读锁,超时是为了不让复杂查询长期占着锁不放。

Runtime filter:join 现场生成的过滤器

第二章讲过 bloom filter 索引:建表时点名某几列,存储层为它们逐 page 建过滤器。这一节的 runtime filter 名字里也有 bloom filter,是另一回事——它不在建表时创建,而是 join 执行到现场动态生成、查询结束即弃。

机制是这样的:build 侧读数据建哈希表时,顺手把连接键的值收集起来做成过滤器,推给 probe 侧的扫描节点——扫描阶段就把肯定 join 不上的行丢掉。省下的不只是 probe 的计算,还有这些行的磁盘 I/O 和到算子的传输。过滤器有四种:IN(值集合小而精确)、Bloom(大集合的近似版,回答「没有」就真没有,回答「可能有」可能只是哈希碰撞)、MIN_MAX(只记最大最小值,适合范围条件)、IN_OR_BLOOM(按实际行数在 IN 和 Bloom 之间自动选)。默认启用后两类:runtime_filter_type 默认值 12,对应 MIN_MAX 加 IN_OR_BLOOM——本文实测 SHOW VARIABLES 显示 IN_OR_BLOOM_FILTER,MIN_MAX,是同一件事的两种写法。多机时每个 join 实例只见过一部分数据,系统会选一个节点当 manager,把各实例生成的过滤器合并成全局的再统一下推。

它同样写在计划里。visits 表(300 万行)join 一张 10 行的维表、维表侧带过滤条件时,join 节点那一行是 runtime filters: RF000[min_max] <- uid, RF001[in_or_bloom] <- uid,扫描节点对应一行 runtime filters: RF000[min_max] -> uid, RF001[in_or_bloom] -> uid——<- 是生成,-> 是下推;EXPLAIN SHAPE PLAN 里则写作 build RFs:RF0 uid->[uid] 与 apply RFs: RF0 RF1。

但它的收益不是必然兑现的。我把同一条 join 开关 runtime filter 各跑了几遍,扫描耗时没有可分辨的差别:这张表的 uid 每 1000 行循环一轮,每个 page 都包含全部取值,过滤器只能逐行丢行,一个 page 也跳不掉——数据的分布又一次决定了优化结构的收益,和第二章低基数列用不好 bloom filter 是同一类教训。另外两个边界:build 侧本身没有过滤条件时,过滤器覆盖全部值、什么也省不下,优化器默认会把它裁掉;扫描节点等过滤器只等 1 秒(runtime_filter_wait_time_ms 默认 1000),等不到就先扫,join 的 build 慢时过滤器可能根本没赶上用。

一条查询的全链路

把两章拼在一起,那条聚合查询的完整旅程是:SQL 文本进入 Nereids,解析、重写、按成本选出计划;计划拆成 fragment 发给相关 BE;每个 BE 把 fragment 切成 pipeline、实例化成并行任务、提交固定线程池;扫描节点完成第二章的三道收缩,读出第一批数据;算子按 4064 行一批向量化地执行聚合和 join;局部结果按分组键 shuffle、汇总、返回。

存储层决定哪些数据不必读,执行层决定读进来的数据怎么算得快、传得少,优化器决定按哪份计划执行。排查一条慢查询,也就从这三层分别去问:多读了什么、多算了什么、计划为什么这么选。

四、数据的契约:数据模型

前两章有几处话只说了一半:宽表点查是列存的弱项,「怎么缓解」没有讲;索引体系表里出现过「Merge-on-Write」,只给了一句话解释;聚合模型、主键模型的名字多次出现,语义始终没展开。本章把这些收拢到同一个概念上——数据模型:建表时选定的 Key 子句与列定义,签下一份同时约束存储语义和查询能力的契约。它决定 Compaction 对数据做什么、读时合并是否存在、谓词能不能下推、UPDATE 和 DELETE 支不支持,甚至 avg() 返回什么。

同一个键、同一批数据、三种答案

Doris 有三种数据模型,区别只在建表语句的 Key 子句和值列写法上。三种都建出来,喂同一批两行数据(同一个键的两笔订单),观察各自的结果:

-- 聚合模型:值列必须声明聚合方式
CREATE TABLE agg_demo (
  dt       DATETIME      NOT NULL,
  order_id BIGINT        NOT NULL,
  amount   BIGINT SUM,             -- 同键相加
  status   VARCHAR(16) REPLACE     -- 同键取后写的
) AGGREGATE KEY(dt, order_id)
DISTRIBUTED BY HASH(order_id) BUCKETS 3 PROPERTIES ("replication_num" = "1");

-- 明细模型:值列就是普通列
CREATE TABLE dup_demo (
  dt       DATETIME      NOT NULL,
  order_id BIGINT        NOT NULL,
  amount   BIGINT,
  status   VARCHAR(16)
) DUPLICATE KEY(dt, order_id)
DISTRIBUTED BY HASH(order_id) BUCKETS 3 PROPERTIES ("replication_num" = "1");

-- 主键模型
CREATE TABLE uni_demo (
  dt       DATETIME      NOT NULL,
  order_id BIGINT        NOT NULL,
  amount   BIGINT,
  status   VARCHAR(16)
) UNIQUE KEY(dt, order_id)
DISTRIBUTED BY HASH(order_id) BUCKETS 3 PROPERTIES ("replication_num" = "1");

然后是同一批写入(三张表各来一遍)和同一个查询:

INSERT INTO agg_demo VALUES ('2026-08-01 10:00:00', 1001, 50, 'new'),
                            ('2026-08-01 10:00:00', 1001, 30, 'paid');
-- dup_demo、uni_demo 吃完全相同的两行

SELECT * FROM agg_demo;   -- 三个模型各跑一次

同一个键的两行数据,三个模型给出三种答案(本文实测):

模型SELECT * 返回
聚合模型1 行:amount = 80, status = 'paid'——SUM 把 50 与 30 相加,REPLACE 取后写的
明细模型2 行:50/new 与 30/paid 全部保留
主键模型1 行:amount = 30, status = 'paid'——后写覆盖前写

列结构完全相同,答案不同,差别全部来自建表时选的模型。

同一批写入在三种数据模型下的去向与结果

这份契约的特别之处是签定后不可改:官方文档明确「建表之后,表模型的属性就已确定,不能修改」,主键模型的实现模式同样「在创建时固定、不能通过 schema 变更改变」。Java 工程师对这个处境不陌生:选错模型不是改配置,是换表重灌。

明细模型:Key 只是排序

三种模型里最朴素的是明细模型(Duplicate Key):Key 列只代表排序,没有任何唯一性约束——官方文档的说法是「Key 列用于排序,没有唯一性约束」,存储层原样保留写入的每一行。第二章讲的排序键、前缀索引、列存编码,落在明细模型上就是它的全部:Key 决定数据怎么摆,仅此而已。

它适合两类场景:必须保留全部原始记录(审计、对账);查询维度事先不定、任何列都可能出现在 WHERE 和 GROUP BY 里(ad-hoc 分析)——明细都在,怎么切都行。对照表里它还有一个容易忽略的差异:DELETE 语句只算「部分支持」。

聚合模型:落盘即聚合

**聚合模型(Aggregate Key)**的 Key 列负责分组,非键的值列必须声明聚合方式,可选的有 SUM(累加)、MAX、MIN、REPLACE(后写替换前写)、REPLACE_IF_NOT_NULL(非 NULL 才替换)、HLL_UNION(HyperLogLog,近似去重计数)、BITMAP_UNION(位图并集)。聚合方式写进列定义本身,SHOW CREATE TABLE 能直接看到:

`amount` bigint SUM NULL,
`status` varchar(16) REPLACE NULL
AGGREGATE KEY(`dt`, `order_id`)

聚合发生在三个时机,每个都在前两章出现过:导入时,每个批次生成一个新版本(第二章的 rowset 和版本号),版本内同键数据先聚一次;后台 Compaction 合并多个版本时,同键数据再聚一次;查询时,系统对跨版本的同键数据做最后的合并,保证结果正确——「读时合并」这个第二章的概念,在聚合模型上的含义就是第三步。所以聚合模型不是查询时才算,而是能提前聚的都提前聚了,落盘的数据已经是聚合后的形态,原始明细不复存在。

这带来聚合模型最大的收益和最大的坑,是同一件事的两面。收益:存储省了、聚合查询的计算量小了。坑:表里的行数不等于业务行数。继续用刚才的表,再来一笔新订单,然后对两种模型跑同一个统计:

INSERT INTO agg_demo VALUES ('2026-08-02 10:00:00', 1002, 10, 'paid');
INSERT INTO dup_demo VALUES ('2026-08-02 10:00:00', 1002, 10, 'paid');

SELECT count(*), sum(amount), avg(amount) FROM agg_demo;
SELECT count(*), sum(amount), avg(amount) FROM dup_demo;

三笔订单(50、30、10),两个模型的结果(本文实测):

模型countsumavg
明细模型39030
聚合模型29045

sum 完全一致,count 与 avg 已经分道扬镳:聚合表里那张 50/30 的订单合成了一行,avg(amount) 算出的是「部分和的平均」,不是「订单的平均」。官方文档把这条写成了通则:聚合模型「对聚合查询友好,但对 count(*) 类查询不友好」,其他聚合语义的正确性需要使用者自行考量。报表场景(固定按天、按地区求和)天然匹配 SUM 语义,没问题;把聚合模型当明细表用,avg、count distinct 这类查询会安静地给你一个错误的数——不报错,只是不对。

主键模型:写入即 upsert

**主键模型(Unique Key)**的 Key 列同时承担排序和唯一性约束:每个键只保留一行,最新写入的生效。这个语义 Java 工程师可以直接映射成 INSERT ... ON DUPLICATE KEY UPDATE——存在就整行更新,不存在就插入。

实现上它分成两种模式。Merge-on-Write(MoW,写时合并):写入时就地合并同键数据,存储层只保留每个键的最新版本——2.1 起它是主键模型的默认模式(本文实测建表回显里有 "enable_unique_key_merge_on_write" = "true")。Merge-on-Read(MoR,读时合并):写入只追加,同键保留多个版本,查询或 Compaction 时再按键合并。第二章讲的「读时合并」在主键模型上的代价形态就是 MoR:版本越积越多、查询要合并的路径越长,而且谓词无法下推到存储层;MoW 换来读侧干净(无合并、谓词可下推),代价是写入时多做合并。官方的建议是多数场景用 MoW,MoR 留给写多读少的场景。两种模式同样在建表时固定。它与聚合模型 REPLACE 的区别值得点一句:REPLACE 只替换挂了它的那一列,其余列各按各的聚合方式走;主键模型是整行覆盖。

对照表里主键模型独占的几项能力都挂在这条契约上:UPDATE 语句只有主键模型支持;导入时部分列更新(只更新某几列、其余列保持原值)也只有主键模型支持,且要求 MoW 模式。另有一条容易踩的约束:分区列必须包含在 Key 列中,否则数据唯一性保证不了。

兑现第二章的欠条:点查的短路径

第二章说过列存的代价:取一整行要把所有列各读一次,宽表高频点查付出 N 倍 I/O。当时欠的「怎么缓解」,答案现在可以拼出来了,而且是主键模型契约的一部分:

  • MoW 是前提:点查要在存储层快速定位「那个键的最新行」,读时合并的多版本形态撑不起这条路径;
  • store_row_column:建表时开启,表里额外存一份按行组织的副本(官方口径:多花磁盘换点查的 I/O),键等值查询直接从行存取整行,不必逐列凑;
  • light_schema_change 必须开:短路径依赖每列的唯一编号。

三个条件凑齐,单表的键等值查询走一条短路径,一次 RPC 返回,EXPLAIN 的输出里会出现 SHORT-CIRCUIT 字样(本文在 MoW + 行存表上实测确认)。再配 JDBC 的 useServerPrepStmts=true 用 PreparedStatement,省掉 FE 逐条解析 SQL 的 CPU(官方口径 CPU 瓶颈时可提效 4 倍以上),服务化高并发点查的整条链路就齐了。行存副本对应的行缓存默认是关的(BE 配置 disable_storage_row_cache),要做高并发点查时记得打开。这套东西只对「按订单号查详情」这类服务场景有意义,纯分析查询用不上,也别为它付行存的存储成本。

契约的选型

三种模型各自的领地,官方对照表加上本文的实测可以收成三句话:固定口径的报表和指标(按天、按地区求和计数)→ 聚合模型,落盘即预计算;有唯一性约束、要更新、要做服务点查 → 主键模型 + MoW;原始明细要保留、查询维度不定 → 明细模型。

回头看这四章的骨架:第二章讲数据在磁盘上怎么摆,第三章讲摆好的数据怎么算,本章的契约签在两者中间——它决定 Compaction 帮你把数据聚到什么形态、读时有没有合并、点查有没有短路径,也决定 count 和 avg 返回的数是不是你想的那个数。建表语句里的每一个子句,都是在给这份契约签字。

五、数据的写入:Stream Load 的完整旅程

机制讲完了,最后看数据怎么进来的。第一章承诺过两件事:单批导入原子可见、多数派副本写成功才算成功;第二章出现过 rowset、版本号和 -235 事故。本章沿一条真实的 Stream Load 请求走完全程,把这些承诺逐一兑现。本章所有请求与响应都在本机 2.1.11 集群实跑,输出原文照录。

一条 HTTP 请求的旅程

Stream Load 不走 SQL 通道,走 HTTP:把一个 CSV/JSON 文件用 PUT 推给 Doris。请求发往 FE 的 8030 端口:

curl -s -u root: \
  -H "label:load_demo_001" -H "column_separator:," -H "Expect:100-continue" \
  -T /tmp/sl2.csv \
  http://127.0.0.1:8030/api/loadtest/load_demo/_stream_load

第一条响应是 HTTP 307 Temporary Redirect,Location 指向某台 BE 的 8040 端口——FE 按轮询挑一台 BE 当本次导入的 coordinator(协调节点),由它接收数据并分发给其他副本所在的 BE,FE 借此在多台 BE 间摊负载。文档同样允许跳过 FE 直接把请求发给某台 BE,两种姿势都合法。本机实测时撞上了这个机制的经典坑:重定向里的地址是 BE 的容器内网 IP,跨网络环境跟过去就是连接失败——官方为此单独写了一页「复杂网络环境 Stream Load」,常见解法就是直连 BE。改直连后导入成功,返回的 JSON 值得一屏屏看:

{
    "TxnId": 40,
    "Label": "load_demo_001",
    "TwoPhaseCommit": "false",
    "Status": "Success",
    "NumberTotalRows": 2,
    "NumberLoadedRows": 2,
    "NumberFilteredRows": 0,
    "NumberUnselectedRows": 0,
    "LoadBytes": 69,
    "CommitAndPublishTimeMs": 63
}

TxnId 是这次导入对应的事务号——导入从进门的第一个响应就是事务语义的。底部还有一串时间分解:BeginTxnTimeMs(开事务)、StreamLoadPutTimeMs(FE 定计划)、ReadDataTimeMs、WriteDataTimeMs(写数据)、CommitAndPublishTimeMs(提交并发布版本),几段相加基本就是总耗时——排查导入慢时,它直接告诉你时间花在哪一段。

事务:从 begin 到 publish version

刚才那个 TxnId 40 在 FE 日志里能找到完整的生命周期,三行原文按顺序是:

beginTransaction: txn id 40 with label load_demo_001
                  from coordinator BE: 172.30.80.3
commitTransaction: [TransactionState. transaction id: 40, label: load_demo_001 ...]
PUBLISH_VERSION finishTransaction [transaction id: 40, label: load_demo_001 ...]

三行正好对应第一章承诺的兑现路径:begin——coordinator BE 收到数据后向 FE 开事务;数据并行写入各副本 BE,多数派写成功后,coordinator 调 commit;FE 再把版本发布(publish version),数据对外可见。发布完成之前有一个窗口:事务已提交但数据还查不到,对应返回 JSON 里的另一种状态 Publish Timeout——导入本身已完成,只是可见性有延迟。上一节那个 CommitAndPublishTimeMs 计时的,就是最后这两步。

Label:重试的安全网

第一次提交的 JSON 里有个 Label: load_demo_001,它是导入的身份标识,规则只有一条:同一个 label 不能被导入两次。实测把同一请求原样重发:

Status: Label Already Exists
Message: [LABEL_ALREADY_EXISTS] Label [load_demo_001] has already been used,
         relate to txn [40], status [VISIBLE].

报错原文把三个概念的关系一次讲全:label 关联着事务号,事务有自己的状态(VISIBLE 表示已发布可见)。客户端网络超时后重试时,带着同一个 label 再来,Doris 会发现这批数据已经导入过、直接拒绝——于是重试不会产生重复数据,官方把这个保证写作 At-Most-Once,并强烈建议「同一批数据使用同一个 label」。label 默认保留三天(label_keep_max_second 可调),期间都能查到某一批数据的导入状态;状态为 CANCELLED(失败取消)的 label 可以复用。

数据质量:零容忍与 ErrorURL

导入对脏数据的默认态度是零容忍:max_filter_ratio 默认为 0(取值 0 到 1),错误行比例一超,整批失败。实测把一个只有 4 列的 CSV 推给 7 列的表,返回 Status: Fail、[DATA_QUALITY_ERROR] too many filtered rows,NumberFilteredRows = 2——两行全被过滤,一行没进。响应里附了一个 ErrorURL,用 curl 访问就能看到逐行的坏行原因:

Reason: actual column number in csv file is less than schema column number.
        actual number: 4, schema column number: 7

容忍比例调大后(比如 max_filter_ratio:0.4),错误行不超比例的批次会成功入库,被丢弃的行数记在 NumberFilteredRows 里。注意区分:查询里 WHERE 条件过滤掉的行记在 NumberUnselectedRows,不算错误、不占容忍额度——前者是「数据不合格」,后者是「按规则不要」。

两阶段提交

有些接入场景希望「数据先放进来、先别让下游看见」,等上游确认后再放行——Stream Load 用两阶段提交支持这个语义。实测全程:

# 第一阶段:加 two_phase_commit 头,数据写入但不发布
curl -s -u root: -H "label:tpc_demo_001" -H "two_phase_commit:true" \
  -H "column_separator:," -T /tmp/sl2.csv \
  http://127.0.0.1:8040/api/loadtest/load_demo/_stream_load
# 返回 Status: Success, TwoPhaseCommit: true —— 事务处于 pre-commit 状态

# 此时查询:行数不变(实测 2 → 2),pre-commit 的数据不可见

# 第二阶段:确认后提交
curl -s -u root: -X PUT -H "txn_operation:commit" -H "label:tpc_demo_001" \
  http://127.0.0.1:8040/api/loadtest/load_demo/_stream_load_2pc
# 提交后查询:行数 2 → 4,数据可见

「写入成功」和「对外可见」在两阶段提交下被拆成两个独立的决定,中间的窗口留给上游业务。这条路径的端点和参数在 2.1 的 Stream Load 手册正文里没有展开,本节流程以本地实测为准。

一条 Stream Load 从请求到数据可见

高频写入的答案:Group Commit

Stream Load 的默认行为是「一次导入 = 一个独立事务 = 一个新版本」。这个模型干净,但高频小批量写入会把两头压垮:FE 要为每次导入解析、定计划,版本数飞涨——第二章那个 -235(单 tablet 版本数超 500 拒绝导入)的源头就在这里。2.1 给出的答案是 Group Commit:官方文档对它的定位是「不是新的导入方式,而是对已有导入方式的优化扩展」——把多个小批量导入在后台合并成一个大事务提交。

它有三种模式(本文实测确认 2.1 没有「自动选」的第四种):

  • off_mode(默认):关闭,行为同上;
  • sync_mode:合并攒批,但事务提交后才返回——写入慢一点,数据立即可见;
  • async_mode:数据先写 WAL(Write Ahead Log,预写日志)立即返回,后台按阈值攒批提交。

攒批的触发条件是时间或量先到者:group_commit_interval 默认 10 秒、group_commit_data_bytes 默认 64 MB。可见性的代价用一次实测看:

SET group_commit = async_mode;
INSERT INTO load_demo VALUES ('2026-08-03 09:00:00', 3001, 1.0, 'new');
SELECT count(*) FROM load_demo;   -- 立即查:4 行,刚插入的不可见
-- 等 12 秒
SELECT count(*) FROM load_demo;   -- 5 行,攒批提交后可见

async_mode 的代价也要如实说:数据先落在单台 BE 的 WAL 文件里,官方明确「磁盘损坏或误删文件可能造成数据丢失」,运维下线 BE 时要按文档流程走;单条数据量太大、WAL 空间吃紧时自动转 sync_mode。另外它不是处处生效:显式事务、带 label 的导入、两阶段提交这些场景会自动绕过——需要精确控制批次边界的功能,和攒批天然冲突。

从 curl 到可见

到这里,第一章立下的承诺全部兑现。一条 Stream Load 的完整时间线:HTTP 请求到 FE,307 重定向给一台 coordinator BE;coordinator 向 FE 开事务(begin),label 校验通过;数据分发到各副本 BE,写进 MemTable、刷成 rowset——第二章的版本号在这里 +1;多数派写成功,事务提交(commit);FE 发布版本(publish),数据对外可见;下游从此能在这条数据上跑出第二章的扫描收缩、第三章的并行执行和第四章契约定义的语义。写入的可靠性靠 label 兜住重试,靠 max_filter_ratio 和 ErrorURL 暴露脏数据,靠两阶段提交把「写入」和「可见」拆成两个决定,靠 Group Commit 把高频小批量的版本代价摊平。

五个章节走完,从一次 OLTP 与 OLAP 的对比开始,到一条数据在集群里的完整一生结束。

留言

欢迎分享你的想法。评论提交后会在审核通过后显示。