订单 9001 在 10:05 已经支付。网络恢复后,10:03 产生的取消事件最后到达 Doris。导入任务全部成功,报表却可能得到三种结果:三条历史、一个从未真实发生过的拼接状态,或者正确的已支付状态。
差异不在 SQL,也不在导入工具,而在建表时那一行 DUPLICATE KEY、AGGREGATE KEY 或 UNIQUE KEY。
Doris 的 Key 模型不是数据如何摆放,而是同一个 Key 再次到达时,存储层必须执行什么业务语义。
三个事件足以让错误模型现形
同一个订单依次到达三次。第三条最晚到达 Doris,但它的业务时间早于第二条。
| 到达顺序 | 业务时间 | 状态 | 支付金额 | 含义 |
|---|---|---|---|---|
| 1 | 10:00 | CREATED | 100.00 | 创建订单 |
| 2 | 10:05 | PAID | 100.00 | 完成支付 |
| 3 | 10:03 | CANCELLED | 0.00 | 迟到的旧事件 |
真正的当前状态只能是 PAID。如果系统按到达顺序覆盖,最后会退回 CANCELLED;如果各列独立聚合,还会拼出业务上从未存在的一行。
下面四张表把这种差异直接暴露出来。为了方便单 BE 测试使用一个 Bucket 和一个副本,生产环境不要照搬这两个数值。
sql
CREATE DATABASE IF NOT EXISTS doris_model_lab;
USE doris_model_lab;
CREATE TABLE order_event_dup (
order_id BIGINT,
update_time DATETIME,
status VARCHAR(16),
pay_amount DECIMAL(12, 2)
)
DUPLICATE KEY(order_id, update_time)
DISTRIBUTED BY HASH(order_id) BUCKETS 1
PROPERTIES ("replication_num" = "1");
CREATE TABLE order_state_agg (
order_id BIGINT,
update_time DATETIME MAX,
status VARCHAR(16) REPLACE,
pay_amount DECIMAL(12, 2) REPLACE
)
AGGREGATE KEY(order_id)
DISTRIBUTED BY HASH(order_id) BUCKETS 1
PROPERTIES ("replication_num" = "1");
CREATE TABLE order_state_unique_arrival (
order_id BIGINT,
update_time DATETIME,
status VARCHAR(16),
pay_amount DECIMAL(12, 2)
)
UNIQUE KEY(order_id)
DISTRIBUTED BY HASH(order_id) BUCKETS 1
PROPERTIES (
"replication_num" = "1",
"enable_unique_key_merge_on_write" = "true"
);
CREATE TABLE order_state_unique_event (
order_id BIGINT,
update_time DATETIME,
status VARCHAR(16),
pay_amount DECIMAL(12, 2)
)
UNIQUE KEY(order_id)
DISTRIBUTED BY HASH(order_id) BUCKETS 1
PROPERTIES (
"replication_num" = "1",
"enable_unique_key_merge_on_write" = "true",
"function_column.sequence_col" = "update_time"
);
对四张表分别执行下面三条写入,每条保持为独立事务,顺序不能调整。<目标表> 依次替换为四个表名。
sql
INSERT INTO <目标表> VALUES
(9001, '2026-08-27 10:00:00', 'CREATED', 100.00);
INSERT INTO <目标表> VALUES
(9001, '2026-08-27 10:05:00', 'PAID', 100.00);
INSERT INTO <目标表> VALUES
(9001, '2026-08-27 10:03:00', 'CANCELLED', 0.00);
预期结果不是简单的三选一。
| 表 | 查询结果 | 原因 |
|---|---|---|
order_event_dup |
三条事件全部保留 | Key 只负责排序,不负责唯一性 |
order_state_agg |
10:05、CANCELLED、0.00 | MAX 与 REPLACE 分列执行,拼出不存在的行 |
order_state_unique_arrival |
10:03、CANCELLED、0.00 | 没有 Sequence 时,后到版本覆盖先到版本 |
order_state_unique_event |
10:05、PAID、100.00 | 同 Key 比较 update_time,业务版本更大的行胜出 |
这个实验揭示了一个比模型定义更重要的事实:Doris 能严格执行你声明的存储语义,但它不会替你判断那是不是正确的业务语义。
Duplicate Key 保存事实,不负责回答现在是什么
Duplicate Key 官方文档 明确规定 Key 列只作为排序列,相同 Key 的记录不会去重或聚合。它最适合不可变事件、日志、行为轨迹和审计明细。
它的优势是写入路径最短、原始信息不丢、任何新分析口径都能回到明细重算。代价也很直接:当前订单状态必须在查询时通过窗口函数、MAX_BY 类逻辑或上游计算得到,重复投递也不会自动消失。
把 CDC 订单直接落成 Duplicate Key 并不是一定错误。下面两种目标必须分开:
text
需要完整变更历史 → Duplicate Key
需要每个订单的当前状态 → Unique Key + 可靠 Sequence
一张表同时承担审计历史与在线状态查询,往往才是问题来源。
Aggregate Key 聚合的是列,不是完整业务行
Aggregate Key 官方文档 规定相同 Key 的 Value 列按照各自声明的函数合并。SUM、MAX、MIN、REPLACE 都只对自己的列负责。
实验中的 update_time MAX 保留 10:05,status REPLACE 和 pay_amount REPLACE 接受最后一批到达的数据,于是得到:
text
9001 | 10:05 | CANCELLED | 0.00
这行数据从未在源系统出现。它不是 Doris 算错,而是表结构允许不同列从不同事件中选值。
Aggregate Key 真正优秀的场景是加法和集合语义稳定的指标,例如:
sql
CREATE TABLE ad_counter (
event_date DATE,
campaign_id BIGINT,
impressions BIGINT SUM,
clicks BIGINT SUM
)
AGGREGATE KEY(event_date, campaign_id)
DISTRIBUTED BY HASH(campaign_id) BUCKETS 8;
广告曝光与点击天然可以按固定维度累加,预聚合能减少查询扫描量。订单当前状态是一个完整业务行,不应该被拆成互不相关的列级聚合。
Unique Key 解决主键覆盖,Sequence 才解决乱序
Unique Key 官方文档 将同 Key 写入解释为 UPSERT。Doris 4.0.8 默认采用 Merge-on-Write:新行写入时识别旧版本,查询直接跳过已经失效的行。
但主键唯一只回答同一个订单保留一行,没有回答哪一行更新。没有 Sequence 时,存储层只能依据写入版本决定胜负,所以迟到的 10:03 仍会覆盖已经可见的 10:05。
function_column.sequence_col = update_time 把业务顺序交给 Doris。同 Key 冲突时,Sequence 更大的版本保留;旧事件即使最后到达,也不能把状态写回过去。更新能力官方文档 同样把 Sequence 定义为乱序数据的版本判定列。
Sequence 也不是万能保险:
- 业务时间可能重复,秒级时间戳无法区分同一秒内的多次变更;
- 源库时间可能回拨,不同数据源的时钟未必可比较;
- 部分更新如果没有携带正确的 Sequence,仍可能产生不符合预期的合并;
- 删除事件必须进入同一版本体系,不能只同步 Insert 和 Update。
CDC 场景更稳妥的 Sequence 通常是可全序比较的提交位点,例如单源 Binlog 位点、递增业务版本或经过设计的复合版本,而不是随手使用应用服务器时间。
源码真正证明的是失败行会被标记,而不是原地覆盖
Apache Doris 4.0.8 中,MergeIndexDeleteBitmapCalculator 的比较逻辑 会先比较去掉 Sequence 后的主键;主键相同时,Sequence 更大的记录排在前面。随后,重复位置被加入 Delete Bitmap。把源码压缩成不依赖实现细节的伪代码,只剩下面这条链:
text
按主键扫描候选行
同 Key 时让较大 Sequence 排在前面
第一行成为当前保留版本
后续同 Key 行记录 Rowset、Segment、RowId
把这些位置加入 Delete Bitmap
查询跳过位图中的行,Compaction 再回收旧版本
跨历史 Rowset 的冲突由 BaseTablet::calc_segment_delete_bitmap 处理。代码路径会查找已经存在的同 Key 行;如果旧 Rowset 中存在更大的 Sequence,新到的当前行反而被加入 Delete Bitmap。反之,旧行被标记失效。
text
新 Rowset 的主键
↓
查找历史 Rowset 中的同 Key 行
↓
历史 Sequence 更大 → 标记新行失效
新 Sequence 更大 → 标记历史行失效
↓
提交版本后对查询可见
这就是 Merge-on-Write 的核心差异:它不是修改旧 Segment 中的一行,而是写入新版本,再用 Delete Bitmap 决定查询应该看见谁。代价是写入阶段需要主键查找、冲突计算和位图维护,后台 Compaction 还要回收失效版本。只追加且从不更新的数据使用 Unique Key,只会为不需要的能力付费。
放到 MySQL 和 ClickHouse 中,差异才真正清楚
下面的比较固定同一批乱序 CDC 数据、同一主键和写入后立即查询的条件,只改变冲突处理机制。
| 方案 | 冲突何时处理 | 乱序依据 | 立即查询的结果 | 主要代价 |
|---|---|---|---|---|
MySQL ON DUPLICATE KEY UPDATE |
单行事务更新时 | 默认按语句到达顺序,需自行增加版本条件 | 主键行立即更新 | 行锁、索引维护与 OLTP 写放大 |
ClickHouse ReplacingMergeTree(version) |
后台 Merge,或查询使用 FINAL |
Version 最大值 | 未合并且不加 FINAL 时可能看见多版本 |
最终去重或查询时去重成本 |
| Doris Unique Key MoW + Sequence | 新版本写入和发布阶段 | Sequence 最大值 | 事务可见后直接得到唯一逻辑行 | 写入查找、Delete Bitmap 与 Compaction |
MySQL 官方文档 中的 ON DUPLICATE KEY UPDATE 本质是主键冲突后执行更新,它不会自动理解业务版本。要防止旧事件覆盖,仍需在更新表达式中增加版本判断。
ClickHouse 官方说明 指出 ReplacingMergeTree 的后台合并是异步的,即使配置 Version,也不能依赖合并时机获得即时去重;需要即时正确性时使用 FINAL。它适合高吞吐不可变写入,而 Doris MoW 把更多去重成本放到写入和发布阶段,换取查询侧直接读取唯一逻辑行。
没有脱离场景的绝对优胜。交易更新与约束仍是 MySQL 的主场;吞吐优先且能接受最终去重时,ReplacingMergeTree 有明显价值;持续 CDC 写入后马上要做点查、聚合和 Join,Doris Unique Key MoW 才体现优势。
建表前只判断数据究竟是哪一种事实
| 数据本质 | 正确模型 | 不要付出的错误代价 |
|---|---|---|
| 不可变事件、日志、审计轨迹 | Duplicate Key | 不要为无更新需求维护主键去重 |
| 可交换、可结合的固定指标 | Aggregate Key | 不要把完整业务行拆成列级聚合 |
| 每个主键只保留当前状态 | Unique Key | 不要忘记为乱序设计 Sequence |
如果业务既要完整历史,又要快速查询当前状态,最稳妥的设计通常是两张表:Duplicate Key 保存事件真相,Unique Key 保存服务状态。前者负责可追溯,后者负责低成本查询,二者通过主键与版本做持续对账。
选错 Key 模型最危险的地方不是查询变慢,而是每条 SQL 都能成功,结果却稳定地表达了错误业务语义。