SQL Server并发INSERT死锁主因是多索引下事务加锁顺序不一致形成环路,而非插入同一行;自增主键仍需争抢页分配锁,高频字段统一置左、删低区分度索引、禁用INSERT...SELECT、启用READ_COMMITTED_SNAPSHOT可有效缓解。

SQL Server 并发 INSERT 死锁,绝大多数不是因为插同一行,而是多个事务在不同索引路径上加锁顺序不一致,形成环路。
为什么自增主键也会死锁
很多人以为 Identity 列天然“安全”,其实不然。SQL Server 在插入时仍需获取 IDENTITY 值对应的页锁或分配锁(如 PAGELATCH_UP),尤其在高并发下,多个会话争抢同一分配点(比如最后一页的 IAM 或 PFS 页)就会触发锁等待甚至升级为死锁。这不是数据冲突,是资源分配阶段的底层同步瓶颈。
- Identity 插入本质是“先锁页、再写值、再释放”,并非原子不可阻塞操作
- 当表有非聚集索引且插入涉及多列更新(如
INSERT ... SELECT同时更新聚集和非聚集索引)时,锁路径更复杂 - 使用
DBCC CHECKIDENT手动重置后首次插入也容易卡住,因需重新初始化分配结构
如何让 INSERT 加锁路径统一
核心思路是让所有并发 INSERT 尽可能走相同的索引扫描/定位路径,避免一个事务按 idx_a_b 查,另一个按 idx_b_c 查,最终在两棵 B 树上交叉加 gap 锁或插入意向锁。
- 把高频用于 WHERE 或 JOIN 的字段(尤其是外键、租户 ID、时间分片字段)统一放在所有复合索引最左侧,例如
idx_tenant_id_created和idx_tenant_id_status - 删除低区分度单列索引(如只有
'pending'/'done'两种值的status索引),它产生的 gap 锁范围大、竞争强,是常见死锁源头 - 避免对同一张表定义多个覆盖不同字段组合的非聚集索引,特别是当这些字段又常出现在 INSERT 的 VALUES 或子查询中时
INSERT ... SELECT 比 VALUES 更容易死锁
INSERT INTO t SELECT ... FROM s 在 SQL Server 中默认按逐行方式申请锁,无法像 INSERT INTO t VALUES (...), (...) 那样批量合并锁请求。每行都触发一次聚集索引定位 + 所有非聚集索引维护,锁持有时间拉长、路径更难收敛。
- 即使源表只返回 10 行,
SELECT也可能触发 10 次独立的 X 锁申请,而VALUES可能合并为 1–2 次批量锁 - 若源表本身有锁(比如被其他事务 UPDLOCK 查询),则 INSERT 还要等源表释放,进一步延长锁链
- 改用临时表预存结果再
INSERT ... VALUES,或用SELECT INTO #t+INSERT ... FROM #t,可显著降低锁粒度
READ COMMITTED SNAPSHOT 是最实用的兜底方案
开启 READ_COMMITTED_SNAPSHOT ON 后,普通 SELECT 不再申请 S 锁,也就不会和 INSERT 的 X 锁形成“读等写、写等读”的经典死锁环。这对读多写少、且存在大量键查找(Key Lookup)的场景效果立竿见影。
- 执行
ALTER DATABASE [YourDB] SET READ_COMMITTED_SNAPSHOT ON即可启用,无需改代码 - 注意 tempdb 会承受额外版本存储压力,需监控
version_store_reserved_page_count - 它不能解决两个 INSERT 事务之间的死锁(X vs X),但能切断 90% 以上由读写混合引发的死锁
真正难处理的是多索引 + 多事务 + 不同过滤条件组合下的锁路径分歧——这没有一键开关,必须结合索引顺序、字段共性、插入模式一起调优。别迷信“加个 NOLOCK 就行”,那只是把问题从死锁换成脏读。










