上一篇从全局视角介绍了 Doris 的定位、安装和性能对比,知道了它是"FE + BE 两个进程、亚秒级查询、支持实时写入"的分析数据库。本篇把视角下沉到内核,讲清楚两件事:底层架构到底是怎么运转的 ,以及三种数据模型(明细、聚合、主键)的内部实现和选型逻辑。
一、底层架构总览
Doris 的架构设计目标是"极简运维 + 极致性能",整个系统只有两类进程,但每个进程内部承担了完整的职责。
┌─────────────────────────────────────────────────────┐
│ Client │
│ MySQL 协议 / HTTP Stream Load │
└──────────────────────┬──────────────────────────────┘
│
┌────────────▼────────────┐
│ FE 集群 │
│ Leader / Follower / Obs │
└────────────┬────────────┘
│ 1. 元数据读写 2. SQL 解析优化 3. 查询计划下发
┌────────────▼────────────┐
│ BE 集群 │
│ Tablet / Segment / MemTable │
│ 向量化执行 / Compaction │
└─────────────────────────┘
下面分别拆开 FE 和 BE 的内部结构。
二、FE 内部架构
FE 是 Doris 的"大脑",承担四类核心职责:元数据管理、SQL 解析与优化、查询调度、集群管理。
1. 元数据管理
FE 把整个集群的元数据(库、表、分区、副本、作业、权限等)存在内存中,并通过 BDBJE(Berkeley DB Java Edition) 持久化。
元数据采用 写前日志(Edit Log)+ 快照(Image) 的机制:
- 所有元数据变更先写 Edit Log,再更新内存;
- 定期把内存元数据 dump 成 Image 文件,加速重启恢复;
- FE 之间通过 Edit Log 同步,保证元数据一致。
FE 角色与一致性:
| 角色 | 职责 | 数量 |
|---|---|---|
| Leader | 写元数据、生成查询计划、下发查询 | 1 个 |
| Follower | 参与 Paxos 投票,可被选为 Leader | 至少 2 个(组成奇数台) |
| Observer | 只读元数据,不参与投票 | 任意,用于读扩展 |
元数据写入走 Paxos 协议,多数 Follower 确认后才算成功,保证强一致。
2. SQL 解析与优化
一条 SQL 从进 FE 到下发执行,经过这条链路:
SQL 文本
│
▼ 1. 解析(Parser)
语法树 AST
│
▼ 2. 分析(Analyzer)
绑定库表、列、类型,生成逻辑计划
│
▼ 3. 逻辑优化(Logical Optimizer)
谓词下推、列裁剪、子查询展开、常量折叠
│
▼ 4. 物理优化(CBO Optimizer)
基于代价选择 Join 顺序、Join 算法、数据分布
│
▼ 5. 生成分布式物理计划
切成多个 Fragment,每个 Fragment 有数据分布要求
CBO(Cost-Based Optimizer) 是 FE 的核心,基于统计信息(行数、NDV、列的 min/max)估算每个执行计划的代价,选取代价最低的。统计信息通过 ANALYZE TABLE 收集。
3. 查询调度
FE 把物理计划切成多个 Fragment,每个 Fragment 是一个可并行执行的单元。调度时:
- 计算每个 Fragment 需要扫描的 Tablet;
- 按数据本地性把 Fragment 调度到对应副本所在的 BE;
- BE 之间通过 Data Stream 流式交换中间数据(类似 Spark 的 Shuffle,但不落盘);
- 最顶层 Fragment 的结果汇聚到 FE,返回客户端。
4. 集群管理
FE 负责管理 BE 的生命周期:
- BE 启动后向 FE 注册,FE 周期性发送心跳;
- FE 维护 Tablet 到 BE 副本的映射;
- 副本缺失时自动补副本,副本不均衡时自动迁移。
三、BE 内部架构
BE 是 Doris 的"手脚",负责数据存储和查询执行。可以拆成三层:存储引擎、执行引擎、写入引擎。
1. 存储引擎
BE 上的数据按 Tablet → Segment → Column → Page 组织:
Tablet(分桶后的逻辑分片)
├── Segment 0 一批数据写入生成一个 Segment 文件
│ ├── Column A 每列独立存储
│ │ ├── Page 1 按 Page 压缩存储
│ │ ├── Page 2
│ │ └── Index Page(ZoneMap 等)
│ ├── Column B
│ └── Short Key Index(稀疏索引)
├── Segment 1
└── ...
各层级作用:
- Tablet:分桶的物理体现,是数据分布和副本的基本单位,每个 Tablet 默认 3 副本,分布在不同 BE;
- Segment:一次批量写入生成的不可变文件,类似 LSM-Tree 的 SSTable;
- Column:列式存储,每列连续存放,查询只读取需要的列;
- Page:列内按 Page(默认 64KB)切分,每个 Page 独立压缩(LZ4/ZSTD),并有 ZoneMap(min/max/null 统计)。
稀疏索引(Short Key Index):
- 按 Key 列排序,每 1024 行(可配置)记录一条索引项,指向某个 Page;
- 查询时通过二分查找定位 Page,只需 2-3 次 I/O 就能找到起始位置;
- Key 列长度超过 36 字节会被截断,所以 Key 不宜过长。
2. Compaction
由于 Segment 是不可变的,随着写入增多,Segment 数量会膨胀,查询时需要合并多个 Segment,影响性能。BE 后台会做 Compaction:
- Cumulative Compaction:合并多个小 Segment 成一个大 Segment,减少 Segment 数量;
- Base Compaction:把增量 Segment 合并到底部的 Base Segment,清理被删除/更新的数据。
Compaction 是 Doris 性能的关键,写入量大时要关注 Compaction 是否跟上,否则查询会越来越慢。
3. 执行引擎
BE 采用向量化执行引擎,核心特征:
- 列式批量处理:每次处理 1024 行(block size),而不是逐行;
- SIMD 加速:利用 CPU 的向量化指令(AVX2/SSE)并行处理一批数据;
- 延迟物化(Late Materialization):先通过过滤条件定位行号,再读取需要的列,减少 I/O;
- Runtime Filter:Join 时把小表的 Join Key 下推到大表扫描阶段,提前过滤数据,大幅减少 Shuffle 数据量。
支持的算子:Scan、Filter、Project、Aggregate、Hash Join、Sort、Window Function 等,全部向量化实现。
4. 写入引擎
写入流程以 Stream Load 为例:
Client
│ HTTP PUT
▼
FE(鉴权、计算 Tablet 分布)
│ 重定向到某个 BE
▼
BE 接收数据
│ 解析、按分桶列 Hash 计算目标 Tablet
▼
写入各 Tablet 对应的 MemTable(内存)
│ 达到阈值(默认 100MB 或 10 秒)
▼
Flush 成 Segment 文件,写入磁盘
│
▼
生成新版本号,元数据上报 FE
关键特性:
- MemTable:写入先在内存聚合,达到阈值再刷盘,减少小文件;
- 两阶段提交:FE 协调所有相关 BE,要么全部成功要么全部回滚,保证事务性;
- 版本管理:每次写入生成一个新版本号,查询时读取对应版本的可见数据。
四、三种数据模型深入解析
数据模型是 Doris 区别于其他 OLAP 数据库的核心特性,建表时通过 DUPLICATE KEY、AGGREGATE KEY、UNIQUE KEY 指定,一旦创建不可修改。
模型总览
| 模型 | 关键词 | 数据处理方式 | 适用场景 |
|---|---|---|---|
| 明细模型 | DUPLICATE KEY |
原样存储所有行 | 日志、流水、明细查询 |
| 聚合模型 | AGGREGATE KEY |
相同 Key 的行按聚合函数合并 | 报表、预聚合、指标汇总 |
| 主键模型 | UNIQUE KEY |
相同主键的行覆盖(最新一行生效) | 实时更新、点查、维表 |
下面分别深入讲。
1. 明细模型(DUPLICATE KEY)
原理 :数据原样存储,不做任何聚合,相同 Key 的行会保留多行。DUPLICATE KEY 只决定排序和索引,不决定去重。
sql
CREATE TABLE detail_log (
dt DATETIME,
user_id BIGINT,
event VARCHAR(50),
cost INT
)
DUPLICATE KEY(dt, user_id)
DISTRIBUTED BY HASH(user_id) BUCKETS 16;
内部行为:
- 数据按
DUPLICATE KEY的列排序存储,稀疏索引基于这些列; - 插入两行
(dt=A, user_id=1, ...)和(dt=A, user_id=1, ...),两行都会保留; - 查询时扫描所有满足条件的行。
适用场景:
- 需要保留每条原始记录的场景:用户行为日志、交易流水、系统日志;
- 查询模式是聚合统计或明细过滤,不需要行级更新。
注意 :DUPLICATE KEY 的列必须是表的前几列,且按查询最常用的过滤维度排序,最大化稀疏索引和 ZoneMap 的效果。
2. 聚合模型(AGGREGATE KEY)
原理 :相同 AGGREGATE KEY 的行,在写入和查询时会按指定的聚合函数自动合并。非 Key 列必须指定聚合类型。
支持的聚合函数:
| 聚合类型 | 说明 |
|---|---|
SUM |
求和 |
REPLACE |
取最后写入的值 |
REPLACE_IF_NOT_NULL |
非空时替换 |
MAX |
取最大值 |
MIN |
取最小值 |
HLL_UNION |
HLL 集合合并(用于去重计数) |
BITMAP_UNION |
Bitmap 合并(用于精确去重) |
sql
CREATE TABLE sales_agg (
dt DATE,
city VARCHAR(20),
amount DECIMAL(10,2) SUM,
order_cnt INT SUM,
max_amount DECIMAL(10,2) MAX
)
AGGREGATE KEY(dt, city)
DISTRIBUTED BY HASH(city) BUCKETS 8;
内部行为:
- 写入时:相同 Key 的行在 MemTable 内、Segment 内、Compaction 时逐级聚合;
- 查询时:如果查询的列都是聚合列,直接返回聚合结果,无需再扫描原始行;
REPLACE类型依赖写入顺序,不能保证取的是"最新"业务意义上的值,只保证是最后写入的。
适用场景:
- 报表预聚合:同维度的指标提前汇总,查询时直接出结果;
- 去重计数:用
HLL_UNION或BITMAP_UNION实现超大数据量的快速去重; - 实时汇总:数据持续写入,自动按维度聚合。
核心优势:查询极快,因为数据已经在存储层聚合好了,扫描量极小。
注意事项:
- 聚合模型不支持查询非聚合的原始明细,一旦选择就无法回退;
REPLACE在并发写入时结果不确定,需要业务保证顺序;- 维度列(Key)不能被聚合,如果维度值会变化,不适合聚合模型。
3. 主键模型(UNIQUE KEY)
原理 :相同 UNIQUE KEY 的行,最新一行覆盖旧行,实现行级更新。这是 Doris 支持实时更新的核心模型。
sql
CREATE TABLE user_profile (
user_id BIGINT,
name VARCHAR(50),
age INT,
city VARCHAR(20)
)
UNIQUE KEY(user_id)
DISTRIBUTED BY HASH(user_id) BUCKETS 16
PROPERTIES (
"enable_unique_key_merge_on_write" = "true"
);
两种实现模式:
| 模式 | 配置 | 写时行为 | 读时行为 | 性能 |
|---|---|---|---|---|
| Merge-on-Read(默认旧) | 关闭 | 直接写入新行,不删除旧行 | 查询时合并,保留最新 | 写快读慢 |
| Merge-on-Write(推荐) | enable_unique_key_merge_on_write=true |
写入时标记旧行删除,新行写入 | 直接读新行 | 写稍慢读快 |
强烈建议开启 Merge-on-Write ,它通过主键索引实现:
- 维护一个主键到行号的索引;
- 写入新数据时,通过索引找到旧行并标记删除;
- 查询时跳过被删除的行,只读最新数据;
- 读性能接近明细模型,同时支持高效更新。
适用场景:
- 需要实时更新的维表(用户画像、商品信息);
- 需要高并发点查的场景(按主键查单条);
- 数据会频繁变更,不能用聚合模型的场景。
支持的操作:
sql
-- 覆盖写入(相同主键更新)
INSERT INTO user_profile VALUES (1, 'Alice', 20, 'Beijing');
INSERT INTO user_profile VALUES (1, 'Alice', 21, 'Shanghai'); -- age 变为 21,city 变为 Shanghai
-- 部分列更新(需要指定要更新的列)
INSERT INTO user_profile (user_id, age) VALUES (1, 22)
-- 只更新 age,name 和 city 保持不变
注意事项:
- 主键列不能被更新,只能更新非主键列;
- 部分列更新需要保证写入数据只包含主键和要更新的列;
enable_unique_key_merge_on_write在建表时指定,建表后不可修改。
五、三种模型的对比与选型
| 维度 | 明细模型 | 聚合模型 | 主键模型 |
|---|---|---|---|
| 存储方式 | 原样存储 | 自动聚合 | 最新行覆盖 |
| 支持更新 | 否(只能追加) | 否(只能追加聚合) | 是(行级更新) |
| 支持点查 | 弱(全表扫描) | 弱 | 强(主键索引) |
| 查询性能 | 取决于扫描量 | 极快(预聚合) | 快 |
| 写入性能 | 快 | 快(写入时聚合) | 中(写时合并开销) |
| 存储空间 | 大(存所有明细) | 小(聚合后数据少) | 中(存最新行+删除标记) |
| 典型场景 | 日志、流水 | 报表、指标 | 维表、实时画像 |
选型决策树:
数据需要按主键更新吗?
├── 是 → 主键模型(UNIQUE KEY)
└── 否 → 查询需要看原始明细吗?
├── 是 → 明细模型(DUPLICATE KEY)
└── 否(只看聚合指标)→ 聚合模型(AGGREGATE KEY)
常见组合用法:
- 明细模型存原始日志,定时同步到聚合模型做报表加速;
- 主键模型存用户维表,和明细表做 Join;
- 聚合模型用
BITMAP_UNION做用户去重指标,查询时直接BITMAP_UNION_COUNT。
六、典型坑点
坑点 1:建表时选错模型,后续无法修改
数据模型在建表时确定,不能通过 ALTER TABLE 修改。选错了只能重建表、重导数据。
对策:建表前按上面的决策树确认需求,不确定时优先选明细模型(最灵活)。
坑点 2:聚合模型 REPLACE 取值不确定
REPLACE 只保证"最后写入的生效",但分布式写入时"最后"不保证是业务上的最新。
对策 :需要"取最新"用主键模型;聚合模型的 REPLACE 只适合覆盖全量导入的场景。
坑点 3:主键模型没有开启 Merge-on-Write
默认是 Merge-on-Read,查询时合并,数据量大时查询慢。
对策 :建表时一定加 PROPERTIES("enable_unique_key_merge_on_write" = "true")。
坑点 4:明细模型的 Key 列顺序不合理
稀疏索引只基于 DUPLICATE KEY 的列,如果查询常用的过滤条件不在 Key 列的前几位,索引就用不上。
对策:把最常用的过滤列放在 Key 的最前面,如时间列 + 高基数 ID。
坑点 5:主键模型部分列更新失败
部分列更新需要写入数据只包含主键和要更新的列,多带了列会用 NULL 覆盖原值。
对策 :部分更新时严格只写主键 + 更新列,或开启 partial_columns_mode=true 让系统自动处理。
坑点 6:聚合模型无法查询明细
聚合模型的数据在存储层已经被合并,无法查出原始行。
对策:如果既要明细又要聚合,用两张表(一张明细、一张聚合),明细导入时同步聚合到聚合表。
七、本篇小结
这一篇抓住两个核心:
-
底层架构:
- FE 是大脑:元数据(BDBJE + Paxos)、SQL 优化(CBO)、查询调度、集群管理;
- BE 是手脚:存储引擎(Tablet → Segment → Column → Page + 稀疏索引 + ZoneMap)、向量化执行引擎、MemTable 写入 + Compaction;
- 写入走 MemTable → Segment,查询走稀疏索引 + ZoneMap 裁剪 + 向量化执行。
-
三种数据模型:
- 明细模型(DUPLICATE KEY):原样存储,适合日志流水;
- 聚合模型(AGGREGATE KEY):按 Key 自动聚合,适合报表预聚合,查询极快;
- 主键模型(UNIQUE KEY):按主键覆盖更新,适合实时更新和点查,务必开启 Merge-on-Write。
理解了架构和模型,下一篇可以继续讲分区分桶的最佳实践、查询优化和 Compaction 调优。