MERGE语句有Bug?SQL Server官方不推荐的背后真相与替代方案

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 FROMINSERT 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 三人复核了十几年,结论收敛到一点------

相关推荐
Zane199443 分钟前
只改一个方向的引用,循环引用就能立刻被回收?一文讲透 weakref 弱引用
后端·python
长大198843 分钟前
窗口函数用不好反而更慢?SQL Server中OVER子句的4个性能陷阱
后端
长大19881 小时前
TempDB 爆满导致系统卡死?SQL Server TempDB 瓶颈诊断与根治方案
后端
lichenyang4531 小时前
从「房间」到「实时通知」:用 NestJS + Socket.IO 实现团队邀请的完整工程实践
前端·后端
大黄评测1 小时前
为什么你的SQL查询慢?这7个执行计划陷阱90%的人都踩过
后端
沙盘客1 小时前
AFSIM 示例解读(09)· 传感器全家桶 sensor_demos(下):ESM / SAR / 被动测向
c++·经验分享·后端
叫我Paul就好1 小时前
Spring 为何没有在 Java之外的地方存在?
java·后端·spring
掘金酱2 小时前
TRAE Work 实战帮征文 | 获奖名单公示
前端·人工智能·后端
用户69371750013842 小时前
前阵子刷屏的 Ox-Alpha 真身揭晓!GLM-5.3-Flash 上线,这价格真的杀疯了
前端·后端·github