SQL Server事务执行过程中中释放锁导致的死锁问题 - 排查与分析

0. 前情提要

系统的某个用来上报数据的接口存在死锁的问题。这个接口内部对多张表进行了Update操作,执行顺序为A表、B表、C表、D表、A表。死锁发生的SQL,一条是第一次更新A表的SQL,另一条是第二次更新A表的SQL。整个更新都处在一个事务内,理论上讲,只要第一个Session开始执行事务,第二个Session就会由于无法获取到A表的锁而被阻塞,直到第一个Session执行完毕释放锁,那为什么对A表的更新还会产生死锁呢?

1. 准备工作

用准备来做测试的表

SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[Test](
	[Id] [int] NOT NULL,
	[Progress] [int] NOT NULL,
	[Score] [int] NOT NULL,
	[Grade] [int] NOT NULL,
	[Usn] [int] NOT NULL,
	[ProjectId] [int] NOT NULL,
	[IsDeleted] [int] NOT NULL,
 CONSTRAINT [PK_Id] PRIMARY KEY CLUSTERED 
(
	[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO
CREATE UNIQUE NONCLUSTERED INDEX [index_usn_and_project_id] ON [dbo].[Test]
(
	[Usn] ASC,
	[ProjectId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, IGNORE_DUP_KEY = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
GO

插入一条测试用的数据

PRINT @@spid
insert into dbo.Test (Id, Progress, Score, Grade, Usn, ProjectId, IsDeleted)
VALUES (1, 1, 1, 1, 1, 1, 1);

2. 非聚簇索引的情况下,在表上执行一次update的加锁过程

用来测试的update语句

BEGIN TRAN
PRINT @@spid
update dbo.Test
set Progress=2
WHERE Usn=1 and ProjectId=1
COMMIT TRAN

利用Sql Server Profiler追踪加锁与释放锁的过程

从图中我们可以得知,在锁增长阶段,存在以下加锁步骤:

  1. 根据输入条件,找到非聚簇索引所在页,并获取非聚簇索引所在页的IU锁
  2. 获取非聚簇索引的U锁
  3. 根据非聚簇索引的值,找到聚簇索引(即主键索引)所在的页,获取页的IU锁
  4. 获取聚簇索引的U锁
  5. 将聚簇索引所在页的锁提升为IX锁
  6. 将聚簇索引的锁提升为X锁
  7. 更新数据
  8. 释放非聚簇索引所在页的IU锁
  9. 释放非聚簇索引的U锁
  10. 释放聚簇索引的X锁
  11. 释放聚簇索引所在页的IX锁

所有的锁都被争取的申请与释放

2. 非聚簇索引的情况下,在表上执行两个update的加锁

BEGIN TRAN
PRINT @@spid
update dbo.Test
set Progress=2
WHERE Usn=1 and ProjectId=1
update dbo.Test
set Score=2,Grade = 2
WHERE Usn=1 and ProjectId=1
COMMIT TRAN


从红框中可以看到,非聚簇索引索引在第一个update时申请U锁、释放U锁,并在第二个update时再次申请U锁、释放U锁。

在这里,有意思的事情就发生了:第一个update在聚簇索引上申请的X锁,在第一个update执行完成后,并没有像非聚簇索引一样被释放。

这里猜测一个原因:由于非聚簇索引并不需要更新,它只是用来更新聚簇索引数据的一个跳板,因此在聚簇索引更新完后就被释放了。而聚簇索引上的X锁,则需要在事务commit之后才会被释放。

为了验证这一点,尝试在第一次update时,同时更新ProjectId

BEGIN TRAN
PRINT @@spid
update dbo.Test
set Progress=2,ProjectId=2
WHERE Usn=1 and ProjectId=1
update dbo.Test
set Score=2,Grade = 2
WHERE Usn=1 and ProjectId=2
COMMIT TRAN


红线上方的就是第一个update, 由于对非聚簇索引进行了修改,所以非聚簇索引页申请了X锁,且没有在第一次Update语句结束后释放锁。由此验证了上面的假设

3. 推测与模拟

由上面的内容,我们知道了一件事情:在Update时,会申请非聚簇索引上的U锁,并且会在Update结束后(而不是事务提交后)释放锁;当在一个事务内存在两个Update(且更新的是同一条数据)时,会在非聚簇索引上存在 获取锁 -> 释放锁 -> 获取锁 -> 释放锁 的过程。

同时,我们也知道,假如同时存在两个Session要Update同一张表的同一条数据,那么晚到的Session由于事务的原因,会等待第一个Session释放非聚簇索引上的锁(因为Update的入口就是获取非聚簇索引的锁)。

那么,我们就能推理出一种情况:Session1执行完了第一个Update,并将自己持有的非聚簇索引的U锁释放,保留着聚簇索引的X锁;Session2看到非聚簇索引的U锁被释放,于是获取了U锁;此时Session1试图再次获取非聚簇索引的U锁,但非聚簇索引的U锁已经被Session2占有。而Session2要进一步更新数据,就需要Session1手里的聚簇索引的X锁。于是,死锁就发生了

接下来就是验证:左侧的sql先执行,在第一个update后,延迟1分钟;左侧sql开始之后,执行右侧的sql。理论上讲,此时左右会触发死锁:

最终的结果,成功触发死锁,右侧的sql作为后来者被牺牲:

本次运行的加锁、释放锁的过程,其中白色部分为左侧sql的执行过程(spid=53),蓝色的部分是右侧的sql的执行过程(spid=57),下面以53和57代替:

可以看到,53释放了非聚簇索引后,57立刻获取了非聚簇索引上的U锁,并获得了聚簇索引所在页的IU锁,但由于53任然持有聚簇索引的X锁,导致57无法进一步申请聚簇索引的锁:

而53这时试图获取非聚簇索引所在页上的IU锁(被57持有),由此引发了死锁。经过一段时间后,数据库发现死锁的存在,并主动终结了57,让57持有的资源被释放,从而保证53可以顺利执行

4. 结论

到这里,本次死锁发生的原因其实已经明确了,原因可以归结为,在一个事务内存在对某一资源的重复申请与释放,在并发场景下,就可能会因为对该资源的争夺而产生死锁。在开发的过程中,应当尽量避免在并发情况下,对同一资源的重复申请与释放

作者:maurrinho原文地址:https://www.cnblogs.com/maurrinho/p/17374160.html

%s 个评论

要回复文章请先登录注册