预计阅读时间: 50 分钟 前置阅读: doc-01 §8 (Scan操作), doc-02 §2b (BE写入) 下一次阅读: doc-07 (Compaction), doc-08 (索引)
1. Tablet 分片模型
Tablet 是什么
Tablet 是 Doris 数据管理的最小单位——表的水平分片(Hash 分桶)的物理存储单元。一个 Tablet 大小 ~1-10GB, 默认每个 BE 可管理数万个 Tablet。
元数据定义
OlapTable (FE Catalog 中)
├── Partition 0 [dt='2026-07-01' .. '2026-07-07']
│ ├── Tablet 0 → Replica 0 (BE-1) + Replica 1 (BE-2) + Replica 2 (BE-3)
│ ├── Tablet 1 → Replica 0 (BE-2) + Replica 1 (BE-3) + Replica 2 (BE-1)
│ └── Tablet 2 → ...
├── Partition 1 [dt='2026-07-08' .. '2026-07-14']
│ └── ...
└── Partition N
BE 侧: Tablet (be/src/storage/tablet.h)
├── _tablet_meta (TabletMeta)
│ ├── tablet_id, schema_hash
│ ├── _rowsets[] → Rowset 列表 (按 Version 排序)
│ └── _version → 当前版本号
├── _data_dir → 本地磁盘路径 (如 /data/doris/data/10000/20000/)
└── _rs_graph → Rowset 版本图 (用于查询时决定读哪些 Rowset)
源码定位
| 文件 | 关键信息 |
|---|---|
be/src/storage/tablet.h | class Tablet — Tablet 主类 |
be/src/storage/tablet_meta.h | TabletMeta — Tablet 的持久化元数据 |
源码导航
| 文件 | 关键类/方法 | 职责 |
|---|---|---|
be/src/storage/tablet.h | Tablet | Tablet 主类(元数据+版本管理) |
be/src/storage/tablet_meta.h | TabletMeta | Tablet 持久化元数据 |
be/src/storage/rowset/rowset.h | Rowset | Rowset 接口 |
be/src/storage/rowset/beta_rowset.h | BetaRowset | 默认 Rowset 实现 |
be/src/storage/rowset/rowset_meta.h | RowsetMeta | Rowset 元数据(protobuf) |
be/src/storage/rowset/rowset_builder.h | BetaRowsetBuilder | Rowset 构建器 |
be/src/storage/rowset/segment_v2/ | SegmentWriter, SegmentReader | Segment 读写 |
gensrc/proto/olap_file.proto | SegmentFooter | Segment 格式定义(Protobuf) |
2. Rowset 体系
Rowset 类型
Rowset (接口, be/src/storage/rowset/rowset.h)
├── BetaRowset (默认, Doris 1.2+)
│ └── 实现: be/src/storage/rowset/beta_rowset.h
│ └── 格式: Segment V2 列存
│ └── 包含: RowsetMeta + N 个 Segment 文件
│
└── AlphaRowset (旧格式, 已废弃)
└── 仅在升级/兼容场景出现, 不再产生新数据
Rowset 生命周期
Create: 用户写入 → DeltaWriter → RowsetBuilder::build_rowset() → Rowset 文件 (磁盘)
Committed: Rowset 写入完成, 但尚未 Publish → 状态: COMMITTED (不可见)
Visible: Publish → Tablet::publish_version() → Rowset 变为 VISIBLE
Compact: 多个 Rowset → Compaction::merge() → 新的大 Rowset
Deleted: Compaction 完成后, 旧的 Rowset 标记为删除 (等待 GC)
源码定位
| 文件 | 用途 |
|---|---|
be/src/storage/rowset/rowset.h | Rowset 基类 |
be/src/storage/rowset/beta_rowset.h | BetaRowset 类 |
be/src/storage/rowset/beta_rowset_reader.h | RowsetReader |
be/src/storage/rowset/beta_rowset_writer.h | RowsetWriter |
be/src/storage/rowset/rowset_builder.h | RowsetBuilder |
be/src/storage/rowset/rowset_meta.h | RowsetMeta |
3. Segment 文件格式——列存物理布局
Segment 文件布局
┌───────────────────────────────────────────────┐
│ Segment File (一个物理文件, 多列) │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ Data Pages │ │
│ │ ┌ Column 0: dt (Date) ────────────┐ │ │
│ │ │ Page 0: rows 0..4095 │ │ │
│ │ │ Page 1: rows 4096..8191 │ │ │
│ │ │ Page 2: rows 8192..12287 │ │ │
│ │ └──────────────────────────────────┘ │ │
│ │ ┌ Column 1: status (String) ───────┐ │ │
│ │ │ Page 0: rows 0..4095 │ │ │
│ │ │ Page 1: rows 4096..8191 │ │ │
│ │ │ ... │ │ │
│ │ └──────────────────────────────────┘ │ │
│ │ ┌ Column 2: amount (Int64) ────────┐ │ │
│ │ │ ... │ │ │
│ │ └──────────────────────────────────┘ │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ Index Pages (元数据) │ │
│ │ ├ Ordinal Index (每列): Page→Offset │ │
│ │ └ ZoneMap Index (每列): Min/Max/Null │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ Footer (文件尾) │ │
│ │ ├ SegmentFooter (protobuf) │ │
│ │ ├ Num rows │ │
│ │ ├ ColumnFooters[] (每列信息) │ │
│ │ │ ├ Column type, encoding, compress │ │
│ │ │ ├ IndexPage offset │ │
│ │ │ └ DataPage offsets[] │ │
│ │ └ Footer offset (末尾 8 bytes) │ │
│ └──────────────────────────────────────┘ │
└───────────────────────────────────────────────┘
与 Parquet 的类比:
- Segment = Parquet File (一整个文件)
- Page = Row Group (4096 行)
- Column Data Pages = Column Chunk (一列的所有 Page)
- Ordinal Index = Page Index (Row Group 到 offset 的映射)
- ZoneMap = Column Statistics (Min/Max/Null count)
Page 分页设计
默认每页 4096 行 (由 write_batch_size 控制, 通常等于 OLAP_TABLE_MAX_RAW_ROWS_PER_BLOCK)。这个粒度的选择:
| 页大小 | 优点 | 缺点 |
|---|---|---|
| 太大 (16384+) | 索引条目少, 索引查找快 | 随机访问开销大 |
| 太小 (1024-) | 精确定位到需要的数据 | 索引文件大, 页切换频繁 |
| 4096 (当前) | 在索引大小和数据粒度间取得平衡 | - |
4. Encoding / Compression 链
列编码 (Encoding)
| 编码 | 适用列 | 说明 |
|---|---|---|
| PLAIN_ENCODING | 所有类型 | 无压缩存储, 直接读取 |
| RLE (Run Length) | 低基数列 | 连续重复值压缩 (如 status='OK' 重复 1000 次 → 1 个值+1000) |
| BIT_SHUFFLE | 数值列 | 按 bit 重排后压缩, 适合浮点数 |
| DICT_ENCODING | 字符串列 | 字典编码 (值→int 映射), 对低基数字符串非常有效 |
| PREFIX_ENCODING | 排序后的字符串列 | 前缀编码 (共享长前缀) |
压缩算法 (Compression)
| 压缩 | 速度 | 压缩率 | 适用 |
|---|---|---|---|
| LZ4 | 极快 | 中等 (~2:1) | 热数据 (查询频率高) |
| ZSTD | 快 | 好 (~4:1) | 温数据 |
| LZ4F | 较快 | 较好 (~3:1) | 默认压缩 (平衡速度快 + 压缩率好) |

端到端链路
写入: Raw Data → [DictEncoding 字典化] → [LZ4/ZSTD 压缩] → Page → Segment File
读取: Segment File → Page → [解压缩] → [解码] → ColumnVector (内存列)
5. SegmentWriter → SegmentReader 读写路径
写入 (SegmentWriter)
// SegmentWriter — 写入一个 Segment
SegmentWriter::init(SegmentFooter* footer_schema)
→ 确定列编码 (RLE/DICT/PLAIN) 和压缩 (LZ4/ZSTD)
SegmentWriter::append_block(Block* block)
→ 将 Block 的 4096 行写入各列的当前 DataPage
→ 当 Page 行数 ≥ 4096 → finalize_page() → flush DataPage
SegmentWriter::finalize()
→ finalize 最后一个 incomplete Page
→ 写各列的 Ordinal Index Page
→ 写 ZoneMap index
→ 写 Footer (SegmentFooter protobuf)
→ posix_fadvise (建议 OS 缓存策略)
读取 (SegmentReader)
// SegmentReader — 读取 Segment 的特定列和行范围
SegmentReader::init(SegmentReadOptions* opts)
→ opts.cols[] = {"status", "amount"} // 只读这两列
→ opts.key_ranges[] = [BJ, SH] // key 范围过滤
→ opts.conditions[] = (amount > 100) // 谓词 (索引过滤用)
→ opts.delete_bitmap // 删除标记
SegmentIterator::next_batch(Block* block)
→ 1. ZoneMap 快速检查: 每列 Min/Max 是否与 key_range/condition 有交集
→ 如果没有 → 跳过整个 Segment (不读任何 Page)
→ 2. Ordinal Index 定位: 找到包含目标 key_range 的 Page
→ 3. BloomFilter 检查 (如有)
→ 4. 读取 DataPage: io.read(page_offset, page_size)
→ 5. 解压缩 → 解码 → 构造 ColumnVector
→ 6. 应用 DeleteBitmap 过滤删除的行
→ 7. 返回 Block (4096 行)
6. 列存优势体现
| 优势 | 原理 | 典型场景 |
|---|---|---|
| 列投影 | 查询 3/100 列 → 只需读 3 列的 ColumnData, 其他 97 列完全跳过 | 宽表分析 (100+ 列) |
| 谓词下推 | WHERE a > 10 → 读 a 列的 ZoneMap → 过滤后只读剩余的 Page | 时序分析, 多维过滤 |
| 编码压缩 | 同列数据相似 → RLE/DICT 编码有效; CPU 缓存友好 | 低基数列 (status, city) |
| SIMD 友好 | ColumnVector<T> → 连续内存布局 → SIMD 操作 (AVX2/AVX512) | Agg, Filter |
| 延迟物化 | 先过滤行的位置, 再按需读其他列 (不会先读所有列再过滤) | 复杂 WHERE 条件 |
7. 常见问题 / 面试题
Q1: 一个 Tablet 最多有多少个 Rowset?
A: 理论上无限。实际上 Cumulative Compaction 控制在 ~10-20 个。如果 Compaction 跟不上写入速度, Rowset 数量会持续增长, 导致查询性能下降 ("Rowset Runaway" 问题)。运维需监控 tablet_max_rosversion 指标。
Q2: Segment 格式为什么不直接用 Parquet? A: (1) Doris 需要更细粒度的索引 (Ordinal Index 按 Page 定位, Parquet 的 Page Index 是后来加的); (2) Segment 支持 Doris 特有的 Merge-on-Write/DeleteBitmap 语义; (3) 性能: Segment 是 Doris 的内存格式的磁盘投影, 读写转换最小。
Q3: 延迟物化在 SegmentReader 中是如何实现的?
A: 查询 SELECT status, amount FROM t WHERE city='BJ' 的执行顺序: (1) 先读 city 列的 ZoneMap + DataPages → 过滤产生 row_ids[1,3,7,...]; (2) 然后只读 row_ids 对应位置的 status 和 amount 列 (不读 city 列的全部数据)。这避免了先读 status 和 amount 列的全部行再过滤。
Q4: Segment 文件的 Footer 为什么放在文件末尾而不是开头? A: 文件尾部的位置固定(最后 8 bytes 存 footer_offset), 可以直接 seek 到。如果放在开头, 写入时需要预留空间 (Segment 写完才知道各列的 Page offset)。这是大多数列存格式的常见设计 (Parquet, ORC 均如此)。

下一步
- Compaction: doc-07-storage-compaction-version.md——多个 Rowset 如何合并
- 索引: doc-08-storage-index.md——5种索引的详细讲解