最近在項目中進行壓力測試遇到了數據庫的死鎖問題,簡言之,如下的代碼在 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 子句使用的是“范圍”條件,例如“>、<、BETWEEN、IN”等。不論該索引是否唯一,WHERE子句規定都會成為 Range S-S 鎖作用的范圍,除此之外,在索引排序規則之下,這個作用范圍的“下一個”索引項也會獲得Range S-S鎖。
我們必須首先解釋一下“下一個”是怎么一回事,“下一個”索引項有兩種情況:
第一:如果在索引排序規則下,作用范圍之外按照數據排布的方向能夠找到一個存在的,或者是“殘存的”索引項(已經提交刪除,數據庫中再也看不到了,但是還沒有從B樹數據頁中刪除),那么這個索引項就是“下一個”索引項;
第二:如果在索引排序規則下,作用范圍之外按照數據排布的方向找不到任何殘存的索引項,那么無限遠(Resource Hash為0xffffffff)的索引項就是“下一個”索引項。
我們結合規則二進行說明,例如我們執行
SELECT [data] FROM [MyTable] WHERE [index_column]>=1 AND [index_column]<=4
那么 index_column 中的值為 1、2、3、4的索引會獲得 Range S-S 鎖,除此以外,4之后的下一個索引值,也就是5對應的索引會獲得 Range S-S鎖。這和我們的實驗結果剛好一致。
我們再來看着一個,例如我們執行:
SELECT [data] FROM [MyTable] WHERE [index_column]>=20 AND [index_column]<=40
那么 index_column 為 25、30的索引會獲得 Range S-S 鎖,除此以外,30之后的下一個索引值,也就是“無限遠”會獲得 Range S-S 鎖,請看實際Dump的鎖的使用情況:
我們最后練一個稍稍復雜點兒情況:
SELECT [data] FROM [MyTable]
WHERE ([index_column]>=2 AND [index_column]<=4) OR ([index_column]>=10 AND [index_column]<=16) OR ([index_column]>=30 AND [index_column]<=40)
這里想說明的問題是,我們的“范圍”是指一個個的閉合的范圍,要一個個套用規則進行分析,我們現在有3塊兒閉合的范圍,分別是 [2,4]、[10,16]、[30,40]。我們一個個的來,對於[2,4],在這個范圍內2,3,4,5獲得 Range S-S鎖;
對於[10,16]范圍,15,16,18獲得 Range S-S鎖;對於[30,40]范圍,30,無限遠獲得 Range S-S鎖,一共9個。
規則一補充:如果 WHERE 子句使用的是“相等”條件,但是該索引不是唯一索引,那么除了WHERE命中的索引獲得 Range S-S鎖之外,“下一個”索引也會獲得 Range S-S鎖。
我今天仔細的做了關於這個規則的驗證。另外查閱了 SQL Server 2000 - 2008 Internals 的圖書中關於這個問題的記載。在不是唯一索引的情況下,沒有以上這種固定的選擇規則。以上規則只有在一些特定情況下才出現。而其他規則是沒有問題的。
對於WHERE子句中的條件不能命中任何記錄的情況
規則三:如果 WHERE 子句使用的是“相等”條件,不論索引是否為唯一索引,若不能夠命中任何記錄,除該 WHERE 子句規定的那個不存在的記錄作為 Range S-S的一部分之外,該記錄的“下一個”索引值也將會獲得 Range S-S 鎖。
例如,我們執行
SELECT [data] FROM [MyTable] WHERE [index_column]=6
那么下一條索引記錄為15所對應的索引,因此這個索引將會獲得 Range S-S 鎖。
又例如,我們執行
SELECT [data] FROM [MyTable] WHERE [index_column]=31
那么下一索引記錄應該是“無限遠”對應的索引,則這個索引將會獲得 Range S-S 鎖。
規則四:如果WHERE子句中使用“范圍”條件,不論索引是否為唯一索引,若不能夠命中任何記錄,除該 WHERE 子句規定的那個不存在的范圍作為 Range S-S的一部分外,該范圍的“下一個”索引值也將會獲得 Range S-S鎖。
例如,我們執行
SELECT [data] FROM [MyTable] WHERE [index_column]>=6 AND [index_column]<=10
我實在是寫不動了,請各位開動腦筋吧,這里直接給結果:
再來一個例子吧,我們執行
SELECT [data] FROM [MyTable] WHERE [index_column]>30 AND [index_column]<40
結果是:
好了,這一篇終於搞定了。下一篇我們到了 Range S-U 以及 Range I-N 這下會死鎖了,有好戲看了。
在上一篇中忘記了一個細節。Range T-K 到底代表了什么?Range T-K Lock 代表了在 SERIALIZABLE 隔離級別中,為了保護范圍內的數據不被並發的事務影響而使用的一類鎖模式(避免幻讀)。它由兩個部分構成:
第一個部分代表了他鎖定了一個索引范圍,在這個范圍內,所有索引使用 T 鎖進行鎖定;
第二個部分是而這個范圍內已經命中的Key,這些 Key 將使用 K 鎖進行鎖定。
合並在一起我們說在這個范圍內,索引范圍和特定的row的鎖定模式為 Range T-K。
舉上一篇的一個例子吧:
SELECT [data] FROM [MyTable] WHERE [index_column]>=20 AND [index_column]<=40
的鎖的使用情況是:
實際上,上述語句產生的鎖有兩個部分,第一個是 Range S 鎖,范圍是 20-40 的索引范圍,第二是 Key 上使用的 S 鎖,在圖中可以看到有三個 Key 被命中了,分別是“無限遠”,“25”對應的索引以及“30”對應的索引。其 Mode 為 Range S-S,其 Type 為 KEY,也就是,他們的范圍鎖為 Range S,Key 鎖為 S 鎖。
更新和插入操作涉及的鎖
涉及的鎖主要是兩種,一種是 Range S-U 鎖,另一種是 Range X-X 鎖。
Range S-U,這個選定索引范圍會獲得 S 鎖而命中的 Key 使用 U 鎖鎖定,以便將來轉換為 X 鎖。而在更新時,則徹底成為 X 鎖,這個范圍內的鎖模式也就成了 Range X-X。由於更新的數據列不同(有可能是索引列,有可能不是),使用的索引也不同(聚集,非聚集,唯一,等),因此其情況就不容易像 Range S-S 鎖那么容易得出規律了。總的來說有幾種情況還是一致的,這里就不再逐個實驗了(這里強烈推薦閱讀 SQL Server 2008 Internals 這本書關於鎖的章節,講述的很清楚):
首先,在相等判斷(例如“=”),且索引為唯一索引的情況下。如果該索引命中,不會有 Range T-K 鎖鎖定記錄范圍,而相應的記錄直接獲得 U 鎖或者 X 鎖;
其次,在相等判斷,不論索引是否為唯一索引,如果該索引沒有命中記錄,則 Range T-K 鎖鎖定 “下一個”記錄。(關於“下一個”的解釋請參見上一篇);
第三,在范圍條件(>、<、BETWEEN),不論索引是否唯一,如果該索引命中,不但該范圍會獲得 Range T-K 鎖,而該范圍的“下一個”記錄也會獲得 Range T-K 鎖。
為什么 Serializable 隔離級別更容易死鎖
我們從第一篇的圖可以看到,SERIALIZABLE 級別能夠保證最嚴格的數據一致性,但是這些保衛的手段只要稍稍變化就可以發展為死鎖。事實上,在各種隔離級別中,數據一致性越高,則越容易發生死鎖;數據一致性越低,則發生死鎖的概率就越小。
在這些隔離級別中,SERIALIZABLE 是最容易死鎖的,這得益於 Range T-K 鎖使鎖定的范圍不僅僅限於現有數據,還有未來數據;不僅僅限定現有的若干數據頁,而是一個廣大的范圍。
這其中,最恐怖的問題莫過於“下一個”數據的鎖定。這非常容易造成大范圍死鎖。我們以第一篇的例子來說明:
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
|
在這個例子中,表 MyTable 的列 fk_related_id 是一個唯一索引(非聚集),事務隔離級別為 SERIALIZABLE。不同的存儲過程執行會傳入不同的 @Argument,表面看來,這不會有任何的問題,但是由於“下一個”數據的鎖定,在稍高水平的並發上,就出現了大約 80% 的失敗情況,這些失敗都來源於死鎖。我們挑選了其中的一次:
我們試圖以每秒鍾 15 個的壓力在 @Argument 屬於 [1, 1000] 的范圍內進行存儲過程調用。在這個過程中,有一個 @Argument 為 115 的記錄首先成功的插入了進去!
id | fk_related_id | data |
1 | 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% 的死鎖失敗幾率。當然我們付出了允許幻讀的代價。