|
Process A |
Process B |
|
|
1. 事务开始 |
1. 事务开始 |
|
|
2. 更新Part数据表 |
2. 更新Supplier数据表 |
|
à |
3. 更新Supplier数据表 |
3. 更新Part数据表 |
ß |
|
4. 提交事务 |
4. 提交事务 |
|
如果进程A和进程B,在它们的重复运行的事务中,在几乎相同的时间都到达步骤#3,它们最终彼此诸塞是很容易出现的(译注:在#2,Process A锁定Part数据表,Process B锁定Supplier数据表,因两个事务都没有提交,在#3锁定不会自动释放)。对这个死锁最明显的解决方案是,在其中一个事务中,改变UPDATE语句的顺序,以便锁定的资源以一致的顺序获取。
代替这个过度简化的死锁,让我们仔细看看在SQL Server 死锁故障排除,第二部分中演示的死锁场景。在那种情况下,两个存储过程最终被死锁。
CREATE PROC p1 @p1 int AS
SELECT c2, c3 FROM t1 WHERE c2 BETWEEN @p1 AND @p1+1
GO
CREATE PROC p2 @p1 int AS
UPDATE t1 SET c2 = c2+1 WHERE c1 = @p1
UPDATE t1 SET c2 = c2-1 WHERE c1 = @p1
GO
在第一个存储过程中,没有UPDATE,DELETE或INSERT;它是由一个单一的SELECT语句构成的。而且在第二个存储过程中,甚至都没有将两个存储过程封装在一个BEGIN TRAN/COMMIT TRAN中。两个更新语句运行在它们自己的自动提交事务中,那意味着它们中的只有一个在死锁中涉及。无疑地,在上面描述的,并不适合老套的“修改A然后,修改B/修改B,然后修改A”的死锁模型。顺便说一句,这不是一个极端的例子。我们实际上看到这种类型的死锁 -- 一个或两个参与者在单一的查询,自动提交事务的中间 -- 比易于理解的,仅仅以不同的顺序修改两个表的、涉及两个多语句的事务更常见。
如此,如果DTA(database engine tuning advisor)没有自动推荐一个预防这种死锁的新索引,你会做什么?要动手制作一个自己的解决方案,你需要比在此刻你所拥有的对死锁的更深刻的理解。
死锁是什么引起的?
我们需要重提一下从-T1222输出中提取出来的死锁概述(参见SQL Server 死锁故障排除,第一部分,复习一下解码-T1222):
Spid X is running this query (line 2 of proc [p1], inputbuffer “… EXEC p1 4 …”):
SELECT c2, c3 FROM t1 WHERE c2 BETWEEN @p1 AND @p1+1
Spid Y is running this query (line 2 of proc [p2], inputbuffer “EXEC p2 4”):
UPDATE t1 SET c2 = c2+1 WHERE c1 = @p1
The SELECT is waiting for a Shared KEY lock on index t1.cidx. The UPDATE holds a conflicting X lock.
The UPDATE is waiting for an eXclusive KEY lock on index t1.idx1. The SELECT holds a conflicting S lock.
首先让我们看一看SELECT查询的查询计划。要查看这个计划,执行“SET STATISTICS PROFILE ON”,然后运行“EXEC p1 4”:
SELECT c2, c3 FROM t1 WHERE c2 BETWEEN @p1 AND @p1+1
|--Nested Loops(Inner Join, OUTER REFERENCES:([Uniq1002], [t1].[c1]))
|--Index Seek(OBJECT:([t1].[idx1]), SEEK:([t1].[c2] >= [@p1] AND [t1].[c2] <= [@p1]+(1)) ORDERED FORWARD)
|--Clustered Index Seek(OBJECT:([t1].[cidx]), SEEK:([t1].[c1]=[t1].[c1] AND [Uniq1002]=[Uniq1002]) LOOKUP ORDERED FORWARD)
一个内嵌的循环连接执行它的第一个子运算符一次,然后为每个由第一个子运算符返回的行执行第二个运算符(详见此博文)。在这种情况下,第一个子运算符是非聚集索引搜索,查找“WHERE c2 BETWEEN @p1 AND @p1+1”的行。在非聚集索引中,为每一个限定的行,在聚集索引上执行第二个搜索,以查找整个数据行。聚集索引搜索是必须的,因为非聚集索引不能替代这个查询。如果你正在运行SQL 2000,你会看到一个不同的样子的计划:
SELECT c2, c3 FROM t1 WHERE c2 BETWEEN @p1 AND @p1+1
|--Bookmark Lookup(BOOKMARK:([Bmk1000]), OBJECT:([t1]))
|--Index Seek(OBJECT:([t1].[idx1]), SEEK:([t1].[c2] >= [@p1] AND [t1].[c2] <= [@p1]+1) ORDERED FORWARD)
实际上,这两个计划是一致的。在SQL 2000中,Bookmark查找的目的是访问聚集索引,去存取由一个非聚集索引标识的行的完全的列集合。在SQL 2005中,这相同的操作被表示做一个在非聚集索引和聚集索引之间的循环连接。对于这个死锁,注意到两个计划执行一个搜索从非集聚索引,然后是执行一个搜索从集聚索引,才是重要的。
现在让我们看看这个UPDATE:
UPDATE t1 SET c2 = c2+1 WHERE c1 = @p1
|--Clustered Index Update(OBJECT:([t1].[cidx]), OBJECT:([t1].[idx1]), SET:([t1].[c2] = [Expr1004]))
|--Compute Scalar(DEFINE:([Expr1013]=[Expr1013]))
|--Compute Scalar(DEFINE:([Expr1004]=[t1].[c2]+(1), [Expr1013]=CASE WHEN CASE WHEN ...
|--Top(ROWCOUNT est 0)
|--Clustered Index Seek(OBJECT:([t1].[cidx]), SEEK:([t1].[c1]=[@p1]) ORDERED FORWARD)
UPDATE有一个相当简单的查询计划。两个最重要的运算符是第一个和最后一个。聚集索引搜索定位符合“WHERE c1 = @p1”断言的行。一旦一个符合条件的行已经被发现,聚集索引更新运算符获取一个在聚集索引上的(eXclusive )排他的键锁,并修改该行。
我们现在有一个UPDATE是如何诸塞SELECT的完全理解:UPDATE获取一个在聚集索引键上的X锁,并且该锁定诸塞SELECT的在聚集索引上的bookmark查找。但死锁的另外一半 -- SELECT诸塞UPDATE的理由 -- 不是十分地明显。-T1222告诉我们,“UPDATE等待一个在索引t1.idx1上的排它锁。SELECT拥有一个冲突的S锁”。从这个计划来看不是非常明显,但UPDATE需要一个在非聚其索引[idx1]上的X锁,因为它正在更新的这个列([c2])是一个非聚集索引键列的一个。任何对索引键列的改变,意味着在索引中行必须重新定位,而重新定位需要X锁。
这是当试图理解许多死锁问题时,需要牢记的关键:查找符合条件行的存取方法是重要的,但由于列的修改隐含的索引更新可能是同等重要的。更令人费解的是,有时你会看到,在查询计划中,为每个需要更新的非聚集索引的,显式的“索引更新”和“索引删除”,但在其他的时候,又不显示在查询计划中(有关更多的信息请检查“宽泛与狭窄的查询计划”)。
简而言之:SELECT使用非聚集索引查找符合条件的行。当在一个非聚集索引持有一个共享锁时,它需要跳到聚集索引,并且存取某些不是非聚集索引一部分的列。当这样做时,UPDATE忙于在聚集索引上搜索。它找到一行,锁定它,然后修改它。但因为修改的列中的一个是非聚集索引的一个键列,它也必须移动这个非聚集索引和更新这个索引。这要求一个在非聚集索引上的第二个X键锁。因此SELECT最终会诸塞,以等待UPDATE去释放它的在聚集索引上的X锁。
希望这是清楚,尽管在死锁中的每一个参与者仅仅是一个单一的查询,这仍然是由于无序的资源存取造成的问题。SELECT语句锁定在一个非聚集索引上的键,然后锁定聚集索引中的键。问题是UPDATE需要锁定相同的两个资源,因为它的查询计划,它试图以相反的顺序锁定它们。在这种意义上,它与在本博文的开始描述的死锁情形具有相同的问题。
查询获取的锁定不是一次性获取的。一个查询计划就像一个小程序。这不会非常不准确,例如,可以把一个嵌套的循环连接想象成一个FOR循环。每次循环的迭代获取一个在外表上的键锁,然后在内表中查找(而且继续锁定)匹配的行时保持有那个锁。像这样一个死锁, 有点难于估算,因为在一个单一查询中的资源存取顺序依赖于查询计划,而不能仅仅通过检查T-SQL来确定。
DTA(database engine tuning advisor)的新索引是如何避免这种死锁的?
此处的索引可以预防死锁:
CREATE INDEX idx2 ON t1 (c2, c3)
此索引“覆盖”查询“SELECT c2, c3 FROM t1 WHERE c2 BETWEEN @p1 AND @p1+1”,这好像只是索引包含所有的查询引用的列的另外一种说法。SQL会使用这个索引替代 [idx1]索引,因为基于此覆盖索引的查询计划是成本较低的。事实上。覆盖查询的索引意味着,针对聚集索引的书签查找不再是必须的。因为SELECT不再需要存取聚集索引,它不会被在聚集索引上的UPDATE锁诸塞。
还有什么其他可用的解决办法?
所有的死锁可以归结为无序的资源存取模式。在本博文的开始处描述的简单死锁情形,解决方案是明显的:仅仅颠倒两个事务中的一个的UPDATE语句顺序,你就不会死锁。但在更复杂的我们刚刚探究过的情形,就不是非常清晰的知道如何改变获取锁的顺序。每一个死锁的参与者仅运行一个单一的查询,自动提交事务,因此你不能仅仅交换两个查询获取资源的顺序。SQL是一个设计于表达高级的集合操作的语言;具体的数据库应如何检索和更新指定的数据集通常,有足够的理由,交给SQL引擎来完成。然而,你也有一些选项用于要么影响一个查询需要的锁定资源,或者修改获取锁定的顺序。以下是六个针对这种死锁的不同解决方案。它们中的一些对特定的死锁不是理想的,但它们仍然值得探究,因为它们演示的死锁避免方法,可能对你遇到的其他死锁是最佳的可能解决方案。