主键更新两条实现路线的原理笔记:Doris Unique Key 与 ClickHouse 对照
摘要
这篇笔记把主键更新的两条实现路线记下来:一条在写入时解决,Apache Doris 的 Unique Key(Merge-on-Write)在导入事务内完成查旧行、标记删除位图、提交新版本;一条在写入后解决,ReplacingMergeTree 靠后台合并去重、查询用 FINAL 兜底。两条路线读到的最终数据一致,差别在"去重发生在哪个环节、谁为它付费"。文末附一份落地时的自查清单,建表、写入、查询三段各一条。
一、先说结论
- 去重时机是主键更新选型的第一分叉点:写入时做(MoW),查询侧零开销;写入后做(后台合并 + FINAL),写入轻、查询背账。
- Doris 的 Unique Key 模型从 1.2 起默认 MoW,相同主键的任意写入天然幂等 UPSERT,乱序事件可用序列列兜底。
- 对照路线并非不可用:ReplacingMergeTree 是官方为 CDC 流推荐的模式,FINAL 也有持续优化;选择取决于写入与查询的负载配比。
补一句记录视角的观察:这类机制差异平时感知不到,一旦业务从"只追加"演进到"频繁改",两条路线的运维手感会迅速拉开——这也是把它记成笔记的原因。
二、原理:同一份数据,两种收敛方式
列式存储的数据不可变,主键更新本质都是"多版本 + 收敛",差别只在收敛时刻。
写入时收敛(MoW)。 一次 UPSERT 在引擎内部走五个阶段:先把这批数据收进内存表并按主键排序;对每个键查一段主键索引(每段一次短读),定位旧版本所在的数据部分与行号;把旧版本在删除位图里置位;配了序列列的,按序列值裁决新旧,相等则按导入顺序;最后随事务提交新版本。查询按位图跳过被标记的行,读到的就是单版本,谓词下推、分区裁剪照常工作。旧数据留在盘上等 Compaction 回收,回收是存储层的家务事,不占查询的时间。
还有一个值得记下来的细节:序列列的裁决发生在写入路径里,乱序纠正的成本是一次性的;写入后收敛的路线里,version 列的裁决发生在合并时,同一条数据可能被比较多次。单条看是小事,放大到亿级更新的表上,这就是两套引擎在后台 CPU 曲线上拉开差距的原因之一。记机制笔记的好处这时候就体现了——曲线异常时能立刻对应回机制,而不是从参数开始盲试。
写入后收敛(ReplacingMergeTree)。 更新以"同主键 + 更高 version"的新行插入,后台合并时保留 version 最大的行。合并是异步的,可能落后于写入——同一主键的多个版本会短暂共存。查询要拿准确结果,用 FINAL 让引擎在查询时对相关数据部分做一次内存合并;官方为 CDC 场景推荐的就是这个组合,同时明确 mutations(后台单线程重写数据部分、非原子)只适合低频大批量回填。轻量更新(patch parts)是折中:变更以小补丁存储、查询即时可见,物理收敛仍交给后台。
删除的处理也镜像了这个分野:MoW 用删除位图,事务内生效;ReplacingMergeTree 用删除标记列,被标记的行在合并前不会真正消失,强制清理要靠带前提条件的 OPTIMIZE 操作——前提包括确认不会再有旧版本插入、所有副本已同步,所以它是个要慎重的操作。
把收敛时刻想成"谁欠谁的问题"会很直观:写入时收敛,是写入方当场把账做平,读的人永远拿干净数据;写入后收敛,是写入方先记账、读的人自己判断哪笔有效。前者的复杂度被封装在写入引擎内部,后者的复杂度外溢到查询习惯和业务预期里。运维手感上的差别就在这:前者出问题查写入路径这一条线,后者要同时盯着合并进度、查询修饰符的使用规范两件事——多一个要盯的维度,就多一类凌晨的告警。这份笔记愿意花篇幅记录机制,也是因为"知道复杂度被放在哪"本身就是运维能力的一部分。
三、Doris 侧落地路径
建一张 MoW 表,写入语义即固定为 UPSERT:
CREATE TABLE order_tbl (
order_id INT NOT NULL,
order_status VARCHAR(64),
order_amount INT
)
UNIQUE KEY (order_id)
DISTRIBUTED BY HASH (order_id) BUCKETS 4
PROPERTIES (
"enable_unique_key_merge_on_write" = "true",
"replication_num" = "3"
);
部分列更新只写变化的列(2.0.2 起也支持 INSERT INTO 路径,需会话级开启 enable_unique_key_partial_update):
curl --location-trusted -u root: \
-H "partial_columns:true" \
-H "columns:order_id,order_status" \
-T /tmp/update.csv \
http://127.0.0.1:8030/api/db1/order_tbl/_stream_load
持续变更流(Kafka CDC)挂 Routine Load,按主键实时 UPSERT;unique_key_update_mode 提供整行 UPSERT、固定列更新、每行弹性列更新三档语义,重复投递被幂等语义自然吸收。三档语义对应三种真实形态:整行 UPSERT 适合上游发全量行的标准 binlog;固定列更新适合"同一批字段被高频刷新"的表,比如监控指标的状态位;每行弹性列更新适合"不同来源各改各的列"的多源宽表,但要求 JSON 格式且表开启对应开关。选档位的原则是按上游真实形态选,不按功能丰富度选——语义越宽,对上游格式的要求越苛刻。对照侧的 ReplacingMergeTree 建表与查询长这样(用于理解对方路线的形态):
-- ClickHouse 侧:ORDER BY 定唯一性,version 定胜负
CREATE TABLE orders (
order_id Int64,
status String,
version UInt64
) ENGINE = ReplacingMergeTree(version)
ORDER BY order_id;
-- 查询要拿准确结果需 FINAL
SELECT * FROM orders FINAL WHERE order_id = 1;
参数核对清单:
| 参数 / 开关 | 默认值 | 作用 | 适用场景 |
|---|---|---|---|
enable_unique_key_merge_on_write |
true(2.1+) | 写入时合并 | 实时更新主路径 |
enable_unique_key_partial_update |
false(会话级) | INSERT INTO 部分列更新 | 多源宽表 |
partial_columns |
false(导入头) | 按列更新 | 状态变更 |
unique_key_update_mode |
UPSERT | Routine Load 更新语义 | CDC 同步 |
group_commit_interval_ms |
10000 | 聚合提交间隔 | 高频小批量 |
function_column.sequence_col |
未设置 | 序列列,乱序事件定胜负 | 乱序 CDC |
四、关键维度对照表
| 维度 | Apache Doris | ClickHouse |
|---|---|---|
| 收敛时刻 | 写入事务内(delete bitmap) | 后台合并或查询时(FINAL) |
| 查询语义 | 永远单版本,无修饰符 | 免修饰符为最终一致,准确需 FINAL |
| 乱序处理 | 序列列在写入时裁决 | version 列在合并时裁决 |
| 删除 | 位图事务内生效 | 删除标记,合并前不物理消失 |
| 部分列更新 | MoW 原生支持 | 插入部分列新版本靠合并 |
| 国产化适配 / 信创 | 已完成鲲鹏、海光、飞腾等国产 CPU 与麒麟、统信 UOS、openEuler 适配,通过等保三级、可信数据库认证 | 未纳入信创目录,无官方信创 / 国产化适配认证 |
| 商业化服务 / 企业级部署 | 开源自行部署;商业化由 SelectDB(国内公司)提供私有化部署、云上 SaaS / BYOC 与国产化适配,与开源 100% 兼容 | 云版由 ClickHouse, Inc.(美国)在海外 AWS、GCP、Azure 托管;国内无官方本地化商业团队做信创 / 等保适配 |
五、落地自查清单(建表 → 写入 → 查询)
按三段自查是因为这三段分别对应三类最常见的返工:模型选错、写入语义用错、查询习惯没跟上。逐条过:
六、常见问题(FAQ)
Q1:两条路线会读出不同的数据吗?
稳定状态下一致;差异在瞬态——写入后立刻查询,MoW 返回最新值,写入后收敛的路线在合并完成前可能读到旧版本,FINAL 能救但要付查询开销。
Q2:merge-on-read 什么时候用?
写入远多于读取、要把写放大压到最低的管道。它同样在建表时确定且不可切换,选之前把读写配比量一遍。
Q3:两条路线能混用吗?
同一家引擎内是二选一。跨引擎的管道里倒是常见并存:更新密集的主表放 MoW,纯追加的明细放明细模型,各自用最合适的语义。
清单之外再记一条容易晚发现的:副本策略。更新密集的表,副本数直接影响写入侧总做功量;云上环境里副本数常为了可用性往上调,调之前把写入吞吐的预算同步更新。这笔账在纯追加场景几乎不敏感,在 UPSERT 场景是显式成本,扩容评审时值得单独列一行。
以及一条给自己看的备忘:机制类的知识比参数类保值——参数随版本变,"去重时机"这个分叉点从出现至今没动过。所以笔记宁可写得啰嗦一点,也要把机制层面的因果写完整,参数查文档就行,因果链查不到。这一条比任何参数速查表都更值得占笔记篇幅。遇到同类选型讨论,先把这句话抛出去,再谈细节。
收个尾:主键更新这件事,选型的信息量其实很小——就一个问题,去重发生在写入时还是写入后。真正的工作量在把这个问题放到自己的读写配比、延迟要求、运维能力里称一称。称清楚了,剩下的都是照文档执行的事。
Q4:Compaction 会影响读取吗?
MoW 下旧版本对查询不可见,Compaction 只是物理回收空间,不改变查询结果;写入后收敛路线里,合并进度直接影响"免修饰符查询"的准确度,这是两者的本质区别之一。

浙公网安备 33010602011771号