Doris 实战与架构

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

表设计与数据建模——从业务问题到可扩展 OLAP Schema

把业务访问模式翻译成数据模型、Sort Key、分区、分桶、索引和 Colocate 设计。

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

预计阅读时间: 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 表结构照搬过来, 而是在四个问题之间取平衡:

  1. 数据如何写入: 追加、更新、聚合还是 CDC。
  2. 查询如何过滤: 时间、租户、业务主键、维度组合。
  3. 数据如何分布: Partition 管生命周期, Bucket 管并行和均衡。
  4. 热点如何加速: Sort Key、Prefix Index、Bloom Filter、Bitmap、Inverted Index、物化视图。

Apache Doris 官方 POC 文档也把建模放在第一步: 数据模型、排序键、分区和分桶会直接决定后续导入和查询性能。

参考:


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 会基于排序键前缀加速定位。真正的设计规则不是"把主键放前面", 而是:

  1. 高频过滤列靠前。
  2. 固定长度类型优先, 例如 DATE、INT、BIGINT。
  3. 高选择性列优先, 但不要破坏时间裁剪。
  4. VARCHAR 放太前会让 Prefix Index 早早截断。
  5. 没进入前缀范围的字段, 用 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 数量和数据分布。它直接影响三个东西:

  1. 查询并行度。
  2. 导入并发。
  3. 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、索引和物化视图之间的组合, 而不是单独背某一种建表语法。