<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>MPP on Renxin's Blog</title><link>https://zh.renxinblog.cn/tags/mpp/</link><description>Recent content in MPP on Renxin's Blog</description><generator>Hugo -- gohugo.io</generator><language>zh</language><lastBuildDate>Fri, 18 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://zh.renxinblog.cn/tags/mpp/index.xml" rel="self" type="application/rss+xml"/><item><title>第六话｜数据的存与算：Doris 的架构与机制</title><link>https://zh.renxinblog.cn/post/gyd-c06-doris-architecture/</link><pubDate>Fri, 18 Sep 2026 00:00:00 +0000</pubDate><guid>https://zh.renxinblog.cn/post/gyd-c06-doris-architecture/</guid><description>&lt;img src="https://zh.renxinblog.cn/images/gyd-c06-doris-architecture-cover.png" alt="Featured image of post 第六话｜数据的存与算：Doris 的架构与机制" /&gt;&lt;p&gt;笔者在工作中同时使用过 MySQL 和 Doris。MySQL 是事务型数据库，面向 OLTP（Online Transaction Processing）场景——比如订单处理、用户登录、库存扣减这类需要高并发、低延迟、强一致性的业务操作。Doris 是分析型数据库，面向 OLAP（Online Analytical Processing）场景——比如用户行为分析、销售报表、数据仓库这类需要扫描大量历史数据、按维度聚合统计的分析需求。&lt;/p&gt;
&lt;p&gt;Doris 最早是百度在 2017 年开源的（当时叫 Palo），后来捐赠给 Apache 基金会，定位为高并发、低延迟的 MPP（Massively Parallel Processing，大规模并行处理）分析型数据库。它不是 MySQL 的升级版，而是从存储到计算都为分析场景重新设计的系统。这篇文章以 MySQL 为参照系，讲 Doris 的架构与原理。&lt;/p&gt;

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

 &lt;/blockquote&gt;
&lt;h2 id="一两种负载oltp-与-olap"&gt;一、两种负载：OLTP 与 OLAP
&lt;/h2&gt;&lt;p&gt;以订单表 &lt;code&gt;orders&lt;/code&gt;（字段：&lt;code&gt;order_id&lt;/code&gt; BIGINT（主键）, &lt;code&gt;user_id&lt;/code&gt; BIGINT, &lt;code&gt;region&lt;/code&gt; VARCHAR, &lt;code&gt;category&lt;/code&gt; VARCHAR, &lt;code&gt;amount&lt;/code&gt; DECIMAL, &lt;code&gt;status&lt;/code&gt; VARCHAR, &lt;code&gt;created_at&lt;/code&gt; DATETIME，几百万行）为例。&lt;/p&gt;
&lt;p&gt;业务后端每秒执行的是这类查询：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;paid&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;单次查询只涉及一行，走 &lt;code&gt;order_id&lt;/code&gt; 索引，毫秒级返回。&lt;/p&gt;
&lt;p&gt;分析师每月执行的是这类查询：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-08-01&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-09-01&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;需要扫描几百万行、按区域和类目分组求和，最后只返回几十行。扫描范围大，聚合计算重。&lt;/p&gt;
&lt;p&gt;两者需求差异显著。数据库领域用两个术语区分：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;OLTP&lt;/strong&gt;（Online Transaction Processing，在线事务处理）：处理「单行数据的正确提交」。重点是事务、并发、单行性能。业务系统普遍采用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OLAP&lt;/strong&gt;（Online Analytical Processing，在线分析处理）：处理「大量数据的扫描与聚合」。重点是扫描、聚合、吞吐。报表和数据分析场景的首选。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;主要差异体现在以下几个方面：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;维度&lt;/th&gt;
					&lt;th&gt;OLTP（MySQL 的主场）&lt;/th&gt;
					&lt;th&gt;OLAP（Doris 的主场）&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;典型操作&lt;/td&gt;
					&lt;td&gt;单行/小批量的读取与更新&lt;/td&gt;
					&lt;td&gt;全表/大范围的扫描与聚合&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;访问方式&lt;/td&gt;
					&lt;td&gt;走索引点查，一次命中目标行&lt;/td&gt;
					&lt;td&gt;只读用到的列，跨大量行扫描&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;并发&lt;/td&gt;
					&lt;td&gt;高并发，大量短事务&lt;/td&gt;
					&lt;td&gt;大查询少，但单个查询计算量大&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;一致性&lt;/td&gt;
					&lt;td&gt;强一致，ACID 事务，出错可回滚&lt;/td&gt;
					&lt;td&gt;单批导入原子可见；几乎不做交互式跨行/跨表强事务&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;写入&lt;/td&gt;
					&lt;td&gt;随时小量写，靠行锁保证并发正确&lt;/td&gt;
					&lt;td&gt;攒批后大批量导入&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;指标&lt;/td&gt;
					&lt;td&gt;每秒事务数（TPS）、单次延迟&lt;/td&gt;
					&lt;td&gt;每秒扫描行数、整批处理吞吐&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;数据形态&lt;/td&gt;
					&lt;td&gt;当前状态（正在处理的订单）&lt;/td&gt;
					&lt;td&gt;长期历史（几个月到几年的明细）&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;InnoDB 适合 OLTP，主要在下面这几处：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;存储结构&lt;/strong&gt;：InnoDB 把整张表按主键组织成一棵 B+ 树（聚簇索引）：叶子节点就是存放数据的页（Page，默认 16KB，落在表空间文件 .ibd 里），每行的各列连续存在同一页内，主键即索引、索引即数据。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;查询执行&lt;/strong&gt;：InnoDB 维护一个缓冲池（Buffer Pool），把频繁访问的页缓存在内存中。执行 &lt;code&gt;WHERE order_id = 1024&lt;/code&gt; 时，沿 根节点 → 中间节点 → 叶子页 逐层向下，走过的页数等于 B+ 树高度（几百万行通常 3 层左右）；根节点和上层索引页一般已命中缓冲池，真正落盘的往往只有叶子页那一次，再从中取出目标行。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;二级索引&lt;/strong&gt;：主键之外的列也建 B+ 树，叶子只存「索引列 + 主键值」，命中后回聚簇索引取整行，这就是回表。联合索引只能按最左前缀用：&lt;code&gt;WHERE col1 = 1 AND col2 = 2&lt;/code&gt; 能用 &lt;code&gt;(col1, col2)&lt;/code&gt; 索引，&lt;code&gt;WHERE col2 = 2&lt;/code&gt; 则不行（官方手册：只有左前缀能被优化器用于查找）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;事务与并发&lt;/strong&gt;：行级锁只锁被改动的行（官方特性表：锁粒度是 row）；多版本并发控制（MVCC）用回滚日志（undo log）保留旧版本，让读不阻塞写。默认隔离级别可重复读（REPEATABLE READ），保证同一事务内前后读取一致。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;水平扩展&lt;/strong&gt;：InnoDB 本质是单机存储引擎，一张表的数据完整在一个实例里，引擎自身不做单表分片（官方特性表：InnoDB 的 Cluster database support 为 No）。要多机承载，只能靠主从复制做高可用与读写分离，或靠中间件分库分表——都不是 InnoDB 单表原生的水平扩展。&lt;/p&gt;
&lt;p&gt;分析查询在 MySQL 上的表现，可以逐条对应：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;索引失效，只能全表扫描：报表常按日期、状态、类目这些列过滤分组，往往跟主键顺序对不上，B+ 树的快速定位用不上，只能逐页读取所有叶子页。&lt;/li&gt;
&lt;li&gt;行存读取整行数据：一行所有列连续存放在同一个页内。即使只查询 &lt;code&gt;amount&lt;/code&gt; 字段，也需要将整个页读入缓冲池。&lt;/li&gt;
&lt;li&gt;分组、排序需要额外开销：当聚合或 &lt;code&gt;ORDER BY&lt;/code&gt; 无法用索引顺序直接满足时，MySQL 要借助内存或临时磁盘文件做排序、分组（官方管这类操作叫 filesort）。&lt;/li&gt;
&lt;li&gt;计算资源受限于单机：所有查询共享同一个实例的 CPU、内存和缓冲池。分析大查询会挤占缓冲池空间，影响其他查询的性能。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一条聚合查询可能同时触发多个瓶颈：全表扫描占用大量缓冲池 + 逐页读取产生大量磁盘 I/O + 排序/分组占用内存或临时文件 + 长时间占用实例资源。这是生产环境中「报表查询导致主库 CPU 告警」的典型场景【生产经验】。&lt;/p&gt;

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

 &lt;/blockquote&gt;
&lt;p&gt;Doris 是分布式的 MPP 分析型数据库，节点分两类：FE（Frontend）与 BE（Backend）。两类节点都多节点部署以消除单点，对外通过兼容 MySQL 协议的接口接入查询（官方架构文档）。这和 MySQL 相反：MySQL 的解析、优化、存储、执行全部在单个实例的进程内完成，也不做这种角色拆分。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;FE（Frontend）&lt;/strong&gt;：接收连接、解析并规划查询，并管理集群的元数据。库、表，以及分区、分桶这样的数据分布与副本位置——这一整套元数据只保存在 FE，并持久化到 FE 本地（每次元数据变更顺序写进 edit log，周期性把整份内存元数据落成 image 快照，重启时按快照加日志回放恢复）；BE 只存数据本身；节点存活与副本状态由 FE 负责心跳检测与副本修复。FE 分 Master、Follower、Observer 三种角色：Master 写元数据并调度副本，Follower 同步元数据、可在 Master 故障时接替，Observer 只读以分担读压力，多个 FE 由此保证管理层高可用。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;BE（Backend）&lt;/strong&gt;：真正存数据、算数据的一方。数据分片以列存文件落在 BE 上、各存多副本；BE 执行 FE 下发的计算算子（扫描、聚合等）。计算是&lt;strong&gt;向量化&lt;/strong&gt;执行——一次处理一批列数据而不是逐行处理（官方口径：列式内存布局 + SIMD 加速）；并以 &lt;strong&gt;Pipeline&lt;/strong&gt; 方式调度，把查询切成可由有限线程轮转执行的任务，避免一个查询占满线程。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;存算一体&lt;/strong&gt;：在 2.1 里，存储与计算发生在同一台 BE 上——数据分片存在哪台 BE，相关计算就在哪台 BE 上做，不必先把数据搬到别处（3.x 起的存算分离不在本文范围）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;数据分布&lt;/strong&gt;：Doris 把一张表分两级切开。第一级是分区（Partition），常按时间这类列做 Range 划分——比如 orders 表按 &lt;code&gt;created_at&lt;/code&gt; 每月一个分区，查询限定在某段时间时只扫对应分区、其余跳过。第二级是分桶（Bucket），在分区内再按某个键取哈希打散——比如按 &lt;code&gt;order_id&lt;/code&gt; 哈希分成若干桶。两级切到最后，每个桶就是一个 tablet：它是 Doris 存数据、做副本、调度（迁移与均衡）的最小单位。于是一张表被摊成「若干分区 × 每分区若干 tablet」，分布到多台 BE 上、每台各管一部分，tablet 又各存多副本（默认 3 副本）以防节点宕机丢数据——这是 InnoDB 单实例没有的能力，Doris 由引擎自己把单表分布出去。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;列式存储&lt;/strong&gt;：tablet 内部按列存放，查询只读用到的列、跳过无关列，不像行存那样即使只用一列也要把整行读进来。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;MPP 并行&lt;/strong&gt;：Doris 把一个查询切成执行片段，下推到持有相关 tablet 的多个 BE 上并行扫描、聚合，最后汇总，而不是由单台机器串行执行完。&lt;/p&gt;
&lt;h2 id="二数据的存tablet列存与版本合并"&gt;二、数据的存：tablet、列存与版本合并
&lt;/h2&gt;&lt;p&gt;一条数据从导入到可查询，在 Doris 里走的路径是：按分区、分桶落到某个 tablet；以列存格式写成一个不可变的 rowset，里面是一个个 segment 文件；tablet 的多副本分布在不同 BE 上；多数副本写入成功后，FE 提交事务，数据对外可见。此后每次导入都追加新的 rowset 和版本，由后台的 Compaction 把版本收敛回去。查询则反向使用这些结构：先裁剪分区，再用前缀索引定位数据块，最后只读所需的列。&lt;/p&gt;
&lt;h3 id="tablet数据落在哪台-be-上"&gt;tablet：数据落在哪台 BE 上
&lt;/h3&gt;&lt;p&gt;第一章说过，一张表先按分区、再按分桶切开。落到存储层，官方给的关系式是：&lt;strong&gt;Tablet 数 = 分区数 × 桶数 × 副本数&lt;/strong&gt;。以 orders 表为例：按 &lt;code&gt;created_at&lt;/code&gt; 每月一个分区、按 &lt;code&gt;order_id&lt;/code&gt; 哈希成 10 个桶、默认 3 副本，那么每个月就是 10 × 3 = 30 个 tablet，分散在不同 BE 上。tablet 是 Doris 存数据、做副本、做调度迁移的最小单位——副本往哪台机器放、哪个副本坏了要补，都以它为单位。&lt;/p&gt;
&lt;p&gt;这三个乘数落在建表语句里就是三行，本章后面反复用到的这张表长这样：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DATETIME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 排序键第 1 列
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 排序键第 2 列
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 排序键第 3 列
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 只做分桶列，不在排序键里
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;DECIMAL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;OLAP&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;DUPLICATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 排序键：必须是列定义的前 K 列
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;RANGE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 分区：决定去哪个分区
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p202608&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;2026-08-01&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;2026-09-01&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;DISTRIBUTED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;HASH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BUCKETS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 分桶：决定落在哪个 tablet
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;PROPERTIES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;replication_num&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;3&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 副本：每个 tablet 存 3 份
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;注意列的顺序和第一章那张表不一样：&lt;code&gt;created_at&lt;/code&gt;、&lt;code&gt;region&lt;/code&gt;、&lt;code&gt;category&lt;/code&gt; 提到了最前面——不是排版偏好，排序键只能取列定义的前 K 列。分区、分桶、副本三行各给出一个乘数，上面这一个分区就是 10 × 3 = 30 个 tablet；&lt;code&gt;SHOW TABLETS FROM orders&lt;/code&gt; 能把这 30 个逐条列出来，含每个副本在哪台 BE、当前版本、行数和大小。本文写作时在单机 Docker 里跑的就是这张表，单台 BE 只能存一份副本，那一行写的是 &lt;code&gt;&amp;quot;1&amp;quot;&lt;/code&gt;，tablet 数相应是每分区 10 个——其余行为与 3 副本一致。&lt;/p&gt;
&lt;p&gt;桶数不是随手填的。官方建表最佳实践的参考指标是：&lt;strong&gt;单个 tablet 的数据量保持在 1G–10G&lt;/strong&gt;。太小（桶切得过碎），每片都要单独写文件、单独记元数据，聚合效果差、FE 的元数据管理压力也大；太大（桶不够多），副本迁移和补齐的代价高，改表结构（Schema Change）、建物化视图这类要重写数据的操作，失败重试的代价也跟着单个 tablet 变大。维表这类小表，官方建议 tablet 数控制在 3–5 个，既保住一定并发，又不制造小文件。分区列方面，官方建议事实表用 date/datetime、一般每日一个分区（本例按月是为了数字好算），缓慢增长的维表可以单分区。分桶列要选&lt;strong&gt;高基数&lt;/strong&gt;（取值种类多）且分布均衡的列，比如 &lt;code&gt;order_id&lt;/code&gt;；选了低基数列，数据会挤在少数几个桶里，等于白分。不想绑定某个键时也可以用 RANDOM 分桶（不指定分桶列、随机撒），官方口径是它适合整体聚合分析、不适合高并发点查。&lt;/p&gt;
&lt;h3 id="segment按列存放与编码"&gt;segment：按列存放与编码
&lt;/h3&gt;&lt;p&gt;官方把 tablet 内部的存储层级概括为 &lt;strong&gt;Tablet → Rowset → Segment → Column → Page&lt;/strong&gt;，它们各自的用途是：&lt;strong&gt;rowset&lt;/strong&gt; 是一次导入产出的文件集合；&lt;strong&gt;segment&lt;/strong&gt; 是 rowset 里真正装数据的文件，一个 rowset 可以有一个或多个；&lt;strong&gt;column&lt;/strong&gt; 指 segment 内每一列单独组织，同一列的值连续排在一起；&lt;strong&gt;page&lt;/strong&gt; 是把一列的值按约 64KB 切出的小块，它是读取和压缩的最小单位——查询要么整页跳过，要么整页读进来解压。&lt;/p&gt;
&lt;p&gt;&lt;img alt="Doris 存储层级：Tablet → Rowset → Segment → Column → Page" class="gallery-image" data-flex-basis="534px" data-flex-grow="222" decoding="async" height="446" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://zh.renxinblog.cn/images/gyd-c06-doris-architecture-storage-hierarchy.svg" width="993"&gt;&lt;/p&gt;
&lt;p&gt;按列存放带来的第二个好处是：同一列的相邻字节长得很像，可以针对性地换一种更省的记法，这就是编码。行存做不到这点——一行的各列挤在一起，相邻字节一会儿是字符串、一会儿是金额、一会儿是时间，没有规律可榨。编码和压缩是两道工序：&lt;strong&gt;编码&lt;/strong&gt;挑一种适合这一列数据规律的表示方式，&lt;strong&gt;压缩&lt;/strong&gt;在编码后的字节流上再跑一道通用无损压缩。Doris 按列的类型和取值分布自动挑编码：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;编码&lt;/th&gt;
					&lt;th&gt;适合什么样的列&lt;/th&gt;
					&lt;th&gt;省在哪&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;PLAIN&lt;/td&gt;
					&lt;td&gt;没有规律可榨的列（官方口径：一般数值）&lt;/td&gt;
					&lt;td&gt;原样存&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;DICT（字典）&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;低基数&lt;/strong&gt;列，即不同取值很少（&lt;code&gt;status&lt;/code&gt; 只有几个状态、&lt;code&gt;region&lt;/code&gt; 只有几十个地区）&lt;/td&gt;
					&lt;td&gt;建一张「值 → 编号」字典，数据里只存短编号，不再重复存整个字符串&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;RLE（游程）&lt;/td&gt;
					&lt;td&gt;连续重复值多的列（官方举的例子是布尔列）&lt;/td&gt;
					&lt;td&gt;记「值 + 连续出现次数」，而不是把同一个值写很多遍&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;BIT_SHUFFLE&lt;/td&gt;
					&lt;td&gt;整数列（&lt;code&gt;order_id&lt;/code&gt;、时间戳）&lt;/td&gt;
					&lt;td&gt;把所有值的第 1 个字节放在一起、第 2 个字节放在一起；整数高位往往相同，于是相同字节连成大片，后面压缩特别有效&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;FOR（frame-of-reference，基准值）&lt;/td&gt;
					&lt;td&gt;日期列&lt;/td&gt;
					&lt;td&gt;只存「减掉一个基准时间后的差值」，用更少的位表示&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;编码之后才轮到通用压缩，默认 LZ4F，可以换成 ZSTD 等，官方给的口径是 ZSTD 典型能把表压到原来的 1/5 到 1/10；反过来，一道 page 压缩后省不到 10%，Doris 就干脆不压，免得解压白烧 CPU。&lt;/p&gt;
&lt;p&gt;收益直接落在读取上：查询只碰涉及列的 page，表越宽、查询涉及的列越少，省得越多。每个 page 带一份 &lt;strong&gt;zone map&lt;/strong&gt;——这一页该列的最大值、最小值、是否含 NULL，条件跟这个范围不沾边的页&lt;strong&gt;不解压就整页跳过&lt;/strong&gt;，这一份是内建的、每列都有。等值查询还想跳得更狠，就得自己加索引了：&lt;strong&gt;bloom filter 索引不是自动的&lt;/strong&gt;，要在建表属性里点名（&lt;code&gt;&amp;quot;bloom_filter_columns&amp;quot; = &amp;quot;user_id, order_id&amp;quot;&lt;/code&gt; 这类写法），Doris 才为这几列逐 page 各建一个 bloom filter。有了它，&lt;code&gt;=&lt;/code&gt; 和 &lt;code&gt;IN&lt;/code&gt; 就能先问一句「这一页里可能有这个值吗」，回答「肯定没有」的页直接跳过。bloom filter 只有假阳性、没有假阴性，说「没有」就是真没有，所以拿它跳页是安全的；代价是对高基数列（&lt;code&gt;user_id&lt;/code&gt;、订单号这类）跳过率高，对低基数列基本没用，Tinyint、Float、Double 三类还建不了。属性一旦写上，查询用到时自动生效、SQL 不用改写法，但它对 &lt;code&gt;=&lt;/code&gt; 和 &lt;code&gt;IN&lt;/code&gt; 之外的条件不帮忙。&lt;/p&gt;
&lt;p&gt;代价也要如实说：取一整行要把所有列各读一次，每列一次独立 I/O，官方文档直言宽表按主键点查会付出 N 倍 I/O，高频 &lt;code&gt;SELECT *&lt;/code&gt; 点查是列存的弱项。&lt;/p&gt;
&lt;h3 id="排序键与前缀索引tablet-内部怎么找到那一段数据"&gt;排序键与前缀索引：tablet 内部怎么找到那一段数据
&lt;/h3&gt;&lt;p&gt;一提到索引，从 MySQL 过来的人默认是 B+ 树：多层节点、叶子之间互相串联、每个二级索引各是一棵树、按查询条件一个个建。Doris 的&lt;strong&gt;前缀索引&lt;/strong&gt;形状简单得多，也单薄得多，它就是一份&lt;strong&gt;排好序的定长数组&lt;/strong&gt;，每项存两样东西：一小段拼接起来的排序键值，和这一段值所在的&lt;strong&gt;起始行号&lt;/strong&gt;。查找时在这个有序数组上二分，定位到目标值落在哪一块，拿到起止行号，其余块不读。它不长成树，也不为每个查询条件各建一套——一张表只有这一套，代价是能力比 B+ 树窄很多。&lt;/p&gt;
&lt;p&gt;它挂在「行」这个维度上，容易和上一节的 page 混：page 是&lt;strong&gt;一列&lt;/strong&gt;的值按 64KB 切出来的，每列各切各的；前缀索引的逻辑数据块是&lt;strong&gt;按行数&lt;/strong&gt;划的，跨所有列共同。两者粒度不重合。&lt;/p&gt;
&lt;p&gt;排序键就是建表时 Key 子句里那几列，官方明确 &lt;strong&gt;Key 列必须是表定义的前 K 列&lt;/strong&gt;（从第一列开始连续声明），单个 tablet 内的数据按这几列整体排序。有序是这一切的前提：一个条件对应的行必然连续成一段，才谈得上「记住每段起点、只读那一段」；无序就只能是全读之后逐行判断。官方对这套存储结构的说法是「类似 SSTable」——按键有序、写完不改、后台合并的不可变文件格式，Java 生态里 RocksDB、LevelDB、HBase 用的都是这一类。&lt;/p&gt;
&lt;p&gt;数组每项存的那段值叫&lt;strong&gt;短 key（short key）&lt;/strong&gt;，取法是把排序键按声明顺序拼接，累加到 &lt;strong&gt;36 字节&lt;/strong&gt;为止。上限只到 36 字节，是为了让整份索引小到能常驻内存：一亿行按每块若干行算下来仍有几万条索引项，每项再宽就成了查索引本身要扫一遍的大对象。每块到底多少行，2.1 的文档只写了「按行数划成逻辑数据块」，没给具体数字，这里也就不猜。碰到变长列时规则收紧：&lt;strong&gt;VARCHAR 可以进短 key，但只截取它自己的头几个字节，且到此为止，后面的列一概不再进入前缀索引&lt;/strong&gt;。官方给的两组数正好说明这条的分量：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;user_id BIGINT, age INT, message VARCHAR(100)&lt;/code&gt;：短 key = &lt;code&gt;user_id&lt;/code&gt; 的 8 字节 + &lt;code&gt;age&lt;/code&gt; 的 4 字节 + &lt;code&gt;message&lt;/code&gt; 的头 20 字节，凑满 36；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;user_name VARCHAR(20), user_id BIGINT&lt;/code&gt;：短 key 只有 &lt;code&gt;user_name&lt;/code&gt; 这 20 字节，没到 36 也停，因为 VARCHAR 之后的 &lt;code&gt;user_id&lt;/code&gt; 已经进不来了。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对照 tablet 那节贴出的建表语句还有一个容易找错地方的地方：整份 DDL 里&lt;strong&gt;找不到「前缀索引」这个词&lt;/strong&gt;，也没有任何子句能调它——短 key 完全由 &lt;code&gt;DUPLICATE KEY(created_at, region, category)&lt;/code&gt; 那一行推出来，排查「我的前缀索引建成了什么样」时，要看的是表定义，不是索引列表。&lt;/p&gt;
&lt;p&gt;这条规则决定了 Key 的排列纪律，也就是&lt;strong&gt;最左匹配&lt;/strong&gt;的实际含义：条件里出现排序键&lt;strong&gt;连续的前几列&lt;/strong&gt;才走得通前缀索引，跳过中间一列，后面的列就匹配不上——这一点和 MySQL 的联合索引同构，直觉可以照搬。所以官方的建议是把最常过滤的定长列（尤其时间列）排在最前面，且不必贪多，超过三四列基本是白写。&lt;/p&gt;
&lt;p&gt;&lt;img alt="前缀索引：在有序的短 key 数组上二分，定位到一段行号" class="gallery-image" data-flex-basis="447px" data-flex-grow="186" decoding="async" height="536" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://zh.renxinblog.cn/images/gyd-c06-doris-architecture-prefix-index-lookup.svg" width="1000"&gt;&lt;/p&gt;
&lt;p&gt;这条纪律落到 orders 表上有个不好听但必须说的结论。前面把它排成 &lt;code&gt;created_at&lt;/code&gt;、&lt;code&gt;region&lt;/code&gt;、&lt;code&gt;category&lt;/code&gt;，&lt;code&gt;created_at&lt;/code&gt; 是 DATETIME、定长，进得来；&lt;code&gt;region&lt;/code&gt; 是 VARCHAR，只能以头几个字节的形式参与；&lt;code&gt;category&lt;/code&gt; 则&lt;strong&gt;完全不在前缀索引里&lt;/strong&gt;，&lt;code&gt;WHERE category = 'food'&lt;/code&gt; 走不到它。也就是说这条查询从前缀索引得到的只有时间那一段。剩下两段要靠对某一列单独建的索引来补。另外，排序键放这三列不干涉分桶：明细模型不要求分桶列与 Key 列一致（那是聚合、主键模型的约束），分桶仍然可以用 &lt;code&gt;order_id&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;Key 在聚合和主键模型里还兼作聚合键、去重键，那是另一层语义；本章只用到它的排序作用。前缀索引到底过滤掉多少行，可在查询 profile 的 &lt;code&gt;RowsKeyRangeFiltered&lt;/code&gt; 指标里核对。&lt;/p&gt;
&lt;h3 id="索引体系哪些内建哪些要自己建"&gt;索引体系：哪些内建，哪些要自己建
&lt;/h3&gt;&lt;p&gt;上一节那份前缀索引是自动来的，建表时不用写任何东西。Doris 还有几种索引必须你点名，实际用表时最常碰到的就是「给某一列开 bloom filter」这类设置。它们和自动的那两种放在一起是这样：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;索引&lt;/th&gt;
					&lt;th&gt;建在哪些列上&lt;/th&gt;
					&lt;th&gt;作用粒度&lt;/th&gt;
					&lt;th&gt;怎么建&lt;/th&gt;
					&lt;th&gt;只加速什么&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;前缀索引&lt;/td&gt;
					&lt;td&gt;排序键（Key）的前 36 字节&lt;/td&gt;
					&lt;td&gt;每个逻辑数据块一项&lt;/td&gt;
					&lt;td&gt;内建，自动&lt;/td&gt;
					&lt;td&gt;覆盖连续最左前缀的等值、范围条件&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;ZoneMap 索引&lt;/td&gt;
					&lt;td&gt;所有列，无一例外&lt;/td&gt;
					&lt;td&gt;每个 page 一份 min / max / 是否含 NULL&lt;/td&gt;
					&lt;td&gt;内建，自动&lt;/td&gt;
					&lt;td&gt;等值、范围、&lt;code&gt;IS NULL&lt;/code&gt; 的整页跳过&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;BloomFilter 索引&lt;/td&gt;
					&lt;td&gt;你点名的若干列&lt;/td&gt;
					&lt;td&gt;每个 page 一个&lt;/td&gt;
					&lt;td&gt;表属性 &lt;code&gt;bloom_filter_columns&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;只有 &lt;code&gt;=&lt;/code&gt; 和 &lt;code&gt;IN&lt;/code&gt; 的整页跳过&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;NGram BloomFilter 索引&lt;/td&gt;
					&lt;td&gt;你点名的字符串列&lt;/td&gt;
					&lt;td&gt;每个 page 一个&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;INDEX ... USING NGRAM_BF&lt;/code&gt;，可配 &lt;code&gt;gram_size&lt;/code&gt;、&lt;code&gt;bf_size&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;只有字符串 &lt;code&gt;LIKE '%…%'&lt;/code&gt; 的整页跳过&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;倒排索引&lt;/td&gt;
					&lt;td&gt;你点名的字符串、数值、日期、数组列&lt;/td&gt;
					&lt;td&gt;值或词项 → 命中的行号&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;INDEX ... USING INVERTED&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;等值、范围、关键词匹配，能定位到行&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这条分界决定了你能优化什么。内建的两种没得选，能调整的只有「数据按什么顺序排」，也就是上一节；要自己建的三种是在排序之外&lt;strong&gt;再为个别列补一份结构&lt;/strong&gt;，所以选择全在「给哪一列、建哪一种」上，代价也全在这里——每补一份，这一列就多占空间、每次写入都要跟着维护，所以每一种都把自己限制在能真正省下读取的那几种条件上，表里「只加速什么」那一栏写的就是这条边界。&lt;/p&gt;
&lt;p&gt;要自己建的这三种里，前两种属于&lt;strong&gt;跳数索引&lt;/strong&gt;（skip index，ClickHouse 里同名同义的那个说法）。它不告诉你数据在哪，只对「这一页」回答一个是或否：这里头肯定没有，于是整块跳过。所以它的收益完全取决于数据分布和条件形状。分析型扫描的瓶颈通常是「读了太多不相关的页」而不是「找不到那一行」，这也是分析型表不必像 OLTP 那样按查询模式铺一串二级索引的原因——ZoneMap 是同一思路的内建版本，BloomFilter 是你手动加强的那一版。加强版也有自己的边界：BloomFilter 只对&lt;strong&gt;高基数&lt;/strong&gt;列有意义，低基数列（只有几个取值的 &lt;code&gt;status&lt;/code&gt;）几乎每页都含目标值，一个也跳不掉；Tinyint、Float、Double 这三种类型官方直接不让建。&lt;/p&gt;
&lt;p&gt;BloomFilter 只认完整值，所以帮不了模糊查询。&lt;strong&gt;NGram BloomFilter 补的就是这个洞&lt;/strong&gt;：把字符串按 N 个连续字符滑窗切开，&lt;code&gt;shanghai&lt;/code&gt; 在 &lt;code&gt;gram_size = 3&lt;/code&gt; 时切成 sha、han、ang、ngh、gha、hai 这一串片段，每个片段作为一个值塞进这一页的 BloomFilter；查询 &lt;code&gt;LIKE '%ang%'&lt;/code&gt; 时把 &lt;code&gt;ang&lt;/code&gt; 也照切一遍，逐个问「在不在过滤器里」，只要有一个不在，这一页就整块跳过。官方那条限制由此而来——&lt;strong&gt;LIKE 里连续的字符数要够到 &lt;code&gt;gram_size&lt;/code&gt;&lt;/strong&gt;，短于 N 就切不出一个完整片段，索引无从问起。它和上一段那个按完整值建的 BloomFilter 抢的是同一个位置，同一列上两者只能有一个。&lt;/p&gt;
&lt;p&gt;倒排索引是这几种里能力最宽的，因为它不走「跳过整块」那条路，而是直接给出&lt;strong&gt;哪些行命中&lt;/strong&gt;。「哪些行含某个值」这张表按值组织，等值、范围、&lt;code&gt;IN&lt;/code&gt; / &lt;code&gt;NOT IN&lt;/code&gt; 都查得动它，数组列可以按元素过滤。字符串列加上分词器才有全文能力：&lt;code&gt;parser&lt;/code&gt; 取 &lt;code&gt;english&lt;/code&gt; / &lt;code&gt;chinese&lt;/code&gt; / &lt;code&gt;unicode&lt;/code&gt;，中文还能选 &lt;code&gt;fine_grained&lt;/code&gt;（细粒度）或 &lt;code&gt;coarse_grained&lt;/code&gt;（粗粒度）；之后 &lt;code&gt;WHERE&lt;/code&gt; 里可以写 &lt;code&gt;MATCH_ANY&lt;/code&gt;（含任一关键词）、&lt;code&gt;MATCH_ALL&lt;/code&gt;（每个关键词都得有，不管顺序）、&lt;code&gt;MATCH_PHRASE&lt;/code&gt;（这些词得挨着、且按这个顺序出现）。它也有建不了的时候：聚合模型，以及没开 &lt;strong&gt;Merge-on-Write&lt;/strong&gt;（主键表的一种写入模式，写入时就把同键的行合并掉）的主键表，只有 Key 列能建倒排索引；&lt;code&gt;FLOAT&lt;/code&gt;、&lt;code&gt;DOUBLE&lt;/code&gt; 因为精度问题不支持。BITMAP 索引官方说已被倒排索引取代，不必再单独学。&lt;/p&gt;
&lt;p&gt;它到底能快多少，官方给的对比是关键词检索比等价的 &lt;code&gt;LIKE '%…%'&lt;/code&gt; 快 7–13 倍，样本是一百万行 HackerNews 数据，同一份数据建索引本身花了一两秒——这两个数得一起看才知道量级。这个量级我在本地一套单 FE、单 BE 的 2.1.11 上复现过：三百万行，&lt;code&gt;title&lt;/code&gt; 平均 121 字节、合计约 363 MB，八个桶，其中 1% 的行含一个生造词。同一个条件，&lt;code&gt;WHERE title LIKE '%那个词%'&lt;/code&gt; 的服务端耗时稳定在 36–54 ms，写成 &lt;code&gt;MATCH_ANY&lt;/code&gt; 是 5–6 ms，&lt;strong&gt;约 7 倍&lt;/strong&gt;。另外两个对照数才说清这钱花在哪：把 &lt;code&gt;LIKE&lt;/code&gt; 里的词换成一个必然不命中的，耗时不变，还是 33 ms，和强制把整列扫一遍的 &lt;code&gt;sum(length(title))&lt;/code&gt;（29–35 ms）基本相等——&lt;code&gt;LIKE&lt;/code&gt; 的成本就是全列扫描的成本，跟命中多少无关；而把关键词换成每行都出现的 &lt;code&gt;doris&lt;/code&gt;，&lt;code&gt;LIKE&lt;/code&gt; 28 ms、&lt;code&gt;MATCH_ANY&lt;/code&gt; 12 ms，差距缩到两倍出头：命中面铺满时，索引省下的扫描抵不掉它为命中行逐个产出行号的开销——后半句是按机制推的，我测到的只有那两个数本身。&lt;strong&gt;倒排索引的收益来自命中少，不是无条件快&lt;/strong&gt;，选列的时候这条比任何倍数都重要。&lt;/p&gt;
&lt;p&gt;最后是怎么补上去。三种索引的建法长在三个不同的地方，对照着 orders 表看一眼就分清了：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- BloomFilter：表属性，列名列表。没有这一行，这一列就没有 bloom filter
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;bloom_filter_columns&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;user_id, order_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- NGram BloomFilter：INDEX 子句，只服务字符串列的 LIKE
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idx_region&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;USING&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NGRAM_BF&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PROPERTIES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;gram_size&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;3&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;bf_size&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;1024&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- 倒排索引：同一个 INDEX 子句换一种类型；事后加的要对已有数据补建一次
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idx_status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;USING&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;INVERTED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;BUILD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idx_status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;第一条是&lt;strong&gt;表属性&lt;/strong&gt;，所以它不会出现在任何 &lt;code&gt;CREATE INDEX&lt;/code&gt; 里；后两条是 &lt;code&gt;INDEX&lt;/code&gt; 子句，&lt;code&gt;ALTER TABLE orders ADD INDEX idx_status (status) USING INVERTED&lt;/code&gt; 与 &lt;code&gt;CREATE INDEX&lt;/code&gt; 等价。字符串列要做全文检索，再在 &lt;code&gt;PROPERTIES&lt;/code&gt; 里加 &lt;code&gt;&amp;quot;parser&amp;quot; = &amp;quot;chinese&amp;quot;&lt;/code&gt; 这类分词器，纯等值和范围过滤不用加。&lt;/p&gt;
&lt;p&gt;这里有个容易踩的坑。改 &lt;code&gt;bloom_filter_columns&lt;/code&gt; 不是改一行元数据就完事，Doris 会为它起一个 &lt;strong&gt;schema change 作业&lt;/strong&gt;——所谓 schema change 是 Doris 的在线改表流程：数据是一个个不可变文件，改结构就得由 FE 调度、BE 把这些文件重写或补齐，所以它是异步的。在 2.1.11 上，改完立刻到 &lt;code&gt;SHOW ALTER TABLE COLUMN&lt;/code&gt; 里能看到一个 PENDING 的作业，表停在 &lt;code&gt;SCHEMA_CHANGE&lt;/code&gt; 状态，这期间再发 &lt;code&gt;CREATE INDEX&lt;/code&gt; 会被直接拒掉（&lt;code&gt;Table[orders]'s state(SCHEMA_CHANGE) is not NORMAL&lt;/code&gt;），作业跑完属性才生效；小表是几秒，大表就是分钟级的事。倒排索引建完&lt;strong&gt;还要对已有数据跑一次 &lt;code&gt;BUILD INDEX&lt;/code&gt;&lt;/strong&gt;，漏了这一步，只有新写入的数据带着索引。&lt;/p&gt;
&lt;p&gt;核对的时候还有一处容易找错：&lt;code&gt;SHOW INDEX FROM orders&lt;/code&gt; 只列 &lt;code&gt;INDEX&lt;/code&gt; 子句建的东西（上面两个 &lt;code&gt;INVERTED&lt;/code&gt;、那个 &lt;code&gt;NGRAM_BF&lt;/code&gt;），&lt;strong&gt;&lt;code&gt;bloom_filter_columns&lt;/code&gt; 不在里面&lt;/strong&gt;，得回 &lt;code&gt;SHOW CREATE TABLE orders&lt;/code&gt; 看表属性。&lt;/p&gt;
&lt;h3 id="物化视图把聚合提前算好"&gt;物化视图：把聚合提前算好
&lt;/h3&gt;&lt;p&gt;索引省的是「读」，还有一类开销索引帮不上：同样的聚合，每次查询都重算一遍。报表每天按地区、类目出一次销售额，明细有几亿行，每天都要把这几亿行重扫重聚一次。物化视图的思路是把这类查询的结果提前算好存下来，查询来了直接取。Doris 2.1 里它有两种形态，分界在「谁来维护这份预计算数据、什么时候维护」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;同步物化视图&lt;/strong&gt;：跟着导入走。建法是一条 &lt;code&gt;CREATE MATERIALIZED VIEW&lt;/code&gt;，定义里只写想提前算什么：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;MATERIALIZED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VIEW&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;mv_region_amt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;本文在 2.1.11 上对 orders 表执行，&lt;code&gt;SHOW ALTER TABLE MATERIALIZED VIEW&lt;/code&gt; 里能看到一个 FINISHED 的作业，输出里有一栏 &lt;code&gt;RollupIndexName&lt;/code&gt;——它的实现就是基表上的一个预聚合副本，导入时同步更新，官方的口径是「只有同步完成后，相关命令才会结束」，无需人工维护。代价也在这里：每一张同步物化视图都让导入多写一份、多聚一次，官方直言单表上物化视图过多会拖慢导入。&lt;/p&gt;
&lt;p&gt;它生效的方式是&lt;strong&gt;透明改写&lt;/strong&gt;：查询照常写基表的名字，Doris 自动匹配能否用物化视图回答。把上一节那条不带时间条件的聚合拿来 &lt;code&gt;EXPLAIN&lt;/code&gt;，扫描节点显示 &lt;code&gt;TABLE: demo.orders(mv_region_amt)&lt;/code&gt;——语句里写的是 orders，实际读的是物化视图。验证命中与否看的也是 &lt;code&gt;EXPLAIN&lt;/code&gt;，文档把改写结果分了三种标记（&lt;code&gt;MaterializedViewRewriteSuccessAndChose&lt;/code&gt; 命中且选用、&lt;code&gt;MaterializedViewRewriteSuccessButNotChose&lt;/code&gt; 命中但按成本未选、&lt;code&gt;MaterializedViewRewriteFail&lt;/code&gt; 未命中），排查「为什么没命中」时到计划里找它们。&lt;/p&gt;
&lt;p&gt;透明改写有一条本文实测撞出来的边界。同一条聚合加上 &lt;code&gt;WHERE created_at &amp;gt;= '2026-08-01' AND created_at &amp;lt; '2026-09-01'&lt;/code&gt; 之后，计划回落到基表：这份物化视图里没有 &lt;code&gt;created_at&lt;/code&gt; 这一列，带时间条件的查询它回答不了。&lt;strong&gt;提前算好的口径必须盖得住查询的过滤条件&lt;/strong&gt;——分组列决定哪些谓词能落到这份预计算数据上，建视图时把常用于过滤的列一并考虑，否则存储白付、改写永不命中。它的能力边界也来自同一个原因：只支持单表（官方明确不支持 JOIN），聚合形式限于 &lt;code&gt;sum&lt;/code&gt; 这类常规聚合和 &lt;code&gt;bitmap_union&lt;/code&gt;（用它可以把 &lt;code&gt;count(distinct)&lt;/code&gt; 变快，官方给的标准做法）；它也不像普通表那样能直接 &lt;code&gt;SELECT&lt;/code&gt;——本文实测对它查询返回 &lt;code&gt;Unknown table&lt;/code&gt;，这是它与异步物化视图最直观的一个区别。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;异步物化视图&lt;/strong&gt;是 2.1 引入的另一种形态：它自己就是一张内表（底层是 Duplicate 模型），定义里可以写多表 JOIN，基表模型不限，数据靠创建时注册的刷新任务重算。本文实测的一条：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;MATERIALIZED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VIEW&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amv_orders_region&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;BUILD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IMMEDIATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;REFRESH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;MANUAL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 创建即构建；手动刷新
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;PROPERTIES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;replication_num&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 单 BE 环境的限制，多 BE 集群不必写
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cnt&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;建完 10 秒内直接 &lt;code&gt;SELECT&lt;/code&gt; 已有数据。刷新方式三种，写在建表语句里：&lt;code&gt;ON MANUAL&lt;/code&gt; 手动触发（&lt;code&gt;REFRESH MATERIALIZED VIEW … AUTO&lt;/code&gt;）、&lt;code&gt;ON SCHEDULE&lt;/code&gt; 定时（&lt;code&gt;EVERY 2 HOUR&lt;/code&gt; 这类）、&lt;code&gt;ON COMMIT&lt;/code&gt; 基表数据一变就刷。它同样支持透明改写（官方称基于 SPJG——SELECT-PROJECT-JOIN-GROUP-BY——模式的改写算法），代价是数据与基表&lt;strong&gt;最终一致&lt;/strong&gt;：官方口径是「无法实时同步」，通常用于对时效性要求不高的场景，「一般是 T+1 的数据」。&lt;/p&gt;
&lt;p&gt;两种形态的选型其实是三个问题：口径是单表还是多表——单表才能用同步，多表只能异步；结果要实时还是容忍延迟——导入即更新是同步的语义，异步是最终一致；写入放大付在何时——同步的每个导入都多写一份，异步把代价挪到刷新时刻。它和聚合模型的关系也在这里收拢：聚合模型在建模时就锁定了数据的聚合口径，物化视图是在已有表上给某几个固定口径补一份预计算——两者都是拿空间和写入换查询时间。索引省下的「读」，物化视图省的是「算」。&lt;/p&gt;
&lt;h3 id="每次导入产生一个新版本rowset"&gt;每次导入产生一个新版本：rowset
&lt;/h3&gt;&lt;p&gt;Doris 的写入属于 LSM 树那一套路子：&lt;strong&gt;写操作从不原地修改已有文件&lt;/strong&gt;，每次导入产生一个新的、不可变的 rowset，带一个版本号。于是一个 tablet 的数据就是一串按版本排开的 rowset。&lt;/p&gt;
&lt;p&gt;代价落在读取上：查询要把这些 rowset &lt;strong&gt;在读取时合并&lt;/strong&gt;起来看——新版本、被删掉的旧版本，都靠版本号和删除标记判断，这就是读时合并。rowset 少时无所谓，但 Stream Load、Group Commit 这类高频导入会让版本数涨得很快。官方给了硬限制：单 tablet 版本数超过 &lt;code&gt;max_tablet_version_num&lt;/code&gt;（默认 500）后导入直接失败，报 &lt;strong&gt;-235&lt;/strong&gt;，官方自己说这是用户群里最常见的生产事故之一。版本堆积还有两笔账：查询要合并的路径变长；被删除的行不会立刻消失，它们以 &lt;strong&gt;tombstone（删除标记）&lt;/strong&gt; 的形式留在旧 rowset 里，每次读都要额外过滤。&lt;/p&gt;
&lt;h3 id="compaction把版本收敛回去"&gt;Compaction：把版本收敛回去
&lt;/h3&gt;&lt;p&gt;把一串小 rowset 合并成少数几个大 rowset 的后台动作就是 Compaction。理解它的关键是一条分界线 &lt;strong&gt;cumulative point&lt;/strong&gt;：&lt;strong&gt;cumulative compaction&lt;/strong&gt; 高频、轻量，只合并 cumulative point 以上的新进小 rowset，先把刚灌进来的碎片归拢；&lt;strong&gt;base compaction&lt;/strong&gt; 低频、重量，把 cumulative point 以下的所有 rowset 合并成一个 &lt;strong&gt;base rowset&lt;/strong&gt;（这个 tablet 的底座数据）。cumulative 的合并结果一旦超过 promotion size（默认 1GB），下一轮就有资格并进 base。&lt;/p&gt;
&lt;p&gt;&lt;img alt="rowset 版本累积与 cumulative 合并" class="gallery-image" data-flex-basis="844px" data-flex-grow="351" decoding="async" height="444" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://zh.renxinblog.cn/images/gyd-c06-doris-architecture-rowset-compaction.svg" width="1562"&gt;&lt;/p&gt;
&lt;p&gt;BE 给每个 tablet 打一个「乱」的分数来决定先合并谁，分数大致等于查询在这个 tablet 上要走的合并路径数，谁最乱先处理谁。合并完成后，读时合并的路径变短、小文件数回落、tombstone 也随旧 rowset 一起被清掉。&lt;/p&gt;
&lt;h3 id="副本多数派成功才算成功"&gt;副本：多数派成功才算成功
&lt;/h3&gt;&lt;p&gt;导入时，数据并行写入各副本所在的 BE，每个 BE 写完后向 FE 确认。FE 统计成功写入的副本数，&lt;strong&gt;超过总副本数的一半即视为导入成功&lt;/strong&gt;——三副本的表，至少 2 个副本写成功。随后 FE 提交导入事务，数据对外可见，这就是第一章说的「单批导入原子可见」的由来。&lt;/p&gt;
&lt;p&gt;没写成功的副本不会永久缺数据，由后台异步复制补齐，最终各副本一致；副本的检测与修复由 FE 负责，就是第一章「节点管理」那条职责。&lt;/p&gt;
&lt;h3 id="查询怎么用这套结构"&gt;查询怎么用这套结构
&lt;/h3&gt;&lt;p&gt;回到第一章那条聚合查询。它在存储层依次经过三道收缩：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;分区裁剪&lt;/strong&gt;：&lt;code&gt;created_at&lt;/code&gt; 的范围条件把扫描目标收窄到 8 月这一个分区，其余分区不碰。这一步 &lt;code&gt;EXPLAIN&lt;/code&gt; 里看得见：这条查询的计划里写着 &lt;code&gt;partitions=1/3 (p202608)&lt;/code&gt;，三个分区只有这一个进了扫描范围；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;前缀索引&lt;/strong&gt;：排序前缀把分区内的扫描范围缩到少数数据块；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;列裁剪与 zone map&lt;/strong&gt;：只读 &lt;code&gt;region&lt;/code&gt;、&lt;code&gt;category&lt;/code&gt;、&lt;code&gt;amount&lt;/code&gt; 三列的 page，zone map 再把条件不沾边的 page 整页跳过。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;读取量从「全表、所有列」收缩到「一个分区里、若干数据块的三列」。数据在磁盘上已经被摆成利于这种扫描的形状：分布开的 tablet、按列存放并逐列编码、按排序键有序、按版本追加再由 Compaction 收敛。&lt;/p&gt;
&lt;p&gt;但一条查询要同时在几十上百个 tablet 上执行这三步、再把结果汇总，光靠存储层的组织还不够，得有一整套把计算铺到多机上的执行机制。&lt;/p&gt;
&lt;h2 id="三数据的算fragment向量化与-pipeline"&gt;三、数据的算：fragment、向量化与 pipeline
&lt;/h2&gt;&lt;p&gt;存储层摆好数据只是回答了一半。本章沿一条 SQL 的执行过程走完另一半：计划怎么拆成并行单元、单元之间怎么交换数据、算子在什么线程模型上跑、以及这份计划是谁做的决定。主角仍是第一章那条聚合查询，中途引入一张几行的维表 &lt;code&gt;regions&lt;/code&gt; 来承载 join 的例子。本章的语句证据都来自作者在本机 Docker 里跑的 2.1.11 单 FE 单 BE 集群，&lt;code&gt;EXPLAIN&lt;/code&gt; 的原文为节选、行内注释为本文所加。&lt;/p&gt;
&lt;h3 id="先把计划拆开fragment-与两阶段聚合"&gt;先把计划拆开：fragment 与两阶段聚合
&lt;/h3&gt;&lt;p&gt;拿到 SQL，Doris 不是直接开跑，而是先编译出一份执行计划。计划的基本单位叫 &lt;strong&gt;fragment&lt;/strong&gt;：FE 把一条 SQL 拆成若干个 fragment，每个 fragment 是发给 BE 的最小执行单元；fragment 之间的数据传递经过 &lt;strong&gt;exchange&lt;/strong&gt;（数据交换节点）。这些结构在 &lt;code&gt;EXPLAIN&lt;/code&gt; 的输出里全部可见——上一章那张 orders 表、那条八月聚合，计划的关键部分长这样（原文列名后的 &lt;code&gt;[#N]&lt;/code&gt; 内部编号已略去）：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;PLAN FRAGMENT 2
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;PARTITION: HASH_PARTITIONED: order_id -- 这个单元处理的数据分布：分桶列
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 0:VOlapScanNode -- 扫描节点
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; partitions=1/3 (p202608) -- 三个分区只有 8 月进入计划
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; tablets=10/10, tabletList=11127,11129, ... -- 该分区 10 个 tablet 全部参与
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 1:VAGGREGATE (update serialize) -- 局部聚合：扫完数据就地先加一遍
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; output: partial_sum(amount)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; group by: region, category
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; STREAM DATA SINK
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; EXCHANGE ID: 02
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; HASH_PARTITIONED: region, category -- 部分和按分组键重分布
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;PLAN FRAGMENT 1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;PARTITION: HASH_PARTITIONED: region, category -- 接收方按同样的键接收
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 3:VAGGREGATE (merge finalize) -- 最终聚合：把各处的部分和合并
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; output: sum(partial_sum(amount))
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;先看 &lt;code&gt;PARTITION&lt;/code&gt; 那一行：fragment 声明自己处理哪一份分布，扫描 fragment 的分布就是分桶列 &lt;code&gt;order_id&lt;/code&gt;——第二章说 tablet 按分桶列哈希散开，这一行就是那句话在计划里的样子。&lt;code&gt;partitions=1/3&lt;/code&gt; 和 &lt;code&gt;tablets=10/10&lt;/code&gt; 也都是第二章的旧相识：分区裁剪和 tablet 列表，FE 在计划阶段就把扫描范围定到了这 10 个 tablet 上。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;VAGGREGATE&lt;/code&gt; 出现了两次，这件事比看起来重要。聚合被拆成两阶段执行：扫描完每个 tablet，先就地按 &lt;code&gt;region, category&lt;/code&gt; 求一遍部分和（&lt;code&gt;update serialize&lt;/code&gt;，输出列叫 &lt;code&gt;partial_sum&lt;/code&gt;），再把部分和按分组键重分布，由另一个 fragment 合并出最终结果（&lt;code&gt;merge finalize&lt;/code&gt;）。为什么要两阶段？分布式的聚合必须有人把散在各处的中间结果收拢，直接把明细行全部传走，网络传的就是全部行；先局部聚合再传，网络上传的是「每个分组一行部分和」。拿本章实测的 bench 表算这笔账：300 万行、8 个桶，若分组数是 1 万，两阶段聚合让网络上跑的是 1 万 × BE 数行，而不是 300 万行——传输量从行数级降到分组数级，这一步省不出意义时（分组数接近行数，比如按用户 ID 分组），优化器可以退回单阶段，这也是它按成本选优的一部分。整个计划还有第三个 fragment（编号 0），负责把各处的最终结果收拢返回客户端，不再展开。&lt;/p&gt;
&lt;p&gt;&lt;img alt="两阶段聚合与 shuffle 的数据流" class="gallery-image" data-flex-basis="370px" data-flex-grow="154" decoding="async" height="648" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://zh.renxinblog.cn/images/gyd-c06-doris-architecture-fragment-flow.svg" width="1000"&gt;&lt;/p&gt;
&lt;h3 id="join-怎么执行buildprobe-与三种数据分发"&gt;join 怎么执行：build、probe 与三种数据分发
&lt;/h3&gt;&lt;p&gt;聚合讲完讲 join。两张表 join 的标准动作是哈希连接：把其中一侧的连接键逐行读出、在内存里建一张哈希表——就是 Java 里 &lt;code&gt;HashMap.put&lt;/code&gt; 那一下；另一侧的每行拿着键去查这张表——&lt;code&gt;HashMap.get&lt;/code&gt;。前一步叫 &lt;strong&gt;build&lt;/strong&gt;，后一步叫 &lt;strong&gt;probe&lt;/strong&gt;。哪侧 build 不是任意的：build 侧的数据要整体进内存，probe 侧按流逐批读，所以倾向选小的一侧。&lt;/p&gt;
&lt;p&gt;多机执行让 join 多了一层问题：两张表的行分散在不同 BE 上，任意两行都可能满足连接条件，得先让相关的行碰面。Doris 有三种让它们碰面的办法，下面三种计划形状都来自本章的实测——broadcast 来自 &lt;code&gt;orders JOIN regions&lt;/code&gt;，shuffle 和 bucket shuffle 来自 bench 库里两张 300 万行的大表（&lt;code&gt;posts&lt;/code&gt;，分桶列 &lt;code&gt;id&lt;/code&gt;；&lt;code&gt;visits&lt;/code&gt;，分桶列 &lt;code&gt;uid&lt;/code&gt;）之间的 join：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;broadcast&lt;/strong&gt;：小表整表复制到每个相关 BE，各处各自完成 join。&lt;code&gt;orders JOIN regions&lt;/code&gt; 的计划里写着 &lt;code&gt;INNER JOIN(BROADCAST)&lt;/code&gt;。代价是小表大小乘以 BE 数，所以这条路只对维表这类小表成立。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;shuffle（PARTITIONED）&lt;/strong&gt;：两侧都大、谁都复制不起时，把两侧都按连接键哈希重分布，同一个键的行落到同一台 BE 再 join。用非分桶列的表达式把 posts 和 visits 相连，计划给出 &lt;code&gt;INNER JOIN(PARTITIONED)&lt;/code&gt;，两侧各挂一个重分布的 exchange——两份数据都得动。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;bucket shuffle&lt;/strong&gt;：如果连接键恰好是某张表的分桶列，这张表的数据本来就按连接键分布着，一行都不必移动——让另一侧按它的分桶规则重排即可。把 join 键换成 visits 的分桶列后，计划给出 &lt;code&gt;INNER JOIN(BUCKET_SHUFFLE)&lt;/code&gt;，大表一侧扫描节点直接接 join、中间没有任何 exchange，小表一侧的输出写着 &lt;code&gt;BUCKET_SHFFULE_HASH_PARTITIONED&lt;/code&gt;（&lt;code&gt;SHFFULE&lt;/code&gt; 是官方输出的原样拼写）。算一笔账：大表 3 亿行时，broadcast 要把小表复制 BE 数份，普通 shuffle 要把大表 3 亿行按连接键重排一遍，bucket shuffle 只把小表按大表的分桶规则重排一次，大表一行都不动。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;第四种是更彻底的：建表时就用 &lt;code&gt;&amp;quot;colocate_with&amp;quot; = &amp;quot;group_name&amp;quot;&lt;/code&gt; 声明两张表同分布，数据落盘时对应的分桶就落在同一台 BE 上，join 时连重排都省了（计划里显示 &lt;code&gt;colocate: true&lt;/code&gt;）。约束也在这里：同组的表分桶列的类型和数量、桶数、副本数必须一致（分区数不必一致）；副本修复或迁移期间组状态不稳定时，会退化成普通 join 并在计划里注明原因。colocate 的分布效果本机单 BE 验证不了，以上按 2.1 官方文档口径陈述。&lt;/p&gt;
&lt;p&gt;默认选哪种由优化器定；认为它选错了，可以在 SQL 里用 &lt;code&gt;[broadcast]&lt;/code&gt;、&lt;code&gt;[shuffle]&lt;/code&gt; 两个 hint 指定 join 右表的分发方式，再用 &lt;code&gt;EXPLAIN SHAPE PLAN&lt;/code&gt; 对比前后的计划。&lt;/p&gt;
&lt;p&gt;分发解决「碰面」，解决不了「不均」：某个连接键的行数远大于其他键，处理它的任务就成了长尾，其余任务干完只能等它。2.1 引入了 local shuffle：单个 BE 内部、在聚合前把数据在并行任务之间再重排一遍，把不均摊平。它与自适应并行扫描一起，是 2.1.0 发布说明里执行引擎一项的主要改进。&lt;/p&gt;
&lt;h3 id="向量化按批处理一次-4064-行"&gt;向量化：按批处理，一次 4064 行
&lt;/h3&gt;&lt;p&gt;上面计划里每个算子都带 V 前缀——&lt;code&gt;VOlapScanNode&lt;/code&gt;、&lt;code&gt;VAGGREGATE&lt;/code&gt;、&lt;code&gt;VEXCHANGE&lt;/code&gt;。V 指 vectorized。要讲清它替代了什么，得先说旧模型。业界通称的火山模型（Volcano）里，每个算子实现成一个迭代器：上层算子调下一层的 &lt;code&gt;next()&lt;/code&gt;，一次取一行。这是 Java 工程师最熟悉的 &lt;code&gt;Iterator&lt;/code&gt; 接口的形状，它的问题也是同一个：调用太密。一条 SQL 十几个算子、扫描几百万行时，算子之间「一行一次」的函数调用要发生几千万次，真正的计算反而不是时间大头。&lt;/p&gt;
&lt;p&gt;Doris 2.1 默认向量化执行，算子改成一次处理一批，批大小默认 &lt;code&gt;batch_size = 4064&lt;/code&gt; 行（本文实测的默认值）。一批数据按列连续存放——和第二章的列存一脉相承，同一列的值类型一致、排在一起，CPU 才能上 SIMD 指令：单条指令对一批数据做同一运算（Java 里 Vector API 的设计目标就是把它暴露给应用）。第二章的列裁剪也在这里兑现最后一层收益：查询没引用的列，从扫描那一步起就不进入批次。算一笔调用次数的账：300 万行、十几个算子，火山模型是行数乘算子数量级的 &lt;code&gt;next()&lt;/code&gt; 调用，向量化是行数除以 4064 再乘算子数——差了四千倍。&lt;/p&gt;
&lt;h3 id="pipeline算子谁来跑"&gt;Pipeline：算子谁来跑
&lt;/h3&gt;&lt;p&gt;fragment 拆好、算子也向量化了，还剩一个问题：这些算子由哪些线程跑。早期的做法是每个 fragment 实例独占一组线程，查询一多线程数跟着涨。2.1 的执行引擎文档把新模型要解决的问题直接写作线程爆炸（thread explosion）。&lt;/p&gt;
&lt;p&gt;2.1 起默认执行引擎是 pipeline 模型（本文实测 &lt;code&gt;experimental_enable_pipeline_engine&lt;/code&gt; 默认为 true）。它把 fragment 里的算子链按&lt;strong&gt;阻塞点&lt;/strong&gt;切开：聚合、排序这类算子必须收齐全部输入才能产出，算子链在这些位置断开，每段是一个 pipeline。Join 算子拆成 &lt;code&gt;JoinBuildOperator&lt;/code&gt; 和 &lt;code&gt;JoinProbeOperator&lt;/code&gt; 两段，聚合、排序同理拆成 Sink 与 Source 两段。pipeline 是逻辑概念，真正执行的是它实例化出来的 &lt;strong&gt;PipelineTask&lt;/strong&gt;，全部提交到一个 BE 级的固定线程池——线程数不再随查询数量增长，这是与旧模型的分界线。&lt;/p&gt;
&lt;p&gt;断开的位置用依赖（Dependency）接起来：build 侧的 pipeline 建完哈希表，调用 &lt;code&gt;set_ready&lt;/code&gt; 唤醒等在依赖上的 probe 侧 pipeline。每个任务跑到时间片用完、或者被依赖挡住时让出线程，线程马上转去执行其他就绪任务——少数线程在大量任务之间流转，而不是大量线程各自守着一个 fragment。&lt;/p&gt;
&lt;p&gt;&lt;img alt="pipeline 执行模型的分工" class="gallery-image" data-flex-basis="402px" data-flex-grow="167" decoding="async" height="596" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://zh.renxinblog.cn/images/gyd-c06-doris-architecture-pipeline-model.svg" width="1000"&gt;&lt;/p&gt;
&lt;p&gt;并行度由 &lt;code&gt;parallel_pipeline_task_num&lt;/code&gt; 控制，默认 0，语义是取 BE 核数的一半（2.1 文档口径），在单查询延迟和并发吞吐之间取平衡：16 核的 BE，一个 fragment 默认开 8 个并行任务。2.1 还拆掉了一道历史限制——此前并行度受 tablet 数封顶，桶切少了并行度就上不去；2.1 起对明细模型和 Merge-on-Write 主键模型支持分片内并发读，官方口径的线程上限是 48。本地实测还有一层版本事实：&lt;code&gt;experimental_enable_pipeline_x_engine&lt;/code&gt; 也默认为 true。PipelineX 是这一代 pipeline 引擎改进版的名称，local shuffle 和自适应并行扫描都算它的组成部分；开关还挂在 experimental_ 前缀下，这个前缀本身值得读出来——生产上动它之前先想一下。&lt;/p&gt;
&lt;h3 id="nereids计划是谁做的决定"&gt;Nereids：计划是谁做的决定
&lt;/h3&gt;&lt;p&gt;前面所有的决定——拆几个 fragment、哪一侧 build、聚合要不要两阶段、扫描定到哪些分区——都是优化器做的。Doris 2.1 默认使用新优化器 Nereids：本地实测 &lt;code&gt;experimental_enable_nereids_planner&lt;/code&gt; 默认 true，每份 &lt;code&gt;EXPLAIN&lt;/code&gt; 的第一行也自报 &lt;code&gt;Explain String(Nereids Planner)&lt;/code&gt;；&lt;code&gt;enable_fallback_to_original_planner&lt;/code&gt; 默认 true，Nereids 处理不了的语句退回旧规划器执行。&lt;/p&gt;
&lt;p&gt;Nereids 的工作分四步：把 SQL 文本解析成语法树（AST）；做语义分析，检查表、列、函数是否存在、用法是否合法；用一组固定的规则重写计划，这一步称 RBO——列裁剪、谓词下推、分区裁剪都在这里，读者已经见过它们的产物；最后基于成本选优（CBO），在等价的计划集合里逐个估算执行成本，取最低者。RBO 的改写是确定性的，CBO 的估算靠统计信息：表和列的行数、每列不同值的个数（NDV，number of distinct values）、最大最小值、NULL 数。&lt;/p&gt;
&lt;p&gt;统计信息的收集默认也是开着的（&lt;code&gt;enable_auto_analyze&lt;/code&gt; 默认 true），后台自动对内部表做采样收集，结果存进一张内部表；要手动触发，&lt;code&gt;ANALYZE TABLE orders WITH SAMPLE ROWS 100000;&lt;/code&gt;。统计过期或缺失时，CBO 的成本估算只能建立在猜测上——查询突然变慢时，统计信息健康度应该在排查清单的前几项。规划本身也有开销：Nereids 默认 30 秒超时（&lt;code&gt;nereids_timeout_second&lt;/code&gt;），因为规划过程要对涉及的表加读锁，超时是为了不让复杂查询长期占着锁不放。&lt;/p&gt;
&lt;h3 id="runtime-filterjoin-现场生成的过滤器"&gt;Runtime filter：join 现场生成的过滤器
&lt;/h3&gt;&lt;p&gt;第二章讲过 bloom filter 索引：建表时点名某几列，存储层为它们逐 page 建过滤器。这一节的 runtime filter 名字里也有 bloom filter，是另一回事——它不在建表时创建，而是 join 执行到现场动态生成、查询结束即弃。&lt;/p&gt;
&lt;p&gt;机制是这样的：build 侧读数据建哈希表时，顺手把连接键的值收集起来做成过滤器，推给 probe 侧的扫描节点——扫描阶段就把肯定 join 不上的行丢掉。省下的不只是 probe 的计算，还有这些行的磁盘 I/O 和到算子的传输。过滤器有四种：IN（值集合小而精确）、Bloom（大集合的近似版，回答「没有」就真没有，回答「可能有」可能只是哈希碰撞）、MIN_MAX（只记最大最小值，适合范围条件）、IN_OR_BLOOM（按实际行数在 IN 和 Bloom 之间自动选）。默认启用后两类：&lt;code&gt;runtime_filter_type&lt;/code&gt; 默认值 12，对应 MIN_MAX 加 IN_OR_BLOOM——本文实测 &lt;code&gt;SHOW VARIABLES&lt;/code&gt; 显示 &lt;code&gt;IN_OR_BLOOM_FILTER,MIN_MAX&lt;/code&gt;，是同一件事的两种写法。多机时每个 join 实例只见过一部分数据，系统会选一个节点当 manager，把各实例生成的过滤器合并成全局的再统一下推。&lt;/p&gt;
&lt;p&gt;它同样写在计划里。visits 表（300 万行）join 一张 10 行的维表、维表侧带过滤条件时，join 节点那一行是 &lt;code&gt;runtime filters: RF000[min_max] &amp;lt;- uid, RF001[in_or_bloom] &amp;lt;- uid&lt;/code&gt;，扫描节点对应一行 &lt;code&gt;runtime filters: RF000[min_max] -&amp;gt; uid, RF001[in_or_bloom] -&amp;gt; uid&lt;/code&gt;——&lt;code&gt;&amp;lt;-&lt;/code&gt; 是生成，&lt;code&gt;-&amp;gt;&lt;/code&gt; 是下推；&lt;code&gt;EXPLAIN SHAPE PLAN&lt;/code&gt; 里则写作 &lt;code&gt;build RFs:RF0 uid-&amp;gt;[uid]&lt;/code&gt; 与 &lt;code&gt;apply RFs: RF0 RF1&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;但它的收益不是必然兑现的。我把同一条 join 开关 runtime filter 各跑了几遍，扫描耗时没有可分辨的差别：这张表的 uid 每 1000 行循环一轮，每个 page 都包含全部取值，过滤器只能逐行丢行，一个 page 也跳不掉——数据的分布又一次决定了优化结构的收益，和第二章低基数列用不好 bloom filter 是同一类教训。另外两个边界：build 侧本身没有过滤条件时，过滤器覆盖全部值、什么也省不下，优化器默认会把它裁掉；扫描节点等过滤器只等 1 秒（&lt;code&gt;runtime_filter_wait_time_ms&lt;/code&gt; 默认 1000），等不到就先扫，join 的 build 慢时过滤器可能根本没赶上用。&lt;/p&gt;
&lt;h3 id="一条查询的全链路"&gt;一条查询的全链路
&lt;/h3&gt;&lt;p&gt;把两章拼在一起，那条聚合查询的完整旅程是：SQL 文本进入 Nereids，解析、重写、按成本选出计划；计划拆成 fragment 发给相关 BE；每个 BE 把 fragment 切成 pipeline、实例化成并行任务、提交固定线程池；扫描节点完成第二章的三道收缩，读出第一批数据；算子按 4064 行一批向量化地执行聚合和 join；局部结果按分组键 shuffle、汇总、返回。&lt;/p&gt;
&lt;p&gt;存储层决定哪些数据不必读，执行层决定读进来的数据怎么算得快、传得少，优化器决定按哪份计划执行。排查一条慢查询，也就从这三层分别去问：多读了什么、多算了什么、计划为什么这么选。&lt;/p&gt;
&lt;h2 id="四数据的契约数据模型"&gt;四、数据的契约：数据模型
&lt;/h2&gt;&lt;p&gt;前两章有几处话只说了一半：宽表点查是列存的弱项，「怎么缓解」没有讲；索引体系表里出现过「Merge-on-Write」，只给了一句话解释；聚合模型、主键模型的名字多次出现，语义始终没展开。本章把这些收拢到同一个概念上——&lt;strong&gt;数据模型&lt;/strong&gt;：建表时选定的 Key 子句与列定义，签下一份同时约束存储语义和查询能力的契约。它决定 Compaction 对数据做什么、读时合并是否存在、谓词能不能下推、UPDATE 和 DELETE 支不支持，甚至 &lt;code&gt;avg()&lt;/code&gt; 返回什么。&lt;/p&gt;
&lt;h3 id="同一个键同一批数据三种答案"&gt;同一个键、同一批数据、三种答案
&lt;/h3&gt;&lt;p&gt;Doris 有三种数据模型，区别只在建表语句的 Key 子句和值列写法上。三种都建出来，喂同一批两行数据（同一个键的两笔订单），观察各自的结果：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- 聚合模型：值列必须声明聚合方式
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;agg_demo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DATETIME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 同键相加
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REPLACE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 同键取后写的
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AGGREGATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;DISTRIBUTED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;HASH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BUCKETS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PROPERTIES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;replication_num&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- 明细模型：值列就是普通列
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dup_demo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DATETIME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DUPLICATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;DISTRIBUTED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;HASH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BUCKETS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PROPERTIES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;replication_num&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- 主键模型
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;uni_demo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DATETIME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;DISTRIBUTED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;HASH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BUCKETS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PROPERTIES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;replication_num&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;然后是同一批写入（三张表各来一遍）和同一个查询：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;agg_demo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-08-01 10:00:00&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;new&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-08-01 10:00:00&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;paid&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- dup_demo、uni_demo 吃完全相同的两行
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;agg_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 三个模型各跑一次
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;同一个键的两行数据，三个模型给出三种答案（本文实测）：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;模型&lt;/th&gt;
					&lt;th&gt;&lt;code&gt;SELECT *&lt;/code&gt; 返回&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;聚合模型&lt;/td&gt;
					&lt;td&gt;1 行：&lt;code&gt;amount = 80, status = 'paid'&lt;/code&gt;——SUM 把 50 与 30 相加，REPLACE 取后写的&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;明细模型&lt;/td&gt;
					&lt;td&gt;2 行：50/new 与 30/paid 全部保留&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;主键模型&lt;/td&gt;
					&lt;td&gt;1 行：&lt;code&gt;amount = 30, status = 'paid'&lt;/code&gt;——后写覆盖前写&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;列结构完全相同，答案不同，差别全部来自建表时选的模型。&lt;/p&gt;
&lt;p&gt;&lt;img alt="同一批写入在三种数据模型下的去向与结果" class="gallery-image" data-flex-basis="379px" data-flex-grow="158" decoding="async" height="632" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://zh.renxinblog.cn/images/gyd-c06-doris-architecture-data-model-contract.svg" width="1000"&gt;&lt;/p&gt;
&lt;p&gt;这份契约的特别之处是&lt;strong&gt;签定后不可改&lt;/strong&gt;：官方文档明确「建表之后，表模型的属性就已确定，不能修改」，主键模型的实现模式同样「在创建时固定、不能通过 schema 变更改变」。Java 工程师对这个处境不陌生：选错模型不是改配置，是换表重灌。&lt;/p&gt;
&lt;h3 id="明细模型key-只是排序"&gt;明细模型：Key 只是排序
&lt;/h3&gt;&lt;p&gt;三种模型里最朴素的是&lt;strong&gt;明细模型（Duplicate Key）&lt;/strong&gt;：Key 列只代表排序，没有任何唯一性约束——官方文档的说法是「Key 列用于排序，没有唯一性约束」，存储层原样保留写入的每一行。第二章讲的排序键、前缀索引、列存编码，落在明细模型上就是它的全部：Key 决定数据怎么摆，仅此而已。&lt;/p&gt;
&lt;p&gt;它适合两类场景：必须保留全部原始记录（审计、对账）；查询维度事先不定、任何列都可能出现在 WHERE 和 GROUP BY 里（ad-hoc 分析）——明细都在，怎么切都行。对照表里它还有一个容易忽略的差异：DELETE 语句只算「部分支持」。&lt;/p&gt;
&lt;h3 id="聚合模型落盘即聚合"&gt;聚合模型：落盘即聚合
&lt;/h3&gt;&lt;p&gt;**聚合模型（Aggregate Key）**的 Key 列负责分组，非键的值列必须声明聚合方式，可选的有 SUM（累加）、MAX、MIN、REPLACE（后写替换前写）、REPLACE_IF_NOT_NULL（非 NULL 才替换）、HLL_UNION（HyperLogLog，近似去重计数）、BITMAP_UNION（位图并集）。聚合方式写进列定义本身，&lt;code&gt;SHOW CREATE TABLE&lt;/code&gt; 能直接看到：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;`amount` bigint SUM NULL,
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;`status` varchar(16) REPLACE NULL
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;AGGREGATE KEY(`dt`, `order_id`)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;聚合发生在三个时机，每个都在前两章出现过：导入时，每个批次生成一个新版本（第二章的 rowset 和版本号），版本内同键数据先聚一次；后台 Compaction 合并多个版本时，同键数据再聚一次；查询时，系统对跨版本的同键数据做最后的合并，保证结果正确——「读时合并」这个第二章的概念，在聚合模型上的含义就是第三步。所以聚合模型不是查询时才算，而是&lt;strong&gt;能提前聚的都提前聚了&lt;/strong&gt;，落盘的数据已经是聚合后的形态，原始明细不复存在。&lt;/p&gt;
&lt;p&gt;这带来聚合模型最大的收益和最大的坑，是同一件事的两面。收益：存储省了、聚合查询的计算量小了。坑：&lt;strong&gt;表里的行数不等于业务行数&lt;/strong&gt;。继续用刚才的表，再来一笔新订单，然后对两种模型跑同一个统计：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;agg_demo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-08-02 10:00:00&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1002&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;paid&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dup_demo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-08-02 10:00:00&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1002&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;paid&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;agg_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dup_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;三笔订单（50、30、10），两个模型的结果（本文实测）：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;模型&lt;/th&gt;
					&lt;th&gt;count&lt;/th&gt;
					&lt;th&gt;sum&lt;/th&gt;
					&lt;th&gt;avg&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;明细模型&lt;/td&gt;
					&lt;td&gt;3&lt;/td&gt;
					&lt;td&gt;90&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;30&lt;/strong&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;聚合模型&lt;/td&gt;
					&lt;td&gt;2&lt;/td&gt;
					&lt;td&gt;90&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;45&lt;/strong&gt;&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;sum&lt;/code&gt; 完全一致，&lt;code&gt;count&lt;/code&gt; 与 &lt;code&gt;avg&lt;/code&gt; 已经分道扬镳：聚合表里那张 50/30 的订单合成了一行，&lt;code&gt;avg(amount)&lt;/code&gt; 算出的是「部分和的平均」，不是「订单的平均」。官方文档把这条写成了通则：聚合模型「对聚合查询友好，但对 &lt;code&gt;count(*)&lt;/code&gt; 类查询不友好」，其他聚合语义的正确性需要使用者自行考量。报表场景（固定按天、按地区求和）天然匹配 SUM 语义，没问题；把聚合模型当明细表用，&lt;code&gt;avg&lt;/code&gt;、&lt;code&gt;count distinct&lt;/code&gt; 这类查询会安静地给你一个错误的数——不报错，只是不对。&lt;/p&gt;
&lt;h3 id="主键模型写入即-upsert"&gt;主键模型：写入即 upsert
&lt;/h3&gt;&lt;p&gt;**主键模型（Unique Key）**的 Key 列同时承担排序和唯一性约束：每个键只保留一行，最新写入的生效。这个语义 Java 工程师可以直接映射成 &lt;code&gt;INSERT ... ON DUPLICATE KEY UPDATE&lt;/code&gt;——存在就整行更新，不存在就插入。&lt;/p&gt;
&lt;p&gt;实现上它分成两种模式。&lt;strong&gt;Merge-on-Write（MoW，写时合并）&lt;/strong&gt;：写入时就地合并同键数据，存储层只保留每个键的最新版本——2.1 起它是主键模型的默认模式（本文实测建表回显里有 &lt;code&gt;&amp;quot;enable_unique_key_merge_on_write&amp;quot; = &amp;quot;true&amp;quot;&lt;/code&gt;）。&lt;strong&gt;Merge-on-Read（MoR，读时合并）&lt;/strong&gt;：写入只追加，同键保留多个版本，查询或 Compaction 时再按键合并。第二章讲的「读时合并」在主键模型上的代价形态就是 MoR：版本越积越多、查询要合并的路径越长，而且谓词无法下推到存储层；MoW 换来读侧干净（无合并、谓词可下推），代价是写入时多做合并。官方的建议是多数场景用 MoW，MoR 留给写多读少的场景。两种模式同样在建表时固定。它与聚合模型 REPLACE 的区别值得点一句：REPLACE 只替换挂了它的那一列，其余列各按各的聚合方式走；主键模型是整行覆盖。&lt;/p&gt;
&lt;p&gt;对照表里主键模型独占的几项能力都挂在这条契约上：&lt;code&gt;UPDATE&lt;/code&gt; 语句只有主键模型支持；导入时部分列更新（只更新某几列、其余列保持原值）也只有主键模型支持，且要求 MoW 模式。另有一条容易踩的约束：分区列必须包含在 Key 列中，否则数据唯一性保证不了。&lt;/p&gt;
&lt;h3 id="兑现第二章的欠条点查的短路径"&gt;兑现第二章的欠条：点查的短路径
&lt;/h3&gt;&lt;p&gt;第二章说过列存的代价：取一整行要把所有列各读一次，宽表高频点查付出 N 倍 I/O。当时欠的「怎么缓解」，答案现在可以拼出来了，而且是主键模型契约的一部分：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;MoW 是前提&lt;/strong&gt;：点查要在存储层快速定位「那个键的最新行」，读时合并的多版本形态撑不起这条路径；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;store_row_column&lt;/code&gt;&lt;/strong&gt;：建表时开启，表里额外存一份按行组织的副本（官方口径：多花磁盘换点查的 I/O），键等值查询直接从行存取整行，不必逐列凑；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;light_schema_change&lt;/code&gt;&lt;/strong&gt; 必须开：短路径依赖每列的唯一编号。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;三个条件凑齐，单表的键等值查询走一条短路径，一次 RPC 返回，&lt;code&gt;EXPLAIN&lt;/code&gt; 的输出里会出现 &lt;code&gt;SHORT-CIRCUIT&lt;/code&gt; 字样（本文在 MoW + 行存表上实测确认）。再配 JDBC 的 &lt;code&gt;useServerPrepStmts=true&lt;/code&gt; 用 PreparedStatement，省掉 FE 逐条解析 SQL 的 CPU（官方口径 CPU 瓶颈时可提效 4 倍以上），服务化高并发点查的整条链路就齐了。行存副本对应的行缓存默认是关的（BE 配置 &lt;code&gt;disable_storage_row_cache&lt;/code&gt;），要做高并发点查时记得打开。这套东西只对「按订单号查详情」这类服务场景有意义，纯分析查询用不上，也别为它付行存的存储成本。&lt;/p&gt;
&lt;h3 id="契约的选型"&gt;契约的选型
&lt;/h3&gt;&lt;p&gt;三种模型各自的领地，官方对照表加上本文的实测可以收成三句话：固定口径的报表和指标（按天、按地区求和计数）→ 聚合模型，落盘即预计算；有唯一性约束、要更新、要做服务点查 → 主键模型 + MoW；原始明细要保留、查询维度不定 → 明细模型。&lt;/p&gt;
&lt;p&gt;回头看这四章的骨架：第二章讲数据在磁盘上怎么摆，第三章讲摆好的数据怎么算，本章的契约签在两者中间——它决定 Compaction 帮你把数据聚到什么形态、读时有没有合并、点查有没有短路径，也决定 &lt;code&gt;count&lt;/code&gt; 和 &lt;code&gt;avg&lt;/code&gt; 返回的数是不是你想的那个数。建表语句里的每一个子句，都是在给这份契约签字。&lt;/p&gt;
&lt;h2 id="五数据的写入stream-load-的完整旅程"&gt;五、数据的写入：Stream Load 的完整旅程
&lt;/h2&gt;&lt;p&gt;机制讲完了，最后看数据怎么进来的。第一章承诺过两件事：单批导入原子可见、多数派副本写成功才算成功；第二章出现过 rowset、版本号和 -235 事故。本章沿一条真实的 Stream Load 请求走完全程，把这些承诺逐一兑现。本章所有请求与响应都在本机 2.1.11 集群实跑，输出原文照录。&lt;/p&gt;
&lt;h3 id="一条-http-请求的旅程"&gt;一条 HTTP 请求的旅程
&lt;/h3&gt;&lt;p&gt;Stream Load 不走 SQL 通道，走 HTTP：把一个 CSV/JSON 文件用 &lt;code&gt;PUT&lt;/code&gt; 推给 Doris。请求发往 FE 的 8030 端口：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -s -u root: &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -H &lt;span class="s2"&gt;&amp;#34;label:load_demo_001&amp;#34;&lt;/span&gt; -H &lt;span class="s2"&gt;&amp;#34;column_separator:,&amp;#34;&lt;/span&gt; -H &lt;span class="s2"&gt;&amp;#34;Expect:100-continue&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -T /tmp/sl2.csv &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; http://127.0.0.1:8030/api/loadtest/load_demo/_stream_load
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;第一条响应是 &lt;code&gt;HTTP 307 Temporary Redirect&lt;/code&gt;，&lt;code&gt;Location&lt;/code&gt; 指向某台 BE 的 8040 端口——FE 按轮询挑一台 BE 当本次导入的 &lt;strong&gt;coordinator&lt;/strong&gt;（协调节点），由它接收数据并分发给其他副本所在的 BE，FE 借此在多台 BE 间摊负载。文档同样允许跳过 FE 直接把请求发给某台 BE，两种姿势都合法。本机实测时撞上了这个机制的经典坑：重定向里的地址是 BE 的容器内网 IP，跨网络环境跟过去就是连接失败——官方为此单独写了一页「复杂网络环境 Stream Load」，常见解法就是直连 BE。改直连后导入成功，返回的 JSON 值得一屏屏看：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;TxnId&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;Label&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;load_demo_001&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;TwoPhaseCommit&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;false&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;Status&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Success&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;NumberTotalRows&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;NumberLoadedRows&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;NumberFilteredRows&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;NumberUnselectedRows&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;LoadBytes&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;69&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;CommitAndPublishTimeMs&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;63&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;TxnId&lt;/code&gt; 是这次导入对应的事务号——导入从进门的第一个响应就是事务语义的。底部还有一串时间分解：&lt;code&gt;BeginTxnTimeMs&lt;/code&gt;（开事务）、&lt;code&gt;StreamLoadPutTimeMs&lt;/code&gt;（FE 定计划）、&lt;code&gt;ReadDataTimeMs&lt;/code&gt;、&lt;code&gt;WriteDataTimeMs&lt;/code&gt;（写数据）、&lt;code&gt;CommitAndPublishTimeMs&lt;/code&gt;（提交并发布版本），几段相加基本就是总耗时——排查导入慢时，它直接告诉你时间花在哪一段。&lt;/p&gt;
&lt;h3 id="事务从-begin-到-publish-version"&gt;事务：从 begin 到 publish version
&lt;/h3&gt;&lt;p&gt;刚才那个 &lt;code&gt;TxnId 40&lt;/code&gt; 在 FE 日志里能找到完整的生命周期，三行原文按顺序是：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;beginTransaction: txn id 40 with label load_demo_001
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; from coordinator BE: 172.30.80.3
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;commitTransaction: [TransactionState. transaction id: 40, label: load_demo_001 ...]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;PUBLISH_VERSION finishTransaction [transaction id: 40, label: load_demo_001 ...]
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;三行正好对应第一章承诺的兑现路径：&lt;strong&gt;begin&lt;/strong&gt;——coordinator BE 收到数据后向 FE 开事务；数据并行写入各副本 BE，多数派写成功后，coordinator 调 &lt;strong&gt;commit&lt;/strong&gt;；FE 再把版本&lt;strong&gt;发布&lt;/strong&gt;（publish version），数据对外可见。发布完成之前有一个窗口：事务已提交但数据还查不到，对应返回 JSON 里的另一种状态 &lt;code&gt;Publish Timeout&lt;/code&gt;——导入本身已完成，只是可见性有延迟。上一节那个 &lt;code&gt;CommitAndPublishTimeMs&lt;/code&gt; 计时的，就是最后这两步。&lt;/p&gt;
&lt;h3 id="label重试的安全网"&gt;Label：重试的安全网
&lt;/h3&gt;&lt;p&gt;第一次提交的 JSON 里有个 &lt;code&gt;Label: load_demo_001&lt;/code&gt;，它是导入的身份标识，规则只有一条：&lt;strong&gt;同一个 label 不能被导入两次&lt;/strong&gt;。实测把同一请求原样重发：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Status: Label Already Exists
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Message: [LABEL_ALREADY_EXISTS] Label [load_demo_001] has already been used,
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; relate to txn [40], status [VISIBLE].
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;报错原文把三个概念的关系一次讲全：label 关联着事务号，事务有自己的状态（&lt;code&gt;VISIBLE&lt;/code&gt; 表示已发布可见）。客户端网络超时后重试时，带着同一个 label 再来，Doris 会发现这批数据已经导入过、直接拒绝——于是重试不会产生重复数据，官方把这个保证写作 At-Most-Once，并强烈建议「同一批数据使用同一个 label」。label 默认保留三天（&lt;code&gt;label_keep_max_second&lt;/code&gt; 可调），期间都能查到某一批数据的导入状态；状态为 &lt;code&gt;CANCELLED&lt;/code&gt;（失败取消）的 label 可以复用。&lt;/p&gt;
&lt;h3 id="数据质量零容忍与-errorurl"&gt;数据质量：零容忍与 ErrorURL
&lt;/h3&gt;&lt;p&gt;导入对脏数据的默认态度是零容忍：&lt;code&gt;max_filter_ratio&lt;/code&gt; 默认为 0（取值 0 到 1），错误行比例一超，整批失败。实测把一个只有 4 列的 CSV 推给 7 列的表，返回 &lt;code&gt;Status: Fail&lt;/code&gt;、&lt;code&gt;[DATA_QUALITY_ERROR] too many filtered rows&lt;/code&gt;，&lt;code&gt;NumberFilteredRows = 2&lt;/code&gt;——两行全被过滤，一行没进。响应里附了一个 &lt;code&gt;ErrorURL&lt;/code&gt;，用 curl 访问就能看到逐行的坏行原因：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Reason: actual column number in csv file is less than schema column number.
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; actual number: 4, schema column number: 7
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;容忍比例调大后（比如 &lt;code&gt;max_filter_ratio:0.4&lt;/code&gt;），错误行不超比例的批次会成功入库，被丢弃的行数记在 &lt;code&gt;NumberFilteredRows&lt;/code&gt; 里。注意区分：查询里 &lt;code&gt;WHERE&lt;/code&gt; 条件过滤掉的行记在 &lt;code&gt;NumberUnselectedRows&lt;/code&gt;，不算错误、不占容忍额度——前者是「数据不合格」，后者是「按规则不要」。&lt;/p&gt;
&lt;h3 id="两阶段提交"&gt;两阶段提交
&lt;/h3&gt;&lt;p&gt;有些接入场景希望「数据先放进来、先别让下游看见」，等上游确认后再放行——Stream Load 用两阶段提交支持这个语义。实测全程：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 第一阶段：加 two_phase_commit 头，数据写入但不发布&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -s -u root: -H &lt;span class="s2"&gt;&amp;#34;label:tpc_demo_001&amp;#34;&lt;/span&gt; -H &lt;span class="s2"&gt;&amp;#34;two_phase_commit:true&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -H &lt;span class="s2"&gt;&amp;#34;column_separator:,&amp;#34;&lt;/span&gt; -T /tmp/sl2.csv &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; http://127.0.0.1:8040/api/loadtest/load_demo/_stream_load
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 返回 Status: Success, TwoPhaseCommit: true —— 事务处于 pre-commit 状态&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 此时查询：行数不变（实测 2 → 2），pre-commit 的数据不可见&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 第二阶段：确认后提交&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -s -u root: -X PUT -H &lt;span class="s2"&gt;&amp;#34;txn_operation:commit&amp;#34;&lt;/span&gt; -H &lt;span class="s2"&gt;&amp;#34;label:tpc_demo_001&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; http://127.0.0.1:8040/api/loadtest/load_demo/_stream_load_2pc
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 提交后查询：行数 2 → 4，数据可见&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;「写入成功」和「对外可见」在两阶段提交下被拆成两个独立的决定，中间的窗口留给上游业务。这条路径的端点和参数在 2.1 的 Stream Load 手册正文里没有展开，本节流程以本地实测为准。&lt;/p&gt;
&lt;p&gt;&lt;img alt="一条 Stream Load 从请求到数据可见" class="gallery-image" data-flex-basis="363px" data-flex-grow="151" decoding="async" height="660" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://zh.renxinblog.cn/images/gyd-c06-doris-architecture-stream-load-sequence.svg" width="1000"&gt;&lt;/p&gt;
&lt;h3 id="高频写入的答案group-commit"&gt;高频写入的答案：Group Commit
&lt;/h3&gt;&lt;p&gt;Stream Load 的默认行为是「一次导入 = 一个独立事务 = 一个新版本」。这个模型干净，但高频小批量写入会把两头压垮：FE 要为每次导入解析、定计划，版本数飞涨——第二章那个 -235（单 tablet 版本数超 500 拒绝导入）的源头就在这里。2.1 给出的答案是 &lt;strong&gt;Group Commit&lt;/strong&gt;：官方文档对它的定位是「不是新的导入方式，而是对已有导入方式的优化扩展」——把多个小批量导入在后台合并成一个大事务提交。&lt;/p&gt;
&lt;p&gt;它有三种模式（本文实测确认 2.1 没有「自动选」的第四种）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;off_mode&lt;/code&gt;（默认）：关闭，行为同上；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sync_mode&lt;/code&gt;：合并攒批，但事务提交后才返回——写入慢一点，数据立即可见；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;async_mode&lt;/code&gt;：数据先写 WAL（Write Ahead Log，预写日志）立即返回，后台按阈值攒批提交。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;攒批的触发条件是时间或量先到者：&lt;code&gt;group_commit_interval&lt;/code&gt; 默认 10 秒、&lt;code&gt;group_commit_data_bytes&lt;/code&gt; 默认 64 MB。可见性的代价用一次实测看：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;group_commit&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;async_mode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;load_demo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-08-03 09:00:00&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;new&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;load_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 立即查：4 行，刚插入的不可见
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- 等 12 秒
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;load_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 5 行，攒批提交后可见
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;async_mode&lt;/code&gt; 的代价也要如实说：数据先落在单台 BE 的 WAL 文件里，官方明确「磁盘损坏或误删文件可能造成数据丢失」，运维下线 BE 时要按文档流程走；单条数据量太大、WAL 空间吃紧时自动转 &lt;code&gt;sync_mode&lt;/code&gt;。另外它不是处处生效：显式事务、带 label 的导入、两阶段提交这些场景会自动绕过——需要精确控制批次边界的功能，和攒批天然冲突。&lt;/p&gt;
&lt;h3 id="从-curl-到可见"&gt;从 curl 到可见
&lt;/h3&gt;&lt;p&gt;到这里，第一章立下的承诺全部兑现。一条 Stream Load 的完整时间线：HTTP 请求到 FE，307 重定向给一台 coordinator BE；coordinator 向 FE 开事务（begin），label 校验通过；数据分发到各副本 BE，写进 MemTable、刷成 rowset——第二章的版本号在这里 +1；多数派写成功，事务提交（commit）；FE 发布版本（publish），数据对外可见；下游从此能在这条数据上跑出第二章的扫描收缩、第三章的并行执行和第四章契约定义的语义。写入的可靠性靠 label 兜住重试，靠 &lt;code&gt;max_filter_ratio&lt;/code&gt; 和 ErrorURL 暴露脏数据，靠两阶段提交把「写入」和「可见」拆成两个决定，靠 Group Commit 把高频小批量的版本代价摊平。&lt;/p&gt;
&lt;p&gt;五个章节走完，从一次 OLTP 与 OLAP 的对比开始，到一条数据在集群里的完整一生结束。&lt;/p&gt;</description></item></channel></rss>