SQL Server 中的事務和鎖(三)-Range S-U,X-X 以及死鎖
在上一篇中忘記了一個細節。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 鎖使鎖定的范圍不僅僅限于現有數據,還有未來數據;不僅僅限定現有的若干數據頁,而是一個廣大的范圍。
這其中,最恐怖的問題莫過于“下一個”數據的鎖定。這非常容易造成大范圍死鎖。我們以第一篇的例子來說明:
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% 的死鎖失敗幾率。當然我們付出了允許幻讀的代價。

浙公網安備 33010602011771號