摘要 :多表关联到底该怎么建表、更新为什么要写成 Merge-on-Write、调优后拿什么指标证明有效------这三件事决定了星型模型能不能在 OLAP 引擎上跑住。本文给出一套从建表语句到参数表再到验证步骤的完整路径,全部基于 Apache Doris 官方文档可核验的语法与默认值;对照数据取自官方 Benchmark(SSB sf1000 11.6s vs 82.2s、TPC-DS sf1000 173.8s vs 1913.9s)。
一、先说结论
判断一个引擎能不能扛住"星型模型 + 实时更新",直接看下面六条命中几条:
- 维度会变:商品类目、组织架构、门店属性按月调整,宽表需要回溯重刷
- 事实表要跟多张维表关联,且关联字段不是单一主键这种固定形态
- 有逐行更新诉求:用户画像、库存余额、账户额度需要 Upsert
- 更新之后要立刻被点到:写入即查,且要求读到确定性最新值
- 写吞吐高但单批小:CDC、埋点、实时指标秒级写入
- 成本敏感:冷数据占比高,希望历史数据按月落到廉价存储
命中 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、限制项、冷数据查看命令)