SQL Server无锁Upsert方案探讨:错误处理与序列化隔离孰优?
首先,先梳理下你提到的两种Upsert方案,再针对你关心的开销问题展开分析:
常规Serializable隔离级方案
这个方案的核心是通过最高隔离级别避免并发插入冲突,但代价很明显:
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; BEGIN TRANSACTION; UPDATE dbo.table SET ... WHERE PK = @PK; IF @@ROWCOUNT = 0 BEGIN INSERT dbo.table(PK, ...) END COMMIT TRANSACTION;
Serializable隔离级会在查询时添加范围锁(你提到的表级锁是极端情况,更多是针对PK范围的键范围锁),这会导致高并发场景下大量事务阻塞排队,吞吐量急剧下降——毕竟它的设计目标是强一致性,而非高并发性能。
无锁TRY/CATCH方案
你提出的这个方案是典型的乐观并发控制思路,先尝试操作,冲突时再处理:
BEGIN TRANSACTION; UPDATE dbo.table SET ... WHERE PK = @PK; IF @@ROWCOUNT = 0 BEGIN BEGIN TRY INSERT dbo.table(PK, ...) END TRY BEGIN CATCH IF ERROR_NUMBER() = 2627 BEGIN UPDATE dbo.table SET ... WHERE PK = @PK; END END CATCH END COMMIT TRANSACTION;
逻辑很清晰:先更新,没找到行就插入;如果插入因为唯一键冲突(错误2627)失败,说明其他事务已经抢先插入了该行,这时再执行一次更新即可。
开销对比:错误处理 vs Serializable锁
你的疑惑点非常关键,这里分场景讨论:
1. 大多数操作是UPDATE(目标行已存在)
这种场景下,无锁方案几乎不会触发CATCH块,性能和普通UPDATE操作几乎一致。而Serializable方案会因为严格的锁机制,导致即使是普通UPDATE也会产生额外的锁开销,甚至引发阻塞——显然无锁方案更优。
2. 并发插入同一不存在PK的场景较多
此时无锁方案会频繁触发2627错误,进入CATCH块执行重试更新。错误处理确实有一定开销(比如捕获错误、检查错误号的逻辑),但对比Serializable隔离级下的锁竞争带来的事务排队阻塞,这种开销几乎可以忽略。
Serializable会让所有并发插入该PK的事务串行执行,相当于把并行操作变成了串行;而无锁方案是乐观尝试,只有在真正冲突时才重试,大部分时候事务可以并行完成,整体吞吐量会高很多。
额外的优化方案
其实还有一种折中的方案,不需要依赖错误处理,也能避免Serializable的强锁:使用UPDLOCK, HOLDLOCK提示来获取行级的更新锁,同时锁定PK范围防止并发插入:
BEGIN TRANSACTION; UPDATE dbo.table WITH (UPDLOCK, HOLDLOCK) SET ... WHERE PK = @PK; IF @@ROWCOUNT = 0 BEGIN INSERT dbo.table(PK, ...) END COMMIT TRANSACTION;
这个方案的锁粒度比Serializable小很多,不会引发大范围阻塞,同时也能避免并发插入冲突,你可以把它也加入测试对比。
总的来说,高并发Upsert场景下,无锁TRY/CATCH方案的错误处理开销远低于Serializable隔离级带来的锁阻塞损失,是更优的选择。
内容的提问来源于stack exchange,提问作者Bruno Gonçalves

