同一筆 POST 來了兩次:如何處理 SQL 唯一鍵衝突與交易競爭

某次同事處理交易建立 API 時,系統突然出現 SQL Server 唯一鍵衝突。第一個直覺通常是:「是不是查詢重複資料的 SQL 壞了?」但往下追查後,真正的問題其實是兩個請求都認為資料不存在,接著一起嘗試新增。

資料庫只是最後那個說「不行」的人。

這類問題不能只靠捕捉 SQL 例外解決,也不能把責任全部推給前端重複送出。比較完整的做法,是先確認重複請求如何進入系統,再把交易建立流程改成冪等:同一筆請求不管送來幾次,系統都只建立一筆資料,並回傳一致的結果。

先確認我們到底看到了什麼

當 SQL Server 回報 2601 或 2627,能確定的事情只有一件:系統嘗試寫入一組已經存在的唯一鍵。

它不能直接證明:

  • 使用者連點兩次按鈕。
  • 前端程式重複送出。
  • 代理伺服器自動重試。
  • 兩個請求一定同時執行。
  • 第一個請求已經完整成功。

兩筆內容相同的 HTTP POST,只能證明接收端收到了兩個請求。至於它們是同時抵達、逾時重送,還是第一次成功後又送了一次,仍要靠請求識別碼、毫秒時間、各階段耗時及資料庫紀錄判斷。

這條證據界線很重要。若原因還沒確認,就直接歸咎於前端連點,修完按鈕後問題通常還會再出現。

為什麼「先查再寫」會失效

常見的建立交易流程大致如下:

IF NOT EXISTS (
    SELECT 1
    FROM dbo.TransactionRecord
    WHERE StoreCode = @StoreCode
      AND OrderNumber = @OrderNumber
)
BEGIN
    INSERT dbo.TransactionRecord (...)
    VALUES (...);
END

單獨看沒有問題,但在兩個請求同時執行時,可能變成:

請求 A:查詢是否已有重複資料 → 沒有
請求 B:查詢是否已有重複資料 → 沒有

請求 A:新增交易 → 成功
請求 B:新增交易 → 唯一鍵衝突

問題出在「查詢」與「新增」是兩個分開的動作。兩個請求可以在彼此尚未寫入前,都得到「資料不存在」的答案。

即使程式有 BEGIN TRANSACTION,也要看 transaction 從哪裡開始。如果查詢發生在 transaction 之前,後面的 transaction 當然保護不到前面的判斷。

把查詢搬進 transaction 也不一定足夠。在 SQL Server 預設的 READ COMMITTED 隔離層級下,一般讀取所取得的共享鎖,通常會在讀取完成後釋放。查詢與新增之間仍可能留下空檔。Microsoft 的交易鎖定說明

如果查詢還使用 NOLOCK,情況會更難控制。NOLOCK 適合部分允許不一致結果的讀取情境,不適合拿來決定「接下來是否可以建立一筆不能重複的交易」。

正確目標不是阻止 POST,而是讓處理具有冪等性

POST 在 HTTP 規格中不是預設冪等的方法,但伺服器可以透過自己的交易規則,讓特定 POST 具有冪等效果。RFC 9110 對冪等的定義是:相同請求執行多次,對伺服器造成的預期結果,應與執行一次相同。

套用到交易建立 API,期待的行為是:

第一次送出 → 建立交易
相同內容再次送出 → 回傳原本的交易
相同交易編號但內容不同 → 拒絕

「回傳原本的交易」不等於忽略第二個請求。第二個請求仍需要得到可繼續後續流程的正常結果,只是不能再次新增資料、發送通知或執行其他一次性動作。

資料庫解法:讓判斷與新增成為同一個受保護區段

若交易識別由同一張資料表上的唯一索引保護,可以考慮在 transaction 內使用 UPDLOCK, HOLDLOCK

BEGIN TRY
    BEGIN TRANSACTION;

    SELECT
        @ExistingTransactionId = TransactionId
    FROM dbo.TransactionRecord WITH (UPDLOCK, HOLDLOCK)
    WHERE StoreCode = @StoreCode
      AND OrderNumber = @OrderNumber;

    IF @ExistingTransactionId IS NOT NULL
    BEGIN
        -- 比對金額、付款方式等不可變資料。
        -- 內容相同:回傳既有交易。
        -- 內容不同:回傳商業衝突。
    END
    ELSE
    BEGIN
        INSERT dbo.TransactionRecord (...)
        VALUES (...);

        SET @ExistingTransactionId = SCOPE_IDENTITY();
    END;

    COMMIT TRANSACTION;
END TRY
BEGIN CATCH
    IF XACT_STATE() <> 0
        ROLLBACK TRANSACTION;

    THROW;
END CATCH;

UPDLOCK 會讓這次讀取取得更新鎖,並保留到 transaction 結束;HOLDLOCK 的行為相當於針對該資料來源使用 SERIALIZABLE 語意。Microsoft 的 Table Hints 文件

理想情況下,兩個請求會這樣執行:

A 先取得該交易鍵值的鎖
B 查詢同一鍵值時等待

A 成功提交
B 繼續執行並讀到 A 建立的交易
B 回傳既有交易,不再新增

如果 A 中途失敗並回滾,B 等待結束後會發現資料仍不存在,便可以接手建立。

這不代表 SQL Server 一定只鎖定某一筆資料。實際鎖定的是資料列、索引鍵、鍵值範圍、資料頁或更大的範圍,會受到索引、查詢條件、執行計畫與鎖升級影響。實作前應確認查詢條件能有效使用對應的唯一索引,並盡量縮短 transaction。

唯一鍵不能拿掉

有些人遇到 2627,第一個反應是移除唯一鍵。這會讓錯誤訊息消失,也會讓重複交易真的寫進資料庫。

唯一鍵應該保留。它是資料一致性的最後一道保護。

即使主要流程已經使用鎖,程式仍應處理 2601/2627。其他尚未改造的入口、部署版本差異或非預期的執行路徑,都可能繞過前面的保護。

比較安全的備援流程是:

  1. 發生唯一鍵例外後先回滾。
  2. 重新讀取已存在的交易。
  3. 比對不可變資料。
  4. 內容相同便回傳既有結果。
  5. 內容不同則回報交易編號衝突。

SQL Server 官方文件也說明,2627 代表嘗試寫入重複的主鍵或唯一值;它證明的是重複寫入,不是請求來源。Microsoft SQL Server 2627 說明

什麼時候考慮 sp_getapplock

若建立交易橫跨多張表、多個資料庫程序,或需要用一組商業識別碼協調不同入口,單一索引的鎖定可能不好表達整個保護範圍。這時可以評估 sp_getapplock

它可以把某個自訂名稱當成鎖定資源,例如:

CreateTransaction:{StoreCode}:{OrderNumber}

所有建立同類交易的入口都必須使用相同的命名規則,否則各鎖各的,仍然可能重複執行。鎖若由 transaction 擁有,會在提交或回滾時釋放。Microsoft 的 sp_getapplock 文件

如果問題只發生在一張已有合適唯一索引的資料表,先使用索引配合 transaction 通常比較直接。sp_getapplock 適合處理跨程序或跨資料表的商業操作,不必一開始就加入。

程式端還要處理一次性動作

資料庫只新增一筆,不代表整個流程已經冪等。建立交易後可能還有:

  • 發送建立成功通知。
  • 寫入只允許建立一次的附加資料。
  • 呼叫外部服務。
  • 推送訊息或觸發後續工作。

因此資料庫程序或服務應明確回傳「這次是否新建」,例如:

TransactionId
IsNewTransaction
ResultCode

程式端依結果處理:

情況 回傳結果 後續行為
本次建立成功 新交易 執行一次性動作
相同內容已存在 既有交易 回傳原交易,不重跑一次性動作
同編號但內容不同 商業衝突 拒絕,不得沿用原交易
非預期資料庫錯誤 系統失敗 回滾並留下診斷紀錄

若外部呼叫不能與資料庫 transaction 綁在一起,可再評估 Outbox 等做法,避免資料庫已提交,但通知或訊息只執行一半。

不可變資料一定要比對

不能只看到相同訂單編號,就直接把既有交易當成成功結果。至少應比對會影響交易內容的欄位,例如:

  • 商店識別。
  • 訂單編號。
  • 金額與幣別。
  • 付款方式。
  • 其他建立後不應變更的交易條件。

相同識別且內容相同,才是安全重送。相同識別但金額不同,代表識別碼遭到錯誤重複使用,必須拒絕並留下紀錄。

怎麼驗證修正真的有效

不要只用單次手動操作測試。至少要涵蓋以下情境:

  1. 兩個相同 POST 同時送入,只建立一筆交易,兩個回應取得相同交易結果。
  2. 第一個請求建立途中失敗並回滾,第二個請求能接手建立。
  3. 相同交易編號搭配不同金額或付款方式,系統明確拒絕。
  4. 重送不會再次發送通知、寫入附加資料或觸發外部操作。
  5. 即使某個入口沒有取得預期的鎖,唯一鍵例外也不會直接顯示給使用者。
  6. 壓力測試期間觀察等待時間、死結與 transaction 長度,確認修正沒有把交易流程變成新的效能瓶頸。

測試紀錄也要有足夠精度。建議替每個 HTTP 請求分配 Request ID,記錄毫秒時間、商業交易識別、是否新建及各主要階段耗時。不要記錄完整卡號、驗證資料、個資或原始敏感請求內容。

結語

重複 POST 很難完全避免。使用者可能連點,呼叫端可能因逾時重試,網路中介層也可能讓同一操作再次出現。

接收端真正能控制的是結果:

同一筆交易只建立一次。
相同重送取得原本結果。
內容不一致時明確拒絕。
一次性動作不重複執行。

處理這類問題時,先證明系統收到什麼,再確認查詢與新增之間是否存在競爭空檔。鎖、transaction 與唯一鍵各有不同責任;把它們放在正確的位置,重複 POST 就不再是偶發的資料庫例外,而是一條可以測試、可以預期的正常流程。

Author image
關於 Richard Zheng
About me 喜歡爬山,瑜伽,溜冰,喜歡新奇的事,最喜歡的還是寫程式帶來的成就感,對於資訊會不斷的出現新事物也能抱持好奇與熱忱。近期開始將學習的心得寫在Blog,發現思路更清晰也加深了記憶。 紙上得來終覺淺,絕知此事要躬行