SaveChanges()不由事务管理吗?不同systemId并行更新为何触发死锁
首先明确:死锁并不只有读写同一行数据才会触发,只要两个事务互相持有对方需要的锁资源,且陷入无限等待,就会触发死锁。即使你每次操作的是不同systemId的记录,也可能因为以下原因出现死锁:
死锁触发原因
- 无有效索引导致全表扫描、锁范围过大
如果PartnersUpdate表没有针对SystemId、Status、UpdateAt的联合索引,SQL执行你的筛选查询时会走全表扫描,扫描过程中会给所有路过的行加共享锁,哪怕这些行不属于你当前操作的systemId。此时不同并行任务扫到同一批行时,A任务持有某行的共享锁要升级为排他锁做更新,B任务也持有这行的共享锁要升级排他锁,就会触发死锁。 - IQueryable延迟执行拉长锁持有时间
你代码中的partners是IQueryable类型,直到foreach遍历的时候才会真正执行SQL查询,查询过程中数据库持有锁的时间和你遍历处理业务逻辑的时间绑定,大大提升了锁冲突的概率。 - 锁获取顺序不一致
你的代码先查vPartnersSystems,再查并更新PartnersUpdate,如果底层表的访问顺序在不同执行计划中出现颠倒,或者不同任务的锁资源获取顺序相反,哪怕操作的行没有重叠也会触发死锁。 - 锁升级问题
当单次操作的行数较多时,SQL会把行锁升级为表锁,直接导致不同systemId的任务抢同一把表锁,必然会出现死锁冲突。
解决方案
- 新增覆盖索引
给PartnersUpdate表创建联合索引:IX_PartnersUpdate_SystemId_Status_UpdateAt,包含列PartnerId,让你的筛选、排序、取PartnerId的操作完全走索引,避免全表扫描,锁只会加在当前systemId对应的行上。同时给vPartnersSystems视图依赖的底层表也新增SystemId、Id、Validity的联合索引,减少扫描范围。 - 提前物化查询结果
把partners的查询末尾加.ToList(),一次性把所有要处理的数据拉到内存,减少数据库锁的持有时间,避免边遍历边查:
var partners = ctx.PartnersUpdate .Where(w => w.SystemId.Equals(systemId) && statusToEvaluate.Contains(w.Status)) .OrderByDescending(p => p.UpdateAt) .Take(500) .ToList();
- 开启快照隔离级别
在SQL Server数据库开启「读已提交快照隔离(RCSI)」,读操作不会再加共享锁,不会和写操作的排他锁冲突,从根源上降低90%以上的死锁概率。 - 增加死锁重试机制
给Hangfire任务加[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Fail)]特性,死锁属于临时可重试错误,重试即可解决偶发问题。 - 调整事务粒度
如果业务允许,可以拆分大事务,把查询和更新分开,或者减少单次SaveChanges的批量操作行数,避免锁升级为表锁。
内容的提问来源于stack exchange,提问作者markzzz
相关产品推荐
相关产品推荐

