Day 20|写入、更新与 Compaction:事务、MemTable、Delete Bitmap、MoW 与合并策略

前面三天依次进入 SQL 生命周期、Nereids 优化器和分布式执行引擎,视角一直停留在查询链路。今天把方向转向写入侧,追踪一批数据从客户端发出,到它成为可查询版本,再到旧版本被后台合并和回收的全过程。
Doris 的写入系统长期维持三份合同:
- 事务合同:一批数据何时提交、何时可见、失败后怎样回滚;
- 更新合同:相同 Key 如何裁决,旧行怎样被 Delete Bitmap 屏蔽,乱序事件怎样处理;
- 健康合同:持续写入产生的 Rowset、Segment、索引与删除信息怎样通过 Compaction 维持可读性和空间效率。
写入吞吐只描述请求进入系统的速度。生产集群还需要同时观察版本增长、主键查找、历史行补齐、后台 I/O、Compaction 队列、查询尾延迟和磁盘水位。某条链路在压测十分钟时表现顺畅,连续运行数周后可能出现版本堆积;某种更新方式节省了上游拼宽表的工作量,也可能把更多随机读和写放大留给 BE。
完成今天的学习后,你应当能够:
- 画出
Begin → Write → Commit → PublishVersion → Visible的完整时序; - 解释 MemTable、Segment、Rowset 和 Tablet Version 的关系;
- 说明 Unique Key MoW 如何借助主键索引和 Delete Bitmap 保持查询侧简单;
- 判断全行 Upsert、固定列部分更新、灵活列更新、Sequence 和 Sequence Mapping 的适用条件;
- 区分 Segment Compaction、Vertical Compaction、Size-Based Compaction 与 Time-Series Compaction;
- 通过 Rowset 数、版本数、Compaction Score、BE I/O 和导入批次定位写入堆积;
- 输出一份《写入放大与 Compaction 健康度报告》。
零、先校准六个 4.x 口径

原培训材料第 130、138–140、165–172、200 页已经覆盖 Group Commit、更新、删除、事务、Sequence 和 Compaction 堆积,教学主线完整。当前课程按照 Doris 4.x 官方资料补充以下更新。
0.1 Unique Key 默认使用 Merge-on-Write
MoW 在 1.2 进入系统,从 2.1 起成为 Unique Key 的默认实现。写入相同 Key 时,BE 查找当前行位置,在旧 Rowset 的 Delete Bitmap 中标记旧 RowID,再把新行写入新 Rowset。查询读取可见 Rowset 时跳过被标记的旧行,无需执行逐 Key 的读时版本合并。[1]
0.2 部分列更新在 MoW 中会补齐完整行
旧材料将部分更新概括为只写 Key 和变化列,读取时补齐。当前 Unique Key MoW 的核心路径位于写入阶段:系统查找旧行、读取缺失字段、补齐完整行,然后写入新 Rowset。这样可以换取稳定的查询性能,代价是更新阶段产生额外随机读、内存和写放大。启用 Row Store 可降低宽表补列所需的 IOPS。[2]
0.3 Flexible Partial Update 从 3.1.0 起支持
普通部分更新要求同一批任务中的每一行更新相同字段集合。Flexible Partial Update 允许一批 JSON 中每一行携带不同字段,通过 enable_unique_key_skip_bitmap_column=true 和 unique_key_update_mode:UPDATE_FLEXIBLE_COLUMNS 启用。该模式与 Group Commit、Sequence Column、同步物化视图和 VARIANT 等能力存在组合限制,生产前应逐项核验。[2]
0.4 Group Commit 当前默认阈值是 10 秒或 128 MB
Group Commit 在 BE 按表维护共享队列,将大量微批合并成一个事务。sync_mode 等待合并事务可见后返回;async_mode 先把数据写入接收 BE 的本地 WAL,返回 PREPARE,随后完成合并提交。当前表属性默认阈值为 group_commit_interval_ms=10000 和 group_commit_data_bytes=134217728。[3]
0.5 当前表级 Compaction 策略有 size_based 与 time_series
size_based 是默认策略,适合大多数业务;time_series 面向日志与时序连续写入,利用时间局部性控制重复合并和写放大。Cumulative 与 Base 仍是 Size-Based Compaction 中的重要阶段;Segment Compaction 发生在大批导入期间;Vertical Compaction 描述按列组执行的资源优化方式。[4]
0.6 单副本 Compaction 已在 4.1.2 移除
单副本 Compaction 曾试图让一个 Replica 完成合并,其余 Replica 拉取结果,以节省 CPU。该机制的 Peer 选择存在数据正确性风险,4.1.2 已移除。课程和生产模板都不再保留 enable_single_replica_compaction 方案。[5]
一、完整写路径:一次写入怎样成为可查询版本

以常规 Stream Load 为例,一批数据大致经历以下路径:
客户端请求
→ FE Begin Transaction
→ FE 生成导入计划
→ Coordinator BE 接收和解析数据
→ 按 Partition / Tablet / Replica 路由
→ Executor BE 的 LoadChannel / Tablet Writer
→ MemTable
→ Flush 为 Segment
→ 形成待提交 Rowset
→ FE Commit Transaction
→ BE PublishVersion
→ Transaction 进入 VISIBLE
1.1 Begin:Label 与事务身份
Coordinator BE 在写入前向 FE 发起 Begin Transaction。FE 检查 Label 是否已经存在,分配 Transaction ID,并将事务置于 PREPARE。Label 负责识别重试请求,同一个生产任务应使用稳定且唯一的 Label。网络超时后直接换一个 Label 重试,可能造成重复写入;保留原 Label 才能让系统识别上一轮提交状态。
1.2 Plan:决定数据写到哪些 Tablet
FE 根据目标表 Schema、分区、分桶、副本和权限生成导入计划。Coordinator BE 解析 CSV、JSON、Parquet 或 ORC,把每行数据映射到目标列,再依据 Partition Key 和 Distribution Key 确定 Tablet。目标 Tablet 的 Replica 分布已经由 FE 元数据记录,数据随后通过 NodeChannel 发往相应 Executor BE。
1.3 Write:每个 BE 独立维护 Tablet Writer
Executor BE 为本次 Load 建立写入通道,并按 Tablet 维护 MemTable。每条输入记录先完成类型转换、默认值处理、数据模型语义和排序组织,再进入内存结构。Unique Key MoW 还会执行主键查找、Sequence 裁决和 Delete Bitmap 计算。
1.4 Flush:不可变 Segment 与待提交 Rowset
MemTable 达到刷新条件、导入内存压力触发提前释放,或整个批次进入收尾阶段时,BE 将数据 Flush 为 Segment。多个 Segment 构成一个 Rowset。此时文件已经落盘,事务仍处在提交前状态,普通查询看不到这份 Rowset。
1.5 Commit 与 PublishVersion 是两个边界
当各 Executor BE 完成写入,Coordinator BE 请求 FE Commit。FE 检查每个 Tablet 的成功 Replica 数是否达到多数派要求;Commit 成功后事务进入 COMMITTED。随后 FE 向相关 BE 下发 PublishVersion,BE 把待提交 Rowset 发布为 Tablet 的下一可见版本。全部条件满足后,事务进入 VISIBLE,后续语句可以读取该版本。[6]
这两个状态需要分清:
- COMMITTED:数据写入达到提交条件,尚未对普通查询开放;
- VISIBLE:PublishVersion 完成,Rowset 已纳入可见版本路径;
- ABORTED:写入失败或显式回滚,临时 Rowset 后续清理;
- PREPARE:事务已建立,写入或外部 2PC 仍在进行。
二、MemTable、Flush 与 Segment:小文件从哪里产生

2.1 MemTable 按 Tablet 和数据模型组织数据
一项 Load 往往同时命中多个 Tablet,每个 Tablet 都会形成自己的内存写入状态。假设一批 1 GB 数据均匀写入 32 个 Tablet,平均每个 Tablet 只获得约 32 MB;如果列很宽、索引多、数据倾斜明显或内存压力高,MemTable 还可能提前 Flush。由此可见,客户端批次大小与单 Tablet 实际数据量之间存在较大差距。
影响 Flush 频率的主要因素包括:
- 批次总量和单行宽度;
- 命中的 Partition 与 Tablet 数;
- Duplicate、Aggregate、Unique 三种模型的处理成本;
- 倒排索引、BloomFilter、Row Store 等附加结构;
- Load 内存限制和 BE 全局内存压力;
write_buffer_size等目标版本配置;- 导入结束时的强制收尾。
2.2 一个大请求也可能生成多个 Segment
Rowset 与 Segment 的数量关系通常是一对多。一次事务在一个 Tablet 上通常形成一个 Rowset,该 Rowset 可以包含多个 Segment。单次大导入、宽表、低基数聚合、内存提前 Flush 都可能增加 Segment 数。Segment Compaction 会在 Load 过程中合并候选 Segment,降低 OLAP_ERR_TOO_MANY_SEGMENTS(常见错误码 -238)的风险。[4]
2.3 高频微批会在两个层面放大
微批写入同时增加:
- 事务和版本开销:每批都要 Begin、写入、Commit、Publish;
- 文件和合并开销:每批在命中的 Tablet 上产生新 Rowset,后台 Compaction 需要反复选择、读取、合并和替换。
当写入速率长期高于 Compaction 消化速率,Rowset 数、版本数和 Compaction Score 会持续上升。查询侧需要打开更多文件和索引,BE 的元数据、Page Cache 和磁盘随机读压力同步增长。
2.4 写入内存控制决定 Flush 的形态
BE 的 Load 内存有任务级和进程级约束。一个批次在多个 Tablet、多个索引和多个 Replica 之间展开后,内存占用可能远高于输入文件的瞬时切片。系统在接近软限制时会选择较大的 MemTable 提前 Flush,以释放内存并保证其他任务继续运行。提前 Flush 可以保护稳定性,同时会增加 Segment 数和后续合并工作。
排查写入内存时,建议同时记录:
输入字节数
× 目标索引数量
× Replica 写入路径
+ MemTable 排序状态
+ 主键索引与 Delete Bitmap
+ JSON / 字符串解析临时对象
单纯调大 write_buffer_size 可能减少文件数,也可能提高单任务峰值内存、延长 Flush 时间和 RPC 等待。更稳妥的方式是先控制批次命中的 Tablet 数、并发 Load 数和单行宽度,再在专用压测中调整目标值。
三、事务、Replica 与可见性:多数派提交之后还发生什么

3.1 常规 Load 的事务状态机
常规导入已经包含事务管理。一个 Load Job 对应一个事务,FE 管理全局状态,BE 管理本地 Tablet 写入状态。事务从 PREPARE 开始,写入成功后进入 COMMITTED,再经 PublishVersion 进入 VISIBLE;写入或校验失败会进入 ABORTED。
Doris 对外提供 READ COMMITTED。每条查询语句开始时捕获快照,执行期间使用同一份可见版本集合。后续写入在查询开始后完成,也不会混入本次结果。下一条语句会重新捕获快照,因此可以看到刚刚提交的新版本。[7]
3.2 多副本写入与 Quorum

生产表常见三副本。导入计划会把数据发送到 Tablet 的多个 Replica。FE 在 Commit 阶段检查成功写入 Replica 数量,达到多数派后允许事务提交。某个 Replica 暂时失败时,事务仍可能提交,缺失副本随后由 Replica Repair 补齐;Replica 状态长期异常会增加修复和重平衡压力。
Commit 成功后,PublishVersion 会在相关 BE 上异步执行。若部分 BE 发布失败,FE 会继续重试。此时客户端可能收到“Commit 成功、Publish 超时”的结果,数据已经写入,最终可见性需要继续查询事务状态,不能直接换 Label 重放整批数据。
3.3 显式事务与 Stream Load 2PC
显式 BEGIN / COMMIT / ROLLBACK 适合需要把多条 DML 组合成一个事务的场景。Stream Load 2PC 则把“数据准备”和“外部提交决定”拆开:
-H "two_phase_commit:true"
首个请求写入数据并返回 TxnId 与 Label,外部系统在 Checkpoint 成功后再发 txn_operation:commit;失败时发送 txn_operation:abort。Flink Connector 通过这条路径协调 Flink Checkpoint 与 Doris 事务可见性。[7]
需要特别核验部署模式。当前官方显式事务文档列出存算分离模式限制:显式事务中的 Unique Key 仅支持 MoR,MoW 表需要按目标版本单独核验。跨模式复用脚本前,应完成目标环境验证。[7]
3.4 失败路径与重试合同
事务失败需要区分发生阶段:
- Begin 失败:常见于权限、Label 冲突、事务数量保护或目标表状态异常;
- 写入失败:常见于格式错误、内存不足、Replica 不可用、磁盘水位或超时;
- Commit 失败:某个 Tablet 的成功 Replica 数没有达到提交条件;
- Publish 超时:事务已经 COMMITTED,数据尚在等待 VISIBLE;
- 客户端超时:服务端状态未知,需要通过 Label 或 TxnId 查询。
生产重试器应先查询上一个 Label 的状态。PREPARE 可以继续等待或按协议终止;VISIBLE 直接视为成功;ABORTED 才能发起新事务;COMMITTED 需要等待 PublishVersion,不能把整批再次写入。这个状态机应固化在 SDK、调度平台或连接器中,避免把网络超时等同于服务端失败。
四、Unique Key MoW:更新发生在写入阶段

Unique Key 表维护“一组 Key 对应一条当前可见记录”的业务语义。MoW 的核心流程可以拆成五步:
- incoming rows 在 MemTable 中按 Key 组织;
- BE 使用每个 Segment 的主键索引查找旧行所在的 Rowset、Segment 和 RowID;
- 对旧行所在 Rowset 生成或更新 Delete Bitmap;
- 新记录写入新的 Segment 和 Rowset;
- 新版本发布后,查询读取可见 Rowset,同时应用 Delete Bitmap 跳过旧行。
Delete Bitmap 常用 (rowset_id, segment_id, version) 作为定位维度,内部记录需要跳过的 RowID。它保存的是“哪些物理行在某个可见版本下无效”,并不立即改写旧 Segment。旧文件仍然存在于磁盘,直到后续 Compaction 生成新的干净 Rowset,再经过安全回收窗口释放空间。[1]
4.1 写放大来自三类工作
一次普通 Duplicate Key 追加写主要处理输入数据和索引。MoW Upsert 还要完成:
- 主键索引查找;
- Sequence 比较;
- Delete Bitmap 计算与持久化;
- 新 Rowset 写入;
- 后续 Compaction 对旧行的物理清理。
因此,MoW 表的高频更新能力需要配合 SSD、合理分桶、合适批次和稳定 Compaction 资源。把每个业务字段变更都拆成单行请求,会把主键随机读、事务开销和版本增长叠加到同一条链路上。
4.2 Query Side 为什么更简单
MoW 已经在写入阶段决定每个 Key 的赢家。查询只需要扫描可见 Rowset,并根据 Delete Bitmap 屏蔽旧行。COUNT、JOIN、点查和聚合无需重建多版本 Key 集合,延迟和内存更容易预测。这也是 Doris 将 MoW 作为当前 Unique Key 默认实现的原因。
4.3 并发更新需要关注 Tablet 锁和批次冲突
多个写入任务同时更新同一批 Key 时,BE 需要保证主键查找、Sequence 裁决和 Delete Bitmap 发布形成一致结果。热点 Key 集中在少数 Tablet,会让这些 Tablet 承担更高的随机读、锁竞争和版本生成压力。业务侧可以通过扩大批次、减少同 Key 并发流、按 Key 稳定分区、使用可靠 Sequence 和拆分更新责任降低冲突。
SQL UPDATE 需要先扫描满足 WHERE 的行,再执行更新写入。大范围更新会同时消耗查询资源和写入资源,通常应转换为批量 Load、分区替换或离线重建。点更新量较小且偶发时,SQL UPDATE 便于运维;持续 CDC 仍以 Load-Based Upsert 为主要路径。
