在設計多層級的電商系統時,必然會遇到多個使用者同時編輯同一件商品的狀況,當我們送出更新時,到底該以哪位使用者送出的請求作為最後的結果呢?
我們可以先想想看,這個情境:
- 使用者 A 正在編輯商品 A,並且將價格改為 100 元。
- 使用者 B 正在編輯商品 A,並且將價格改為 200 元。
- 使用者 B 先完成編輯,並且將價格更新為 100 元。
在兩位使用者沒有互相溝通的前提下,如果此時使用者 A 也送出更新,那麼我們預期的結果是:
- 價格應該要更新為 100 元。
- 價格應該要維持為 200 元。
- 系統跳出提示:在使用者 A 編輯期間,商品已被其他使用者修改,是否覆蓋?
從上面這個這個情境可以帶出一個問題,也是 Race Condition 競態條件的概念:「當多個執行緒或請求同時操作同一個共享資源,最終結果取決於『執行順序』,然而這個順序本身是不可預期的,那麼系統要如何確保結果正確?」
最直覺的解法:鎖(lock)
鎖有分成多種類型,但他們的核心目的都是「同一時間,只允許一位使用者操作資源」。
悲觀鎖 Pessimistic Lock
悲觀鎖的思路是:「假設在大多數情況下,資源會被多個使用者同時操作,所以我們需要額外的機制來保護資源,避免發生競態條件」。
以上廁所為例,如果廁所只有一間,第一個人進去上廁所時,會把門鎖起來,直到他出來為止,這樣就可以避免第二個人進去時,第一個人還沒有出來的情況發生。
這樣的好處是絕對安全、不會發生同時使用的狀況,然而缺點也顯而易見,當前一個人遲遲不出來時,後面的人只能等待,別無他法。
樂觀鎖 Optimistic Lock
樂觀鎖的思路是:「假設在大多數情況下,資源不會被多個使用者同時操作,所以我們不需要額外的機制來保護資源,只需要在更新時檢查資源是否有被修改即可」。
例如有多位使用者在編輯同一份文件,系統只要在使用者提交更新時,檢查文件的版本號是否有被修改,如果版本號有被修改,則表示有其他使用者正在編輯該文件,此時可以拒絕更新,並提示使用者重新編輯。
雖然表面上看起來比起悲觀鎖有著更好的使用者體驗,然而有可能到最後檢查時才發現自己白忙一場。
除了悲觀鎖、樂觀鎖,還有另外幾種常見的鎖機制,像是分散鎖(Distributed Lock)、讀寫鎖(Read-Write Lock)、自旋鎖(Spin Lock)等,這些鎖雖然在實務操作上有些不同,但最終的目的都是為了要「保證一致性」,然而為了保證一致性卻比必須犧牲某部分的使用者。
分散鎖 Distributed Lock
在單機環境中,我們使用互斥鎖來保護共享資源;但在多台機器組成的分散式系統裡,本地鎖無法保護別台主機。分散鎖是延伸至整個叢集的鎖,它保證某一段時間內只有一個節點可以使用某個資源。例如複數服務同時要修改同一筆帳戶資料,必須先到「協調者」取得鎖,取得者才能進行寫入,其餘服務只能等待或放棄,這樣可以避免跨機器的競態問題。
想像有 5 家門市共享「唯一一個活動名額」。每一家門市接到客戶時,都必須先打電話給總公司確認名額是否已經被搶走,若總公司回覆「還有名額」,該門市便可以幫客戶報名;若回覆「名額已滿」,則必須告知客戶已經沒有位置。總公司就扮演分散鎖協調者的角色,確保同一時間只有一個門市可以使用這個資源。
讀寫鎖 Read‑Write Lock
讀寫鎖解決的是「讀多寫少」的場景。它允許多個讀者在沒有寫入者時並行讀取,當有人要寫入時,必須取得獨占鎖,此時其餘讀者與寫入者都會被阻塞,這樣可以提升讀操作的吞吐量,同時確保寫入的一致性。
想像圖書館的一本參考書。大多數人只是翻閱內容,不會更改文字,這時可以允許多人同時閱讀;但如果有老師要在書上做筆記或更新內容,他必須把書帶走並鎖上檢閱室,直到寫入完成後其他讀者才可以再次借閱,這就像讀寫鎖的行為:讀者可並行,寫者必須獨占。
自旋鎖 Spin Lock
自旋鎖是一種低階的鎖實作,當線程無法取得鎖時不會睡眠,而是反覆檢查鎖是否可用,直到取得為止,因為持續佔用 CPU 而不做實質工作,所以適合臨界區極短、等待時間非常短暫的情境。
像是朋友打電話佔線,你不停地撥打並聽著佔線音,直到對方掛上你才能撥通。這種「忙等」的行為就是自旋鎖;若等待時間很長,你會浪費很多時間和電力,所以大多數時候不建議使用。
那麼有沒有可能「不用鎖」呢?
不用鎖的設計思路
對於大多數應用來說,鎖只是防止競態的一種手段。若能透過系統設計本身來避免同時操作,不僅效能更好,也能降低實作複雜度。以下介紹幾種「不用鎖」卻能保障一致性的做法。
原子操作 Atomic Operation
原子操作指的是一個「不可分割的動作」:要麼全部完成,要麼完全不做,不會被其他執行緒看到中間狀態。 資料庫本身就提供了不可分割的原子操作。這種操作在「要嘛全做完,要嘛完全不做」的情況下執行,任一使用者只能看到操作前或操作後的完整結果,中間狀態不會曝光。
例如針對庫存的扣減可以寫成:
UPDATE products SET stock = stock - 1 WHERE id = 123 AND stock > 0;
透過指令可以同時檢查庫存是否大於零並扣減庫存,只要有人搶先把庫存扣光,後面的請求便不會有任何影響,完全不需要鎖。
其他類似原子操作包括:
- 資料庫觸發器:若一次需要更新多個表,也可以利用資料庫的交易(Transaction)及觸發器機制,確保整串操作要嘛成功要嘛失敗,避免部分成功導致資料不一致。
- 快取系統:像 Redis 也提供 INCRBY, DECRBY, SETNX 等指令,這些都是在伺服器端一次完成,無需鎖也不會出現競態。
當操作能用一條指令完成,Race Condition 根本無從發生。
事件佇列 Queue
另一種解法是讓「同時」變成「排隊」。把對於某資源的所有修改請求放進訊息佇列,由後端消費者依序處理,就能自然避免同時操作。
- 實際運用:訂單入帳、庫存扣減、推送通知等需要串行化處理的場景,都可以將請求送入,並按順序處理。
- 優點:沒有鎖、容易水平擴充、可實現高可用。
- 代價:請求會有佇列延遲,並且需要考慮訊息重試與死信隊列等問題。
這種模式常用於需要高吞吐的後端服務,如電商結帳、金流、訂閱通知等。
單執行緒模型 Single Thread Model
如果一個程式只能同時處理一個請求,自然不會出現 Race Condition。某些語言或框架就是採用這種模式:
- Node.js:其事件迴圈一次只處理一個事件,雖然能快速切換任務,但真正執行 JS 程式碼的時間永遠只有一條主線程,不會出現兩段程式同時修改同一個狀態。
- Actor Model:如 Akka 或 Erlang,每個 Actor 都有自己的信箱,只有收信這一刻會處理訊息,其它時間不會並行執行共享狀態。
- 背景工作者:對單一資源採用單一消費者,例如每個商品建立一個獨立消費者或協程來處理所有對該商品的修改請求,就可以透過程式結構保證不會有兩個執行緒同時更改同一個實體。
這種方式非常適合需要簡化並發邏輯的場景,但在高負載時需要透過分片或多個服務實例來分攤壓力。
設計上的權衡
在考慮是否採用鎖之前,可以先問自己:
- 這個操作是否可以透過資料庫或快取提供的原子指令完成?
- 這個過程能否改為非同步排隊處理?
- 是否能把資源切分,讓每個單元由單一執行緒負責?
只有在上述方法都無法滿足需求時,才需要接續考慮悲觀鎖、樂觀鎖或分散鎖等傳統同步機制。大部分成熟系統都會優先使用無鎖的設計,因為沒有鎖也意味著沒有阻塞,整體效能與可靠性都會更好,透過這樣的思路,不只可以避免 Race Condition,也能使系統更加彈性與易於維護。