SQL Server--存在則更新問題


在博客園看到一篇討論特別多的文章“探討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鎖

=================================================

語文不好,本來很簡單的一個東西,被自己描述成這樣,各位包含。

處理問題,先看原理,再考慮如何解決,才最簡單有效。

=================================================

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM