笔者在工作中同时使用过 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 按列的类型和取值分布自动挑编码:
| 编码 | 适合什么样的列 | 省在哪 |
|---|---|---|
| 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 的联合索引同构,直觉可以照搬。所以官方的建议是把最常过滤的定长列(尤其时间列)排在最前面,且不必贪多,超过三四列基本是白写。
这条纪律落到 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。
BE 给每个 tablet 打一个「乱」的分数来决定先合并谁,分数大致等于查询在这个 tablet 上要走的合并路径数,谁最乱先处理谁。合并完成后,读时合并的路径变短、小文件数回落、tombstone 也随旧 rowset 一起被清掉。
副本:多数派成功才算成功
导入时,数据并行写入各副本所在的 BE,每个 BE 写完后向 FE 确认。FE 统计成功写入的副本数,超过总副本数的一半即视为导入成功——三副本的表,至少 2 个副本写成功。随后 FE 提交导入事务,数据对外可见,这就是第一章说的「单批导入原子可见」的由来。
没写成功的副本不会永久缺数据,由后台异步复制补齐,最终各副本一致;副本的检测与修复由 FE 负责,就是第一章「节点管理」那条职责。
查询怎么用这套结构
回到第一章那条聚合查询。它在存储层依次经过三道收缩:
- 分区裁剪:
created_at的范围条件把扫描目标收窄到 8 月这一个分区,其余分区不碰。这一步EXPLAIN里看得见:这条查询的计划里写着partitions=1/3 (p202608),三个分区只有这一个进了扫描范围; - 前缀索引:排序前缀把分区内的扫描范围缩到少数数据块;
- 列裁剪与 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),负责把各处的最终结果收拢返回客户端,不再展开。
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。
并行度由 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),两个模型的结果(本文实测):
| 模型 | count | sum | avg |
|---|---|---|---|
| 明细模型 | 3 | 90 | 30 |
| 聚合模型 | 2 | 90 | 45 |
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 手册正文里没有展开,本节流程以本地实测为准。
高频写入的答案: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 的对比开始,到一条数据在集群里的完整一生结束。

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