Doris 实战与架构

分析型数据库 / 源码阅读 / LESSON 06

存储引擎——Tablet / Rowset / Segment 文件格式

从 Tablet、Rowset、Segment 到 Page,读懂 Doris 列存文件格式和扫描边界。

阅读时间
50 分钟
学习路径
Doris 实战与架构
内容来源
Doris 深度笔记

预计阅读时间: 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.hclass Tablet — Tablet 主类
be/src/storage/tablet_meta.hTabletMeta — Tablet 的持久化元数据

源码导航

文件关键类/方法职责
be/src/storage/tablet.hTabletTablet 主类(元数据+版本管理)
be/src/storage/tablet_meta.hTabletMetaTablet 持久化元数据
be/src/storage/rowset/rowset.hRowsetRowset 接口
be/src/storage/rowset/beta_rowset.hBetaRowset默认 Rowset 实现
be/src/storage/rowset/rowset_meta.hRowsetMetaRowset 元数据(protobuf)
be/src/storage/rowset/rowset_builder.hBetaRowsetBuilderRowset 构建器
be/src/storage/rowset/segment_v2/SegmentWriter, SegmentReaderSegment 读写
gensrc/proto/olap_file.protoSegmentFooterSegment 格式定义(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.hRowset 基类
be/src/storage/rowset/beta_rowset.hBetaRowset 类
be/src/storage/rowset/beta_rowset_reader.hRowsetReader
be/src/storage/rowset/beta_rowset_writer.hRowsetWriter
be/src/storage/rowset/rowset_builder.hRowsetBuilder
be/src/storage/rowset/rowset_meta.hRowsetMeta

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)默认压缩 (平衡速度快 + 压缩率好)

存储引擎——Tablet / Rowset / Segment 文件格式 图 01

端到端链路

写入: 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 均如此)。


Segment 文件物理布局 (列存格式)

下一步