MERGE语句有Bug?SQL Server官方不推荐的背后真相与替代方案
"一句 MERGE 搞定 upsert,多优雅。"------这是很多开发者第一次接触 SQL Server 2008 引入的 MERGE 时的直观感受。但到了生产环境,你可能会遇到:并发下偶发主键冲突、触发器 @@ROWCOUNT 不对、列存储/时态表上报错、执行计划选出一个离谱的 Join 顺序......
社区里 Aaron Bertrand 那篇著名的《Please stop using MERGE》不是标题党。 微软官方文档也写得克制:"为某些应用程序需求编写离散 INSERT/UPDATE/DELETE 语句可能更有效;大规模地,MERGE 可能会引入复杂的并发问题或需要高级故障排除。"
MERGE 不是不能写,而是它承诺的"原子 upsert"在 SQL Server 里并没有你想象中那么稳。 下面把真相拆开讲。
一、最大的误解:MERGE 是"单条原子语句"
直觉上,MERGE 是一条语句,应该像一条 UPDATE 一样原子、隔离、安全。
但 SQL Server 在引擎内部并不是把它当成"一个操作"执行的。它会被拆成:先扫源表和目标表做 Join → 再按匹配结果分别调度 UPDATE / INSERT / DELETE 分支。
这意味着两件事:
- 它不会自动给你 SERIALIZABLE 级别的隔离保护 。两个会话同时跑同一个
MERGE,都可能先在匹配阶段没找到行,然后都去 INSERT,于是主键冲突或重复业务数据。 - 所谓"单语句原子性"只保证事务回滚语义,不保证并发下不会出现竞态。
修复办法是手写 HOLDLOCK(等价于在目标上加 SERIALIZABLE 范围锁):
sql
MERGE dbo.Target WITH (HOLDLOCK) AS t
USING dbo.Source AS s ON t.Id = s.Id
WHEN MATCHED THEN UPDATE SET t.Val = s.Val
WHEN NOT MATCHED THEN INSERT (Id, Val) VALUES (s.Id, s.Val);
但 HOLDLOCK 又引入新坑:如果数据库开了 RCSI(Read Committed Snapshot Isolation) ,HOLDLOCK 强制的 SERIALIZABLE 范围锁和快照读混合,在并发下反而更容易死锁。 这是一个"不加锁有竞态,加锁可能死锁"的两难。
结论:MERGE 的"简洁"是把并发控制的复杂性藏起来了,但你躲不掉,只是看不见。
二、那些年积累的"Wrong Results" Bug 清单
Aaron Bertrand 在 MSSQLTips 上整理过一份 MERGE 已知问题清单,后来 Michael J Swart、Hugo Kornelis 陆续跟进复核。到 2023 年 Kornelis 的结论是:SQL Server 2022 CU7 之后,大部分老 Bug 已修,但仍有少量边缘问题存活,而且"已修复"不等于"你线上的版本已修"。
几个典型类别:
- 触发器行为异常 :MERGE 会按分支分别触发 INSERT/UPDATE/DELETE 触发器,
@@ROWCOUNT返回的是各分支影响行数之和,不是你直觉里的"这一行被更新了"。应用层如果靠触发器里的@@ROWCOUNT判断"是否真改了数据",会误判。 - 源表有重复行直接炸 :源里同一个 PK 出现两次,
WHEN MATCHED会对同一目标行尝试更新两次,报Msg 8672。其他数据库(PostgreSQL/ Snowflake)不一定复现,这是 SQL Server 特有。 - 索引视图 / 时态表 / 列存储边缘错误 :早期版本里 MERGE + 索引视图 DELETE 分支不刷新视图;时态表历史表带非聚集索引时报"设非空列为 NULL";tempdb 列存储表同时做 MERGE 目标 + OUTPUT 插入目标会断言失败。部分在 2022 CU16 / 2025 RTM 修了一部分,但没全清。
- 优化器选错计划:统计信息倾斜或带 JOIN Hint 时,可能把同一源行映射到多个目标位置,导致 INSERT 分支被多次触发(KB4017359 类问题)。
- 外键/CHECK 约束逃逸:早期有"MERGE 绕过引用完整性"的 Connect item,部分 2012+ 修了,但涉及表变量 + 计算列 + CHECK 的组合在 2025 仍有人复现到异常。
微软从来没有发过"我们不建议用 MERGE"的红色横幅,但官方文档的措辞是"彻底测试后再上生产""离散语句对某些场景更高效"------翻译过来就是:别默认信它。
三、性能:MERGE 并不比"分开写"更快
很多人用 MERGE 是以为"一条语句少一次网络往返、执行计划更优"。实测里:
- 批量 ETL 场景,MERGE 的 Join 形状经常不如"先
UPDATE FROM再INSERT WHERE NOT EXISTS"好调; - 分开写可以分别针对 UPDATE 和 INSERT 走不同索引、不同并行度;
- MERGE 在大规模写入时锁持有时间更长,TempDB 压力更明显;
- 社区基准普遍显示分离语句在 OLTP 关键路径上平均快 25--30% ,且执行计划可读、好排查。
MERGE 唯一占便宜的是"代码短"和"语义集中"------但这点收益,在出一次竞态 bug 或错数据之后就不值钱了。
四、生产级替代方案
方案 1:UPDATE + INSERT(最常用)
sql
-- 1. 更新已存在
UPDATE t
SET t.Val = s.Val
FROM dbo.Target t
JOIN dbo.Source s ON t.Id = s.Id;
-- 2. 插入不存在
INSERT INTO dbo.Target (Id, Val)
SELECT s.Id, s.Val
FROM dbo.Source s
WHERE NOT EXISTS (
SELECT 1 FROM dbo.Target t WHERE t.Id = s.Id
);
要并发安全,包一层事务 + 合适隔离(或目标 Id 上唯一索引 + 捕获主键冲突重试)。
方案 2:CTE + 分离 DML(解决源表重复行)
sql
WITH dedup AS (
SELECT Id, Val, rn = ROW_NUMBER() OVER (PARTITION BY Id ORDER BY (SELECT NULL))
FROM dbo.Source
)
UPDATE t SET t.Val = d.Val
FROM dbo.Target t JOIN dedup d ON t.Id = d.Id AND d.rn = 1;
INSERT INTO dbo.Target (Id, Val)
SELECT d.Id, d.Val FROM dedup d
WHERE d.rn = 1
AND NOT EXISTS (SELECT 1 FROM dbo.Target t WHERE t.Id = d.Id);
顺手把源表去重,避开 MERGE 8672。
方案 3:并发 UPSERT 正确姿势(HOLDLOCK 或 UPDLOCK)
如果一定要单语句语义,用分离写法 + 显式锁提示,比 MERGE 更可控:
sql
BEGIN TRAN;
UPDATE t SET t.Val = s.Val
FROM dbo.Target t WITH (UPDLOCK, HOLDLOCK)
JOIN dbo.Source s ON t.Id = s.Id;
INSERT INTO dbo.Target (Id, Val)
SELECT s.Id, s.Val FROM dbo.Source s
WHERE NOT EXISTS (SELECT 1 FROM dbo.Target t WITH (UPDLOCK, HOLDLOCK) WHERE t.Id = s.Id);
COMMIT;
RCSI 下仍要测死锁;高并发建议加应用层重试。
方案 4: staging + 批量装载
数据仓库/ETL 场景,不要直接 MERGE 大表。先进 staging 表,再分离 UPDATE/INSERT,甚至对维度表用 TRUNCATE + INSERT(全量刷新比三分支 MERGE 简单得多)。
五、什么时候 MERGE 还能用?
不是全盘否定。以下场景 MERGE 风险可控:
- 单线程 ETL 作业,没有并发写同一批键;
- 源已去重、
ON条件列有唯一索引、不涉及触发器/索引视图/时态表/列存储; - SQL Server 2022 CU7+ 或 2025 RTM+,且写过
WITH (HOLDLOCK); - 原型代码、一次性迁移脚本,不在 OLTP 关键路径。
除此之外------新写的生产代码,默认用分离语句。
六、一句话总结
MERGE 的"Bug"不全是引擎崩溃级错误,更多是三类慢性问题叠加:伪原子性带来的竞态、多年累积的边缘错结果 Bug、以及并不优于分离写法的性能与可维护性。微软"不推荐"的官方态度,是用文档里的委婉措辞表达的;社区里 Bertrand、Swart、Kornelis 三人复核了十几年,结论收敛到一点------