SQL Server死锁需从查询和事务设计入手,C#仅能通过缩短事务时间、统一访问顺序、合理重试及UPDLOCK+HOLDLOCK等机制缓解,而非防止。

SQL Server 死锁不是 C# 能“防止”的,而是要从查询和事务设计入手
死锁发生在数据库层面,C# 只是触发者。你用 SqlTransaction 包住一段逻辑,如果两个并发请求以不同顺序访问同一组行(比如先更新用户再更新订单,另一个相反),SQL Server 就会选一个做牺牲者,抛出 SqlException,错误号通常是 1205。
常见错误现象:程序偶发报错 “Deadlock encountered …”,但日志里看不出明显规律;重试后又正常——这恰恰说明你没处理死锁,只是靠运气躲过去了。
- 别在 C# 里试图“预测”或“拦截”死锁,SQL Server 才是裁判
- 真正有效的做法是缩短事务时间 + 统一访问顺序 + 必要时重试
- 用
SET DEADLOCK_PRIORITY LOW可以让当前会话更愿意当牺牲者,但这是权衡,不是解决
设置事务隔离级别:用 IsolationLevel 参数,不是 ConnectionString
C# 中控制隔离级别,是在创建 SqlTransaction 时传参决定的,跟连接字符串里的 Transaction Isolation Level 无关(那个是无效配置)。
典型使用场景:读多写少的报表页需要避免脏读,但又不想全程加锁阻塞写入;或者高并发扣库存必须保证数据一致性。
-
IsolationLevel.ReadCommitted(默认):能防脏读,但不可重复读和幻读仍可能发生 -
IsolationLevel.RepeatableRead:锁住已读数据,防不可重复读,但幻读仍存在,且锁范围更大 -
IsolationLevel.Serializable:最严格,防幻读,但极易引发死锁和阻塞,慎用 -
IsolationLevel.Snapshot:需数据库开启ALLOW_SNAPSHOT_ISOLATION,用行版本控制替代锁,对读操作友好,但写冲突时仍可能失败
示例:
using var tx = conn.BeginTransaction(IsolationLevel.Snapshot);
死锁重试逻辑不能只 catch 1205 错误号
捕获 SqlException.Number == 1205 是基础,但实际部署中常漏掉两点:重试次数限制和退避策略。
容易踩的坑:无限重试导致线程积压;立刻重试加剧竞争;没清空业务状态造成重复执行。
- 最多重试 3 次,超过就抛出原始异常,让上层决定是否降级或告警
- 每次重试前加随机毫秒级延迟(如
Thread.Sleep(50 + new Random().Next(100))),打散竞争节奏 - 确保整个重试块是幂等的——比如扣款前先查余额,更新时用
WHERE balance >= @amount,避免两次都成功 - 不要在
using块外捕获异常后重用同一个SqlConnection,它可能已处于无效状态
高并发下,UPDLOCK + HOLDLOCK 比盲目加事务级别更精准
很多死锁源于“先查后更”模式:SELECT 读到数据 → 业务判断 → UPDATE 修改。中间窗口期别人也读了同一行,然后一起 UPDATE,就容易形成环形等待。
这时候与其拉高整个事务隔离级别,不如在 SELECT 时就主动加锁,把意图明确告诉 SQL Server。
- 在关键 SELECT 后加
WITH (UPDLOCK, HOLDLOCK):UPDLOCK 防止别人再加更新锁,HOLDLOCK 等效于SERIALIZABLE,锁住范围直到事务结束 - 注意:这个提示只对当前语句生效,必须和后续 UPDATE 在同一个事务内
- 不要滥用——锁太重会影响吞吐,只在真正有竞争风险的热点路径上加
- 测试时用
SELECT * FROM sys.dm_tran_locks观察锁类型和持有时间,比猜更可靠
示例:
SELECT id, stock FROM products WITH (UPDLOCK, HOLDLOCK) WHERE id = @id;
事务边界越短越好,锁粒度越小越好,重试逻辑越稳越好。这些事不在 C# 语法里,而在你写每条 SQL 和每个 BeginTransaction 的那一瞬间。


















