预计阅读时间: 55 分钟 前置阅读: doc-00, doc-06, doc-08 下一次阅读: doc-18(慢 SQL 与 Profile 调优)
1. 为什么专家必须先会建模
会读查询链路和存储格式, 还不等于能把 Doris 用好。生产环境里最常见的性能问题, 很多不是优化器或执行引擎的 bug, 而是表设计阶段已经埋下了代价:
- 分区列没有出现在高频查询条件里, 导致扫描大量无关 Partition。
- Bucket 太多, FE 调度开销和小文件数量上升。
- Bucket 太少, Scan 并行度被锁死。
- Sort Key 把低频字段放在前面, Prefix Index 过滤收益很低。
- Unique Key 被当成万能 Upsert 表, 导入、Compaction 和查询都被放大。
- 文本检索、半结构化字段、明细查询都塞进同一张宽表, 索引和治理边界混乱。
Doris 表设计不是把 MySQL 表结构照搬过来, 而是在四个问题之间取平衡:
- 数据如何写入: 追加、更新、聚合还是 CDC。
- 查询如何过滤: 时间、租户、业务主键、维度组合。
- 数据如何分布: Partition 管生命周期, Bucket 管并行和均衡。
- 热点如何加速: Sort Key、Prefix Index、Bloom Filter、Bitmap、Inverted Index、物化视图。
Apache Doris 官方 POC 文档也把建模放在第一步: 数据模型、排序键、分区和分桶会直接决定后续导入和查询性能。
参考:
- https://doris.apache.org/docs/4.x/getting-started/before-you-start-the-poc/
- https://doris.apache.org/docs/4.x/table-design/data-partitioning/basic-concepts/
- https://doris.apache.org/docs/4.x/table-design/data-partitioning/auto-partitioning/
2. 三种主模型的选择
Duplicate Key: 明细事实表的默认选择
Duplicate Key 保留所有行, 适合日志、事件、订单流水、行为明细、Trace Span、指标样本等追加型数据。
CREATE TABLE event_log
(
event_time DATETIME NOT NULL,
tenant_id BIGINT NOT NULL,
service_name VARCHAR(64),
event_type VARCHAR(64),
trace_id VARCHAR(64),
message STRING
)
DUPLICATE KEY(event_time, tenant_id, service_name)
AUTO PARTITION BY RANGE(date_trunc(event_time, 'day')) ()
DISTRIBUTED BY HASH(tenant_id) BUCKETS 24
PROPERTIES (
"replication_num" = "3"
);
判断标准:
| 问题 | 如果答案是 yes |
|---|---|
| 同一个业务主键是否允许多条历史记录 | Duplicate Key |
| 查询是否主要按时间范围扫描 | Duplicate Key + 时间分区 |
| 是否更关心导入吞吐和扫描性能 | Duplicate Key |
Unique Key: CDC 与当前状态表
Unique Key 表表达"同一个 Key 只保留最新值"。它适合用户画像、设备状态、订单当前状态、账户余额等更新型数据。
CREATE TABLE user_profile
(
user_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
status TINYINT,
level_name VARCHAR(32),
updated_at DATETIME
)
UNIQUE KEY(user_id)
DISTRIBUTED BY HASH(user_id) BUCKETS 32
PROPERTIES (
"enable_unique_key_merge_on_write" = "true",
"replication_num" = "3"
);
Unique Key 的专家判断不是"能不能更新", 而是"更新代价能不能被业务接受":
- 高频更新会增加写入、Compaction 和版本管理压力。
- 如果主键分布倾斜, 部分 Tablet 会成为热点。
- 如果查询经常按非主键过滤, 需要补 Sort Key、索引或物化视图思路。
- 如果只需要保留历史流水, 不要强行使用 Unique Key。
Aggregate Key: 预聚合指标表
Aggregate Key 适合写入时就能合并的指标, 比如 PV、UV 近似值、总金额、最大/最小值。
CREATE TABLE site_metric_daily
(
dt DATE NOT NULL,
site_id BIGINT NOT NULL,
channel VARCHAR(32) NOT NULL,
pv BIGINT SUM DEFAULT "0",
uv HLL HLL_UNION,
amount DECIMAL(18, 2) SUM DEFAULT "0"
)
AGGREGATE KEY(dt, site_id, channel)
AUTO PARTITION BY RANGE(date_trunc(dt, 'day')) ()
DISTRIBUTED BY HASH(site_id) BUCKETS 16;
使用边界:
- 聚合函数必须是业务语义的一部分, 不是为了省存储硬凑。
- 明细追溯需求强时, 保留明细表, 再通过物化视图或异步任务生成聚合表。
- UV 这类指标要提前决定精确 Bitmap 还是近似 HLL。
3. Sort Key 与 Prefix Index
Doris 的排序键决定数据在 Segment 内的组织顺序, Prefix Index 会基于排序键前缀加速定位。真正的设计规则不是"把主键放前面", 而是:
- 高频过滤列靠前。
- 固定长度类型优先, 例如 DATE、INT、BIGINT。
- 高选择性列优先, 但不要破坏时间裁剪。
- VARCHAR 放太前会让 Prefix Index 早早截断。
- 没进入前缀范围的字段, 用 Bloom Filter、Bitmap 或 Inverted Index 补位。
示例:
-- 常见查询:
-- WHERE event_time >= ? AND event_time < ? AND tenant_id = ? AND service_name = ?
DUPLICATE KEY(event_time, tenant_id, service_name)
AUTO PARTITION BY RANGE(date_trunc(event_time, 'day')) ()
DISTRIBUTED BY HASH(tenant_id) BUCKETS 24;
如果业务更常按租户看近期数据:
DUPLICATE KEY(tenant_id, event_time, service_name)
区别:
| 排序键 | 适合查询 | 风险 |
|---|---|---|
(event_time, tenant_id) | 全站时间范围分析 | 单租户查询可能扫描更多块 |
(tenant_id, event_time) | 租户隔离报表 | 全局时间报表可能不如前者 |
(trace_id, event_time) | Trace 点查 | 时间聚合不适合作为主表排序 |
专家做法通常是: 主明细表服务最主要的扫描模式, 其他查询通过索引、Rollup、物化视图或字典表补齐。
4. Partition: 生命周期与裁剪
Partition 的第一职责是管理数据生命周期, 第二职责才是查询裁剪。日志、指标、行为事件一般按时间分区。
自动分区
AUTO PARTITION BY RANGE(date_trunc(event_time, 'day')) ()
适合:
- 数据源不断产生新日期。
- 不希望人工提前创建分区。
- POC 或早期业务阶段。
动态分区
动态分区按规则滚动创建和删除分区, 常用于 TTL。
PROPERTIES (
"dynamic_partition.enable" = "true",
"dynamic_partition.time_unit" = "DAY",
"dynamic_partition.start" = "-30",
"dynamic_partition.end" = "3",
"dynamic_partition.prefix" = "p",
"dynamic_partition.buckets" = "24"
);
但官方文档也提示, Auto Partition 是动态分区的后继能力。新表优先考虑 Auto Partition, 老系统再评估是否保留 Dynamic Partition。
分区设计反例
| 设计 | 问题 |
|---|---|
| 按用户 ID 分区 | 分区数量不可控, 生命周期难管理 |
| 按小时分区但数据量很小 | Tablet 和小文件过多 |
| 按月分区但每天查询 | 每次查询扫大量无关数据 |
| 查询条件没有分区列 | Partition Pruning 失效 |
5. Bucket: 并行度与均衡
Bucket 决定 Tablet 数量和数据分布。它直接影响三个东西:
- 查询并行度。
- 导入并发。
- FE/BE 元数据和调度开销。
一个实用估算:
单分区压缩后数据量 / 目标单 Tablet 大小 = bucket 数
经验目标:
- Duplicate Key: 单 Tablet 压缩后通常控制在 1GB 到 20GB。
- Unique Key: 更新和 Compaction 更敏感, 目标更保守。
- Bucket 数通常取 BE 数量的整数倍。
- 不要为了"更并行"把每个小分区切成几百个 Bucket。
检查命令:
SHOW TABLETS FROM event_log;
SHOW PARTITIONS FROM event_log;
关注:
- 同一分区下 Tablet 大小是否接近。
- 是否存在单个 Tablet 明显大于其他 Tablet。
- 是否存在大量很小 Tablet。
- 扩容后新旧数据是否均衡。
6. Colocate 与 Join 建模
如果两张大表经常 Join, 且 Join Key 稳定, 可以考虑 Colocate Group, 让相同 Key 的 Bucket 落在相同 BE, 减少 Shuffle。
CREATE TABLE orders
(
order_id BIGINT,
user_id BIGINT,
dt DATE,
amount DECIMAL(18, 2)
)
DUPLICATE KEY(order_id, user_id)
PARTITION BY RANGE(dt) (...)
DISTRIBUTED BY HASH(user_id) BUCKETS 32
PROPERTIES (
"colocate_with" = "user_group"
);
CREATE TABLE user_profile
(
user_id BIGINT,
city VARCHAR(64),
level_name VARCHAR(32)
)
UNIQUE KEY(user_id)
DISTRIBUTED BY HASH(user_id) BUCKETS 32
PROPERTIES (
"colocate_with" = "user_group"
);
适合:
- Join Key 相同。
- Bucket 数一致。
- 大表之间 Join 频繁。
- 集群节点稳定, 不频繁扩缩容。
不适合:
- 小维表 Join, 直接 Broadcast 更简单。
- Join Key 经常变化。
- 表生命周期不同, 经常重建。
7. 建模评审清单
上线前, 至少回答这些问题:
| 维度 | 问题 |
|---|---|
| 写入 | 是追加、Upsert、聚合还是 CDC |
| 查询 | Top 10 SQL 的 WHERE、JOIN、GROUP BY 是什么 |
| 分区 | 分区列是否出现在高频查询条件中 |
| 分桶 | Bucket 数是否匹配 BE 数和单分区数据量 |
| 排序 | Sort Key 是否服务最高频过滤路径 |
| 索引 | Prefix Index 覆盖不到的列是否需要补索引 |
| 变更 | 分区列、分桶列、Key 列未来是否稳定 |
| 运维 | Tablet 数、版本数、Compaction 压力是否可控 |
一句话总结:
Doris 专家的建模能力, 是把业务访问模式翻译成 Partition、Bucket、Sort Key、索引和物化视图之间的组合, 而不是单独背某一种建表语法。