1. 程式人生 > >事務隔離級別原理詳解

事務隔離級別原理詳解

背景

       當用戶併發嘗試訪問同一資料的時,SQL Server嘗試用鎖來隔離不一致的資料和使用隔離級別查詢資料時控制一致性(資料該如何讀取),說起鎖就會聯想到事務,事務是一個工作單元,包括查詢/更新資料和資料定義。

鎖型別

在SQL Server中,根據資源的不同,鎖分為以下三種類型:
    行鎖:是SQL Server中資料級別中粒度最小的鎖級別,行鎖根據表是否存在聚集索引,分為鍵值鎖和標識鎖
    頁鎖:針對某個資料頁新增的鎖,在T-SQL語句中,使用了頁鎖就不會在使用相同型別的行鎖,反之依然,在對資料頁加鎖後,無法在對其新增不相容的鎖
    表鎖:新增表鎖則無法新增與其不相容的頁å鎖和行鎖

鎖模式

   共享鎖(S):發生在資料查詢之前,多個事務的共享鎖之間可以共存
   排他鎖(X):發生在資料更新之前,排他鎖是一個獨佔鎖,與其他鎖都不相容
   更新鎖(U):發生在更新語句中,更新鎖用來查詢資料,當查詢的資料不是要更新的資料時轉化為S鎖,當是要更新的資料時轉化為X鎖
   意向鎖:發生在較低粒度級別的資源獲取之前,表示對該資源下低粒度的資源新增對應的鎖,意向鎖有分為:意向共享鎖(IS) ,意向排他鎖(IX),意向更新鎖(IU),共享意向排他鎖(SIX),共享意向更新鎖(SIU),更新意向排他鎖(UIX)
   共享鎖/排他鎖/更新鎖一般作用在較低級別上,例如資料行或資料頁,意向鎖一般作用在較高的級別上,例如資料表或資料。鎖是有層級結構的,若在資料行上持有排他鎖的時候,則會在所在的資料頁上持有意向排他鎖. 在一個事務中,可能由於鎖持有的時間太長或個數太多,出於節約資源的考慮,會造成鎖升級
   除了上述的鎖之外,還有幾個特殊型別的鎖,例如架構鎖,架構鎖包含兩種模式,架構穩定鎖(Sch-S)和架構更新鎖(Sch-M) ,架構穩定鎖用來穩定架構,當查詢表資料的時候,會對錶新增架構穩定鎖,防止架構發生改變。當執行DDL語句的時候,會使用架構更新鎖,確保沒有任何資源對錶的佔用。大資料量的表避免執行DDL操作,這樣會造成架構更新鎖長時間佔用資源,影響其他操作,除非必要不然不要執行DDL語句,如在必要的情況下新增欄位,需要先給欄位初始化,在設定為非空。

鎖的相容性


如何檢視一個事務中所請求的鎖型別和鎖的順序,可使用SQL Profiler 檢視 Mode 屬性

資料準備

IF OBJECT_ID('dbo.Nums','u') IS NOT NULL
    DROP TABLE dbo.Nums;
GO
CREATE TABLE dbo.Nums
(
     ID INT PRIMARY KEY,
     NUM INT
);
GO
IF EXISTS(SELECT * FROM SYS.SEQUENCES WHERE OBJECT_ID=OBJECT_ID('dbo.NumSequence'))
    DROP SEQUENCE dbo.NumSequence;
GO
CREATE
SEQUENCE dbo.NumSequence MINVALUE 1 MAXVALUE 1000 NO CYCLE GO DECLARE @num AS INT = NEXT VALUE FOR dbo.NumSequence INSERT INTO dbo.Nums VALUES(@num,@num); GO 1

執行UPDATE dbo.Nums SET Num += 1
檢視SQL Profiler 的跟蹤,可以清楚的看到鎖的請求順序和型別(請自定配置跟蹤模版,以便於想要看到自己想要的屬性)

事務的隔離級別

事務

事務是一個工作單元,包含查詢/修改資料以及修改資料定義的多個活動的組合,說起事務就需要提起事務的四個基本特性ACID:
   原子性:事務要麼全部成功,要麼全部失敗。
   一致性:事務為提交前或者事務失敗後,資料都和未開始事務之前一致
   隔離性:事務與事務之間互不干擾
   永續性:事務成功後會被永久儲存起來,不會在被回滾

隔離級別

事務的隔離級別控制併發使用者的讀取和寫入的行為,即不同的隔離界別對鎖的控制方式不一樣,隔離級別主要分為兩種型別:悲觀併發控制和樂觀併發控制,悲觀併發控制有:READ UNCPOMMITTED / READ COMMITTED (會話預設) /REPEATABLE READ / SERIALIZABLE . 樂觀併發控制主要以在Tempdb中建立快照的方式來實現,有:SNAPSHOT 和 READ COMMITTED SHAPSHOT,也被稱為基於行版本的控制的隔離級別。

READ UNCOMMITTED

此隔離級別的主要特點是可以讀取其他事務中未提交更改的資料,該隔離級別下請求查詢的資料不需要共享鎖,這樣對於請求的行正在被更改,不會出現阻塞,這就造成了髒讀.此隔離級別是最低的隔離級別,併發性良好,但是對於資料的一致性方面有缺陷,在一些不重要的查詢中可以採用這種方式
以上面的表為例,開始兩個會話,在會話1中執行如下程式碼:

BEGIN TRAN
    UPDATE  dbo.Nums SET NUM = 10
    WHERE ID = 1

開啟會話2並且執行如下程式碼:

SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED
GO
SELECT * FROM dbo.Nums WHERE ID = 1

檢視執行結果

在事務未提交成情況下,卻讀取到了資料,這就是髒讀,可以通過SQL Profiler 檢視具體的請求鎖的型別和順序。


如圖可以看出,對於會話2只請求了架構穩定鎖(Sch-S) 並未請求共享鎖

READ COMMITTED

此隔離級別可以看作是對READ UMCOMMITTED 隔離級別的升級,解決帶了髒讀的問題,主要方式是對應查詢資料的請求需要先請求共享鎖定,由於鎖之間的相容性,造成阻塞,但是該模式也會帶來一個問題那就是不可重複讀,在同一事務中的兩個相同的查詢 查出來的結果不一致,主要是因為該隔離級別對應共享鎖並不會一致保持,在兩條查詢語句之間是沒有鎖存在的,這樣其他事務就是更新資料
以上面的表為例,開始兩個會話,在會話1中執行如下程式碼:

BEGIN TRAN
    UPDATE  dbo.Nums SET NUM = 10
    WHERE ID = 1 

在會話2中執行如下程式碼,該會話會被阻塞

SET TRANSACTION ISOLATION LEVEL READ COMMITTED
GO
SELECT * FROM dbo.Nums WHERE ID = 1

開啟會話3執行如下語句,檢視當前阻塞狀態,連線資訊,阻塞語句等其他資訊

SELECT request_session_id,resource_type,resource_database_id,DB_NAME(resource_database_id) AS dbname,resource_associated_entity_id,request_mode,request_status FROM sys.dm_tran_locks

執行結果如圖:

從圖中可以看出當前,會話55的請求狀態為WAIT,也就是阻塞狀態,圖中54為UPDATE操作的DML正在持有一個更新鎖(X).進一步檢視程序的相關資訊,執行如下程式碼

SELECT session_id,most_recent_session_id,connect_time,last_read,last_write, most_recent_sql_handle FROM sys.dm_exec_connections WHERE session_id IN (54,55)


可以看到各個程序的連線時間,最後一次讀取時間和最後一次寫入時間,和對應的T-SQL語句,要想檢視具體的語句資訊請執行如下程式碼

SELECT session_id,text FROM sys.dm_exec_connections CROSS APPLY sys.dm_exec_sql_text(most_recent_sql_handle) AS A WHERE session_id IN (54,55)


可以具體的檢視到執行語句,要想知道具體某個會話阻塞原因,即正在等待哪個會話的資源,執行如下語句

SELECT session_id,blocking_session_id,command,text,database_id,wait_type,wait_resource,wait_time FROM sys.dm_exec_requests cross apply sys.dm_exec_sql_text(sql_handle)
WHERE blocking_session_id > 0


從圖中可以看出,會話55正在等待會話54及競爭的資源資訊,等待型別和等待時間,從上述的語句可以輕鬆檢視想要知道的資訊,對於各個會話對鎖的請求順序和型別請自行檢視SQL Profiler.
下面我們來說說不可重複讀的問題,新建會話1執行如下程式碼

SET TRANSACTION ISOLATION LEVEL READ COMMITTED
GO
BEGIN TRAN
    SELECT * FROM dbo.Nums WHERE ID=1
    WAITFOR DELAY '00:00:10'
    SELECT * FROM dbo.Nums WHERE ID=1

新建會話2並執行如下程式碼

BEGIN TRAN
    UPDATE dbo.Nums SET NUM+=1 WHERE ID = 1  
    COMMIT TRAN

檢視會話1的執行結果如圖,從圖中可以看出兩次讀取出來的資料不一致,這就是不可重複讀

REPEATABLE READ

此隔離級別可以看作的是READ COMMITTED 的升級,該模式可以解決READ COMMITTED 的不可重複讀的問題,主要是因為該級別下對共享鎖的佔用時間較長,會一直持續到事務的結束。但是該模式也會存在一個叫做幻讀的缺陷,幻讀指的是在查詢一定範圍內的資料時,其他事務對該範圍的資料進行INSERT操作,導致再次執行相同的查詢語句,查詢的結果可能多或者是和第一句不一致,造成幻讀的原因是因為被鎖定的資料行是在第一次查詢資料時確定的,對未來的資料並沒有鎖。此隔離級別不建議在更新頻率較高的環境下使用,會造成效能不佳
以上面的表為例,開啟兩個會話,在會話1中執行下面的程式碼:

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ
GO
BEGIN TRAN
    SELECT * FROM dbo.Nums WHERE ID=1
    WAITFOR DELAY '00:00:10'
    SELECT * FROM dbo.Nums WHERE ID=1

開啟會話2並且執行如下程式碼

BEGIN TRAN
    UPDATE dbo.Nums SET NUM+=1 WHERE ID = 1  
COMMIT TRAN

檢視結果:

執行過程中可以發現UPDATE的DML會一直等待會話1中事務的提交,並不會造成不可重複讀,下面來演示下幻讀的問題,重新開啟兩個會話,在會話1中執行下面的程式碼:

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ
GO
BEGIN TRAN
    SELECT * FROM dbo.Nums 
    WAITFOR DELAY '00:00:10'
    SELECT * FROM dbo.Nums 
COMMIT TRAN

開啟會話2執行如下程式碼:

BEGIN TRAN
    INSERT INTO dbo.Nums VALUES(2,2)
COMMIT TRAN

執行結果:

會話2並沒有被阻塞,這次檢視下會話1的執行結果可以看到,讀取出了2行資料,被稱為幻讀,關於鎖的請求型別和順序請開啟SQL Profiler 自行檢視.

SERIALIZABLE

此隔離級別可以看作是 REPEADTABLE READ 的升級,解決了幻讀的問題,因為該模式下不僅可以鎖定第一次查詢的資料行,還可以鎖定未來滿足條件的資料行,是一個區間鎖的概念,該級別不會出現上述的問題,但是相對的代價就是一致性強犧牲了併發性
以上表為例,修改會話1的隔離級別為 SERIALIZABLE,程式碼如下:

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE

從結果可以看到會話2一直在等待會話1的完成,關於鎖的請求型別和順序請開啟SQL Profiler 自行檢視.

SNAPSHOT

當前隔離級別和接下來要介紹的隔離級別都是樂觀併發控制的兩種模式,又稱行版本控制的隔離級別,在tempdb中儲存事務未提交之前的資料行,使用基於行版本的控制隔離級別不會請求共享鎖,對於查詢資料的請求直接從快照讀取,但是這種快照方式還是很消耗效能的,尤其是對於更新或刪除操作,仍然會出現阻塞. SNAPSHOT級別對快照的讀取是以事務為單位的。同一個事務中的讀取操作都會讀取同一快照,無論其他事務是否更新了快照。在 READ COMMITTED 的隔離級別下還是會從快照讀取,但是其他模式就按照本身的控制方式進行控制,目標是源表,只有SNAPSHOT隔離級別可以檢測衝突。
要使用該隔離級別需要在資料庫中開啟任意會話執行如下程式碼:

ALTER DATABASE TEST  SET ALLOW_SNAPSHOT_ISOLATION ON

以上面的表為例,開啟兩個會話,在會話1中執行如下程式碼:

BEGIN TRAN
    UPDATE dbo.Nums set NUM +=1
    WHERE ID = 1

開啟會話2並執行如下程式碼:

SET TRANSACTION ISOLATION LEVEL SNAPSHOT
GO
BEGIN TRAN
    SELECT * FROM dbo.Nums  
    WHERE ID = 1

此時會話2並沒有被阻塞,而是返回了之前的版本,結果如下:

切換會會話1執行 COMMIT TRAN ,緊接著繼續在會話2中在執行一遍相同的查詢,執行結果如下

發現與上次的結果相同,但是會話1明明已經提交了,為什麼還是原來的資料呢,這是因為該模式的特點,要是想讀取新的資料需要,需要提交本次事務,繼續在會話2中執行如下程式碼:

COMMIT TRAN
BEGIN TRAN
    SELECT * FROM dbo.Nums  
    WHERE ID = 1
COMMIT TRAN

結果如圖所示:

下面看一個衝突檢測的例子
重新開啟兩個會話,在會話1中執行如下程式碼:

SET TRANSACTION ISOLATION LEVEL SNAPSHOT
GO
BEGIN TRAN
    SELECT * FROM dbo.Nums  
    WHERE ID = 1

開啟會話2執行如下程式碼:

BEGIN TRAN 
    UPDATE dbo.Nums SET NUM =10000
    WHERE ID =1

回到會話1,繼續執行如下程式碼:

UPDATE dbo.Nums SET NUM =100
    WHERE ID =1

此時會話1出現阻塞,可以通過執行如下語句:

SELECT session_id,blocking_session_id,command,text,database_id,wait_type,wait_resource,wait_time FROM sys.dm_exec_requests cross apply sys.dm_exec_sql_text(sql_handle) WHERE blocking_session_id > 0

結果如圖所示:

從圖中可以看出競爭的資源是源表的資料行,並不是快照的,這就說明對於UPDATE 或者是DELETE 最終的目標是源表,切換會話2 執行 COMMIT TRAN 發現會話1中出現了錯誤:

READ COMMITTED SNAPSHOT 模式對於衝突檢測這一案例結果是不支援,會話1中的更新操作會成功,讀者可以自行實驗。

READ COMMITTED SNAPSHOT

同SNAPSHOT很像,但對於快照的讀取是以語句為單位的,同一個事務中的查詢資料的語句每次都讀取快照的最新版
要使用該隔離級別需要在資料庫中開啟任意會話執行如下程式碼:

ALTER DATABASE TEST SET READ_COMMITTED_SNAPSHOT ON

以上表為例,開啟2個會話,在會話1執行如下程式碼:

BEGIN TRAN 
    UPDATE dbo.Nums SET NUM +=1
    WHERE ID =1

開啟會話2,並執行如下程式碼:

BEGIN TRAN 
    SELECT * FROM dbo.Nums 
    WHERE ID =1

執行結果為:

是從快照中讀取出來的,繼續在會話1中執行 COMMIT TRAN ,之後在會話2中的當前事務中繼續執行相同的查詢,結果如下:

這就是之前所說的語句為單位的讀取快照,在這裡有一個很有趣的現象就是,在會話2中並未設定隔離級別,這是因為預設情況下的隔離級別為 READ COMMITTED 由於運行了如上語句修改資料庫標記,故,會話的預設的隔離級別變成了 READ COMMITTED SNAPSHOT,當顯示修改為其他隔離級別是,則會按照修改後的隔離級別執行。若修改會話2的隔離級別為 READ UNCOMMITTED 時,並不會進行快照查詢,仍然出現了髒讀。

對於解決髒讀/不可重複讀/幻讀等問題,可以通過升級隔離級別的方式解決問題。

死鎖

說起鎖的問題,那當然少不了談起死鎖這種現象,主要發生於兩個或多個事務之間存在相互阻塞,造成死鎖,在SQL Server 中會犧牲工作最少的事務,SQL Server 可以設定一個DEADLOCK_PRIORITY的會話選項設定事務的在發生死鎖的情況下犧牲的順序,值在-10~10之間,在發生死鎖的情況下,會優先犧牲數值最低的事務,不管其做的工作有多麼的重要,當存在平級的時候,將根據工作數量進行犧牲。
下面來演示一個死鎖的例子,以上面的表為例,並建立一個Nums副本表取名CopyNums,並新增(1,1)記錄,開啟兩個會話,在會話1中執行如下程式碼:

SET DEADLOCK_PRIORITY 0
BEGIN TRAN
    UPDATE dbo.Nums SET NUM=100
    WHERE ID = 1

開啟會話2執行如下程式碼:

SET DEADLOCK_PRIORITY 1
BEGIN TRAN
    UPDATE  dbo.CopyNums SET NUM = 100
    WHERE ID = 1

切換回會話1 繼續執行如下程式碼:

SELECT * FROM dbo.CopyNums 
    WHERE ID = 1

此時會發生阻塞,等待排他鎖(X)釋放,切換會話2執行如下程式碼:

SELECT * FROM dbo.Nums
    WHERE ID = 1

此次也會發生阻塞,但是阻塞一會你就會發現,會話1終止了,並出現如下錯誤:

為什麼會終止的是會話1呢?可以發現在會話中我們設定了 DEADLOCK_PRIORITY,會犧牲數值低的那個會話事務,檢視SQL Profiler 可以發現,確實有死鎖現象發生(為了清晰僅顯示死鎖)

那麼既然死鎖會發生,就要有對應的避免死鎖的對策:
   1. 事務時間越長,保持鎖的時間就越長,造成死鎖的可能性就越大,檢查事務中是否放置了過多的不應該屬於同一工作單元的邏輯,有的話請移除到,從而縮短事務的時間
   2. 上述死鎖發生的關鍵在於訪問順序的問題,將兩個會話中的語句變成一個順序(都先操作Nums 或者 CopyNums ),就沒有了死鎖現象,所以在沒有邏輯的單元中,調換順序也會減少死鎖的發生

   3. 考慮選擇隔離級別,不同隔離級別對鎖的控制方式不一樣,例如:行版本控制就不會請求共享鎖(S)