Serializable事务下Dapper并发更新数据库死锁的原因与解决方法
死锁问题分析与解决
问题场景
我实现了如下类,用于通过Dapper在Serializable事务中向数据库发送更新请求:
public async Task<int> UpdateTable(UpdateModel model) { await using var connection = createConnection(); await connection.OpenAsync(); await using var tx = connection.BeginTransaction(IsolationLevel.Serializable); var rowsCount = await connection.ExecuteAsync(@$" UPDATE Table WITH (UPDLOCK, SERIALIZABLE) SET Date = @Date, Attempts = Attempts + 1 WHERE Id = @Id AND DATEDIFF(MINUTE, Date, GETDATE()) < 60", new { model.Id, model.Date }, commandTimeout: connection.CommandTimeout, transaction: tx); tx.Commit(); return rowsCount; }
通过如下代码发送10个针对同一Id的异步请求:
var count = 10; while (true) { var tasks = new List<Task>(); var providerId = Guid.NewGuid(); for (var i = 0; i < count; i++) tasks.Add(upsert(providerId)); var result = Task.WhenAll(tasks.ToArray()); result.Wait(); Thread.Sleep(5000); Console.WriteLine("------------------------------------------------------------------"); } async Task upsert(Guid id) { var response = await client.Upsert(new UpsertModel { Id = id, Date = DateTime.Now }); Console.WriteLine($"RowsCount: {response.Content}, Id: {id}"); }
运行后出现死锁,报错信息:
Transaction (Process ID 67) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction
死锁原因
- 同一资源的并发锁竞争:10个异步请求同时针对同一个
Id发起更新,每个请求都开启了Serializable隔离级别的事务,并通过UPDLOCK, SERIALIZABLE申请了强锁。多个事务同时争抢同一行数据的锁资源,形成循环等待,触发SQL Server的死锁检测机制,最终选择一个事务作为牺牲品终止。 - 过度的锁强度叠加:事务级别使用
Serializable隔离(最严格级别,会添加键范围锁),同时UPDATE语句又重复指定SERIALIZABLE提示,导致锁的粒度更粗、持有时间更长,大幅提升了锁冲突的概率。实际上UPDLOCK已经能保证更新操作的锁优先级,不需要叠加这么高的隔离级别。 - 事务锁持有时间过长:每个事务从连接打开、事务开启到执行更新再提交,全程持有锁,异步并发场景下多个事务的锁持有时间完全重叠,进一步加剧了死锁的发生概率。
解决方案
- 降低事务隔离级别:将事务隔离级别从
Serializable调整为Repeatable Read或默认的Read Committed。这两个级别足够避免脏读、不可重复读,同时大幅减少锁的强度和冲突概率,配合UPDLOCK就能保证更新操作的一致性。 - 移除语句级冗余锁提示:把UPDATE语句中的
WITH (UPDLOCK, SERIALIZABLE)简化为WITH (UPDLOCK),避免重复的强锁提示,减少不必要的锁竞争。 - 避免同一资源的并发更新:业务层面做控制,不要同时发起多个针对同一个
Id的更新请求。可以在客户端合并请求,或者通过分布式锁(如Redis锁)保证同一时间只有一个请求能操作该Id的数据。 - 添加死锁重试逻辑:即使做了上述优化,极端场景下仍可能出现死锁。可以捕获错误号为1205的死锁异常,在代码中添加重试机制(比如重试3次),自动重新执行更新操作。
- 缩短事务时长:简化事务内的操作,只保留必要的数据库操作,减少锁的持有时间。比如可以优化连接和事务的生命周期,避免不必要的等待。
内容的提问来源于stack exchange,提问作者Russo
相关产品推荐
相关产品推荐

