依赖注入Scoped生命周期DBContext时如何使用事务解决死锁
托管在Azure上的.NET应用运行时抛出如下错误:
Transaction was deadlocked on lock resources with another process and has been chosen as the deadlock victim.(事务在锁资源上与其他进程发生死锁,已被选为死锁牺牲品)
当前应用采用依赖注入(DI)方式以Scoped(作用域)生命周期获取EF Core的DbContext对象,以下针对相关疑问逐一给出解答:
首先纠正一个常见误解:EF Core使用事务完全不需要手动创建DbContext实例,注入的Scoped DbContext原生支持事务操作,按业务场景分两类用法:
- 隐式事务:EF Core默认会将所有
SaveChanges()/SaveChangesAsync()调用包裹在隐式事务中,如果业务操作仅需单次提交即可完成,不需要额外编写事务代码,默认机制即可保证单批次提交的原子性,多余的显式事务代码反而会拉长持锁时间。 - 显式事务:需要跨多次SaveChanges调用、或需要自定义事务隔离级别的场景,直接在注入的DbContext实例上调用事务API即可,示例代码如下:
// _dbContext为构造函数注入的Scoped生命周期实例 using var transaction = await _dbContext.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted); try { // 执行多组数据库操作 _dbContext.Orders.Add(newOrder); await _dbContext.SaveChangesAsync(); _dbContext.Inventories.Update(targetStock); await _dbContext.SaveChangesAsync(); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; }
如果需要在当前请求作用域内跨多个服务复用同一个事务,直接将开启的事务实例传递给同作用域下的其他服务即可,所有复用同一个DbContext实例的操作会自动加入当前事务上下文,不需要额外处理。
绝对不推荐直接手动new DbContext实例,核心原因如下:
- 手动创建的DbContext脱离DI容器生命周期管理,很容易出现数据库连接泄漏、配置不一致(无法复用DI中注册的拦截器、连接字符串、执行策略等)、Dispose不及时等问题。
- 手动创建的多份DbContext会各自维护独立的数据库连接和事务上下文,同一个业务操作中的多组逻辑会落到不同的事务里,反而会大幅提升锁冲突和死锁概率,完全违背使用事务规避死锁的初衷。
- Scoped生命周期的DbContext本身就是为单个请求/业务作用域复用设计的,天然适配工作单元模式,没有任何手动创建实例的必要。
唯一的例外场景:需要执行和当前业务事务完全无关的独立后台操作,这种场景也不应该直接new DbContext,而是通过IServiceScopeFactory创建独立的DI作用域,再从新作用域中获取DbContext实例,示例代码:
using var independentScope = _serviceScopeFactory.CreateScope(); var separateDbContext = independentScope.ServiceProvider.GetRequiredService<AppDbContext>(); // 用该独立上下文执行和当前事务无原子性要求的操作
死锁的本质是多个事务互相持有对方需要的锁、形成循环等待,并非单纯开启事务就能解决,按落地优先级从高到低可采用以下方案:
- 统一多表访问顺序:梳理所有涉及多表读写的业务逻辑,全局固定表的访问顺序,比如所有涉及订单表、库存表的操作都固定先访问订单表再访问库存表,从根源上避免“A事务锁订单等库存、B事务锁库存等订单”的循环等待,这是解决死锁最彻底的手段。
- 缩短事务持锁时间:禁止在事务范围内编写任何与数据库无关的逻辑,包括调用第三方接口、执行复杂内存计算、等待外部输入等,所有数据校验、参数准备逻辑全部放在事务开启前完成,事务内仅保留必要的数据库读写和提交操作,尽可能缩小锁占用的时间窗口。
- 降低锁冲突概率:
- 非必要场景不要使用高于
ReadCommitted的事务隔离级别(如RepeatableRead、Serializable),避免不必要的范围锁。 - 针对Azure SQL数据库,可开启数据库级别的
READ_COMMITTED_SNAPSHOT选项,开启后读操作基于行版本快照执行,不再占用共享锁,从根源上避免读写操作互相阻塞导致的死锁,开启命令如下:ALTER DATABASE 你的业务数据库名 SET READ_COMMITTED_SNAPSHOT ON; - 不要随意给查询加更新锁、排他锁提示,避免人为扩大锁范围。
- 非必要场景不要使用高于
- 添加死锁重试机制:利用EF Core内置的执行策略,针对SQL Server/Azure SQL的死锁错误码(1205)配置自动重试,示例配置如下:
services.AddDbContext<AppDbContext>(options => { options.UseSqlServer(connectionString, sqlOpts => { sqlOpts.EnableRetryOnFailure( maxRetryCount: 3, maxRetryDelay: TimeSpan.FromMilliseconds(300), errorNumbersToAdd: new[] { 1205 } ); }); });
注意重试逻辑对应的业务操作必须保证幂等,避免重试导致数据重复写入。
- 优化索引缩小锁范围:通过Azure SQL的扩展事件、查询性能洞察工具抓取死锁日志,定位死锁涉及的SQL语句,给查询条件、关联字段添加合适的索引,避免无索引导致的全表扫描,将锁的粒度从整表缩小到目标行,大幅降低锁冲突概率。
- 错峰执行长耗时操作:报表查询、批量数据更新这类长耗时、锁范围大的操作,统一安排到业务低峰期执行,且批量操作拆分为小批次提交,避免一次性锁定大量数据阻塞线上业务。
内容的提问来源于stack exchange,提问作者Ghanshyam Mirani

