iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Software Development

Spring Boot + Kotlin 協程高併發,後端開發新選擇系列 第 25 篇

Day 24:庫存扣減的併發正確性,用資料庫機制取代分散式鎖

  • 分享至 

  • xImage
  •  

Day 23:把併發控制手段實際裝進訂單服務 結尾講得很直白:Semaphore 與 withTimeout 這兩道防護都裝上了,但真正動到庫存數量本身的那個操作,扣減庫存,一行都沒有動。這句話不是隨口帶過的懸念,是刻意留到今天才處理的一道題目。

節流做得再好,也擋不住這個問題

先把今天要處理的問題邊界講清楚,這件事在 Day 15:併發限制,用 Semaphore 保護下游別被打爆 那篇文章其實已經先講過一次。當時明確區分了兩件事:Semaphore 節流處理的是「呼叫端同時發起過多請求,需要限制同時執行的數量以保護下游資源」,這是流量控制層次的問題;庫存被多個請求同時修改導致資料不正確,關注的是資料庫交易層級的併發正確性,兩者不是同一個層次的問題,也不能用同一套手段解決。

那篇文章當時只點出了這個邊界,並沒有給答案,留了一句話:「訂單服務日後真正要解決庫存扣減的併發正確性問題,具體會用什麼手段,留到後面深入資料庫層級的併發控制時再正式定案。」今天,就是那個「後面」。

Day 23 疊上去的 Semaphore 與逾時機制,管的都是同一台伺服器行程內部,同時有多少個協程正在執行呼叫下游服務這個動作。就算把這些機制設定得再合理,permits 抓得再精準,它們的作用範圍終究只到「這台伺服器願意同時發起幾個呼叫」為止,管不到另一台伺服器上正在跑的另一個協程,更管不到資料庫裡那一列庫存資料,實際上正被誰讀取、被誰寫入。呼叫端的節流做得再仔細,都無法保證資料本身不會被同時發生的兩個操作弄錯。這個問題要解決,得從資料本身被修改的那一刻下手,而不是從呼叫發起的那一刻下手。

兩個請求,同一件僅剩的商品

把這個問題攤開來看最直接的方式,是走一次具體的時間序列。假設某筆商品庫存目前剩下 1 件,兩個請求幾乎同時進來,都想買下這件商品。

如果程式邏輯寫得很直覺,先查詢目前庫存數量,確認大於零,再執行扣減,這兩個步驟之間就存在一段時間差。時間點一,請求 A 查詢庫存,讀到 1,判斷可以購買。時間點二,幾乎在同一瞬間,請求 B 也查詢庫存,這時候請求 A 還沒來得及寫回更新後的結果,資料庫裡的庫存欄位仍然是 1,請求 B 也讀到 1,同樣判斷可以購買。時間點三,請求 A 執行扣減,把庫存從 1 改成 0。時間點四,請求 B 也執行扣減,它並不知道請求 A 已經動過這筆資料,一樣把庫存從自己讀到的 1 改成 0。

請求 A 與請求 B 搶購最後一件商品的時間序列圖,兩者先後查詢到庫存皆為 1,再各自扣減寫入 0,兩次扣減互相覆蓋,正確結果應為 -1 代表賣超,資料庫卻停在 0,兩個請求都回應購買成功

最終結果,庫存實際被扣減了兩次,正確答案應該是 -1,代表賣超,但如果資料表沒有任何保護機制,庫存欄位最後停在的數字可能只是 0,兩次扣減互相覆蓋掉彼此的痕跡,這正是這類問題最麻煩的地方,錯誤不會大聲喧嘩,它安安靜靜地讓兩個請求都收到購買成功的回應,等到有人真的去對帳,才會發現庫存數字對不上實際出貨紀錄。查詢與扣減這兩個動作之間的時間差,就是這整個問題發生的空間。

這裡要特別強調一次,這個問題與 Semaphore 節流完全無關。就算 Day 23 那個 stockCheckSemaphore 把某條呼叫路徑限制到同一時間只允許一個協程通過,只要允許的數量大於零,兩個各自獨立的請求依然可能各自拿到名額、各自完成查詢與扣減這兩個步驟,時間差照樣存在。更現實的情況是,真實的生產環境幾乎不會只跑一份應用程式行程,訂單服務為了應付高併發流量通常部署在多台伺服器上,就算把單一伺服器內部的併發限制到極致,跨伺服器之間的兩個協程,彼此完全不知道對方的存在,呼叫端節流在這裡完全使不上力。

為什麼不用 Redis 分散式鎖

講到這裡,很多讀者心裡可能已經冒出一個熟悉的答案:這種跨伺服器的併發正確性問題,不就是分散式鎖該登場的時機嗎?業界確實有不少教學資源,處理這類庫存扣減情境時會往這個方向走,這不是一個錯誤的方向,也有不少團隊在實際生產環境裡靠這套機制撐住了大流量場景。

但這個系列不打算走這條路,理由與技術對錯無關,純粹是複雜度權衡的結果。引入一套額外的鎖服務,代表系統多了一個原本不需要存在的外部依賴,這個依賴本身也需要維運,需要設計鎖的過期時間該抓多長,抓太短可能鎖還沒真正釋放前效期就到了,抓太長又可能讓一個已經當掉的請求,白白占著鎖不放,讓其他人一起卡住。萬一這個鎖服務自己出狀況,整套防護機制也跟著一起失靈,這時候還得另外設計一套因應方案。這些複雜度不是解決不了,只是對於這個系列真正想聚焦的核心主題,協程與資料庫層級機制怎麼搭配,並非必要的負擔。

反過來看,資料庫本身,Day 12:R2DBC,讓資料庫存取也不阻塞執行緒 定案的 PostgreSQL,原生就提供了一整套保證資料一致性的機制,樂觀鎖與悲觀鎖都是資料庫這一層與生俱來的能力,不需要另外接一個外部服務,也能直接與 R2DBC 的交易機制搭配運作。這是這個系列刻意採用的技術堆疊方向,把庫存扣減的併發正確性問題,收斂在資料庫這一層徹底解決,全系列後續也不會再回頭引入任何額外的協調機制處理類似的問題。今天正式把這個方向定案:庫存扣減併發控制,資料庫層級機制。接下來兩節,就是這個機制具體長什麼樣子。

做法一,樂觀鎖:讓版本號替你把關

樂觀鎖的核心假設是,多數情況下不會真的發生衝突,因此不需要在讀取資料的當下就急著鎖住它,而是在庫存資料表加上一個版本欄位,這裡沿用 version 這個常見命名。每次要更新這筆資料時,同時檢查這次更新所依據的版本號,是否與資料庫裡目前的版本號一致。一致,代表這段期間沒有人動過這筆資料,允許更新,同時把版本號加一;不一致,代表這筆資料在讀取之後已經被別人改過,這次更新應該失敗,而不是硬把資料蓋過去。

用剛才的情境重新走一次。請求 A 與請求 B 都讀到版本號 1、庫存 1。請求 A 先執行更新,更新時附帶的條件是「版本號必須等於 1」,資料庫這時候版本號確實還是 1,條件成立,更新成功,庫存變成 0,版本號同步變成 2。緊接著請求 B 也執行更新,它同樣帶著「版本號必須等於 1」這個條件,但資料庫裡的版本號此刻已經是 2,條件不成立,這次更新不會有任何一列資料被真正修改。程式邏輯只要檢查這次更新實際影響了幾列資料,就能判斷這次操作到底成不成立,不需要額外去猜測發生了什麼事。

延續 Day 22:收斂進一個具名專案,訂單服務實戰整合版啟動 建立的 Repository 層風格,OrderRepository 目前只是一個繼承 CoroutineCrudRepository<Order, Long> 的空介面,今天要在庫存這一側新增一個對應的 StockRepository,加上一個自訂的更新方法:

interface StockRepository : CoroutineCrudRepository<Stock, Long> {

    @Modifying
    @Query(
        """
        UPDATE stock
        SET quantity = quantity - 1, version = version + 1
        WHERE id = :stockId AND version = :expectedVersion AND quantity > 0
        """
    )
    suspend fun deductOneWithVersionCheck(stockId: Long, expectedVersion: Long): Int
}

@Modifying 標註告訴 Spring Data R2DBC 這是一段會修改資料的語句,回傳型別選擇 Int,代表這次執行實際影響了幾列資料,這正是判斷版本衝突有沒有發生的關鍵依據。WHERE 子句裡同時放了 version = :expectedVersion 與 quantity > 0 兩個條件,前者是樂觀鎖的版本比對,後者則是額外守住庫存不能扣成負數這條底線,兩者一起把「查詢時看到的狀態」與「真正寫入時的狀態」綁在同一個原子操作裡完成,不再需要先查詢、再判斷、再寫入這三個步驟分開執行。

Service 層呼叫這個方法時,需要先讀出目前的版本號,再帶著這個版本號嘗試扣減:

suspend fun deductStockOptimistic(stockId: Long): Boolean {
    val stock = checkNotNull(stockRepository.findById(stockId))
    val affectedRows = stockRepository.deductOneWithVersionCheck(
        stockId = stockId,
        expectedVersion = stock.version,
    )
    return affectedRows > 0
}

affectedRows > 0 就是整段邏輯的判斷關鍵,回傳 true 代表這次扣減確實成功寫入,回傳 false 代表版本號已經被別人改過,或者庫存本來就已經是零,這次操作沒有真正生效。呼叫端拿到 false 之後,可以視情況重新讀取最新狀態再重試一次,或者直接回應使用者「庫存不足」,這裡只需要先把判斷依據建立起來,重試策略要設計得多細緻,留給讀者依照自己系統的容錯需求決定。樂觀鎖比較適合衝突機率相對較低、也能接受偶爾需要重試或回報失敗的場景,如果多數請求彼此其實不會真的搶到同一筆資料,樂觀鎖不需要預先鎖定任何東西,等於是把成本壓到最低。

做法二,悲觀鎖:先把資料列鎖住再說

悲觀鎖的假設方向正好相反,它預設衝突機率較高,與其等到寫入那一刻才發現衝突,不如在讀取資料的同時就直接把這筆資料列鎖住。SELECT ... FOR UPDATE 這個語句會在一個交易裡鎖定被查詢到的資料列,直到這個交易結束才釋放,其他嘗試對同一筆資料列下 FOR UPDATE 的交易,必須排隊等待,確保同一時間只有一個交易能夠讀取並修改這筆庫存。

同樣走一次前面那個情境。請求 A 的交易先送出 SELECT ... FOR UPDATE,成功取得這筆庫存資料的鎖,接著在同一個交易裡完成扣減、提交。請求 B 的交易也想對同一筆資料下 SELECT ... FOR UPDATE,但這筆資料此刻已經被請求 A 的交易鎖住,請求 B 只能先卡在這裡等待,一直等到請求 A 的交易結束、鎖被釋放,請求 B 才真正拿到這筆資料。這時候它讀到的庫存數量,已經是請求 A 扣減之後的最新結果,不會發生誤判。

程式碼上,這個做法需要把查詢與更新這兩個動作,放進同一個交易範圍內完成,延續同樣的 Repository 層風格:

interface StockRepository : CoroutineCrudRepository<Stock, Long> {

    @Query("SELECT * FROM stock WHERE id = :stockId FOR UPDATE")
    suspend fun findByIdForUpdate(stockId: Long): Stock?

    @Modifying
    @Query("UPDATE stock SET quantity = quantity - 1 WHERE id = :stockId")
    suspend fun deductOne(stockId: Long): Int
}
@Service
class StockService(
    private val stockRepository: StockRepository,
    private val transactionalOperator: TransactionalOperator,
) {
    suspend fun deductStockPessimistic(stockId: Long): Boolean =
        transactionalOperator.executeAndAwait {
            val stock = stockRepository.findByIdForUpdate(stockId)
            if (stock == null || stock.quantity <= 0) {
                false
            } else {
                stockRepository.deductOne(stockId) > 0
            }
        }
}

findByIdForUpdate 與 deductOne 這兩個動作,被包在同一個 transactionalOperator.executeAndAwait { } 區塊裡,代表它們共享同一個資料庫交易,鎖從 findByIdForUpdate 執行的那一刻取得,一直到這個區塊結束、交易提交或回滾才釋放。這段時間內,任何其他交易想對同一筆資料下 FOR UPDATE,都會被擋在外面排隊,不會有機會插進來讀到一個尚未定案的中間狀態。

悲觀鎖的代價也很直接:等待鎖的請求,會被實實在在地阻塞在資料庫交易層級,如果持有鎖的那個交易執行時間拉得太長,排在後面的請求就會跟著被拖累。這正是 Day 16:逾時與取消,讓卡住的協程別拖垮整個系統 已經建立的逾時概念可以派上用場的地方,替這類交易搭配一個合理的逾時上限,是相對務實的保護措施,這裡不重新展開逾時機制的細節,只需要記得這兩者是可以搭配使用的。

交易怎麼跟協程搭在一起,還算不算一個原子單位

前面兩段程式碼都出現了一個共同的疑問,查詢與更新這兩個動作,怎麼確保它們真的被當成一個完整的原子單位看待,而不是各自獨立跑完就算了事。答案落在 TransactionalOperator 這個角色身上。

executeAndAwait 是 Spring 替 Kotlin 協程情境準備的 suspend function 版本交易入口,包在這個區塊裡的所有資料庫操作,會被視為同一個交易的一部分,區塊正常執行完畢就提交,執行過程中發生任何未被攔截的例外,整個交易就回滾,不會出現一半資料寫進去、另一半沒寫的中間狀態。悲觀鎖那段程式碼裡,findByIdForUpdate 取得的鎖,之所以能夠一路保持到 deductOne 執行完畢才釋放,靠的正是兩者共享同一個交易範圍這件事。

這裡也值得回扣一次 Day 06:Structured Concurrency,為什麼協程不能亂長亂放 定案的結構化並發精神。executeAndAwait { } 這個區塊本身,運作邏輯與協程的父子收斂規則是一致的:如果外層呼叫這段程式碼的協程,因為某種原因被取消,例如使用者提早斷線,這個取消訊號會沿著結構傳播進交易區塊內部,交易也應該能適當地回應這個取消,進而回滾,而不是留下一個沒人等待、卻還在資料庫裡默默執行到一半的操作。交易的邊界與協程的結構邊界,在這裡是彼此呼應的,這裡只需要建立這一層一致性的認識,實際回滾行為背後牽涉到的隔離等級與交易傳播機制,不在今天的範圍內展開。

至於樂觀鎖與悲觀鎖之間該怎麼選,這裡給一個方向性的建議,而非唯一標準答案。如果訂單服務預期會遇到熱門商品搶購這類高衝突機率的情境,例如限量商品開賣那一刻,悲觀鎖搭配合理的逾時設定,可能是更直接的選擇,畢竟這種情境下衝突本來就是常態,與其讓大量請求各自重試,不如讓它們老老實實排隊。如果多數商品平常的衝突機率其實偏低,樂觀鎖能省下不必要的鎖等待成本,只有在真正發生衝突的少數情況才需要付出重試的代價。這個選擇終究要看實際的商品特性與流量分布,沒有哪一種做法能通吃所有情境。

資料正確了,但這一切目前還是黑盒子

今天把庫存扣減的併發正確性問題,正式收斂進資料庫這一層解決。樂觀鎖透過版本欄位比對,讓衝突能被正確識別而不是被悄悄蓋過去;悲觀鎖透過 SELECT ... FOR UPDATE 鎖住資料列,確保同一時間只有一個交易能真正動到這筆庫存。兩種做法搭配 R2DBC 的 TransactionalOperator,都能在協程情境下把查詢與更新視為一個完整的原子單位,也維持了結構化並發那套取消訊號能夠正確傳播的一致性。

但坦白講,這裡還留著一個完全沒被回答的問題。樂觀鎖的版本衝突,實際上發生得頻不頻繁,多少請求需要重試第二次、第三次?悲觀鎖的等待,實際上會讓請求卡多久,是幾毫秒的小延遲,還是足以讓使用者感覺到卡頓的等待?這些問題今天寫的每一段程式碼都答不出來,因為目前完全沒有任何方式,可以觀察到這些機制實際運作時發生了什麼事,一切都還是黑盒子。

下一篇要開始處理這件事:可觀測性。

《Day 25:可觀測性第一步,讓協程執行狀況不再是黑盒子》 見。


上一篇
Day 23:把併發控制手段實際裝進訂單服務
系列文
Spring Boot + Kotlin 協程高併發,後端開發新選擇 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言