<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Doris on Renxin's Blog</title><link>https://zh.renxinblog.cn/tags/doris/</link><description>Recent content in Doris on Renxin's Blog</description><generator>Hugo -- gohugo.io</generator><language>zh</language><lastBuildDate>Tue, 22 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://zh.renxinblog.cn/tags/doris/index.xml" rel="self" type="application/rss+xml"/><item><title>第七话｜从建表到圈选：Doris 在 CDP 场景的实战</title><link>https://zh.renxinblog.cn/post/gyd-c07-doris-practice-in-cdp/</link><pubDate>Tue, 22 Sep 2026 00:00:00 +0000</pubDate><guid>https://zh.renxinblog.cn/post/gyd-c07-doris-practice-in-cdp/</guid><description>&lt;img src="https://zh.renxinblog.cn/images/gyd-c07-doris-practice-in-cdp-cover.png" alt="Featured image of post 第七话｜从建表到圈选：Doris 在 CDP 场景的实战" /&gt;&lt;p&gt;上一章讲机制：数据在磁盘上怎么摆、摆好的数据怎么算。这一章讲决定。同样是「建一张会员画像表」，模型选哪个、排序键放谁、分桶怎么分，官方文档给的都是原则和区间；原则怎么变成你手上那个数字，只有在真实负载形状上跑一遍才看得出来。&lt;/p&gt;
&lt;p&gt;本章和后面几章的场景是同一个：&lt;strong&gt;一个 SaaS 会员运营平台&lt;/strong&gt;，商户在页面上勾标签组合圈人群、看预估人数、把人群包推到投放渠道，人群包每天凌晨重算一遍。会员总量 2 亿，分布在 500 家商户下，标签列上百个且每家启用的子集不同（本文示例表建 40 个标签列，判据同构）。&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;数据与口径声明&lt;/strong&gt;：场景参数（会员数、商户分布、标签数、日回流量）是&lt;strong&gt;示例设定&lt;/strong&gt;，用来复现这类负载的形状，不是某个生产系统的真实数据。所有耗时、体积、扫描量数字来自作者本机一套 &lt;strong&gt;单 FE 单 BE 的 Doris 2.1.11 集群&lt;/strong&gt;（4 核、24 GB），用同构数据实测。耗时一律取 FE 审计日志的 &lt;code&gt;Time(ms)&lt;/code&gt;（服务端口径），并区分&lt;strong&gt;冷跑&lt;/strong&gt;与&lt;strong&gt;预热后中位数&lt;/strong&gt;——这两个数字差着一个数量级，不标口径的耗时没有意义。机制解释沿用上一章的口径，出自 2.1 官方文档。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;h2 id="一把这张画像表立起来"&gt;一、把这张画像表立起来
&lt;/h2&gt;&lt;h3 id="先看四类动作对表的要求"&gt;先看四类动作对表的要求
&lt;/h3&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;/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;任意标签列组合进 &lt;code&gt;WHERE&lt;/code&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;code&gt;count(*)&lt;/code&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;code&gt;user_id&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;/p&gt;
&lt;h3 id="决定一模型选主键但要知道主键贵在哪"&gt;决定一：模型选主键，但要知道主键贵在哪
&lt;/h3&gt;&lt;p&gt;上一章讲过三种模型的契约：明细模型原样保留每一行，聚合模型落盘即合并，主键模型每个键只留最新一行。「当前状态」这个语义就是主键模型的定义，没有第二个候选。真正要算的账是：&lt;strong&gt;和「明细模型 + 每天全量重灌」这条替代路比，主键模型贵在哪、省在哪&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;贵的那部分是存储。同一份 2 亿行 × 42 列的数据，本机建两张表各灌一遍：&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;明细模型 member_profile_dup 6.886 GB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;主键 MoW member_profile 7.746 GB 多 11%
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;多出来的 11% 是主键模型的两份内部结构：删除位图（记录哪些键的旧版本作废）和持久化索引（写入时定位同键旧行要用）。上一章说 MoW「写入时就地合并」，这句话的成本就在这儿——它得先能找到那个键，才能合并。&lt;/p&gt;
&lt;p&gt;省的那部分是写入。业务上的「标签回流」不是重灌全表，而是每天有一部分会员的若干标签变了。假设一天要更新 3000 万会员的 3 个标签列，两条路的写法完全不同：&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;-- 主键模型 + MoW：部分列更新，只写这 3 列，其余列保持原值
&lt;/span&gt;&lt;/span&gt;&lt;/span&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;enable_unique_key_partial_update&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;true&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;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;member_profile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_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;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rfm_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;last_order_time&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="n"&gt;tenant_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;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;新等级&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;新&lt;/span&gt;&lt;span class="n"&gt;RFM&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;新下单时间&lt;/span&gt;&lt;span class="o"&gt;&amp;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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;上游算好的回流结果&lt;/span&gt;&lt;span class="o"&gt;&amp;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;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tenant_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;1&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;6 - level&lt;/code&gt; 之类），写入行数与列数与真实回流一致。&lt;/p&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;/th&gt;
					&lt;th&gt;耗时（本机实测）&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;主键 MoW 部分列更新&lt;/td&gt;
					&lt;td&gt;3000 万行 × 3 列&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;27.31 秒&lt;/strong&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;明细模型全量重灌&lt;/td&gt;
					&lt;td&gt;2 亿行 × 42 列&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;3 分 02 秒&lt;/strong&gt;&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;写入量差了约 93 倍，耗时只差 6.7 倍——这个不对称本身就是结论：主键模型省下的不是那两分钟，而是**「每天必须把 2 亿行全量重算并搬一遍」这件事**。上游标签计算的输出量、导入窗口占用的资源、重灌期间的版本压力，都随全量这两个字放大。&lt;/p&gt;
&lt;p&gt;读侧这一项没有差别。同一条圈选查询在两张表上预热后的中位数都是 &lt;strong&gt;84 ms&lt;/strong&gt;（明细表 84 / 主键表 84，各 6 次取中位）——上一章说 MoW 换来「读侧干净」，在这个数据量上它确实是干净的：计划里那个 &lt;code&gt;__DORIS_DELETE_SIGN__ = 0&lt;/code&gt; 的谓词，成本量级可以忽略。&lt;/p&gt;
&lt;p&gt;所以第一个决定的答案是：&lt;strong&gt;选主键模型，付 11% 的存储，换掉全量重灌这条路&lt;/strong&gt;。代价不在读，在写侧的合并与那份位图。&lt;/p&gt;
&lt;h3 id="决定二排序键放-tenant_id-user_id然后接受它的局限"&gt;决定二：排序键放 &lt;code&gt;(tenant_id, user_id)&lt;/code&gt;，然后接受它的局限
&lt;/h3&gt;&lt;p&gt;排序键必须是列定义的前 K 列（上一章的纪律），所以这一步同时决定了列的排列顺序。两个约束把它钉死了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;圈选&lt;strong&gt;永远带租户&lt;/strong&gt;——SaaS 的行级隔离靠 &lt;code&gt;WHERE tenant_id = ?&lt;/code&gt;，它是所有查询的第一个条件，按上一章「最常过滤的定长列排前面」的建议，它当第一列；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;user_id&lt;/code&gt; 当第二列，服务另一类动作：按会员主键查详情，以及导出时的有序游标翻页。&lt;/li&gt;
&lt;/ul&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;member_profile&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;tenant_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;-- 排序键第 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;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="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;-- 排序键第 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;city_code&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INT&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="k"&gt;level&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TINYINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- │ 示例表建了 40 个标签列
&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;lifecycle&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TINYINT&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;order_cnt_90d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- │ 多的是列，少的是能进短 key 的位置
&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;total_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;12&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 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;last_order_time&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DATETIME&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="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="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="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;tenant_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;user_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;user_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;32&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;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="s2"&gt;&amp;#34;enable_unique_key_merge_on_write&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;true&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;短 key 上限 36 字节，&lt;code&gt;tenant_id&lt;/code&gt; 8 + &lt;code&gt;user_id&lt;/code&gt; 8 只占 16 字节，还剩 20 字节的空间——但&lt;strong&gt;没有值得放进来的第三列&lt;/strong&gt;。把某个标签（比如 &lt;code&gt;level&lt;/code&gt;）提上来，它确实能让「租户 + 等级」这一类条件走通前缀索引；代价是 &lt;code&gt;user_id&lt;/code&gt; 被挤出连续前缀，按会员主键点查和导出翻页从此用不上前缀索引。而圈选的条件组合是任意的，任何一个单独标签都只服务其中一类查询——三个位置里，用「覆盖必带条件」和「主键访问路径」占掉两个，比赌某个标签常当首条件更划算。&lt;/p&gt;
&lt;p&gt;于是接受它的局限。这条圈选语句在本机的执行计划与 profile：&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;SELECT count(*) FROM member_profile
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;WHERE tenant_id = 1 AND level &amp;gt;= 3 AND order_cnt_90d &amp;gt; 5 AND total_amount &amp;gt; 3000;
&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;partitions=1/1 -- 只建了一个分区，没有可裁的东西
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tablets=32/32 -- 32 个 tablet 全部进扫描
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;RawRowsRead 30,000,000 -- 从存储层读出 3000 万行 = 租户 1 的全部
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;RowsRead 1,825,506 -- 过滤后剩 182.6 万
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;前缀索引完成了它该做的那一段：2 亿行按租户收窄到 3000 万。剩下的三个标签条件，一个索引都没吃到，&lt;strong&gt;3000 万行全部读出来逐行判断&lt;/strong&gt;。16 倍的读取放大就藏在这里——这也是为什么这条查询「跑得动，但不对」：84 ms 是 4 核单机把蛮力跑满的结果，不是定位能力的结果。&lt;/p&gt;
&lt;h3 id="决定三分桶列和桶数一个服务均衡一个不服务裁剪"&gt;决定三：分桶列和桶数，一个服务均衡、一个不服务裁剪
&lt;/h3&gt;&lt;p&gt;分桶列选 &lt;code&gt;user_id&lt;/code&gt;。理由很朴素：它是这张表里唯一分布均匀的列，租户 ID 不行（头部一家占 15%）、任何标签列更不行（上一章的高基数纪律）。&lt;/p&gt;
&lt;p&gt;这个选择有一个容易被忽略的后果：&lt;strong&gt;圈选条件里没有 &lt;code&gt;user_id&lt;/code&gt;，所以桶裁剪不会发生&lt;/strong&gt;——上面计划里的 &lt;code&gt;tablets=32/32&lt;/code&gt; 就是这个意思，2 亿行摊在 32 个桶上，任何一个桶都可能藏着目标行，只能全扫。这不是错误，是这个场景的固有形状：按主键点查能裁到 1 个桶，按标签圈选一个桶都裁不掉。&lt;strong&gt;分桶列在圈选场景里服务的是均衡与并行，不服务裁剪&lt;/strong&gt;，指望它像 MySQL 索引那样「命中就少读」是错配。&lt;/p&gt;
&lt;p&gt;桶数按上一章的官方口径估：单个 tablet 保持在 1G–10G。这张表全量约 7.7 GB，照这个区间，&lt;strong&gt;8 个桶（每 tablet 约 970 MB）比 32 个桶（每 tablet 248 MB）更贴近建议值&lt;/strong&gt;。本机把 8 桶版本也建了一遍，两笔可查的账：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;&lt;/th&gt;
					&lt;th&gt;32 桶&lt;/th&gt;
					&lt;th&gt;8 桶&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;每 tablet&lt;/td&gt;
					&lt;td&gt;248 MB / 625 万行&lt;/td&gt;
					&lt;td&gt;970 MB / 2500 万行&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;全表体积&lt;/td&gt;
					&lt;td&gt;7.746 GB&lt;/td&gt;
					&lt;td&gt;7.549 GB&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;碎桶多花的 2% 是实测数字，归因是我按机制推的——每个 tablet 各自带一份短 key 索引与 page 级元数据，切得越碎这份固定开销越多；我测到的只有这两个体积数本身。桶数少一半还有另一笔对称的账：副本迁移、Schema Change 这类要重写 tablet 的操作，单次代价就大一倍（上一章讲过）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;桶数对查询耗时有多敏感，这台机器测不出可信答案&lt;/strong&gt;：本机 BE 只有 4 核，&lt;code&gt;parallel_pipeline_task_num&lt;/code&gt; 默认取核数一半，也就是 2 个扫描实例——8 桶版本的同一条圈选预热后中位数 117 ms、32 桶 84 ms，这个差值里混着缓存、Compaction 时机和调度抖动；而生产集群的 BE 核数通常远高于 4，扫描实例数不是一个量级，把这个差值外推过去必然失真。所以这里只留一句判断：&lt;strong&gt;桶数按 tablet 目标大小倒推，不要按「多切几块并行快点」的直觉定&lt;/strong&gt;，后者在 2.1 的 pipeline 引擎下已经不是主要收益来源（上一章的分片内并发读讲过原因）。&lt;/p&gt;
&lt;h3 id="三个决定签完之后"&gt;三个决定签完之后
&lt;/h3&gt;&lt;p&gt;把这一章的账并排放：模型选了主键，付 11% 存储、省掉全量重灌；排序键选了 &lt;code&gt;(租户, 会员)&lt;/code&gt;，换来必带条件能裁到 3000 万，代价是标签条件零索引、16 倍蛮力；分桶选了 &lt;code&gt;user_id&lt;/code&gt;，换来均匀分布，代价是圈选时一个桶都裁不掉。&lt;/p&gt;
&lt;p&gt;三个决定都没有「错」的那个，但每一个都把成本挪了个位置。而圈选这条查询真正的问题——&lt;strong&gt;读 3000 万只为留 182.6 万&lt;/strong&gt;——不在建表里，在标签列的索引形态里。&lt;/p&gt;
&lt;h2 id="二第一处不匹配谓词与落盘顺序"&gt;二、第一处不匹配：谓词与落盘顺序
&lt;/h2&gt;&lt;p&gt;上一章末尾那条圈选读 3000 万行、留 182.6 万行。这一章把它补上，补法有两种——跳数索引和点查索引——但先用实测说清一件事：&lt;strong&gt;在这张表上，第一种基本是白付的&lt;/strong&gt;。&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;测量口径&lt;/strong&gt;：这一章的主证据是 query profile 里的 &lt;code&gt;RawRowsRead&lt;/code&gt;（从存储层真实读出的行数），不是耗时。原因是同一台机器上同一条查询冷跑 309 ms、热跑 33 ms，耗时里混着缓存与调度；而 &lt;code&gt;RawRowsRead&lt;/code&gt; 是个确定量，它掉不下来就是没生效。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;h3 id="先量清楚三条查询各自在蛮力什么"&gt;先量清楚：三条查询各自在蛮力什么
&lt;/h3&gt;&lt;p&gt;2 亿行、无任何用户索引的基线（命中行数是 SQL 的真实返回）：&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;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;tenant_id=1 AND city_code=101&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;3000 万&lt;/td&gt;
					&lt;td&gt;6.0 万&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;499×&lt;/strong&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;tenant_id=1 AND level&amp;gt;=3 AND order_cnt_90d&amp;gt;5 AND total_amount&amp;gt;3000&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;3000 万&lt;/td&gt;
					&lt;td&gt;182.6 万&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;16×&lt;/strong&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;tenant_id=1 AND is_vip=1&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;3000 万&lt;/td&gt;
					&lt;td&gt;1049.8 万&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;3×&lt;/strong&gt;&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;三条读的都是 3000 万——前缀索引把租户那一段裁完之后，标签条件一律逐行判断。它们的差别只在留下多少：放大 499 倍的那条最该救，放大 3 倍的那条救了也没多少可省。这个「留下多少」可以量化，就是各列的&lt;strong&gt;单值命中占比&lt;/strong&gt;：&lt;code&gt;city_code&lt;/code&gt; 有 500 个取值、最大值的占比 &lt;strong&gt;0.2%&lt;/strong&gt;；&lt;code&gt;is_vip&lt;/code&gt; 只有 2 个取值、最大占比 &lt;strong&gt;65%&lt;/strong&gt;。这两个数是下面所有判断的起点。&lt;/p&gt;
&lt;h3 id="第一种补法bloom-filter一次干净的白付"&gt;第一种补法：bloom filter，一次干净的白付
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;city_code&lt;/code&gt; 是等值查询、基数不低，看起来正是上一章说的 bloom filter 的适用场景。加上它：&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;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;member_profile&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;city_code&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;命令 0.11 秒返回，后台的 schema change 作业在 2 亿行上跑了&lt;strong&gt;约 80 秒&lt;/strong&gt;才 FINISHED，存储从 8.450 GB 涨到 8.581 GB（+131 MB）。然后重跑那条 &lt;code&gt;city_code=101&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;RawRowsRead = 30,000,000 ← 一行没少读
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;RowsRead = 60,138
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;放大 = 499× ← 与加索引前完全相同
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;一点没变&lt;/strong&gt;。原因在上一章讲过的那条纪律上：bloom filter 是&lt;strong&gt;跳数索引&lt;/strong&gt;，它只对「这一页里有没有这个值」回答「没有」，从而整页跳过。而这张表按 &lt;code&gt;(tenant_id, user_id)&lt;/code&gt; 排序，&lt;code&gt;city_code&lt;/code&gt; 是随用户哈希随机散布的——租户 1 的 3000 万行里，&lt;strong&gt;每一个 page 都装着全部 500 个城市码&lt;/strong&gt;。过滤器于是永远回答「可能有」，一个页也跳不掉。&lt;/p&gt;
&lt;p&gt;所以判据不是「等值 + 高基数就建 bloom filter」，而是：&lt;strong&gt;这一列的取值在页之间是否有偏斜&lt;/strong&gt;。有偏斜的列，是那些与排序键共序、或本身按时间单调写入的列（比如 &lt;code&gt;last_order_time&lt;/code&gt;，同一批回流的数据时间聚在一处）；标签列按用户随机分布时，跳数索引没有饭吃。&lt;/p&gt;
&lt;h3 id="第二种补法倒排索引它不吃分布"&gt;第二种补法：倒排索引，它不吃分布
&lt;/h3&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;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_city&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;member_profile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;city_code&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_city&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;member_profile&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;code&gt;BUILD INDEX&lt;/code&gt; 在 2 亿行上跑了 &lt;strong&gt;804 秒&lt;/strong&gt;，这一列的索引多占 &lt;strong&gt;412 MB&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;RawRowsRead = 60,138 ← 从 3000 万降到与命中行数相等
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;RowsRead = 60,138
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;放大 = 1×
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;499 倍的蛮力一次消掉，热态耗时 33 ms。&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-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tenant_id=1 AND city_code BETWEEN 100 AND 275 -- 覆盖 35% 的取值
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;RawRowsRead = 30,000,000 ← 又回到全读
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;RowsRead = 10,562,749
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;命中面铺到三分之一时，索引没有省下任何读取。按机制推，是因为「按索引逐行取位 + 随机读」在命中行足够多时比不过顺序扫全量（这一句是推演，我测到的只是 &lt;code&gt;RawRowsRead&lt;/code&gt; 没变）。这与上一章那条「倒排的收益来自命中少，不是无条件快」是同一件事的两面：&lt;strong&gt;索引省下的读取量，上限就是你不命中的那部分行数&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id="组合条件的木桶第一个没索引的条件决定下限"&gt;组合条件的木桶：第一个没索引的条件决定下限
&lt;/h3&gt;&lt;p&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;tenant_id=1 AND city_code=101 AND level&amp;gt;=3
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; RawRowsRead = 60,138 RowsRead = 13,150 放大 5×
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;city_code&lt;/code&gt; 上的索引把读取钉死在 6 万行，&lt;code&gt;level&lt;/code&gt; 没有索引，就在这 6 万行里逐行筛掉 78%——它筛得动，因为基数已经被前一个条件压下去了。反过来，把有索引的那一列拿掉：&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;tenant_id=1 AND level&amp;gt;=3 AND order_cnt_90d&amp;gt;5 AND total_amount&amp;gt;3000
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; RawRowsRead = 30,000,000 放大 16× ← 索引一点没帮上
&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;strong&gt;最先出现、且命中占比最低&lt;/strong&gt;的那几个列有没有被覆盖到。&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;。别靠猜——Doris 的 FE 审计日志记录了每一条查询的原文，按周统计一次各标签列出现在 &lt;code&gt;WHERE&lt;/code&gt; 里的次数，就是运营的真实习惯。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;单值命中占比&lt;/strong&gt;。上面那两个数（0.2% 与 65%）就是筛子：占比低的列建了才可能省，占比高的列建了也省不动。一条 SQL 就能量出来：&lt;/li&gt;
&lt;/ol&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="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&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;top_value_rows&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="k"&gt;c&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;tenant_rows&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="p"&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;city_code&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;c&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;member_profile&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="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tenant_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;1&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;city_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;x&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;ol start="3"&gt;
&lt;li&gt;&lt;strong&gt;预算&lt;/strong&gt;。这一项在 2 亿行上是硬的：每列一次 &lt;code&gt;BUILD INDEX&lt;/code&gt; 约 13 分钟、约 412 MB 存储。40 个标签列全建，就是 9 小时窗口和 16 GB 索引——荒谬。所以真实动作是&lt;strong&gt;从高频且低占比的列里挑 5 到 8 个&lt;/strong&gt;，其余列接受「在已筛剩的行上逐行判断」，只要前面那一两个索引把基数压得够低，后面的逐行过滤并不贵。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;顺带一句运维量级：这些索引作业都是异步的、且互斥（上一章讲过 &lt;code&gt;SCHEMA_CHANGE&lt;/code&gt; 状态会挡住其他结构变更）。在 2 亿行的表上，「加一列索引」意味着一个 13 分钟的窗口和一次不能并发的变更——这是它和 MySQL 上加个索引完全不同的地方。&lt;/p&gt;
&lt;h2 id="三第二处不匹配写入节奏与合并节奏"&gt;三、第二处不匹配：写入节奏与合并节奏
&lt;/h2&gt;&lt;p&gt;前两章都在动「读」这一侧。但这类系统每天真正压着集群的是写：标签要回流，行为事件要持续进来。上一章建立过一个等式——&lt;strong&gt;一次导入 = 一个不可变的 rowset = 一个版本&lt;/strong&gt;，读的时候要把这些版本叠成一张视图。这个等式在导入频率高的时候会开始收利息。&lt;/p&gt;
&lt;h3 id="先量一遍一次导入一个版本有多贵"&gt;先量一遍「一次导入一个版本」有多贵
&lt;/h3&gt;&lt;p&gt;一张 4 桶的小表，连灌 24 条单行 &lt;code&gt;INSERT&lt;/code&gt;，tablet 的 &lt;code&gt;Version&lt;/code&gt; 从 3500 涨到 3524（重跑一轮是 3553 → 3577）：&lt;strong&gt;精确的 24&lt;/strong&gt;。没有合并、没有优化，一条语句一个版本。&lt;/p&gt;
&lt;p&gt;大表上单笔大写入反而是便宜的——第一章那次 3000 万行的部分列更新，27.31 秒完成，版本只 +1。贵的是&lt;strong&gt;高频小批&lt;/strong&gt;：每次导入都留下一个待合并的 rowset，读的时候就要多叠一层。同一个圈选查询，在这次更新刚落地后的头两次跑出了 502 ms 和 655 ms，随后回落到 81 ms 和 65 ms。多出来的那一段，按机制推是新 rowset 首次读取（缓存未热）加上合并路径变长——我测到的是这四个数本身，归因是推的。&lt;/p&gt;
&lt;h3 id="-235-的真实成因不是导入次数多"&gt;-235 的真实成因不是「导入次数多」
&lt;/h3&gt;&lt;p&gt;版本堆到有上限，导入就会被拒绝，报 -235。但这里有两处口径要先说清，因为它们直接影响你怎么设防线。&lt;/p&gt;
&lt;p&gt;第一，&lt;strong&gt;默认值文档自己不一致&lt;/strong&gt;。官方 FAQ「数据操作问题」页写的是「默认 500」，而 BE 配置项文档页和本机 2.1.11 的实际配置都是 &lt;strong&gt;2000&lt;/strong&gt;（且可动态修改）。以本机为准：&lt;code&gt;max_tablet_version_num = 2000&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;第二，也是更要紧的：&lt;strong&gt;这个限制判的不是版本号&lt;/strong&gt;。&lt;code&gt;SHOW TABLETS FROM 表&lt;/code&gt; 里有两个容易混的字段——&lt;code&gt;Version&lt;/code&gt; 是这条 tablet 的时间线走到第几号，只增不减；&lt;code&gt;VersionCount&lt;/code&gt; 是当前还并存的 rowset 份数，会被合并压回去。撞 -235 判的是后者。&lt;/p&gt;
&lt;p&gt;这一点有直接证据。往同一个 tablet 连灌 24 条单行 &lt;code&gt;INSERT&lt;/code&gt;，逐条采样：&lt;code&gt;Version&lt;/code&gt; 从 3553 一路走到 3577（&lt;strong&gt;每条 +1，精确 24&lt;/strong&gt;），而 &lt;code&gt;VersionCount&lt;/code&gt; 自始至终钉在 &lt;strong&gt;5&lt;/strong&gt;——这张小表的合并是毫秒级的，攒不起来。此时 &lt;code&gt;Version&lt;/code&gt; 早已越过 2000 这个阈值 1500 多，导入一次都没被拒。反向实验指向同一处：把阈值临时调到 200、再调到 8，&lt;code&gt;Version&lt;/code&gt; 涨到 3500 仍不报错；要是判的是版本号，调到 8 之后的第二次导入就该失败。&lt;/p&gt;
&lt;p&gt;&lt;img alt="同一个 tablet 上 Version 一路涨、VersionCount 恒为 5，检查比的是后者" class="gallery-image" data-flex-basis="320px" data-flex-grow="133" decoding="async" height="766" 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-c07-doris-practice-in-cdp-version-vs-rowset.svg" width="1023"&gt;&lt;/p&gt;
&lt;p&gt;所以「导入次数多就会撞 -235」是错的。事故条件是&lt;strong&gt;导入速度大于合并速度&lt;/strong&gt;，而这恰恰发生在大表上：一个 248 MB 的 tablet 合并要秒级，此时高并发小批导入就会把 rowset 堆起来。小表反而几乎撞不到——这也是为什么这类事故常在业务增长、tablet 变大之后才第一次出现。&lt;/p&gt;
&lt;p&gt;排查动作按顺序：&lt;code&gt;SHOW TABLETS FROM 表&lt;/code&gt; 看每个 tablet 的 &lt;code&gt;Version&lt;/code&gt;；BE 日志里搜 &lt;code&gt;compaction&lt;/code&gt; 与该 tablet id，看合并是否积压；导入侧看 Stream Load 返回 JSON 里的时间分解（上一章讲过），确认是卡在写还是卡在等版本发布。&lt;/p&gt;
&lt;h3 id="解法与代价攒批的合并率来自并发"&gt;解法与代价：攒批的合并率来自并发
&lt;/h3&gt;&lt;p&gt;Doris 2.1 的 Group Commit 是官方给的答案：把多个小事务在存储侧合并提交。但「合到什么程度」取决于&lt;strong&gt;同一时刻落在同一个 tablet 上的导入数&lt;/strong&gt;，这一点只有实测说得清。四组配置，同一张小表：&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;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;串行&lt;/td&gt;
					&lt;td&gt;24&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;+24&lt;/strong&gt;&lt;/td&gt;
					&lt;td&gt;约 0.5 s/条&lt;/td&gt;
					&lt;td&gt;立即&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;async_mode&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;串行&lt;/td&gt;
					&lt;td&gt;30&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;+1&lt;/strong&gt;&lt;/td&gt;
					&lt;td&gt;秒级返回&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;约 12 秒后可见&lt;/strong&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;sync_mode&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;串行&lt;/td&gt;
					&lt;td&gt;20&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;+20&lt;/strong&gt;&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;202 秒&lt;/strong&gt;&lt;/td&gt;
					&lt;td&gt;立即&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;sync_mode&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;8 会话并发&lt;/td&gt;
					&lt;td&gt;40&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;+5&lt;/strong&gt;&lt;/td&gt;
					&lt;td&gt;51 秒&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;code&gt;async_mode&lt;/code&gt; 把 30 次导入合成 1 个版本，&lt;code&gt;sync_mode&lt;/code&gt; 一个都没合，还让每条都等了约 10 秒的提交窗口&lt;/strong&gt;。原因是 &lt;code&gt;async_mode&lt;/code&gt; 客户端不等提交就返回，后续导入于是落进同一个窗口；而 &lt;code&gt;sync_mode&lt;/code&gt; 要等本次提交完成才返回，串行时窗口里永远只有自己。第四行验证了另一半：并发一开，&lt;code&gt;sync_mode&lt;/code&gt; 40 条只产生 5 个版本。&lt;/p&gt;
&lt;p&gt;语法上有个坑要标出来：&lt;code&gt;group_commit&lt;/code&gt; 是&lt;strong&gt;会话变量&lt;/strong&gt;，不是表属性——&lt;code&gt;ALTER TABLE ... SET (&amp;quot;group_commit&amp;quot; = &amp;quot;async_mode&amp;quot;)&lt;/code&gt; 会直接报 &lt;code&gt;Unknown table property&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;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 class="c1"&gt;-- 会话级；三种取值：off_mode / sync_mode / async_mode
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="圈选场景该开哪一档"&gt;圈选场景该开哪一档
&lt;/h3&gt;&lt;p&gt;有了上面那组数字，选择就变成业务问题了：&lt;strong&gt;能不能接受写入后延迟可见&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;预估人数、人群包定时重算&lt;/strong&gt;：晚 10 秒知道结果完全可接受，甚至更好（重算期间不该被读到中间状态）。这一类走 &lt;code&gt;async_mode&lt;/code&gt;，把版本压力直接压掉一个数量级。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;投放前最后一次确认、部分列更新后立刻查&lt;/strong&gt;：不可接受。这一类要么保持默认（自己把批量攒大，比如按分片攒到百万级再灌），要么用 &lt;code&gt;sync_mode&lt;/code&gt; &lt;strong&gt;并且真的并发写&lt;/strong&gt;——串行 + &lt;code&gt;sync_mode&lt;/code&gt; 是实测里最坏的组合，既没省版本又赔了时延。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;再往回退一步看这件事：合并能力是有预算的，它吃的是 BE 的 CPU 和磁盘 IO。&lt;strong&gt;提高导入频率，本质是借未来的合并算力换现在的低延迟&lt;/strong&gt;。所以「我们的导入能不能更频繁」这个问题，正确的问法是「我们的合并还追不追得上」——&lt;code&gt;SHOW TABLETS&lt;/code&gt; 里的版本号和 BE 日志里的 compaction 记录，就是它的仪表盘。&lt;/p&gt;
&lt;h2 id="四第三处不匹配人群包形态与集合运算"&gt;四、第三处不匹配：人群包形态与集合运算
&lt;/h2&gt;&lt;p&gt;前三章都在处理「一张表怎么被查、怎么写」。这一章换到业务对象上：&lt;strong&gt;圈选跑出来的结果本身要怎么存&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;CDP 里人群包（audience）在语义上就是一个用户 ID 集合，但仓库里的第一版实现通常是一张明细结果表——&lt;code&gt;pack_id + user_id&lt;/code&gt;，一行一个成员。它最直观：能数、能 JOIN、能分页拉走。等到要做「两个人群包求交」「在包上再叠一个标签条件」「排除风控名单后再并上某个渠道包」这类操作时，才会发现集合运算在关系表上就是一串 JOIN，而每一个 JOIN 都要把成员重新读出来、重新装进哈希表（哈希表是 Java 侧最熟的结构：把一批值按哈希放进桶里，用 O(1) 判断某个值在不在）。&lt;/p&gt;
&lt;p&gt;Doris 给了另一条路：&lt;strong&gt;BITMAP 聚合列&lt;/strong&gt;——把整个成员集合压进一行的一个二进制列里，集合运算退化成位运算。这条路到底省了什么、又在哪里付出代价，只能用同一批人群包在两种形态上跑同一组操作来回答。&lt;/p&gt;
&lt;p&gt;本章的对比在两张表上做，成员列都是 BIGINT：&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;-- 明细结果表：一行一个成员，成员 ID 进排序键
&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;audience_member&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;pack_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;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="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;tenant_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="k"&gt;version&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&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;pack_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;user_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;user_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;8&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;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;-- 位图聚合表：一个包一行，成员压进一个 BITMAP 列
&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;audience_bitmap&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;tenant_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;pack_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;user_bm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BITMAP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BITMAP_UNION&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="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;tenant_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;pack_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;pack_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;4&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;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;第二段是 Doris 聚合模型的写法：列声明时跟在类型后面挂聚合函数，&lt;code&gt;user_bm BITMAP BITMAP_UNION&lt;/code&gt; 表示同一个聚合键下新来的位图和已有的取并。（这里踩过一个语法坑：&lt;code&gt;user_bm BITMAP_UNION&lt;/code&gt;——省掉类型只写聚合函数——在 2.1 直接报语法错，必须写全 &lt;code&gt;BITMAP BITMAP_UNION&lt;/code&gt;。）&lt;/p&gt;
&lt;h3 id="四个包以及它们的真值"&gt;四个包，以及它们的真值
&lt;/h3&gt;&lt;p&gt;四个包都属租户 1（3000 万会员），规模跨两个数量级——这类系统的真实分布就是这样，头部活动圈千万级，长尾条件只圈几万：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;包 ID&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;9001&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;tenant_id=1 AND is_vip=1&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;10,497,584&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;9002&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;tenant_id=1 AND active_30d=1&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;8,401,829&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;9003&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;tenant_id=1 AND level&amp;gt;=4&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;2,097,972&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;9004&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;tenant_id=1 AND city_code=101&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;60,138&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;成员 ID 必须是整型&lt;/strong&gt;。2.1 官方文档的原话是「Doris 使用 RoaringBitmap 实现精确去重」，且只对 &lt;code&gt;TINYINT&lt;/code&gt;/&lt;code&gt;SMALLINT&lt;/code&gt;/&lt;code&gt;INT&lt;/code&gt;/&lt;code&gt;BIGINT&lt;/code&gt; 生效，其他类型「需要构建全局字典」。这里的 &lt;code&gt;user_id&lt;/code&gt; 恰好是 1 到 2 亿的连续整型，所以两张表都能直接用；真实系统的会员标识经常是 openid、手机号这类字符串，这个前提就得单独处理。&lt;/p&gt;
&lt;h3 id="构建贵的是扫描形态只影响内存"&gt;构建：贵的是扫描，形态只影响内存
&lt;/h3&gt;&lt;p&gt;两种形态都用 &lt;code&gt;INSERT INTO ... SELECT&lt;/code&gt; 从画像表生成同一批包，耗时取审计日志 &lt;code&gt;Time(ms)&lt;/code&gt;（热跑，服务端），内存取同一条日志的 &lt;code&gt;PeakMemoryBytes&lt;/code&gt;：&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;10,497,584&lt;/td&gt;
					&lt;td&gt;1816 ms / 643 MB&lt;/td&gt;
					&lt;td&gt;1915 ms / 94.6 MB&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;8,401,829&lt;/td&gt;
					&lt;td&gt;913 ms / 611 MB&lt;/td&gt;
					&lt;td&gt;1280 ms / 85.0 MB&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;2,097,972&lt;/td&gt;
					&lt;td&gt;262 ms / 134 MB&lt;/td&gt;
					&lt;td&gt;333 ms / 57.3 MB&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;60,138&lt;/td&gt;
					&lt;td&gt;92 ms / 6.7 MB&lt;/td&gt;
					&lt;td&gt;102 ms / 6.3 MB&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;位图不但没更快，还慢 5% 到 40%&lt;/strong&gt;。因为构建的成本主体是「扫 2 亿行、命中一千万行」，这一段两种形态完全一样；位图多出来的时间是逐行把整型值塞进压缩容器的开销。所以「换成位图，圈选就快了」在构建这一步不成立——这句话常被写成选型理由，实测不支持。&lt;/p&gt;
&lt;p&gt;不对称的地方在内存：明细侧要把 1050 万行从扫描算子搬到写入算子，峰值 643 MB；位图侧同一个位置只搬一个聚合值，94.6 MB，&lt;strong&gt;差 6.8 倍&lt;/strong&gt;。这笔内存会随导入并发线性叠加（此句是按机制推的，本机没有做并发导入压测），是并发排队时的真实约束。&lt;/p&gt;
&lt;h3 id="存储位图没有比明细省省不省取决于成员-id-在不在排序键里"&gt;存储：位图没有比明细省——省不省取决于成员 ID 在不在排序键里
&lt;/h3&gt;&lt;p&gt;这是本章最反直觉的一个结果，所以做了控制变量的对照。同一批 10,497,584 个成员、单个 tablet，绕开 FE 统计缓存（&lt;code&gt;SHOW DATA&lt;/code&gt; 有约 1 分钟的延迟），直接量 BE 上 tablet 目录的磁盘占用：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;形态（同一批 1050 万成员，1 桶）&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;明细表，成员 ID 在排序键内（自然升序）&lt;/td&gt;
					&lt;td&gt;16,568,475 B = 15.79 MB&lt;/td&gt;
					&lt;td&gt;1.58 B&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;明细表，成员 ID 在排序键内（值被打乱）&lt;/td&gt;
					&lt;td&gt;16,809,834 B = 16.03 MB&lt;/td&gt;
					&lt;td&gt;1.60 B&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;明细表，成员 ID 挪出排序键当值列（打乱）&lt;/td&gt;
					&lt;td&gt;37,401,273 B = 35.67 MB&lt;/td&gt;
					&lt;td&gt;3.56 B&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;位图聚合表&lt;/td&gt;
					&lt;td&gt;21,132,945 B = 20.15 MB&lt;/td&gt;
					&lt;td&gt;2.01 B&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;HLL 聚合表&lt;/td&gt;
					&lt;td&gt;101,704 B = 99.3 KB&lt;/td&gt;
					&lt;td&gt;0.01 B&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;成员 ID 进了排序键的那张明细表，比位图还小 21%&lt;/strong&gt;。要理解这件事得看两边各自在压什么。&lt;/p&gt;
&lt;p&gt;位图这边，Doris 的 &lt;code&gt;BITMAP&lt;/code&gt; 列内部存的是 Roaring bitmap。它的通用做法（Roaring 自己的公开设计，不是 Doris 特有）是：把整数按每 65536 个值切成一块，块内值少就用「数组容器」逐个存 2 字节，值多就改用「位图容器」——固定 8 KB、一位代表一个值，切换阈值是 4096。这批包在 2 亿值域上的密度只有 5%，2 亿 / 65536 ≈ 3052 个块，每块摊到约 3440 个成员，正好留在数组容器区间。按这个规则估是 2.0 字节/成员，实测 2.01 字节。&lt;strong&gt;换句话说，这份位图完全没有「压」，它就是把每个成员存成 2 个字节的数组&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;明细表这边，上一章讲过存储层级：同一个 tablet 内数据按排序键有序落盘，官方文档对明细模型的表述是「建表时指定的 Duplicate Key 决定了排序与存储的列」。排好序的整型列相邻差值很小，delta 编码 + bit-packing 能把一个 int64 压到 1~2 字节——实测 1.58 字节/行。&lt;strong&gt;同样是 1050 万个整型 ID，排好序的明细表和数组容器状态的位图，压缩率在同一个量级；前者还略优&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;「排序键里放不放」才是那个变量，这一点单独做了对照实验来证明。把成员 ID 用一个同余映射打乱：&lt;code&gt;(user_id * 7919) % 200000003&lt;/code&gt;（&lt;code&gt;7919&lt;/code&gt; 与 &lt;code&gt;200000003&lt;/code&gt; 互质，且所有 &lt;code&gt;user_id&lt;/code&gt; 都小于这个模数，所以映射在取值域上是单射，成员数不会因碰撞而变——实测打乱后仍是 10,497,584 个不同值）。然后写进两张表：一张仍让 &lt;code&gt;user_id&lt;/code&gt; 参与排序键，另一张把排序键换成一个常量列、让 &lt;code&gt;user_id&lt;/code&gt; 退成普通值列（于是落盘顺序 = 插入顺序，也就是打乱后的顺序）。&lt;/p&gt;
&lt;p&gt;结果是：&lt;strong&gt;同一样一批值，放进排序键 16.03 MB，挪出来 35.67 MB，差 2.2 倍&lt;/strong&gt;。而打乱输入顺序对「放进排序键」那一版几乎没有影响（15.79 → 16.03 MB），因为键内序列一定会被排好——决定体积的是排序这件事本身，不是值长什么样。&lt;/p&gt;
&lt;p&gt;这条不只对人群包表有用：&lt;strong&gt;它解释了「为什么把高频出现在 &lt;code&gt;WHERE&lt;/code&gt; 里的整型列放进排序键，连存储都一起省了」&lt;/strong&gt;——排序键同时买到的是前缀索引 + 有序落盘带来的编码收益。&lt;/p&gt;
&lt;p&gt;两个规模端的补充：60,138 人的小包，明细 149,121 B、位图 145,398 B，打平；四个包合到一张表的整表口径，明细 &lt;code&gt;SHOW DATA&lt;/code&gt; 40.015 MB、位图 40.357 MB，也一样。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果只要「人数」不要「成员」，还有第三档：HLL&lt;/strong&gt;（HyperLogLog，一种只保存基数草图的类型，不管来多少个值，占用的空间基本不变；对应聚合函数 &lt;code&gt;HLL_UNION&lt;/code&gt;）。同一个 1050 万成员的包，HLL 列实测 99.3 KB，&lt;strong&gt;比位图小 208 倍&lt;/strong&gt;，估出来的人数是 10,535,810、误差 &lt;strong&gt;+0.364%&lt;/strong&gt;。千分之一的空间换 0.4% 的人数误差，只用于看板上的规模趋势时它比位图更划算；代价是它只能求并——交集和差集都算不了。&lt;/p&gt;
&lt;h3 id="集合运算这才是真正的分水岭"&gt;集合运算：这才是真正的分水岭
&lt;/h3&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;/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;A ∩ B&lt;/td&gt;
					&lt;td&gt;2,941,079&lt;/td&gt;
					&lt;td&gt;493 ms / 189.9 MB&lt;/td&gt;
					&lt;td&gt;273 ms / 0.74 MB&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;A ∪ B&lt;/td&gt;
					&lt;td&gt;15,958,334&lt;/td&gt;
					&lt;td&gt;1400 ms / 1.39 GB&lt;/td&gt;
					&lt;td&gt;219 ms / 0.12 MB&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;A − B&lt;/td&gt;
					&lt;td&gt;7,556,505&lt;/td&gt;
					&lt;td&gt;716 ms / 259.9 MB&lt;/td&gt;
					&lt;td&gt;208 ms / 0.11 MB&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;(A ∪ B) ∩ C − D&lt;/td&gt;
					&lt;td&gt;1,113,829&lt;/td&gt;
					&lt;td&gt;1874 ms / 0.96 GB&lt;/td&gt;
					&lt;td&gt;309 ms / 0.41 MB&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;耗时差 1.8 到 6.4 倍，&lt;strong&gt;峰值内存差 256 倍到 1.2 万倍&lt;/strong&gt;——内存才是这一节的分水岭。&lt;/p&gt;
&lt;p&gt;机制上没有悬念。明细形态做集合运算，要把两侧成员读出来、装进哈希表——&lt;code&gt;count(DISTINCT user_id)&lt;/code&gt; 求并那行为 1596 万个不同值各维护一个条目，1.39 GB 就是这么来的，而且中间结果仍然是行。位图形态两侧各是一个二进制容器，&lt;code&gt;bitmap_and&lt;/code&gt; / &lt;code&gt;bitmap_or&lt;/code&gt; / &lt;code&gt;bitmap_andnot&lt;/code&gt; 在容器上逐段做与或非，中间结果还是位图，&lt;strong&gt;整个查询从头到尾只有一行数据在算子之间流动&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;img alt="明细形态把两侧成员装进哈希表，位图形态只在容器上逐段运算" class="gallery-image" data-flex-basis="332px" data-flex-grow="138" decoding="async" height="832" 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-c07-doris-practice-in-cdp-form-execution-diff.svg" width="1153"&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;bitmap_andnot_count&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;bitmap_and&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;bitmap_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bm&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- A ∪ B
&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bm&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- ∩ C
&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;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- − D
&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="p"&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;user_bm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bm&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;audience_bitmap&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;pack_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;9001&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&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="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;user_bm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bm&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;audience_bitmap&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;pack_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;9002&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;b&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="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;user_bm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bm&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;audience_bitmap&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;pack_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;9003&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&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="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;user_bm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bm&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;audience_bitmap&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;pack_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;9004&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;d&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;明细侧的等价写法是三段 UNION + JOIN + 反连接嵌套，这里不贴——它长不是问题，问题是每多一层就把成员多搬一遍。真实业务里的「排除已购买人群、排除风控名单、再和某个渠道包求交」就是这个深度。&lt;/p&gt;
&lt;p&gt;顺带一个写法层面的实测：同样是 A − B，&lt;code&gt;LEFT JOIN ... IS NULL&lt;/code&gt; 要 716 ms，&lt;code&gt;NOT IN&lt;/code&gt; 子查询 233 ms，差 3.1 倍，内存都是 259.9 MB。差异在中间结果——前者先产出 1050 万行带 NULL 列的宽结果再过滤，后者直接在反连接里计数。用 &lt;code&gt;NOT IN&lt;/code&gt; 的前提是被比较列非空（这里 &lt;code&gt;user_id&lt;/code&gt; 是 NOT NULL）；换成可空列，三值逻辑会让结果整个变空，这个坑在 Doris 上和 MySQL 完全一样。&lt;/p&gt;
&lt;h3 id="回表位图不吃亏"&gt;回表：位图不吃亏
&lt;/h3&gt;&lt;p&gt;集合运算之外，人群包最高频的用法是「拿包去画像表取字段」——投放名单要带渠道、要带最近下单时间。同一个问题（pack 9003 里有多少 VIP，真值 733,742）三种写法：&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;明细包表 JOIN 画像表&lt;/td&gt;
					&lt;td&gt;691 ms&lt;/td&gt;
					&lt;td&gt;243.1 MB&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;user_id IN (子查询)&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;914 ms&lt;/td&gt;
					&lt;td&gt;243.1 MB&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;位图展开成行后 JOIN 画像表&lt;/td&gt;
					&lt;td&gt;432 ms&lt;/td&gt;
					&lt;td&gt;72.2 MB&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;位图这一路把整包先还原成数组再炸开行（&lt;code&gt;LATERAL VIEW explode(bitmap_to_array(...))&lt;/code&gt;，2.1 的 Nereids 优化器不吃 &lt;code&gt;UNNEST(...)&lt;/code&gt; 那套 ANSI 写法，实测会报语法错，只有 Hive 风格的 &lt;code&gt;LATERAL VIEW explode&lt;/code&gt; 能用），省掉了「从一张 210 万行的表里把 ID 一行行解码出来」的开销。所以「位图查详情一定要付额外代价」这个直觉，在本机数据里不成立——它反而是三者最省的。&lt;/p&gt;
&lt;h3 id="导出唯一稳定吃亏的地方"&gt;导出：唯一稳定吃亏的地方
&lt;/h3&gt;&lt;p&gt;要真正把成员一行一行交出去时，位图开始付出代价。同一份 1050 万成员写进同一张落地表：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;导出路径（同一目标表、同一 1050 万行）&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;1084 ms&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;从位图展开后读&lt;/td&gt;
					&lt;td&gt;1615 ms&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;多出的 49% 就是「反序列化位图 → 构造 ARRAY&amp;lt;BIGINT&amp;gt; → 再炸开」这条链；单独量这一步，把整包 1050 万个成员炸成行要 643 ms、288 MB。方向是固定的：&lt;strong&gt;位图形态在导出上不会比明细便宜，而且规模越大越亏&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;分批游标导出更明显。明细表按 &lt;code&gt;(pack_id, user_id)&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;SELECT&lt;/span&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="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;audience_member&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;pack_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;9001&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;user_id&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;上一页末值&lt;/span&gt;&lt;span class="o"&gt;&amp;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;ORDER&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;user_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LIMIT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 实测 267 ms，取回 10 万行
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;位图侧只有区间能力，没有「按顺序取第 N 到 M 个」的稳定分页语义。&lt;code&gt;bitmap_subset_in_range&lt;/code&gt; 可以做区间计数——实测 &lt;code&gt;[50000000, 60000000)&lt;/code&gt; 得 524,605，与明细表上等价 &lt;code&gt;WHERE&lt;/code&gt; 的结果一致；边界口径是小位图单独验过的：{1,2,3,4,5} 上 &lt;code&gt;[2,4)&lt;/code&gt; 数出 2 个，即&lt;strong&gt;左闭右开&lt;/strong&gt;。真要交明细，还是得整包炸开再自己分页，那比在明细表上翻页贵得多。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;导出是明细形态的主场&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id="两个会静默出错的坑"&gt;两个会静默出错的坑
&lt;/h3&gt;&lt;p&gt;&lt;strong&gt;坑一：字符串 ID 进位图不报错，给你一个空集合&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;CDP 的成员标识经常是 openid、手机号、雪花 ID 字符串，而 &lt;code&gt;to_bitmap()&lt;/code&gt; 只接受整型。传字符串时 Doris 做隐式转换，转不动就返回空位图，&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;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bitmap_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_bitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;P3&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 0
&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;bitmap_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_bitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;123&amp;#39;&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;（第一条是在一个 &lt;code&gt;pref_category&lt;/code&gt; 列取值为 &lt;code&gt;t_11&lt;/code&gt;、&lt;code&gt;t_5&lt;/code&gt; 这类编码的表上验证的：整列 &lt;code&gt;to_bitmap&lt;/code&gt; 之后 &lt;code&gt;bitmap_count&lt;/code&gt; 全是 0。）&lt;/p&gt;
&lt;p&gt;于是整条圈选链路上你会拿到一个「人人都是 0」的人群包，作业状态 &lt;code&gt;OK&lt;/code&gt;。两条出路：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;按官方口径建那张「全局字典」，把字符串 ID 映射成稠密自增整型。这是位图方案真实的前置成本——字典表要维护增量、要能重放，本文的 &lt;code&gt;user_id&lt;/code&gt; 恰好已经是 1~2 亿的连续整型，所以省掉了这一步。&lt;/li&gt;
&lt;li&gt;用 &lt;code&gt;bitmap_hash64()&lt;/code&gt; 把字符串直接哈希成 64 位。实测同一个 60,138 成员的集合，&lt;code&gt;bitmap_union_count(bitmap_hash64(CAST(user_id AS STRING)))&lt;/code&gt; 和 &lt;code&gt;bitmap_union_count(to_bitmap(user_id))&lt;/code&gt; 都数出 60,138，64 位哈希在这个量级没有可感知碰撞。代价是哈希值不可逆，导出明细时必须回到原 ID，等于又得留一张映射表。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;坑二：重算人群包时不删旧位图，人数静默虚高 7 倍&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;BITMAP_UNION&lt;/code&gt; 是聚合函数，只会把新位图并进旧的，不会剔除任何成员。让 pack 9003 重算一次——条件从 &lt;code&gt;level&amp;gt;=4&lt;/code&gt; 收紧到 &lt;code&gt;level&amp;gt;=5&lt;/code&gt;，真值从 2,097,972 降到 298,562：&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;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;audience_bitmap&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;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9003&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;to_bitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_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="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;member_profile&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;tenant_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;1&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="k"&gt;level&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="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 269 ms，State=OK
&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="n"&gt;bitmap_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_bm&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;audience_bitmap&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;pack_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;9003&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;-- 2097972
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;因为新集合恰好是旧集合的子集，并完之后就是旧集合本身：&lt;strong&gt;报告人数 2,097,972，是真值的 7.03 倍，全链路没有一处错误&lt;/strong&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;DELETE&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;audience_bitmap&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;pack_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;9003&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 118 ms
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- 再灌新结果 205 ms
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;删完重灌后 &lt;code&gt;bitmap_count&lt;/code&gt; 回到 298,562，与真值一致。明细侧同一次重算是 DELETE 46 ms + INSERT 241 ms。&lt;strong&gt;两侧总耗时几乎一样（323 ms vs 287 ms），差别在漏掉那一步的后果&lt;/strong&gt;：位图侧漏 DELETE 是静默错数，明细侧漏 DELETE 会立刻以重复行的形式暴露出来。所以定时重算的调度里，删除和写入必须绑成同一个作业的两步，而不是只挂一个「跑圈选 SQL」。&lt;/p&gt;
&lt;p&gt;第三章把版本与合并的账算过，这里补一笔删除的账：&lt;code&gt;DELETE&lt;/code&gt; 只写删除标记，不回收物理空间。用 &lt;code&gt;pack_dump&lt;/code&gt; 这张 1050 万行的表做了一次干净对照——删掉一半（&lt;code&gt;WHERE user_id &amp;lt; 100000000&lt;/code&gt;，40 ms），可见行数从 10,497,584 变成 5,249,303，BE 上这个 tablet 的磁盘占用是 &lt;strong&gt;18.84 MB → 18.84 MB，一字节没少&lt;/strong&gt;。物理回收要等合并把带标记的行真正丢掉，本文的观察窗口内没有发生。所以「每天重算一次人群包」对存储的影响是累加的：白天的删除标记会一直占着空间，直到合并追上。这件事在做磁盘容量预算时得单独算一行。&lt;/p&gt;
&lt;h3 id="落地判据谁负责算谁负责交"&gt;落地判据：谁负责算，谁负责交
&lt;/h3&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;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;包成员 ID 是不是整型（或愿意为它建字典表）&lt;/td&gt;
					&lt;td&gt;不是整型且不想建映射：&lt;code&gt;bitmap_hash64&lt;/code&gt; 或直接退回明细表&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;这批包的典型操作是计数与集合运算，还是导出明细&lt;/td&gt;
					&lt;td&gt;计数/交并差为主：位图，最多省 1.2 万倍内存；导出为主：明细表，省 49%&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;有没有「放排序键」的位置可给成员 ID&lt;/td&gt;
					&lt;td&gt;有：明细表存成员本身不比位图贵（实测 1.58 vs 2.01 字节/成员）；没有：位图反超 1.8 倍&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;人数能容忍千分之几误差吗&lt;/td&gt;
					&lt;td&gt;能（只做趋势看板）：HLL，比位图小 208 倍，但只能求并&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;我的落地方案是两者并存、各管一段：位图表负责「算」——预估、交并差、多层组合；明细表负责「交」——投放名单导出、快照版本留痕。两张表从同一条圈选 SQL 生成，构建耗时几乎相同（1816 ms vs 1915 ms），多存一份的收益远大于省一份的开销。真正被实测推翻的，是「用位图替掉明细表能省存储、还能提速」这个开工前的预期：&lt;strong&gt;省的是集合运算的内存，不是存储、也不是构建时间&lt;/strong&gt;。&lt;/p&gt;
&lt;h2 id="五把三处不匹配收成一张表"&gt;五、把三处不匹配收成一张表
&lt;/h2&gt;&lt;p&gt;前四章是分开量下来的，但它们其实是同一件事的三个投影：&lt;strong&gt;Doris 的每一项定位能力，都建立在「事先挑出少数几列、由这几列决定数据物理顺序」之上&lt;/strong&gt;。而圈选这类负载的形状是「任意列组合 × 大值域 × 集合运算」，正好和这个前提错开。三处不匹配分别落在谓词、写入节奏、结果形态上，每一处的解法都不是调参数，而是换一个更能接住这个动作的形态。&lt;/p&gt;
&lt;p&gt;&lt;img alt="写入侧 → 存储侧 → 读取侧 → 结果侧一条链，三处错位标在各自的接缝上" class="gallery-image" data-flex-basis="157px" data-flex-grow="65" decoding="async" height="1016" 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-c07-doris-practice-in-cdp-three-mismatches.svg" width="665"&gt;&lt;/p&gt;
&lt;h3 id="落地判断表"&gt;落地判断表
&lt;/h3&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;/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;主键比明细多 11% 存储（7.746 vs 6.886 GB）；换来部分列更新 27.31 s，替掉 3 分 02 秒的全量重灌&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;strong&gt;一定&lt;/strong&gt;出现在 &lt;code&gt;WHERE&lt;/code&gt; 前面；有没有主键访问路径要保&lt;/td&gt;
					&lt;td&gt;每个位置都是排他资源：放了标签就挤掉 &lt;code&gt;user_id&lt;/code&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;唯一分布均匀的列；tablet 目标大小 1G–10G&lt;/td&gt;
					&lt;td&gt;分桶列放 &lt;code&gt;user_id&lt;/code&gt; 服务均衡，圈选时一个桶都裁不掉（&lt;code&gt;tablets=32/32&lt;/code&gt;）；桶切碎多付 2% 存储&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;strong&gt;进条件频率 × 单值命中占比&lt;/strong&gt;两个维度；取值在页间有无偏斜&lt;/td&gt;
					&lt;td&gt;每列一次 &lt;code&gt;BUILD INDEX&lt;/code&gt; 约 13 分钟、约 412 MB；无偏斜列上 bloom filter 白付 131 MB&lt;/td&gt;
					&lt;td&gt;二&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;导入批大小与提交方式&lt;/td&gt;
					&lt;td&gt;同一 tablet 上的&lt;strong&gt;并发&lt;/strong&gt;导入数；版本增速 vs 合并追得上的速度&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;async_mode&lt;/code&gt; 省 30 倍版本但延迟 12 秒可见；串行 + &lt;code&gt;sync_mode&lt;/code&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;位图集合运算最多省 1.2 万倍内存，但导出多付 49%、构建不省、重算漏删静默虚高 7 倍&lt;/td&gt;
					&lt;td&gt;四&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这张表里唯一跨行通用的判据是「命中占比」那一列。它同时决定了索引值不值得建（第二章：0.2% 的列省 499 倍，35% 的列一分不省）、决定了位图值不值（第四章：密度 5% 时它退化成 2 字节/值的数组容器，压不出东西），也决定了明细表划算不划算（同一个 ID 放进排序键 1.58 字节/行，挪出来 3.56 字节/行）。&lt;strong&gt;先看数据的分布，再挑引擎的特性&lt;/strong&gt;，顺序反过来就是白付。&lt;/p&gt;
&lt;h3 id="变慢之后的提问顺序"&gt;变慢之后的提问顺序
&lt;/h3&gt;&lt;p&gt;圈选查询慢下来时，按这个顺序问，每一步都有确定的取证动作，不要从「加索引」开始猜。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一步：多读了什么&lt;/strong&gt;。先拿扫描量，别拿耗时——同一台机器上同一条查询冷跑 309 ms、热跑 33 ms，耗时不能当证据。扫描量在 query profile 里，2.1 的取数路径三步（&lt;code&gt;SHOW PROFILELIST&lt;/code&gt; 在 2.1.11 直接报语法错，走 HTTP 接口）：&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;enable_profile&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;true&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;-- 跑一次那条圈选，然后从 FE 审计日志里拿它的 QueryId
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&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;tail -5 /opt/apache-doris/fe/log/fe.audit.log &lt;span class="p"&gt;|&lt;/span&gt; grep -o &lt;span class="s1"&gt;&amp;#39;QueryId=[a-f0-9-]*&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -s -u root: &lt;span class="s2"&gt;&amp;#34;http://127.0.0.1:8030/api/profile?query_id=&amp;lt;QueryId&amp;gt;&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; &lt;span class="p"&gt;|&lt;/span&gt; grep -E &lt;span class="s1"&gt;&amp;#39;RawRowsRead|RowsRead&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;两个数对照着看：&lt;code&gt;RawRowsRead&lt;/code&gt; 是从存储层真实读出的行数，&lt;code&gt;RowsRead&lt;/code&gt; 是过滤后交给上层的行数。两者的比值就是本章反复出现的那个「放大倍数」，第二章那条 499× 就是这么量出来的。审计日志里那个 &lt;code&gt;ScanRows&lt;/code&gt; 别拿来算放大——第二章那条「读 3000 万、留 182.6 万」的组合圈选，审计里 &lt;code&gt;ScanRows&lt;/code&gt; 报的是 &lt;strong&gt;1,825,511&lt;/strong&gt;，和 &lt;code&gt;RowsRead&lt;/code&gt; 几乎相等。它统计的是过滤后交给上层的行数，不是从存储层真实读出的行数；照它算，任何查询都不存在读放大。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二步：多算了什么&lt;/strong&gt;。比值正常（1~3 倍）但耗时高、内存高，问题在算子层。峰值内存看审计日志的 &lt;code&gt;PeakMemoryBytes&lt;/code&gt;：第四章的 &lt;code&gt;count(DISTINCT user_id)&lt;/code&gt; 求并吃了 1.39 GB，同一结果用位图只要 0.12 MB。&lt;strong&gt;OLAP 查询的内存几乎总是「把 N 万个值装进哈希表」这件事&lt;/strong&gt;，Java 侧的直觉在这里完全适用：先看聚合与连接的基数，再看扫描。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第三步：计划为什么这么选&lt;/strong&gt;。前两步定位到列或算子之后，用 &lt;code&gt;EXPLAIN&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;EXPLAIN SELECT count(*) FROM member_profile
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;WHERE tenant_id = 1 AND city_code = 101;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; PREDICATES: ((tenant_id[#0] = 1) AND (city_code[#2] = 101))
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; AND (__DORIS_DELETE_SIGN__[#42] = 0)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; partitions=1/1 (member_profile)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; tablets=32/32, tabletList=14134,14136,14138 ... -- 一个桶都没裁掉
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; cardinality=200000015
&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;EXPLAIN SELECT level FROM member_profile
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;WHERE tenant_id = 1 AND user_id = 12345678;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; tablets=1/32 -- 分桶列进条件了，裁到 1 个桶
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;PREDICATES&lt;/code&gt; 那几行告诉你谓词被推到了扫描节点上（以及主键表必然带的 &lt;code&gt;__DORIS_DELETE_SIGN__&lt;/code&gt; 过滤），&lt;code&gt;partitions&lt;/code&gt; 与 &lt;code&gt;tablets&lt;/code&gt; 的 &lt;code&gt;已选/总数&lt;/code&gt; 告诉你裁剪有没有发生，&lt;code&gt;cardinality&lt;/code&gt; 是优化器以为自己扫多少。&lt;strong&gt;这三个数没有任何一个能从耗时里看出来&lt;/strong&gt;，而本章的每一次「明明加了却没生效」都是在这里被抓到的：bloom filter 加了、&lt;code&gt;RawRowsRead&lt;/code&gt; 一行没少；倒排索引建了、命中面一放大又回到全读。&lt;strong&gt;生效与否只能用扫描量和计划文本证明，不能由「DDL 返回 OK」证明&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;写入侧变慢是另一条链，顺序也一样：&lt;code&gt;SHOW TABLETS FROM 表&lt;/code&gt; 看每个 tablet 的 &lt;code&gt;Version&lt;/code&gt; 与 &lt;code&gt;VersionCount&lt;/code&gt; → BE 日志搜 &lt;code&gt;compaction&lt;/code&gt; 与该 tablet id，确认合并追不追得上 → 导入返回 JSON 里的时间分解确认卡在哪一段——本机一次真实导入的返回给了这几个字段：&lt;code&gt;BeginTxnTimeMs&lt;/code&gt; 41、&lt;code&gt;StreamLoadPutTimeMs&lt;/code&gt; 10（FE 建导入计划）、&lt;code&gt;ReceiveDataTimeMs&lt;/code&gt; 54、&lt;code&gt;WriteDataTimeMs&lt;/code&gt; 26、&lt;code&gt;CommitAndPublishTimeMs&lt;/code&gt; 23，总计 &lt;code&gt;LoadTimeMs&lt;/code&gt; 103。&lt;/p&gt;
&lt;p&gt;本章那个 &lt;code&gt;max_tablet_version_num = 2000&lt;/code&gt; 的坑就是在这条链上发现的：&lt;strong&gt;阈值判的是未合并的 rowset 数，不是版本号&lt;/strong&gt;，所以「灌 500 次一定撞 -235」这类推演不成立，别把它写进容量文档。&lt;/p&gt;
&lt;h3 id="三件可以直接带走的事"&gt;三件可以直接带走的事
&lt;/h3&gt;&lt;p&gt;第一，&lt;strong&gt;口径先于数字&lt;/strong&gt;。这篇文章里所有耗时都取 FE 审计日志的 &lt;code&gt;Time(ms)&lt;/code&gt;、所有扫描量都取 query profile、所有存储都在 BE 上直接量目录——因为这三类数字在 Doris 里各有两三个看似可用的来源，而它们互相矛盾（&lt;code&gt;SHOW DATA&lt;/code&gt; 有分钟级延迟、审计 &lt;code&gt;ScanRows&lt;/code&gt; 不可信、shell 墙钟被建连开销吃掉 40 ms）。没有固定口径，前后两次测量就没有可比性，调优会变成随机漫步。&lt;/p&gt;
&lt;p&gt;第二，&lt;strong&gt;任何「加了 X 就会更快」的判断，都要在同一份数据上跑一遍对照&lt;/strong&gt;。本文写到这里，被实测推翻的直觉有四个：位图省存储（不省，明细放排序键更省）、位图让圈选更快（构建慢 5%~40%，省的是集合运算的内存）、bloom filter 适用于高基数等值列（无偏斜时一行都跳不掉）、桶切得多并行更好（tablet 碎到 248 MB，官方建议区间是 1G–10G）。四个都来自「合理但错」的通用经验，共同点是&lt;strong&gt;它们跳过了数据的实际分布&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;第三，&lt;strong&gt;形态选择比参数调整更值钱&lt;/strong&gt;。这台机器上没有一个数字是靠调 BE/FE 参数得到的：换模型省了全量重灌、换排序键位置换来了游标翻页、换索引形态把 499 倍降到 1 倍、换人群包形态把 1.39 GB 降到 0.12 MB。参数当然有用，但圈选场景里最大的一笔收益始终在「这一份数据以什么形状落在磁盘上」——这也正是上一章那些机制（有序、版本、列编码、分桶）在使用侧的兑付方式。&lt;/p&gt;</description></item><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>