在博客園看到一篇討論特別多的文章“探討SQL Server併發處理存在就更新七種解決方案”,這種業務需求很常見:如果記錄存在就更新,不存在就插入。 最常見的做法: 一個很明顯的問題,在高併發下可能存在操作同一條記錄的多個線程都進入到INSERT環節,導致插入失敗。 上面問題原因在於進入INSERT或 ...
在博客園看到一篇討論特別多的文章“探討SQL Server併發處理存在就更新七種解決方案”,這種業務需求很常見:如果記錄存在就更新,不存在就插入。
最常見的做法:
BEGIN TRANSACTION IF EXISTS ( SELECT 1 FROM Test WHERE Id = @Id ) UPDATE Test SET [Counter] = [Counter] + 1 WHERE Id = @Id; ELSE INSERT Test ( Id, Name, [Counter] ) VALUES ( @Id, @Name, 1 ); COMMIT
一個很明顯的問題,在高併發下可能存在操作同一條記錄的多個線程都進入到INSERT環節,導致插入失敗。
上面問題原因在於進入INSERT或UPDATE環節沒有“排他”鎖,如果每個線程在進行插入或更新前就獲得記錄的“排他鎖”,也就解決了其他線程併發處理相同記錄的可能性。換個說法,一個蘿蔔一個坑,先不管這個坑有沒有蘿蔔,先把坑占上,再考慮其他的。
如何占坑呢?而且是“排他地”占坑呢?
在SQL SERVER中,排他鎖即X鎖,對目標加X鎖有兩種方式:
1、使用SELECT+WITH(XLOCK)查詢提示
2、使用UPDATE/INSERT/DELETE操作
雖然SELECT+WITH(XLOCK)查詢提示能做到加X鎖,但是這種X鎖有點“不靠譜”,MSDN給出解釋:
Using XLOCK in SELECT statements will not prevent reads from happening. This is because SQL Server has a special optimization under read committed isolation level that checks if the row is dirty or not and ignores the xlock if the row has not changed. Since this is acceptable under the read committed isolation level semantics it is by design.
哪就只能UPDATE/INSERT/DELETE方式,DELETE肯定排除,直接INSERT如果碰到記錄已存在又會報錯,最終只能選擇UPDATE,於是將業務需求實現為:
BEGIN TRANSACTION --先嘗試更新記錄占坑 UPDATE Test SET [Counter] = [Counter] + 1 WHERE Id = @Id; --如果更新操作沒有影響行,證明記錄不存在,則插入 IF @@ROWCOUNT<1 BEGIN INSERT Test ( Id, Name, [Counter] ) VALUES ( @Id, @Name, 1 ); END COMMIT
哪這樣真的把坑占上沒?
當ID=1記錄不存在時,執行下麵SQL:
BEGIN TRANSACTION --先嘗試更新記錄占坑 UPDATE Test SET [Counter] = [Counter] + 1 WHERE Id = 1; EXEC sp_lock @@SPID
雖然UPDATE的確會產生X排他鎖,但是沒有把鎖“持續”地占下來,因此也無法保證高併發下對該記錄的INSERT/UPDATE操作以“串列”方式執行。
要“持續”鎖,也有兩個辦法:
1、使用WITH(HOLDLOCK)鎖提示
2、使用SERIALIZABLE事務隔離級別
看下使用WITH(HOLDLOCK)鎖提示獲得的鎖,同樣當ID=1記錄不存在時:
BEGIN TRANSACTION --先嘗試更新記錄占坑 UPDATE Test WITH(HOLDLOCK) SET [Counter] = [Counter] + 1 WHERE Id = 1; EXEC sp_lock @@SPID
可以看到除對錶和唯一索引上加IX和IS鎖以及頁上IX鎖外,還有一個KEY級別的範圍鎖RangX-X,由於範圍X鎖的存在,任何其他回話嘗試對此範圍的UPDATE和INSERT操作都將被阻塞,因此可以繼續判斷是否需要插入。
當然,使用SERIALIZABLE隔離級別也是相同的效果
BEGIN TRANSACTION SET TRANSACTION ISOLATION LEVEL SERIALIZABLE --先嘗試更新記錄占坑 UPDATE Test SET [Counter] = [Counter] + 1 WHERE Id = 1; EXEC sp_lock @@SPID
======================================
更新後如何判斷是否需要插入呢?
方式1: 使用@@ROWCOUNT來判斷更新數據是否影響行,如果影響,則證明數據存在,無需更新
方式2: 使用IF NOT EXISTS也是可以的,反正坑已經被占上,別的回話也不能改,再查一次就是畫蛇添足而已,不影響結果。
總的還是推薦使用方式1,效率最高,避免一次SELECT操作,最後的腳本推薦為:
BEGIN TRANSACTION --先嘗試更新記錄占坑 UPDATE Test WITH(HOLDLOCK) SET [Counter] = [Counter] + 1 WHERE Id = @Id; --如果更新操作沒有影響行,證明記錄不存在,則插入 IF @@ROWCOUNT<1 BEGIN INSERT Test ( Id, Name, [Counter] ) VALUES ( @Id, @Name, 1 ); END COMMIT
================================================
PS1: 通常情況下,個人不太推薦修改事務隔離級別,事務隔離級別影響的是整個事務,而鎖提示隻影響特定語句。
================================================
PS2: 上面業務查詢和更新都基於主鍵,在很多真實的業務場景下,主鍵通常為非業務鍵即自增鍵,而需要根據業務鍵來操作,存在以下死鎖可能:
回話1: 先獲取聚集索引上X鎖,嘗試獲取非聚集索引上的X鎖
回話2:先獲取到非聚集索引上的X鎖,嘗試獲取聚集索引上X鎖
=================================================
語文不好,本來很簡單的一個東西,被自己描述成這樣,各位包含。
處理問題,先看原理,再考慮如何解決,才最簡單有效。
=================================================