前言
終於進入死鎖系列,前面也提到過我一直對隔離級別和死鎖以及如何避免死鎖等問題模棱兩可,所以才鼓起了重新學習SQL Server系列的勇氣,本節我們來講講SQL Server中的死鎖,看到許多文章都只簡述不能這樣做,這樣做會導致死鎖,但是未理解其基本原理,下次遇到類似情況依然會犯錯,所以基於了解死鎖原理並且得到治療死鎖良方,博主不惜花費多天時間來學習死鎖最終總結出本文,若有敘述不當之處請在評論中指出。
死鎖定義
死鎖是兩個或多個進程互相阻塞的情況。兩個進程死鎖的例子是,進程A阻塞進程B且進程B阻塞進程B。涉及多個進程死鎖的例子是,進程A阻塞進程B,進程B阻塞進程C且進程C阻塞進程A。在任何一種情況下,SQL Server檢測到死鎖,都會通過終止其中的一個事務盡心干預。如果SQL Server不干預,涉及的進程永遠陷於死鎖狀態。
除外另外指定,SQL Server選擇終止工作最少的事務,因為它便於回滾該事務的工作。但是,SQL Server允許用戶設置一個叫做DEADLOCK_PRIORITY的會話選項,可以是范圍在-10~10之間的21個值中的任意值,死鎖優先級最低的進程將被作為犧牲對象,而不管其做了多少工作。我們可以舉一個生活中常見和死鎖類似的例子,當在車道上行駛時,快到十字路口的紅燈時,此時所有的小車都已經就緒等待紅燈,當變綠燈時,此時有駕駛員發現走錯了車道,於是開始變換車道,但是別的車道都擁堵在一塊根本插不進去,駕駛員只有等待有空隙時再插進去,同時駕駛員車道上后面的小車又在等待駕駛員開到別的車道。這種情況雖然不恰當,但是在一定程度上很好的表現了死鎖的情況,所以在開車時盡量別吵吵,否則誰都走不了,keep silence。
下面我們來演示常見的一種死鎖情況,然后我們再來討論如何減少系統中死鎖的發生。
讀寫死鎖
在SQL Server數據庫中我們打開兩個連接並確保都已連接到數據庫,在會話一中我們試圖去更新Production.Products表中產品2的行。
SET TRAN ISOLATION LEVEL READ COMMITTED BEGIN TRAN; UPDATE Production.Products SET unitprice += 1.00 WHERE productid = 2;
在會話2中再來打開一個事務,更新Sales.OrderDetails表中產品2的行,並使事務保持打開狀態
SET TRAN ISOLATION LEVEL READ COMMITTED BEGIN TRAN UPDATE Sales.OrderDetails SET unitprice += 1.00 WHERE productid = 2;
此時上述會話一和會話二都用其會話的排他鎖且都能更新成功,下面我們再來在會話一中進行查詢Sale.OrderDetails表中產品2的行並提交事務。
SET TRAN ISOLATION LEVEL READ COMMITTED BEGIN TRAN; SELECT orderid, productid, unitprice FROM Sales.OrderDetails WHERE productid = 2; COMMIT TRAN;
因為需要查詢Sales.OrderDetails表中產品2的行,但是在之前我們更新產品2的行同時並未提交事務,因為查詢的共享鎖和排它鎖不兼容,最終導致查詢會阻塞,接下來我們在會話二中再來查詢Producution.Products表中產品為2的行。
SET TRAN ISOLATION LEVEL READ COMMITTED BEGIN TRAN SELECT productid, unitprice FROM Production.Products WHERE productid = 2; COMMIT TRAN;
此時我們看到在會話二中能成功查詢到Production.Products表中產品2的行,同時我們再來看看會話一中查詢情況。
上述死鎖算是最常見的死鎖情況,在會話一(A進程)中去更新Production.Products表中產品2的行,在會話二(B進程)去更新Sales.OrderDetails表中產品2的行,但是接下來在會話一中去查詢Sales.OrderDetails表中產品2的行,此時B進程要等待A進程中未提交的事務進行提交,所以導致A進程將阻塞B進程,接着在會話二中去查詢Production.Products表中產品2的行,此時A進程要等待B進程中未提交的事務進行提交,所以導致B進程阻塞A進程,最終結果將是死鎖。所以到了這里我們能夠很清楚地知道在兩個或多個事務中注意事務之間不能交叉進行。要是面試時忘記了腫么辦,告訴你一個簡單的方法,當軍訓或者上體育課正步走時只有1、2、1,沒有1、2、2就行。
寫寫死鎖
想必大家大部分只知道上述情況的死鎖,上述情況是什么情況,我們抽象一下則是不同表之間導致的死鎖,下面我們來看看同一表中如何產生死鎖,這種情況大家更加需要注意了。我們首先創建死鎖測試表並對表中列Id,Name創建唯一聚集索引,如下:
USE tempdb GO CREATE TABLE DeadlocksExample (Id INT, Name CHAR(20), Company CHAR(50)); GO CREATE UNIQUE CLUSTERED INDEX deadlock_idx ON DeadlocksExample (Id, Name) GO
接下來在會話一中插入一條測試數據開啟事務但是並未提交,如下:
BEGIN TRAN INSERT INTO dbo.DeadlocksExample VALUES (1, 'Jeffcky', 'KS')
接下來再來打開一個會話二插入一條數據開啟事務但是並未提交,如下:
BEGIN TRAN INSERT INTO DeadlocksExample VALUES (10, 'KS', 'Jeffcky')
再來在會話一中插入一條數據。
INSERT INTO DeadlocksExample VALUES (10, 'KS', 'Jeffcky')
此時此次插入將會阻塞,如下:
最后再來在會話二中插入一條數據
INSERT INTO DeadlocksExample VALUES (1, 'Jeffcky', 'KS')
此時此次插入能進行但是會顯示死鎖信息,如下:
想必大多數情況下看到的是通過不同表更新行產生的死鎖,在這里我們演示了在相同表通過插入行也會導致死鎖,死鎖真是無處不在。上述發生死鎖的主要原因在於第二次在會話一中去插入相同數據行時此時由於我們創建了Id和Name的唯一聚集索引所以SQL Server內部會嘗試去讀取行導致插入阻塞,在會話一中去插入行同理,最終造成彼此等待而死鎖。為了更深入死鎖知識,我們來看看如何從底層來探測死鎖,上述發生死鎖后,我們通過運行如下語句來查詢死鎖圖:
SELECT XEvent.query('(event/data/value/deadlock)[1]') AS DeadlockGraph FROM ( SELECT XEvent.query('.') AS XEvent FROM ( SELECT CAST(target_data AS XML) AS TargetData FROM sys.dm_xe_session_targets st JOIN sys.dm_xe_sessions s ON s.address = st.event_session_address WHERE s.name = 'system_health' AND st.target_name = 'ring_buffer' ) AS Data CROSS APPLY TargetData.nodes ('RingBufferTarget/event[@name="xml_deadlock_report"]') AS XEventData ( XEvent ) ) AS src;
此時你將發現會出現如下xml的數據:
我們點看死鎖圖來分析分析:
<deadlock> <victim-list> <victimProcess id="process17602d868" /> </victim-list> <process-list> <process id="process17602d868" taskpriority="0" logused="300" waitresource="KEY: 2:2089670228247904256 (4e0d37de3c51)" waittime="4222" ownerId="49122" transactionname="user_transaction" lasttranstarted="2017-03-04T21:56:15.447" XDES="0x16db8c3a8" lockMode="X" schedulerid="4" kpid="8296" status="suspended" spid="59" sbid="0" ecid="0" priority="0" trancount="2" lastbatchstarted="2017-03-04T21:56:47.080" lastbatchcompleted="2017-03-04T21:56:15.450" lastattention="1900-01-01T00:00:00.450" clientapp="Microsoft SQL Server Management Studio - 查詢" hostname="WANGPENG" hostpid="1640" loginname="wangpeng\JeffckyWang" isolationlevel="read committed (2)" xactid="49122" currentdb="2" lockTimeout="4294967295" clientoption1="671090784" clientoption2="390200"> <executionStack> <frame procname="adhoc" line="1" stmtstart="84" sqlhandle="0x02000000ea13d9115e8a4d429bc3d549e9053a3a784358020000000000000000000000000000000000000000"> INSERT INTO [DeadlocksExample] values(@1,@2,@3) </frame> <frame procname="adhoc" line="1" sqlhandle="0x020000009882c20809f279b6638fea1ef34b7986efb6b60a0000000000000000000000000000000000000000"> INSERT INTO DeadlocksExample VALUES (1, 'Jeffcky', 'KS') </frame> </executionStack> <inputbuf> INSERT INTO DeadlocksExample VALUES (1, 'Jeffcky', 'KS') </inputbuf> </process> <process id="process17602dc38" taskpriority="0" logused="300" waitresource="KEY: 2:2089670228247904256 (381c351990d5)" waittime="20467" ownerId="49022" transactionname="user_transaction" lasttranstarted="2017-03-04T21:56:06.070" XDES="0x16db8d6a8" lockMode="X" schedulerid="4" kpid="2684" status="suspended" spid="54" sbid="0" ecid="0" priority="0" trancount="2" lastbatchstarted="2017-03-04T21:56:30.837" lastbatchcompleted="2017-03-04T21:56:06.070" lastattention="1900-01-01T00:00:00.070" clientapp="Microsoft SQL Server Management Studio - 查詢" hostname="WANGPENG" hostpid="1640" loginname="wangpeng\JeffckyWang" isolationlevel="read committed (2)" xactid="49022" currentdb="2" lockTimeout="4294967295" clientoption1="671090784" clientoption2="390200"> <executionStack> <frame procname="adhoc" line="1" stmtstart="84" sqlhandle="0x02000000ea13d9115e8a4d429bc3d549e9053a3a784358020000000000000000000000000000000000000000"> INSERT INTO [DeadlocksExample] values(@1,@2,@3) </frame> <frame procname="adhoc" line="1" sqlhandle="0x02000000579d610429b0df7caee58044a9e5b493ea0d8e450000000000000000000000000000000000000000"> INSERT INTO DeadlocksExample VALUES (10, 'KS', 'Jeffcky') </frame> </executionStack> <inputbuf> INSERT INTO DeadlocksExample VALUES (10, 'KS', 'Jeffcky') </inputbuf> </process> </process-list> <resource-list> <keylock hobtid="2089670228247904256" dbid="2" objectname="tempdb.dbo.DeadlocksExample" indexname="1" id="lock172b46e80" mode="X" associatedObjectId="2089670228247904256"> <owner-list> <owner id="process17602dc38" mode="X" /> </owner-list> <waiter-list> <waiter id="process17602d868" mode="X" requestType="wait" /> </waiter-list> </keylock> <keylock hobtid="2089670228247904256" dbid="2" objectname="tempdb.dbo.DeadlocksExample" indexname="1" id="lock172b48b00" mode="X" associatedObjectId="2089670228247904256"> <owner-list> <owner id="process17602d868" mode="X" /> </owner-list> <waiter-list> <waiter id="process17602dc38" mode="X" requestType="wait" /> </waiter-list> </keylock> </resource-list> </deadlock>
東西貌似比較多哈,別着急我也是菜鳥,我們慢慢看,我們將其折疊,重點是分為如下兩塊:
死鎖最重要的兩個節點則是如上process和resource,我們再來一塊一塊分析,首先看process-list
如上我們能夠很清晰的看到關於死鎖的所有細節,我們查詢的SQL語句、隔離級別以及事務開始和結束的時間等更多詳細介紹,我們再來看看resource-list
而resource-list則列舉出了關於鎖的所有資源,如上列舉出了每個進程獲取到的鎖以及請求的鎖。我們從上看出通過 objectname 來標識數據庫關於死鎖的表,我們可以通過 associatedObjectId 關聯對象Id來得到表明和索引,運行如下查詢:
SELECT OBJECT_NAME(p.object_id) AS TableName , i.name AS IndexName FROM sys.partitions AS p INNER JOIN sys.indexes AS i ON p.object_id = i.object_id AND p.index_id = i.index_id WHERE partition_id = 2089670228247904256
上述resource-list節點下有兩個重要的子節點:owner-list和waiter-list,owner-list從字面意思理解則是擁有鎖的進程,同理waiter-list則是請求鎖並且等待這個擁有鎖的進程釋放的進程。我們能夠看到上述涉及到的都是排它鎖。resource-list中的過程大概如下:
(1)進程dc38獲取在表 DeadlocksExample 上鍵中的排它鎖。
(2)進程d868獲取在表 DeadlocksExample 上鍵中的排它鎖。
(3)進程d868請求在表 DeadlocksExample 上鍵中的排它鎖。
(4)進程dc38請求在表 DeadlocksExample 上鍵中的排它鎖。
所以為何一般不推薦使用聯合主鍵,若使用聯合主鍵則該情況如上述所述,此時兩個列默認創建則是唯一聚集索引,當有並發情況產生時會就有可能導致在同一表中插入相同的值此時將導致死鎖情況發生,想必大部分使用聯合主鍵的情景應該是在關聯表中,將兩個Id標識為聯合主鍵,此時我們應該重新設置一個主鍵無論是INT或者GUID也好都比聯合主鍵要強很多。
實戰拓展
上述我們大概了解了下死鎖圖以及相關節點解釋,接下來我們來演示幾種常見的死鎖並逐步分析。我們來看看。
避免邏輯死鎖
我們創建如下測試表並默認插入數據:
CREATE TABLE Table1 ( Column1 INT, Column2 INT ) GO INSERT INTO Table1 VALUES (1, 1), (2, 2),(3, 3),(4, 4) GO CREATE TABLE Table2 ( Column1 INT, Column2 INT ) GO INSERT INTO Table2 VALUES (1, 1), (2, 2),(3, 3),(4, 4) GO
此時我們進行數據更新對Column2,如下:
UPDATE Table1 SET Column1 = 3 WHERE Column2 = 1
此時由於我們對列Column2沒有創建索引,所以會造成SQL Server引擎會進行全表掃描去堆棧中找到我們需要更新的數據,同時呢SQL Server會對該行更新的數據獲取一個排它鎖,當我們進行如下查詢時
SELECT Column1 FROM Table1 WHERE Column2 = 4
此時將獲取共享鎖,即使上述更新和此查詢語句在不同的會話中都不會造成阻塞,雖然排它鎖和共享鎖不兼容,因為上述更新數據的內部事務已經提交,所以排它鎖已經釋放。下面我們來看看死鎖情況,我們打開兩個會話並確保會話處於連接狀態。
在會話一中我們對表一上的Column1列進行更新通過篩選條件Column2同時開啟事務並未提交,如下:
BEGIN TRANSACTION UPDATE Table1 SET Column1 = 3 WHERE Column2 = 1
會話二同理
BEGIN TRANSACTION UPDATE Table2 SET Column1 = 5 WHERE Column2 = 2
同時去更新兩個會話中的數據。接下來再在會話一中更新表二中的數據行,如下:
BEGIN TRANSACTION SELECT Column1 FROM Table2 WHERE Column2 = 3 ROLLBACK TRANSACTION
在讀寫死鎖中我們已經演示此時查詢會造成堵塞,就不再貼圖片了。
同理在會話一中更新表一中的數據行。
BEGIN TRANSACTION SELECT Column1 FROM Table1 WHERE Column2 = 4 ROLLBACK TRANSACTION GO
此時運行會話二中的語句,將得到如下死鎖信息,當然二者必然有一個死鎖犧牲品,至於是哪個會話,那就看SQL Server內部處理機制。
上述關於表一和表二死鎖的情況,大概如下圖所示
上述由於沒有建立索引導致全表掃描所以對於每行記錄都會獲取一個共享鎖,但是呢,更新數據進程又為提交事務最終會導致死鎖,其實我們可以通過建立索引來找到匹配的行同時會繞過在葉子節點上被鎖定的行,那么應該創建什么索引呢,對篩選條件創建過濾索引?顯然是不行的,因為查詢出的列還是到基表中去全表掃描,所以我們常見覆蓋索引,如下:
CREATE NONCLUSTERED INDEX idx_Column2 ON Table1(Column2) INCLUDE(column1)
CREATE NONCLUSTERED INDEX idx_Column2 ON Table2(Column2) INCLUDE(column1)
當我們再次重新進行如上動作時,你會發現在會話一中進行查詢時此時將不會導致阻塞,直接能查詢出數據,如下:
所以通過上述表述我們知道好的索引設計能夠減少邏輯沖突上的死鎖。
避免范圍掃描和SERIALIZABLE死鎖
比如我們要進行如下查詢。
SELECT CustomerIDFROM Customers WHERE CustomerName = @p1
一旦有並發則有可能造成幻影讀取即剛開始數據為空行,但是第二次讀取時則存在數據,所以此時我們設置更高的隔離級別,如下:
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
通過設置該隔離級別即使剛開始數據為空行,它能保證再次讀取時返回的數據一定為空行,通過鎖住 WHERE CustomerName = @p1 並且它會鎖住值等於@p1的所有記錄,我們經常有這樣的需求,當數據存在時則更新,不存在時則插入,如果你沒有想到並發情況的發生,估計到時投訴將落在你身上,所以為了解決兩次讀取一致的情況我們設置最高隔離級別,如下:
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; BEGIN TRANSACTION IF EXISTS ( SELECT 1 FROM [dbo].[Customers] WITH ( ROWLOCK ) WHERE CustomerName = @p1 ) UPDATE dbo.Customers SET LatestOrderStatus = NULL , OrderLimit = 0 WHERE CustomerName = @p1; ELSE INSERT INTO dbo.Customers ( CustomerName , RegionID , OrderLimit , LatestOrderStatus ) VALUES ( @p1 , 0 , 0 , NULL ); COMMIT TRANSACTION
上述假設我們對CustomerName建立了唯一索引並加了行鎖來鎖住單行數據,看起來so good,實際上有沒有問題呢。如果當CustomerName = 'Jeffcky'在該行上面的CustomerName = 'Jeffcky1',在其下方的CustomerName = 'Jeffcky2',此時通過設置最高隔離級別將鎖住這三行來阻止任何數據的插入,我們可以將其叫做范圍共享鎖,如果在不同會話中在該范圍內插入不同行,此時極有可能造成死鎖,你以為設置最高隔離級別就萬事大吉了嗎。那么該如何解決這個麻煩呢,我們可以通過Merge來避免該死鎖,因為Merge操作為單原子操作,我們不在需要最高隔離級別,但是貌似有潛在的bug發生未驗證過,同時該Merge我也未用過。
這個問題是在博問中看到dudu老大提出插入重復數據而想到(博問地址:https://q.cnblogs.com/q/90745/),dudu老大所給語句為如下SQL語句:
IF NOT EXISTS(SELECT 1 FROM [Relations] WHERE [UserId]=@UserId AND [RelativeUserId]=@RelativeUserId AND IsActive=1) BEGIN BEGIN TRANSACTION INSERT INTO [Relations]([UserId], [RelativeUserId]) VALUES (@UserId,@RelativeUserId) UPDATE [Users] SET FollowingCount=FollowingCount+1 WHERE UserID=@UserId UPDATE [Users] SET FollowerCount=FollowerCount+1 WHERE UserID=@RelativeUserId COMMIT TRANSACTION END
當時我所給出的答案為如下:
INERT INTO.....SELECT ...FROM WHERE NOT EXSITS(SELECT 1 FROM...)
對應上述情況我們將上述隔離級別去掉利用兩個語句來操作,如下:
UPDATE dbo.Customers SET LatestOrderStatus = NULL , OrderLimit = 0 WHERE CustomerName = @p1; INSERT INTO dbo.Customers ( CustomerName , RegionID , OrderLimit , LatestOrderStatus ) SELECT @p1~ , 0 , 0 , NULL WHERE NOT EXISTS ( SELECT 1 FROM dbo.Customers AS c WHERE CustomerName = @p1 )
此時沒有事務,上述雖然看起來很好不會引起死鎖但是對於插入操作會導致阻塞。我看到上述dudu老大提出的問題有如下答案:
BEGIN TRANSACTION IF NOT EXISTS(SELECT 1 FROM [Relations] WITH(XLOCK,ROWLOCK) WHERE [UserId]=@UserId AND [RelativeUserId]=@RelativeUserId AND IsActive=1) BEGIN INSERT INTO [Relations]([UserId], [RelativeUserId]) VALUES (@UserId,@RelativeUserId) UPDATE [Users] SET FollowingCount=FollowingCount+1 WHERE UserID=@UserId UPDATE [Users] SET FollowerCount=FollowerCount+1 WHERE UserID=@RelativeUserId END COMMIT TRANSACTION
經查資料顯示對於XLOCK,SQL Server優化引擎極有可能忽略XLOCK提示,而導致無法解決問題,具體未經過驗證。在這里我覺得應該使用UPDLOCK更新鎖。通過UPDLOCK與其他更新鎖不兼容,通過UPDLOCK來序列化整個過程,當運行第二個進程時,由於第一個進程占用鎖導致阻塞,所以直到第一個進程完成整個過程第二個進程都將處於阻塞狀態,所以對於dudu老大提出的問題是否最終改造成如下操作呢。
BEGIN TRANSACTION
IF NOT EXISTS(SELECT 1 FROM [Relations] WITH (ROWLOCK, UPDLOCK) WHERE [UserId]=@UserId AND [RelativeUserId]=@RelativeUserId AND IsActive=1)
UPDATE [Users] SET FollowingCount=FollowingCount+1 WHERE UserID=@UserId;
UPDATE [Users] SET FollowerCount=FollowerCount+1 WHERE UserID=@RelativeUserId;
ELSE
INSERT INTO [Relations]
( [UserId],
[RelativeUserId]
)
VALUES ( @UserId,
@RelativeUserId
);
COMMIT TRANSACTION
總結
本節我們比較詳細講解了SQL Server中的死鎖以及避免死鎖的簡單介紹,對於如何避免死鎖我們可以從以下來看。
(1)鎖保持的時間越長,增加了死鎖的可能性,盡量縮短事務的時間即盡量使事務簡短。
(2)事務不要交叉進行,按照順序執行。
(3)對於有些邏輯可能不可避免需要交叉進行事務,此時我們可能通過良好的索引設計來規避死鎖發生。
(4)我們也可以通過try..catch,捕獲事務出錯並retry。
(5)最后則是通過設置隔離級別來減少死鎖頻率發生。
好了本文到此結束,內容貌似有點冗長,沒辦法,太多需要學習,對於SQL Server基礎系列可能還剩下最后一節內容,那就是各種鎖的探討,我們下節再會,see u。