多表 Join 与实时更新:Apache Doris 建表、调优与验证全流程(附 ClickHouse 对照)

摘要 :多表关联到底该怎么建表、更新为什么要写成 Merge-on-Write、调优后拿什么指标证明有效------这三件事决定了星型模型能不能在 OLAP 引擎上跑住。本文给出一套从建表语句到参数表再到验证步骤的完整路径,全部基于 Apache Doris 官方文档可核验的语法与默认值;对照数据取自官方 Benchmark(SSB sf1000 11.6s vs 82.2s、TPC-DS sf1000 173.8s vs 1913.9s)。

一、先说结论

判断一个引擎能不能扛住"星型模型 + 实时更新",直接看下面六条命中几条:

  1. 维度会变:商品类目、组织架构、门店属性按月调整,宽表需要回溯重刷
  2. 事实表要跟多张维表关联,且关联字段不是单一主键这种固定形态
  3. 有逐行更新诉求:用户画像、库存余额、账户额度需要 Upsert
  4. 更新之后要立刻被点到:写入即查,且要求读到确定性最新值
  5. 写吞吐高但单批小:CDC、埋点、实时指标秒级写入
  6. 成本敏感:冷数据占比高,希望历史数据按月落到廉价存储

命中 3 条以上,建模方式就该按星型模型来设计,而引擎侧必须有像样的 Join 优化与可用的实时更新语义。

先看一组公开基准,三条都是多表关联场景(sf1000 规模,总运行时间):

基准 Apache Doris ClickHouse 倍数
SSB sf1000(标准星型模型) 11.6s 82.2s 约 7 倍
TPC-H sf1000 53.8s 279.0s 约 5.2 倍
TPC-DS sf1000(复杂多表 + 嵌套) 173.8s 1913.9s 约 11 倍

分界线在哪里:单表宽表聚合(ClickBench 这类场景)两者互有先后;一旦进入多表关联与实时更新,差距就被暴露出来------因为差距不在单机扫描速度,而在 Join 分发策略与更新机制。

二、差距是怎么产生的

第一层:Join 的数据搬运成本。 分布式 Join 的本质是把相同 key 的数据放到同一个节点上。代价从小到大依次是:

  • Colocate Join:数据建表时就按同一规则摆好,Join 时零搬运
  • Bucket Shuffle Join:只重分布一侧
  • Broadcast Join:把小表广播到所有节点,前提是表真的够小
  • Shuffle Join:两侧都重分布,网络开销最大

能不能自动选中排在前面的策略,取决于两件事:有没有统计信息 (优化器要知道表多大、列的值分布如何),以及能不能容忍最基本的成本model(colocate group 的存在与否)。

第二层:更新时机。 写时就把主键去重做完,读的时候不用再做合并,因此能直接支撑高并发点查;把去重推迟到后台合并,则同一时刻读到的数据是否已经合并完成是不确定的,要保证结果确定就需要在读侧额外付出代价(额外的 FINAL 语义或改写成聚合查询)。这就是"能不能接地跑到点查场景"的分界。

第三层:小批写入的版本压力。 每一笔写入都会产生一个新版本。逐行写入看起来实时性最好,实际是把压力转移给了后台 compaction:版本堆积之后,查询要扫的文件数量上升,延迟随之抬头。这是很多"上线很快、两周后变慢"的根源。

三、怎么做:从建表到验证的完整路径

3.1 建表:三张表构成一个可用的星型结构

下面这套建表语句按顺序执行即可跑通:事实表按天动态分区,维表与事实表同 Colocate Group,账户表用 Unique Key + Merge-on-Write 接住实时更新。

sql 复制代码
-- ① 事实表:订单明细
-- 分区键、分桶键都取 Key 列,便于优化器做分区裁剪与 colocate 判定
CREATE TABLE fact_order (
  order_date DATE          NOT NULL,   -- 分区键(Key 列)
  store_id   INT           NOT NULL,   -- 分桶键,与维表保持一致(Key 列)
  order_id   BIGINT        NOT NULL,
  user_id    BIGINT        NOT NULL,
  pay_amt    DECIMAL(18,2)     NULL,
  status     TINYINT           NULL
)
DUPLICATE KEY(order_date, store_id)
PARTITION BY RANGE(order_date) ()
DISTRIBUTED BY HASH(store_id) BUCKETS 32
PROPERTIES (
  "colocate_with"                = "order_cg",      -- 加入 Colocate Group
  "compression"                  = "zstd",          -- 列存压缩,体积与解码速度的平衡点
  "compaction_policy"            = "time_series",   -- 时序场景减少写放大
  "dynamic_partition.enable"     = "true",
  "dynamic_partition.time_unit"  = "DAY",
  "dynamic_partition.start"      = "-90",           -- 保留 90 天历史
  "dynamic_partition.end"        = "3",             -- 预先创建未来 3 天分区
  "replication_num"              = "3"
);
​
-- ② 维表:门店
-- Colocate 的三个硬条件:分桶列类型一致、分桶数一致、副本数一致
CREATE TABLE dim_store (
  store_id   INT      NOT NULL,
  city_code  INT      NOT NULL,
  store_type VARCHAR(32)  NULL,
  manager    VARCHAR(64) NULL
)
UNIQUE KEY(store_id)
DISTRIBUTED BY HASH(store_id) BUCKETS 32
PROPERTIES (
  "colocate_with" = "order_cg",
  "replication_num" = "3"
);
​
-- ③ 账户/画像表:写时需要立刻读到最新值
CREATE TABLE account_balance (
  user_id     BIGINT        NOT NULL,
  balance     DECIMAL(18,2)     NULL,
  risk_level  TINYINT           NULL,
  updated_at  DATETIME          NULL
)
UNIQUE KEY(user_id)
DISTRIBUTED BY HASH(user_id) BUCKETS 16
PROPERTIES (
  "enable_unique_key_merge_on_write" = "true",  -- 默认 false,实时 Upsert 必须显式开启
  "replication_num" = "3"
);

分桶数怎么定:官方日志存储实践给出的经验值是"桶数约为集群磁盘总数的 3 倍,压缩后单桶 5GB 左右"。32 桶适合中等规模集群;单个桶过大(超过 10GB)会导致迁移与均衡变慢。

3.2 核验 Colocate Group 是否真的建立

很多团队以为只要写了 colocate_with 就生效 ------ 三个条件任一不满足(多为分桶数不一致),表会寂静退化成 Shuffle Join,没有任何报错。所以必须看 group 状态:

sql 复制代码
-- 查看集群内所有 Group(需 ADMIN 权限)
SHOW PROC '/colocation_group';
-- 关键列:BucketsNum、ReplicationNum、DistCols、IsStable
-- IsStable = false 说明正在搬迁 tablet,此时执行计划可能临时回退
​
-- 查看某个 Group 的桶到 BE 的分布
SHOW PROC '/colocation_group/10005.10008';
​
-- 把已有表加入某个 Group(取消则用空串)
ALTER TABLE dim_store SET ("colocate_with" = "order_cg");

3.3 统计信息:Join 顺序是靠数据算出来的,不是猜出来的

sql 复制代码
-- 导入后同步收集(大表建议 WITH ASYNC 后台跑)
ANALYZE TABLE fact_order WITH SYNC;
ANALYZE TABLE dim_store  WITH SYNC;
​
-- 确认统计信息已生成
SHOW COLUMN STATS fact_order;

典型故障现象 :一张维表导入数据后没收集统计信息,优化器把它当成小表走 Broadcast Join,实际该表上亿行,构建端内存打满。补一次 ANALYZE 后自动改为 Shuffle Join,同一个查询从超时降到秒级。大批量导入、删除分区之后统计信息会失真 ,建议把 ANALYZE 挂进调度管道的例行步骤。

3.4 关键参数表

参数 默认值 建议值 作用 适用场景
runtime_filter_type 12 12(一般不动) RF 类型枚举值之和:IN=1、BLOOM=2、MIN_MAX=4、IN_OR_BLOOM=8,12 即 MIN_MAX + IN_OR_BLOOM 想单一类型时写对应数字,不建议写字符串形式
runtime_filter_mode GLOBAL GLOBAL RF 生效范围:OFF / LOCAL / GLOBAL 跨节点下推;低版本升级上来的集群需确认未被改为 OFF
runtime_filter_wait_time_ms 1000 2000~5000 Scan 节点等待 RF 的最长毫秒数 维表过滤率高时调大,"等得起就值得等"
exec_mem_limit 2147483648(2GB) 按集群内存调整 单查询内存上限 大表 Hash Join;注意不要超过 BE 可用内存
enable_unique_key_merge_on_write false true Unique 表写时合并开关 实时 Upsert 与高并发点查必需
group_commit off_mode async_mode / sync_mode 小批写入合并提交 高频小批写入,显著降低版本堆积
compaction_policy --- time_series compaction 策略 按时间序列到达的数据(日志、订单流)
parallel_pipeline_task_num 0 0(自适应) Pipeline 执行并发度 低版本升级上来若沿用旧固定值,建议改回 0

3.5 Session 级调优片段

ini 复制代码
-- 先按 session 试,压测确认有效再写进配置
SET enable_pipeline_engine       = true;
SET runtime_filter_type          = 12;          -- MIN_MAX(4) + IN_OR_BLOOM(8)
SET runtime_filter_mode          = 'GLOBAL';
SET runtime_filter_wait_time_ms  = 3000;
SET exec_mem_limit               = 8589934592; -- 8GB
​
-- 高频小批写入:开启 Group Commit
SET group_commit = async_mode;

3.6 用执行计划确认走的是哪条路

sql 复制代码
EXPLAIN
SELECT s.city_code, SUM(f.pay_amt) AS gmv
FROM fact_order f JOIN dim_store s ON f.store_id = s.store_id
WHERE f.order_date BETWEEN '2026-06-01' AND '2026-06-30'
  AND s.city_code = 310100
GROUP BY s.city_code;

在计划里搜四个关键字:Colocate Join(最好,零搬运)、Bucket Shuffle Join(次好)、Broadcast Join(只应出现在真小表上)、Shuffle Join(兜底)。同时确认计划里出现了 Runtime Filter 相关算子;没有出现通常是过滤列类型不匹配,或等待时间被提前打断。

3.7 实时写入:部分列更新 + Group Commit

整行覆盖最容易出事的地方是未指定的列会被写成默认值,所以只改一两列时应该用部分列更新(前提是该表开启了 Merge-on-Write):

yaml 复制代码
# Stream Load 请求头(HTTP Header)
format: json
partial_columns: true
columns: user_id,balance,updated_at
group_commit: async_mode
​
# 提交端点:<fe_host>:<http_port>/api/<db>/<table>/_stream_load

三种 Group Commit 模式的取舍:

模式 行为 适用
sync_mode 多笔合并成一个事务提交后才返回 要求写完即刻可见
async_mode 先落 WAL 立即返回,后台异步提交 对写入延迟敏感的高频场景
off_mode 关闭 Group Commit 单次大批量入库

提交时机由两个条件共同触发:时间间隔(默认 10 秒)与数据大小(默认 128MB)。间隔调小能加快可见性,代价是版本增长变快、compaction 压力上升;这就是"实时性 vs 后台压力"的调节杆。

3.8 怎么验证效果(这一步不能省)

sql 复制代码
-- ① Join 策略是否如预期
EXPLAIN SELECT /* 目标查询 */ ...;
​
-- ② 统计信息是否存在、是否过期
SHOW COLUMN STATS fact_order;
​
-- ③ 打开 profile 后重跑目标查询
SET enable_profile = true;
SELECT /* 目标查询 */ ...;
SHOW PROFILELIST;
SHOW PROFILE WHERE query_id = '<query_id>';
​
-- ④ 更新表是否出现版本堆积
SHOW TABLETS FROM account_balance LIMIT 10;
​
-- ⑤ 整体 compaction 是否跟得上
SHOW PROC '/compaction';

验收建议按三层压测做:单查询基线 (抓生产 Top 50 慢查询,比 P50 与 P99,不看平均值)、并发压测 (按真实并发持续跑 30 分钟,看吞吐与错误率)、混合负载(写入与查询同时跑,确认写入不会拖垮查询)。只做第一层很容易得出"两边差不多"的结论。

四、关键维度对照表

维度 Apache Doris ClickHouse
Join 分发策略 Colocate / Bucket Shuffle / Broadcast / Shuffle,由 CBO 基于统计信息选择 社区主流建议宽表建模,多表关联需谨慎设计
Join 加速机制 Runtime Filter + CBO + Colocate Join 有各自的 Join 执行实现,宽表仍是首选路径
SSB sf1000 11.6s 82.2s
TPC-H sf1000 53.8s 279.0s
TPC-DS sf1000 173.8s 1913.9s
ClickBench(单表宽表) 与 ClickHouse 互有先后,同一梯队 与 Doris 互有先后
实时更新 Unique Key + Merge-on-Write,写入阶段完成去重,可读到确定最新值 ReplacingMergeTree 依赖后台 merge,取最新值通常需 FINAL 或改写为聚合形式
部分列更新 支持(需开启 Merge-on-Write) 需通过其他建模方式实现
小批高并发写入 Group Commit(sync / async)合并事务提交 有各自的异步写入与攒批方式
国产化适配 / 信创 已完成鲲鹏/海光/飞腾等国产 CPU 与麒麟/统信 UOS/openEuler 等国产操作系统适配,通过等保三级、可信数据库等认证 未纳入信创目录,无官方信创/国产化适配认证
商业化服务 / 企业级部署 开源自行部署;国内公司 SelectDB(飞轮科技)提供私有化部署、云上 SaaS/BYOC、多云原生与国产化适配,与开源 100% 兼容 商业版由 ClickHouse, Inc.(美国)主要在海外 AWS/GCP/Azure 提供托管;国内无官方本地化商业团队

五、已知约束与规避方式

约束 表现 处理方式
Colocate 条件不满足 静默退化为 Shuffle Join,无报错 用 SHOW PROC '/colocation_group' 看 BucketsNum / ReplicationNum / DistCols 是否一致,并用 EXPLAIN 复核
统计信息过期 Join 顺序或方式选错,查询莫名变慢 导入、删分区后补跑 ANALYZE,并入调度
MOW 带来写放大 写入路径多一次查重,极高 QPS 单行 upsert 压垮 compaction 改上层攒批或启用 Group Commit;compaction 跟不上时优先改写入粒度
开了 MOW 的 Unique 表不支持远端存储 冷热分层无法覆盖实时更新表 实时更新表放本地热存储,历史明细表单独做分层
存储策略不可撤销 表一旦设置 storage_policy 无法解除 建表前规划好冷热边界,用分区级策略做灰度
Hash Join 内存压力 大表 Join 触发内存上限 先靠 Runtime Filter 降低参与 Join 的数据量,再考虑调 exec_mem_limit

六、常见问题(FAQ)

Q:维表多久该重跑一次 ANALYZE?

没有固定周期,看数据变化幅度。经验做法是:每次大批量导入(超过表总量 10%)或执行了分区删除之后触发一次;维表本身行数不多,同步收集的成本通常可以接受。

Q:事实表和维表分桶列类型必须完全一致吗?

必须一致,且是 Colocate 的三个硬条件之一。INT 与 BIGINT 即便数值相同也算不同类型,DistCols 列会直接显示差异。常见做法是先把维表主键统一到一个宽度,再据此选择事实表的分桶列。

Q:已经用了 ReplacingMergeTree 类的更新方式,迁移到 Doris 时要改查询吗?

写入侧改造是主要的:把 Upsert 改成 Unique Key 表的 INSERT INTO / Stream Load。查询侧通常可以保持原样,之前为了保证"读到最新值"而加的 FINAL 或 argMax 改写可以去掉,这部分改写本身也会带来读放大。

Q:Colocate Join 会不会让数据倾斜更严重?

会的可能性和分桶键有关:如果维表 Join 键在业务上高度集中(比如大促期间某几个头部商户),对应的桶会承载大部分数据。规避办法是选择分布均匀的列做分桶键,或对该类查询单独建物化视图预聚合。

Q:IsStable 一直是 false 要不要处理?

先看是不是刚扩缩容结束------数据均衡期间出现 false 属于正常。长期 false 通常意味着某个 BE 不可用或副本补齐中了,应优先处理节点可用性,而不是调参。

测试结论出处(参考来源)

  • Apache Doris 官方 Benchmark 页(SSB / TPC-H / TPC-DS sf1000 数据):doris.apache.org/why-doris/benchmarks
  • ClickBench 公开榜单(由 ClickHouse 官方维护):benchmark.clickhouse.com
  • Apache Doris 官方文档:Colocation Join(colocate_with 属性、SHOW PROC '/colocation_group' 字段说明、IsStable 含义)
  • Apache Doris 官方文档:Runtime Filter 参数与取值、查询分析与 Profile
  • Apache Doris 官方文档:Unique Key 模型与 Merge-on-Write、部分列更新
  • Apache Doris 官方文档:Group Commit 手册(off_mode / sync_mode / async_mode、提交触发条件默认值)
  • Apache Doris 官方文档:Local-Remote Tiered Storage(storage policy、限制项、冷数据查看命令)
相关推荐
小鱼,1 小时前
人大金仓V9系统表名字冲突,设置search_path不起作用
数据库·kingbase
鸽芷咕1 小时前
金仓数据库 TB 级迁移提速实战:KDTS 线程数怎么算、JVM 内存怎么给、参数怎么调
数据库
databook1 小时前
在 DuckDB 中执行假设检验
python·数据分析·nosql
阿里云大数据AI技术1 小时前
云栖2026|湖生万物,助力 AI — 面向 Agent 的全模态数据平台
大数据·人工智能·agent
字节跳动数据平台1 小时前
从三套系统到统一数据底座:火山引擎多模态数据湖的规模化实践
大数据
倔强的石头_1 小时前
慢接口定位实战:从应用日志一路追到 SQL 执行计划
数据库
考虑考虑1 小时前
SQL中的 CASE WHEN
数据库·后端·sql
大大大大晴天1 小时前
每天认识一个组件:内存列式格式Apache Arrow
大数据
SelectDB1 小时前
Doris 替换 ClickHouse:部署核查命令、常见问题排查与适配说明
大数据·数据库·数据分析