Day 19|存储引擎:Tablet、Rowset、Segment、Page、编码、压缩与 MVCC

Day 18 站在 BE 执行引擎的视角,理解了 Fragment、Instance、PipelineTask 和 Operator 怎样消耗 CPU、内存、网络与磁盘。今天继续向下进入持久化层,追踪一批数据从写入请求开始,怎样进入 Tablet,怎样形成 Rowset 与 Segment,怎样被编码、压缩、索引和发布,又怎样在并发写入与后台合并期间稳定地提供查询结果。
存储引擎承担的任务可以归纳为四类:
- 组织数据:把一张逻辑表拆成 Partition、Tablet、Replica、Rowset、Segment 和 Page;
- 控制版本:让批次提交、实时更新、删除和 Compaction 形成清晰的可见性边界;
- 降低读取成本:通过列存、编码、压缩、索引和按页读取减少磁盘与网络 I/O;
- 维持长期健康:控制 Rowset 数量、小文件、版本压力、磁盘空间与后台合并负载。
很多线上问题表面上出现在 SQL 或导入任务,根因会落到存储层。例如:
- 导入批次过小,单次写入速度看起来很快,Tablet 版本数持续升高;
- 一条查询只使用三个字段,宽表打开大量 Segment 时仍消耗很多内存;
- 数据已被更新或删除,磁盘空间短时间内没有下降;
- 相同 SQL 在冷缓存时波动很大,热缓存又恢复正常;
- 某个 Tablet 的 Rowset 数明显高于其他 Tablet,随后出现长尾查询;
- 更换压缩算法后磁盘下降,写入 CPU 和查询 P99 同时发生变化。
完成今天的学习后,你应当能够:
- 准确说明
Table → Partition → Tablet → Rowset → Segment → Page的职责边界; - 解释一次 Load 如何形成 Segment、Rowset、事务版本与 VISIBLE 数据;
- 区分编码和压缩,理解 Dictionary、RLE、Plain、BitShuffle、ZSTD 与 LZ4 的作用;
- 说明查询如何选择可见 Rowset,并逐层裁剪到真正需要解压的 Page;
- 理解单次查询快照、版本链与 Compaction 文件替换之间的关系;
- 使用
SHOW TABLETS、information_schema.rowsets、Replica 状态和 BE 页面定位存储问题; - 设计一套批次、压缩、索引、Row Store 和 Compaction 相互协调的生产方案。
概览图用于快速建立结构,具体参数、默认值和命令以正文及目标版本实测为准。
零、先校准四个当前版本口径

原培训材料已经覆盖 LSM-Tree、MemTable、Rowset、Segment、Page、压缩与 MVCC,部分默认值和事务术语需要按照 Doris 4.x 官方文档更新。
0.1 MemTable 阈值由配置与写入场景共同决定
旧材料文字出现过 200 MB,配图中又出现 100 MB。当前课程不固化单一数值。MemTable Flush 会受到 write_buffer_size、列宽、表模型、导入内存压力、数据分布和批次结束等因素影响;Compaction 输出 Segment 的大小还受 vertical_compaction_max_segment_size 等配置影响。实验中应保存目标 BE 配置、Rowset 数和实际 Segment 平均大小。
0.2 当前 4.x 默认压缩算法是 ZSTD
官方 Data Compression 文档明确说明,表未显式配置 compression 时,默认值由 FE 配置 default_compression_type 控制,当前默认是 ZSTD。历史表可能使用 LZ4、LZ4F 或显式配置,因此已有对象以 SHOW CREATE TABLE 和实际元数据为准。
0.3 当前隔离级别是 READ COMMITTED
Doris 当前唯一支持 READ COMMITTED。每条语句在开始时捕获一次表快照,执行期间看不到随后提交的数据;同一多语句事务中的下一条语句可以看到两条语句之间已经提交的新数据。本文继续用 MVCC 解释 Rowset 版本选择,对外语义统一表述为“READ COMMITTED + 语句级快照”。[13]
0.4 WAL 按写入入口分别说明
Group Commit async_mode 会先把数据写入接收 BE 的 WAL,再返回 PREPARE。常规 Stream Load 的主线是事务、Tablet 路由、MemTable、Segment、Rowset、Commit 与 PublishVersion。架构文档需要明确入口类型,避免把 WAL 套到所有导入路径。
一、先建立统一层级:每一层都在解决不同问题

Doris 的一张表进入物理世界后,会形成多层结构。当前官方文档将磁盘层级概括为:
Table
└── Partition
└── Tablet
└── Rowset
└── Segment
└── Column Page / Index Page
这些名词需要放在各自的管理边界中理解。
| 层级 | 核心职责 | 最常见的设计或运维问题 |
|---|---|---|
| Table | 逻辑 Schema、数据模型、排序键、属性 | Duplicate / Aggregate / Unique 如何选择 |
| Partition | 生命周期、范围裁剪、冷热管理 | 分区粒度、历史清理、动态分区 |
| Tablet | 数据分片、Replica、迁移、修复、Compaction | Bucket 数、热点分片、Tablet 总量 |
| Rowset | 版本控制与不可变文件集合 | 高频小批、版本堆积、Compaction 压力 |
| Segment | 真正承载列数据与索引的文件 | Segment 数量、打开成本、索引构建粒度 |
| Page | 编码、压缩、统计信息和按需解压 | 压缩率、ZoneMap 裁剪、Page Cache 命中 |
1.1 Partition 与 Tablet 的关系
分区首先决定一行数据属于哪个业务范围,例如某一天、某个月或某个地区。进入分区后,分桶规则继续把数据映射到某个 Bucket;Bucket 在物理层对应 Tablet。Tablet 是 Doris 数据移动、复制和后台合并的重要单元,也是容量规划中最容易被低估的元数据单位。[1]
假设一张表有 365 个日分区,每个分区 32 个 Bucket,副本数为 3:
逻辑 Tablet 数 = 365 × 32 = 11,680
物理 Replica 数 = 11,680 × 3 = 35,040
随后每个 Tablet 内还会持续产生 Rowset 与 Segment。过细的分区和过高的 Bucket 数会同时增加 FE 元数据、BE Tablet 对象、Replica 调度、Compaction 队列和故障修复成本。Day 8 已经讲过表结构设计,今天需要把这些选择与底层文件数量联系起来。
1.2 Tablet 在两种架构中的位置

在存算一体架构中,BE 同时保存 Tablet Replica 和执行查询。Segment 文件位于 BE 本地磁盘,读取具有较强的数据本地性。Tablet 扩容、迁移和副本修复会伴随真实数据复制。[2]
在存算分离架构中,Segment 与索引文件位于共享存储,BE 作为无状态计算节点使用本地 File Cache 缓存热点数据;Tablet 与 Rowset 的数据层元数据由 Meta Service 管理。逻辑层级仍然存在,数据文件与元数据的落点发生了变化。[2]
因此,同一句“某个 Tablet 有 20 个 Rowset”在两种架构中代表相同的版本压力,后续影响会有差异:
- 存算一体更关注本地磁盘、Replica 一致性与 Compaction I/O;
- 存算分离还要关注新 Rowset 在只读计算组中的缓存预热和对象存储访问;
- 两种架构都要控制高频小批带来的 Rowset 增长。
二、写入路径:从请求到 VISIBLE Rowset

以常规 Stream Load 为例,一批数据从客户端到查询可见,大致经历以下阶段:[3]
客户端请求
→ FE 开启事务并选择协调 BE
→ 协调 BE 解析数据并按 Partition / Bucket 路由
→ 目标 BE 写入 MemTable
→ MemTable Flush 为 Segment
→ Segment 组成 Rowset
→ FE Commit 事务
→ 各 Replica PublishVersion
→ Rowset 进入 VISIBLE
2.1 MemTable 的作用
MemTable 位于写入内存路径,负责接收、转换和组织本批数据。具体行为取决于数据模型:
- Duplicate Key 主要保留明细并按排序键组织;
- Aggregate Key 会在写入阶段执行可合并的预聚合;
- Unique Key MoW 会参与主键查找、版本裁决和 Delete Bitmap 生成;
- 部分列更新还可能读取历史字段,补齐完整行。
当 MemTable 达到刷新条件,或导入批次进入收尾阶段,数据会写成不可变 Segment。Segment 大小受写入缓冲、单批规模、索引构建和 Compaction 设置共同影响,具体阈值需要以目标版本配置为准。
2.2 Rowset 为什么对应版本
Rowset 是一个不可变文件集合,也是一项写入或 Compaction 在 Tablet 上产生的版本控制单元。普通 Load 通常为 Tablet 增加一个新版本;Compaction 会把多个连续版本合并成覆盖更大版本区间的新 Rowset。[4]
Rowset 元数据包含:
ROWSET_ID;- 所属
TABLET_ID; - 产生它的
TXN_ID; START_VERSION与END_VERSION;- 行数与 Segment 数;
- 数据、索引磁盘大小;
- Schema Version 与创建时间。
Doris 4.x 提供 information_schema.rowsets,可以从系统表直接观察这些信息。[5]
2.3 Commit 与 PublishVersion
各 BE 完成当前批次文件写入后,协调节点请求 FE 提交事务。Commit 成功后,FE 向 Replica 下发 PublishVersion。只有进入可见版本集合的 Rowset,才会被新的查询选择。[3]
这条链路带来两个关键性质:
- 一批数据以事务为边界整体成功或整体失败;
- 查询不会读取到只完成了部分 Replica、尚未发布的半成品版本。
2.4 关于 WAL 的准确口径
旧培训材料把写入路径统一描述成“MemTable 同时写 WAL”。当前课程需要按具体入口区分。
Doris 4.x 的 async_mode Group Commit 明确使用接收 BE 上的 WAL,请求在数据持久后即可返回 PREPARE,合并事务稍后提交;sync_mode 不执行这一步。[6] 常规 Stream Load 的公开主线是事务、Tablet 路由、MemTable Flush、Replica 确认、Commit 与 PublishVersion。[3]
因此,架构设计和故障恢复文档应写清使用的是常规 Stream Load、Group Commit、Routine Load、Flink 2PC 还是其他写入方式。统一写成“所有导入都依赖同一种 WAL”会掩盖不同入口的可靠性边界。
三、Rowset:读写放大的关键中间层

Rowset 位于 Tablet 与 Segment 之间。理解 Rowset 后,很多线上现象就有了统一解释。
3.1 一次写入为什么容易制造很多 Rowset
假设一个 Stream Load 批次写入 32 个 Tablet,每个 Tablet 都会形成对应的版本变化。客户端每秒提交大量小批时,数据量可能并不大,版本数会快速增加。
高频小批会带来:
- FE 事务与 PublishVersion 开销;
- Tablet Rowset 数持续增加;
- 更多小 Segment;
- 查询需要合并更多版本路径;
- Compaction 线程持续追赶;
- 版本数超过阈值后可能触发
-235类写入失败。
当前官方 Compaction 文档指出,每次 Load 都会产生不可变 Rowset;查询需要在读取时合并多个 Rowset。Tablet 版本数量过多会增加读放大,并可能阻塞后续写入。[4]
3.2 Rowset 的版本区间
普通 Load 产生的 Rowset 常见形式是:
Rowset A [101-101]
Rowset B [102-102]
Rowset C [103-103]
一次 Cumulative Compaction 把三者合并后,新 Rowset 可以表示:
Rowset D [101-103]
版本区间变化了,逻辑数据版本保持连续。查询只需要找到一条覆盖目标快照版本的连续 Rowset 路径。这个过程也是 Replica 健康检查关注 Version、LastFailedVersion 和 LastSuccessVersion 的原因。
3.3 Rowset 数量与 Segment 数量需要分开观察
一个 Rowset 可以包含多个 Segment。以下两张表的存储压力不同:
| 表 | Rowset 数 | 每 Rowset Segment 数 | 主要风险 |
|---|---|---|---|
| A | 80 | 1 | 版本路径长、Compaction 压力 |
| B | 8 | 20 | 单批切片多、Segment 打开与索引文件多 |
Rowset 多通常指向批次和 Compaction;单个 Rowset 的 Segment 多则需要继续检查单批数据量、MemTable Flush、索引构建、写入并行度和单 Segment 大小。
四、Segment 与 Page:真正承载数据的文件结构

官方资料把 Segment 定义为 Rowset 下真正承载数据的文件,倒排索引、向量索引等也常以 Segment 为构建粒度。[7]
一个 Segment 可以从概念上拆成:
- 各列的数据 Page;
- Page 与列级索引;
- Null Bitmap;
- ZoneMap 等统计信息;
- Column Metadata;
- Footer 与 Page Pointer;
- 倒排、NGram、向量等独立或关联索引文件。
4.1 为什么 Segment 一旦生成就保持不可变
不可变文件可以简化并发读写:
- 新写入创建新 Segment;
- 更新通过新版本和可见性规则覆盖旧行;
- 删除通过逻辑标记或 Delete Bitmap 控制可见性;
- Compaction 生成新文件并原子替换元数据路径;
- 活跃查询继续持有旧文件句柄,完成后再进入清理。
它牺牲了一部分写放大和后台合并成本,换取顺序写、稳定快照和更简单的并发控制。
4.2 Page 为什么是编码和压缩的关键单位
列数据会被切成多个 Page,各 Page 独立编码和压缩。查询命中某个 Segment 后,还可以继续依据 Page 的 ZoneMap、索引和谓词跳过无关页,只对候选 Page 进行读取与解压。[8]
Page 级组织带来三个价值:
- 局部读取:只解压目标列的目标 Page;
- 独立统计:每个 Page 可以维护 Min、Max、NULL 等信息;
- 缓存友好:热点 Page 可以进入 Page Cache,重复查询减少磁盘读取。
Page 过小会增加元数据、索引和调用次数;Page 过大又会提高无效解压比例。系统会根据类型和存储格式完成内部组织,用户主要通过表结构、排序、压缩、索引和批次间接影响结果。
4.3 Storage Format V3 的位置
Doris 4.1.0 开始支持 Wide Table Storage Format V3。旧格式把大量 ColumnMetaPB 集中在 Segment Footer 中,打开宽表 Segment 时需要先反序列化完整 Footer。V3 把列元数据拆到独立区域,Footer 只保留轻量指针,查询按需加载涉及列的元数据。[9]
这个优化主要服务:
- 数百到数千列的宽表;
- VARIANT 动态展开后形成大量物理子列的表;
- 对象存储或冷热分层上的宽表;
- Profile 中 Segment Opening 阶段耗时和内存明显偏高的场景。
普通窄表通常没有必要为了版本新而迁移存储格式。V3 属于 4.1+ 能力,4.0.8 稳定基线需要跳过相关实验。
4.4 一行数据怎样变成列式文件
以一条订单记录为例:
(order_id=9001, user_id=101, city="北京", amount=199.00, status="PAID")
进入列存后,它不会作为一块完整行对象长期放在 Segment 中。order_id 会追加到订单号列的当前 Page,user_id 进入用户列,city 与 status 可能使用字典编码,amount 进入定点数列。每列独立形成数据流、Page 边界和压缩块。
这种组织方式使聚合查询可以只读取 city 与 amount,其余三列保持未打开状态。代价也很明确:SELECT * WHERE order_id = ? 需要从多个列 Page 取值并重组成一行。Day 13 介绍的 Row Store 会额外保存热点字段的行式副本,用空间和写入成本换取多列点查的更短 I/O 路径。
列式组织还会影响 Schema Change。新增 Nullable Value 列可以通过元数据和默认值快速表达,修改排序键、分桶键或需要重写数据的类型变化则会触碰大量 Segment。评审结构变更时,需要估算受影响的 Tablet 数、Segment 总量、读取旧文件与写出新文件的 I/O,以及 Compaction 同期运行带来的资源竞争。
五、编码与压缩:先改善表达,再压缩字节

编码和压缩经常被合并描述,二者处理的问题不同。
编码利用列值的结构特征改变表达方式。例如:
- Dictionary:把重复字符串替换成更短的字典 ID;
- RLE:把连续重复值表示成“值 + 次数”;
- BitShuffle:重新排列数值位,提高后续压缩效果;
- Plain:使用直接二进制表达,减少解码 CPU;
- V3 的
BINARY_PLAIN_ENCODING_V2:为字符串与 JSONB 使用更紧凑的流式长度布局。
压缩继续处理编码后的字节流,例如 ZSTD、LZ4、LZ4F、LZ4HC、Snappy 和 Zlib。每个 Page 独立压缩,查询时按需解压。[8]
5.1 排序键会影响压缩率
排序键首先服务 Prefix Index 和数据裁剪,同时会改变相邻行的分布。以下数据按 region 和 status 聚集后,Dictionary 与 RLE 通常更容易获得收益:
未聚集:BJ, SH, GZ, BJ, SZ, SH, BJ ...
聚集后:BJ, BJ, BJ, GZ, GZ, SH, SH ...
因此,排序键、查询模式和压缩效果彼此关联。为了压缩率强行改变排序顺序,也可能损失常用查询的 Prefix Index 效果,需要通过真实工作负载比较。
5.2 当前 4.x 的默认压缩口径

旧培训材料将 LZ4 写成默认压缩算法。当前 Doris 4.x 官方压缩文档说明,未显式指定时默认使用 ZSTD,该行为受 FE 配置 default_compression_type 控制。[8]
官方当前给出的选择方向是:
| 场景 | 建议候选 | 关注点 |
|---|---|---|
| 高并发实时分析 | LZ4、Snappy | 解压速度与 CPU |
| 查询性能和容量平衡 | ZSTD、LZ4F | 压缩率、写入 CPU、读取延迟 |
| 容量优先 | ZSTD、Zlib | 更高压缩率,解压成本更高 |
| 冷数据与归档 | Zlib、LZ4HC | 写入压缩时间可接受 |
建表示例:
CREATE TABLE fact_event_zstd (
event_time DATETIME(3),
user_id BIGINT,
event_name VARCHAR(64),
amount DECIMAL(18,2),
payload STRING
)
DUPLICATE KEY(event_time, user_id, event_name)
PARTITION BY RANGE(event_time) ()
DISTRIBUTED BY HASH(user_id) BUCKETS 16
PROPERTIES (
"compression" = "zstd"
);
上线前应比较:
- 原始数据量与 Doris 物理数据量;
- 写入吞吐与 BE CPU;
- 冷缓存、热缓存 P95/P99;
ScanBytes与解压 CPU;- Compaction 吞吐;
- 索引和 Row Store 额外空间。
压缩率最高的方案未必拥有最低的总成本。若节省 20% 磁盘,却让高峰查询 CPU 上升 40%,整体资源账单可能更高。
5.3 压缩算法怎样做可复现 POC
压缩评测应使用同一份原始文件、相同表结构、相同 Bucket 数和相同副本数。建议先关闭其他批量任务,连续执行三轮导入和三组查询,分别记录冷缓存与热缓存。
一份可交付的压缩报告至少包含:
| 指标 | 说明 |
|---|---|
| LoadBytes / LoadTime | 写入吞吐与解析、压缩耗时 |
| DATA_DISK_SIZE | Rowset 数据文件大小 |
| INDEX_DISK_SIZE | 索引空间,避免把索引差异算进压缩收益 |
| BE CPU 与磁盘写吞吐 | 判断压缩阶段资源成本 |
| ScanBytes 与 CPU Time | 判断读取和解压成本 |
| p50 / p95 / p99 | 判断尾延迟变化 |
| Compaction Duration | 判断后台重写是否受压缩算法影响 |
评测完成后保留 SHOW CREATE TABLE、Doris 版本、硬件型号、原始数据摘要和查询参数分布。缺少这些上下文的“压缩率提升 30%”很难复现,也无法直接支持生产决策。
