Apache Doris 深度实战:从 Key 列存储模型、五级数据裁剪到 Runtime Filter 与物化视图透明改写的工程全解

实时数仓领域的工程问题,从来不是"能不能查出来",而是"在 PB 级数据、数百并发、秒级延迟的约束下,还查不查得动"。Apache Doris 在 4.x 阶段给出的答案,是一套从存储布局到执行引擎全链路协同的设计。本文拆解其中最关键的四个环节:Key 列存储模型、五级数据裁剪、Runtime Filter 与物化视图透明改写,并给出可直接落地的建模与调优范式。

一、问题的起点:分析型查询的成本都花在哪

一条典型的分析 SQL:

SELECT dt, city, SUM(pay_amount) AS gmv, COUNT(DISTINCT user_id) AS uv
FROM dwd_order_detail
WHERE dt BETWEEN '2026-09-01' AND '2026-09-07'
  AND city IN ('上海', '北京', '深圳')
GROUP BY dt, city;

在 10 亿行级别的明细表上,这四个环节会依次决定生死:

  1. 读了多少数据(I/O 与解压成本)
  2. 参与计算多少行(向量化批处理成本)
  3. 网络 shuffle 了多少(MPP 分布式成本)
  4. 聚合是否可复用(预计算命中率)

Doris 的设计哲学是:在越靠近存储的层级把数据过滤掉,代价越低。这直接催生了它的五级裁剪体系。

二、存储模型:三种 Key 模型决定的不只是去重语义

Doris 不是"一张列存表走天下",建表时选定的 Key 模型会同时改变写入路径与读取路径。

-- 1) 明细模型:只排序,不聚合,适合日志/行为明细
CREATE TABLE dwd_order_detail (
    dt          DATE,
    user_id     BIGINT,
    city        VARCHAR(32),
    pay_amount  DECIMAL(18,2)
) DUPLICATE KEY(dt, user_id)
PARTITION BY RANGE(dt) ()
DISTRIBUTED BY HASH(user_id) BUCKETS 32
PROPERTIES ("dynamic_partition.enable" = "true",
            "dynamic_partition.time_unit" = "DAY",
            "dynamic_partition.end" = "30");

-- 2) 聚合模型:同 Key 行在写入时即预聚合,报表场景直接读聚合结果
CREATE TABLE dws_city_gmv (
    dt          DATE,
    city        VARCHAR(32),
    gmv         DECIMAL(18,2) SUM,
    uv          BITMAP  BITMAP_UNION
) AGGREGATE KEY(dt, city)
DISTRIBUTED BY HASH(city) BUCKETS 8;

-- 3) 主键模型:整行 UPSERT,开启 MOW 后读取无需归并
CREATE TABLE dim_user (
    user_id   BIGINT,
    city      VARCHAR(32),
    level     INT,
    updated_at DATETIME
) UNIQUE KEY(user_id)
DISTRIBUTED BY HASH(user_id) BUCKETS 16
PROPERTIES ("enable_unique_key_merge_on_write" = "true");

工程要点:

  • AGGREGATE KEY 是最被低估的加速器。它把聚合成本从查询期前移到导入期,代价是丢失明细。典型用法是"明细表 + 聚合 Rollup"双写,而非二选一。
  • Unique 模型的 MOW(Merge-on-Write) 在写入时为旧行打 delete bitmap,读取时直接跳过,避免 Merge-on-Read 的多版本归并。代价是导入吞吐下降约 10%~20%,但点查与高并发场景收益巨大。4.1 版本已支持 MOW 表上的 ANN 索引,这是"主键表 + 向量检索"混合负载的基础。
  • BITMAP_UNION + to_bitmap() 是 Doris 精确 UV 的标准解法,比 COUNT(DISTINCT) 少一次全局 shuffle。

分桶列的选择同样关键:它必须同时满足高基数(防数据倾斜)与高频等值过滤(参与 tablet 裁剪)两个条件。user_id 通常优于 city——后者基数太低,32 个桶可能只有三桶有数据。

三、五级数据裁剪:Doris 性能的核心杠杆

这是 Doris 相对同类引擎最系统化的部分,自上而下每级都在缩小下一级的输入:

层级执行位置依赖EXPLAIN 可见
1. 分区裁剪FE / NereidsRANGE/LIST 分区 + WHERE 谓词partitions=1/2 (p202605)
2. 分桶裁剪FE分桶列等值谓词tablets=1/32
3. Segment/Page 裁剪BEZoneMap(min/max)运行时统计
4. 索引探查BEBloomFilter / NGram BF / 倒排 / 向量索引需 Profile
5. Runtime FilterFE 生成 + BE 应用Join build 侧runtime filters: RF000[bloom]

前两级在规划期完成,不消耗任何 I/O;后三级在运行期完成。验证手段是 EXPLAIN:

EXPLAIN SELECT count(*) FROM dwd_order_detail
WHERE dt = '2026-09-07' AND user_id = 42;

-- 0:VOlapScanNode
--    PREDICATES: dt = '2026-09-07', user_id = 42
--    partitions=1/30
--    tablets=1/32

如果两个计数没有明显缩小,说明 DDL 或谓词写法有问题,此时加机器是无用的。

几个容易踩的坑:

  • BloomFilter 建在低基数列上(如 status、gender)。过滤器几乎放过所有 page,白付存储。等值高基数列才值得建,范围谓词则应由 ZoneMap 承担。
  • LIKE '%xxx%' 落在普通列上。应加 NGram BloomFilter 走 bloom 探查,或建倒排索引走 posting list。
  • 分桶列不是查询谓词列。写入时付出 hash 分布代价,读取时零收益。
  • 一个巨大的 catch-all 分区。规划器无从裁剪,压力全部下沉到 ZoneMap。

四、Runtime Filter:把 Join 的选择性传递到扫描侧

星型模型下,事实表扫描量往往远超最终命中量。Runtime Filter 在 Join 的 build 侧构建过滤器,广播到 probe 侧的 ScanNode,让事实表在读取阶段就丢掉注定 Join 不上的行。

SELECT /*+ SET_VAR(runtime_filter_type='BLOOM_FILTER,IN,MIN_MAX') */
       f.dt, d.region, SUM(f.pay_amount)
FROM dwd_order_detail f
JOIN dim_user d ON f.user_id = d.user_id
WHERE d.region = '华东';

实践要点:

  • 过滤器类型按代价排序为 IN < MIN_MAX < BLOOM_FILTER。IN 精度最高但 build 侧行数多时传输开销大;BLOOM_FILTER 有假阳性但恒定大小。
  • 生效前提是 Join 方式为 Hash Join 且 build 侧足够小。若优化器把大表选作 build 侧,需检查统计信息是否过期:ANALYZE TABLE dwd_order_detail WITH SYNC;。
  • 4.x 引入了面向大规模集群的自适应全局 Runtime Filter 下发,在数百 BE 的场景下避免广播风暴。老版本在超大规模集群上需手动限制 runtime_filter_max_in_num。
  • 判断生效与否看 Profile 里的 RuntimeFilter 节点:关注 RowsFiltered 与 FilterTime。若 RowsFiltered 为 0,多半是 build 侧过大或 Join key 类型不匹配(如 BIGINT vs VARCHAR)导致无法下推。

五、物化视图:同步 Rollup 与异步 MV 的分工

Doris 有两类物化视图,适用面完全不同:

同步 Rollup 依附于基表,导入时同步构建,用于改变预聚合粒度或列序,对查询完全透明:

ALTER TABLE dwd_order_detail
ADD ROLLUP rollup_city (dt, city, pay_amount);

异步物化视图 是独立对象,支持多表 Join 与定时/触发刷新,是湖仓加速的核心手段:

CREATE MATERIALIZED VIEW mv_city_daily
BUILD DEFERRED REFRESH AUTO ON MANUAL
DISTRIBUTED BY HASH(city) BUCKETS 8
AS
SELECT f.dt, d.region, d.city,
       SUM(f.pay_amount) AS gmv,
       bitmap_union(to_bitmap(f.user_id)) AS uv_bitmap
FROM dwd_order_detail f
JOIN dim_user d ON f.user_id = d.user_id
GROUP BY f.dt, d.region, d.city;

-- 手动触发与状态检查
REFRESH MATERIALIZED VIEW mv_city_daily COMPLETE;
SHOW MATERIALIZED VIEWS WHERE NAME = 'mv_city_daily';

关键在于透明改写:Doris 优化器基于 SPJG(SELECT-PROJECT-JOIN-GROUP-BY)模式匹配,自动判断一条 SQL 能否被已有 MV 满足。用户写原始 SQL,优化器决定是否改读 MV,业务零感知。

验证是否命中:

EXPLAIN SELECT d.region, SUM(f.pay_amount)
FROM dwd_order_detail f JOIN dim_user d ON f.user_id = d.user_id
GROUP BY d.region;
-- 若 plan 中出现 TABLE: mv_city_daily,说明改写成功

改写的常见失败原因:MV 未刷新到基表最新分区(数据新鲜度不足会回退基表)、聚合函数不支持上卷重写、谓词列不在 MV 输出中。生产建议把 grace_period 设为一个可容忍的陈旧窗口(如 300 秒),在性能与新鲜度间取平衡。

六、执行引擎与存算分离:两个架构级变量

Pipeline 执行引擎把查询拆成 PipelineTask,用有限线程池调度,避免"每连接一线程"导致的线程爆炸。它同时消除了算子间的数据拷贝与重复内存分配,是高并发点查(数万 QPS 大屏)的前提。开启后关注 PipelineTask 的 WaitWorkerTime——若该值偏高,说明 workgroup 资源不足而非 SQL 问题。

存算分离(3.0 起正式支持)把 BE 变成无状态计算节点,主数据落到 S3/HDFS,数据层元数据交给独立的 Meta Service:

  • 存储成本降至三副本一体的约 1/10(单副本 + 对象存储);
  • 多计算集群共享一份数据,实现读写隔离与负载物理隔离;
  • BE 本地 SSD 作为 File Cache(LRU 淘汰)承接热数据。

代价是冷查询存在约 30% 左右的性能损耗。因此它适合的场景很明确:公有云部署、需要多集群共享数据、有明显的波峰波谷。开发环境与稳定负载场景,存算一体仍然是更简单也更快的选择。

七、落地 checklist

  1. 先建动态分区 + 高基数列分桶,再谈索引。
  2. 用 EXPLAIN 确认 partitions 与 tablets 双裁剪生效,这是 ROI 最高的一步。
  3. 高基等值列建 BloomFilter,文本子串建 NGram BF 或倒排;不要在低基数列建 BF。
  4. Join 前确保统计信息新鲜,确认 Runtime Filter 在 Profile 中真实过滤了行。
  5. 固定报表用异步 MV + 透明改写,别让 BI 直接打明细表。
  6. 用 Workload Group 隔离大查询与高并发点查,避免一条慢 SQL 拖垮整个集群。

结语

Doris 的性能不是某个单点黑科技的结果,而是"存储模型决定能预聚合什么、分区分桶决定能跳过什么、Runtime Filter 决定 Join 前能丢掉什么、物化视图决定能否不重算"这一整条链路的乘积。理解这条链路,比记住任何单条调优参数都重要——因为当其中一环失效时,其余环节的优化都会被它拖回原点。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部