|
最近在项目中进行压力测试遇到了数据库的死锁问题,简言之,如下的代码在 SERIALIZABLE 隔离级别造成了死锁:
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14 |
SELECT @findCount=COUNT(id) FROM MyTable
WHERE [fk_related_id]=@Argument
IF (@findCount > 0)
BEGIN
ROLLBACK TRANSACTION
RETURN ERROR_CODE
END
INSERT INTO MyTable ([fk_related_id],…)
VALUES (@Argument,…)
COMMIT TRANSACTION
RETURN SUCCESS_CODE |
在搞清楚这个问题的过程中做了不少的实验,与各位共享。这一篇是开篇,主要说明的是 SQL Server 的四种(其实还有别的)经典的事务隔离级别,以及在不同的隔离级别下锁的使用手段,以及所带来的不同的数据一致性。
SQL Server 中锁的种类(Schema操作就暂时不涉及了)
锁类型 | 描述 | (Shared Lock) | 用于只读操作数据锁定 | (Update Lock) | 用于数据的更新,在数据真正的需要更新的时候会申请升级为X锁。 | X(Exclusive Lock) | 独占锁,用于数据的更改。 | Key-Range Lock(稍后讨论) | 仅仅在 Serializable 隔离级别保护数据,以避免任何有可能使得本事务第二次读取信息产生错误的数据插入操作 | 各个事务隔离级别下锁的使用
SQL Server 中有四种事务隔离级别,具体的大家去参建 MSDN。下面列出在不同的事务隔离级别下这些锁是如何使用的:
隔离级别 | 读数据锁状态 | 写数据锁状态 | 锁持有时间 | Read Uncommitted | 不获得任何锁 | 不获得任何锁 | | Read Committed | 数据获得S锁 | 对于 INSERT、DELETE、UPDATE的执行,获得X锁;对于UPDATE的标记,获得U锁; | 读完即释放,并不持有至事务结束。 | Repeatable Read | 数据获得S锁 | 对于 INSERT、DELETE、UPDATE的执行,获得X锁;对于UPDATE的标记,获得U锁; | 持有至事务结束 | Serializable | 数据获得S锁,同时获得Key-Range锁。 | 对于 INSERT、DELETE、UPDATE的执行,获得X锁;对于UPDATE的标记,获得U锁,同时获得Key-Range锁。 | 持有至事务结束 | 我们可以利用这些知识形象说明各个隔离级别下的数据一致性:
Read Uncommitted 级别
(1)脏读
(2)更新丢失
(3)不可重复读
(4)幻读
Read Committed 级别
(1)脏读
(2)更新丢失
(3)不可重复读
(4)幻读
Repeatable Read 级别
(1)脏读
(2)更新丢失
(3)不可重复读
(4)幻读
Serializable 级别
(1)脏读
(2)更新丢失
(3)不可重复读
(4)幻读
我们从上图可以比较直观的看到以下的结论
| 脏读 | 更新丢失 | 不可重复读 | 幻读 | Read Uncommitted | 可能 | 可能 | 可能 | 可能 | Read Committed | 不可能 | 可能 | 可能 | 可能 | Repeatable Read | 不可能 | 不可能 | 不可能 | 可能 | Serializable | 不可能 | 不可能 | 不可能 | 不可能 | 这一篇到此为止,下一篇详细介绍 Key-Range Lock 并分析开篇提到的死锁问题。
在这篇随笔中,我们的主要关注点在 Key-Range Lock。Key-Range Lock有 S-S、S-U、I-N、X-X几种情况。我们一个一个来说,力求明白。遗憾的是,这里可能会比较冗长,那么死锁分析只好依次顺延了。
Range S-S锁的获取规则
MSDN 对 Range 锁的规则有部分描述,但是言简意赅,以下我们会将各种情况分解开来,理清MSDN中涉及的或者未涉及的规则,这些规则适用于SQL Server 2000/2005/2008/2008 R2。关于MSDN的描述,请参见:http://technet.microsoft.com/zh-cn/library/ms191272(en-us,SQL.110).aspx。
在描述规则之前需要声明的是,我们的聚集索引就建立在 WHERE 字句之上,这很重要,否则是不会获得 Range 锁的,也就达不到 SERIALIZABLE 的要求了;另外,为了讨论简便,以下的 SQL 全部省略 SET TRANSACTION ISOLATION LEVEL SERIALIZABLE 的声明。
我们假设有以下的表:
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14 |
CREATE TABLE [dbo].[MyTable](
[id] [int] IDENTITY(1,1) NOT NULL,
[index_column] [int] NOT NULL,
[data] [int] NOT NULL,
CONSTRAINT [PK_MyTable] PRIMARY KEY NONCLUSTERED
(
[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]
CREATE UNIQUE CLUSTERED INDEX [IX_MyTable] ON [dbo].[MyTable]
(
[index_column] 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] | 并假设我们有如下的数据:
?
1
2
3
4
5
6
7
8
9
10 |
INSERT INTO [MyTable] ([index_column],[data]) VALUES (1, 1)
INSERT INTO [MyTable] ([index_column],[data]) VALUES (2, 2)
INSERT INTO [MyTable] ([index_column],[data]) VALUES (3, 3)
INSERT INTO [MyTable] ([index_column],[data]) VALUES (4, 4)
INSERT INTO [MyTable] ([index_column],[data]) VALUES (5, 5)
INSERT INTO [MyTable] ([index_column],[data]) VALUES (15, 6)
INSERT INTO [MyTable] ([index_column],[data]) VALUES (16, 7)
INSERT INTO [MyTable] ([index_column],[data]) VALUES (18, 8)
INSERT INTO [MyTable] ([index_column],[data]) VALUES (25, 9)
INSERT INTO [MyTable] ([index_column],[data]) VALUES (30, 10) | 那么这张表看起来应该是这样的(我另外还将Index的Hash值以及row所在的数据页Dump出来了,以便咱们做实验)。
id | index_column | data | index hash | row page | 1 | 1 | 1 | (8194443284a0) | 78 | 2 | 2 | 2 | (61a06abd401c) | 78 | 3 | 3 | 3 | (98ec012aa510) | 78 | 4 | 4 | 4 | (a0c936a3c965) | 78 | 5 | 5 | 5 | (59855d342c69) | 78 | 6 | 15 | 6 | (f1de2a205d4a) | 78 | 7 | 16 | 7 | (f07ed88b2b23) | 78 | 8 | 18 | 8 | (e9069d930a93) | 78 | 9 | 25 | 9 | (b81181109ebc) | 78 | 10 | 30 | 10 | (8034b699f2c9) | 78 | 对于WHERE子句中的条件命中现有记录的情况
规则一:如果 WHERE 子句使用的是“相等”条件,例如“WHERE [index_column]=6”,并且该索引是唯一索引,则该索引不会获得Key-Range S-S锁,仅仅是Key上获得普通S锁;
假设我们执行
SELECT [data] FROM [MyTable] WHERE [index_column]=1
那么我们使用 sp_lock 得到锁的情况:
可以发现第一个索引上获得了S锁,但并不是 Range S-S 锁。
规则二:如果 WHERE 子句使用的是“范围”条件,例如“>、=1 AND [index_column]=20 AND [index_column]=2 AND [index_column]=10 AND [index_column]=30 AND [index_column]=6 AND [index_column]30 AND [index_column]=20 AND [index_column]、 0)
BEGIN
ROLLBACK TRANSACTION
RETURN ERROR_CODE
END
INSERT INTO MyTable ([fk_related_id],…)
VALUES (@Argument,…)
COMMIT TRANSACTION
RETURN SUCCESS_CODE[/td][/tr][/table] 在这个例子中,表 MyTable 的列 fk_related_id 是一个唯一索引(非聚集),事务隔离级别为 SERIALIZABLE。不同的存储过程执行会传入不同的 @Argument,表面看来,这不会有任何的问题,但是由于“下一个”数据的锁定,在稍高水平的并发上,就出现了大约 80% 的失败情况,这些失败都来源于死锁。我们挑选了其中的一次:
我们试图以每秒钟 15 个的压力在 @Argument 属于 [1, 1000] 的范围内进行存储过程调用。在这个过程中,有一个 @Argument 为 115 的记录首先成功的插入了进去!
接下来有一个 @Argument 为 74 的记录获得了机会,我们假设它的 Session Id 为 A。它执行了 SELECT 语句:
id | fk_related_id | data | 1 | 115 (A 获得了Range S-S Lock) | … | 接下来有一个 @Argument 为 4 的记录获得了机会,我们假设它的 Session Id 为 B。它执行了 SELECT 语句:
id | fk_related_id | data | | 115 (A 、B获得了Range S-S Lock) | … | 接下来,Session A 执行到了 INSERT 语句,那么 Range S-S 锁会试图进行一个转换测试(Range I-N 锁),但这显然是行不通的,因为 Session B 也获得了 Range S-S Lock,因此 Session A 陷入了等待;
而 Session B 也执行到了 INSERT 语句,相同的,它也陷入了等待;这样,Session A 等待 Session B 放弃 Range 锁,Session B 等待 Session A 放弃锁,这是一个死锁了。
而更糟糕的事情是,凡是 @Argument 小于 115 的记录,他都会试图令下一个记录获得新的 Range S-S 锁,从而进入无限的等待中,至少,1-115 号记录死锁,并且最终 114 个需要放弃,1个成功。这就是为什么 SERIALIZABLE 隔离级别不但会发生死锁,而且在某些时候,是大面积死锁。
总之:在 SERIALIZABLE 隔离级别下,只要有类似同一索引为条件先读后写的状况的,在较大并发下发生死锁的概率很高,而且如果碰巧既有的记录索引按照排序规则在非常靠后的位置,则很可能发生大面积死锁。
那么如何解决这个问题呢,呃,降低隔离级别当然是一个方法,例如,如果你能接受幻读,那么 REPEATABLE READ 是一个不错的选择。但是我突然在某篇博客中看到了使用 SELECT WITH UPDLOCK 的方法。事实上,这种东西让死锁更容易了。
例如,一个存储过程 SELECT B,而后 SELECT A;而另外的存储过程先 SELECT A,再 SELECT B,那么由于顺序不同,排他锁仅仅是 Read 的情况就可能发生死锁了。
那么为什么 REPEATABLE READ 会好得多呢?因为 REPEATABLE READ 紧紧锁定现有记录,而不会使用 Range 锁。我们仍然以上述存储过程为例,这样,只有两个被锁定的行数据在同一个页上(因为默认情况下使用页级锁),或者说挨得足够近,才有可能死锁,并且这个死锁仅仅限于这个数据页上的记录而不会影响其他记录,因此死锁的概率大大降低了。
我们实际测试中,在相同的测试条件下,并发提高到 100 的情况下时才有不到 0.1% 的死锁失败几率。当然我们付出了允许幻读的代价。 |
|