併發出價:兩個人在同一秒出價,系統到底該怎麼辦
一句話總結:當兩筆出價在極短時間內同時進來,系統如果沒有把「讀取目前最高價」到「寫入新出價」這段過程鎖起來,就會產生兩個相同金額的得標者,而這個錯誤在資料庫裡看起來完全正常,只有在客訴時才會被發現。
有一次幫朋友的平台看客訴,兩個買家同時堅稱自己是得標者。我調出出價紀錄,兩筆出價金額一模一樣,時間差 0.03 秒,狀態都是「有效」。
系統沒有壞掉——它非常忠實地執行了程式碼寫的邏輯。問題是那段邏輯假設了「出價是一個接一個發生的」,而現實裡它們是同時發生的。
這是拍賣系統最經典的一類錯誤,它平常不會出現,只在流量最高的結標前那幾秒發作,而且事後幾乎無法重現。 這篇就講清楚它為什麼會發生、有哪些解法、以及怎麼確認自己的系統沒這個問題。
併發出價問題是什麼?
併發出價問題(Race Condition in Bidding)是指兩個以上的出價請求在時間上重疊執行,導致它們讀到相同的舊狀態,各自做出「我的出價有效」的判斷。
用最白話的方式描述那 0.03 秒發生的事:
系統處理一筆出價,做的其實是三個動作——先查目前最高價多少、再判斷新出價有沒有比它高、最後把新出價寫進去。這三個動作寫在同一個函式裡,看起來像是一氣呵成的。
但在伺服器上,這三個動作之間是有縫隙的。A 的請求做完第一步、還沒走到第三步的時候,B 的請求進來也做了第一步。於是兩個人都看到「目前最高價 10,000」,兩個人都認為出 10,500 是合法的,兩筆都寫進去了。
結果:資料庫裡有兩筆 10,500 元的有效出價。每一筆單獨看都完全正確,合在一起就是錯的。
這件事的發生機率跟同時出價的人數平方成正比。平常兩三個人在看,幾乎不會遇到;結標前三十秒五十個人在搶,就變成必然事件。
為什麼資料庫的交易機制不夠?
因為交易保證的是「這幾個寫入要嘛全成功要嘛全失敗」,不保證「我讀到的資料在我寫入前沒被別人改過」。
這是很多人第一次遇到這個問題時的直覺解法:把三個動作包進一個交易裡就好了吧?
不夠。交易解決的是原子性——如果寫到一半失敗,前面的會被回滾,不會留下半套資料。但兩個交易可以同時進行,各自讀到相同的舊值,然後各自成功提交。交易沒有被違反,錯誤還是發生了。
要靠資料庫解決的話,得用更強的隔離層級或明確的資料列鎖定,但這在拍賣場景有兩個實際困擾:
第一,鎖住的時間可能比你想的長。 出價流程裡除了寫入之外,還可能要計算代理出價、判斷是否觸發防狙擊延長、更新結標時間。這些邏輯在交易裡執行,資料列就一直被鎖著,其他請求全部排隊。
第二,跨資源的操作沒辦法納入交易。 出價成功後要把新價格廣播出去、要寫快取。這些不是資料庫操作,交易回滾時它們不會跟著回滾。
所以實務上更常見的做法是在應用層加一把明確的鎖,把整段業務邏輯保護起來,而不是只依賴資料庫的交易語意。
鎖該鎖多大?粒度決定效能
鎖到「單一拍賣品」這個層級,不要鎖整張出價表——這是效能能不能撐住的分水嶺。
鎖的粒度有個直覺但錯誤的選法:既然要避免衝突,鎖大一點比較安全吧?
不行。假設平台上同時有五百件拍賣在進行,如果每次出價都要取得同一把「出價鎖」,那五百件商品的所有出價會排成一條隊伍。A 商品的出價要等 B 商品的出價處理完——它們明明毫無關係。結標高峰時這條隊伍會長到使用者全部逾時。
正確的粒度是每件拍賣品一把獨立的鎖。同一件商品的出價互相排隊(這是必要的),不同商品之間完全不干擾。五百件商品就是五百把互不相干的鎖,可以完全平行處理。
| 鎖的粒度 | 正確性 | 併發效能 | 適用情況 |
|---|---|---|---|
| 鎖整張表 | 正確 | 極差 | 不建議 |
| 鎖單一拍賣品 | 正確 | 好 | 標準做法 |
| 不鎖 | 錯誤 | 最好 | 只有讀取才可以 |
實作上,Laravel 的快取原子鎖很適合這個場景,因為它是跨程序有效的——多台網頁伺服器共用同一個 Redis,鎖就能跨機器生效。如果只用 PHP 程序內的鎖,多台機器的環境下等於沒鎖。
還有兩個容易忽略的設定:
鎖要有存活時間上限。 如果某個請求在持有鎖的時候當掉了,鎖沒被釋放,那件商品就再也沒人能出價。設一個上限(例如五秒),時間到自動釋放。
等鎖的時間要短。 取不到鎖時不要無限等待。等超過一兩秒就直接告訴使用者失敗請重試——在拍賣情境下,讓使用者轉圈十秒比直接告訴他失敗更糟糕,因為那十秒裡價格早就變了。
除了上鎖,還有哪些做法?
主流有三種解法,各自適合不同的規模與團隊。
做法一:悲觀鎖。 就是前面講的——先取得鎖再處理。優點是邏輯直觀、正確性容易驗證。缺點是有鎖的開銷,而且極端高併發下鎖本身會變成瓶頸。中小型平台建議直接選這個,簡單可靠。
做法二:樂觀鎖。 不預先上鎖,寫入時附帶「我讀到的版本號」,如果資料在這期間被改過就寫入失敗、請客戶端重試。優點是沒有鎖開銷,讀多寫少時效能好。缺點是衝突時要重試,而拍賣結標前恰好是「寫入極度密集」的情境,重試風暴反而更糟。
做法三:佇列序列化。 把同一件商品的所有出價丟進一個專屬佇列,由單一消費者依序處理。這樣天然就不會有併發問題,因為根本沒有同時執行。優點是正確性最強、順序明確。缺點是架構複雜,而且出價變成非同步的——使用者按下按鈕後不會立刻知道結果,需要另外設計狀態回報。
| 做法 | 正確性 | 高併發表現 | 實作複雜度 | 使用者立即得知結果 |
|---|---|---|---|---|
| 悲觀鎖 | 高 | 中 | 低 | 是 |
| 樂觀鎖 | 高 | 寫入密集時差 | 中 | 是 |
| 佇列序列化 | 最高 | 高 | 高 | 否,需額外設計 |
我的建議是:先用悲觀鎖把正確性做對,等真的撞到效能天花板再考慮換。 併發問題的代價是資料錯誤和客訴,而效能問題的代價只是慢——先解決前者永遠是對的順序。這個決策邏輯跟拍賣系統整體的架構取捨是一致的。
出價失敗時該給使用者看什麼?
「已有人出到更高價,目前是 10,800 元」比「系統忙碌中,請稍後再試」有用一百倍。
這是技術問題裡少數直接影響體驗的部分,卻經常被當成邊角料處理。
當出價因為併發而失敗時,使用者的心理狀態是緊繃的——他正在搶東西,倒數在跑。這時候給他一個模糊的錯誤訊息,他會做兩件事:瘋狂重試(增加系統負擔),或是直接放棄(你損失一次成交)。
好的失敗訊息要包含三個資訊:發生了什麼、現在的狀態是什麼、他可以做什麼。例如「出價失敗:已有人出到 10,800 元。要出 11,000 元嗎?」並附上一個按鈕。這樣使用者不用重新輸入、不用重新整理頁面,情緒也不會失控。
另外一個重要原則:不要替使用者自動重試加價。系統自動幫他往上加,看起來很貼心,實際上會讓他花超出預算的錢——這在消費爭議上站不住腳,也會嚴重傷害信任。出價金額必須是使用者的明確決定。
怎麼測試一個平常不會發生的問題?
手動測試永遠測不到併發問題,必須寫自動化測試主動製造衝突。
具體做法是在測試裡對同一件拍賣品同時發出多個出價請求,然後斷言三件事:
- 最終只有一筆出價成立
- 成立的那筆是金額最高的(或依規則該勝出的那筆)
- 其他筆都得到明確的失敗狀態,而不是靜默消失
這類測試跑起來比一般測試慢,也比較難寫,但它是唯一能在上線前抓到競態條件的方法。我會建議把它放在必跑的測試集裡,而不是那種「有空才跑」的分類——因為併發相關的程式碼特別容易在後續重構時被改壞,而改壞了不會有任何徵兆。
除了單元測試,上線前也值得做一次壓力測試:模擬結標前三十秒的流量尖峰,看看鎖的等待時間、失敗率、以及資料是否仍然正確。特別要測防狙擊延長觸發時的併發情況——那是兩個複雜機制同時作用的時刻,也是最容易出事的地方。
常見問題
Q:小平台流量不大,可以先不處理併發嗎? 不建議。併發問題的觸發條件不是「平均流量高」而是「瞬間有人同時出價」,這在任何規模的平台上都會發生——只要有兩個人搶同一件商品。而且這是少數「一開始沒做,之後補會很痛」的東西,因為出價邏輯會被其他功能依賴。
Q:出價紀錄採用只新增不修改的設計,是不是就不用鎖了? 不是。只新增不修改的資料結構解決的是「歷史被覆蓋」的問題,讓紀錄可追溯;併發解決的是「同時寫入導致邏輯錯誤」。兩者互補,都要做。實際上正因為紀錄完整,併發出錯時才有辦法事後查證是哪裡出的問題。
Q:使用者連續快速點擊出價按鈕,也算併發嗎? 算,而且很常見。除了後端要有鎖,前端也該在送出後立刻禁用按鈕直到收到回應。這是最便宜的一道防線,能過濾掉大部分的重複請求。
Q:代理出價會讓併發問題更複雜嗎? 會。因為一筆手動出價可能連鎖觸發多筆系統自動出價,這整串連鎖反應都必須在同一把鎖的保護下完成。如果中途放開鎖讓別人插隊,最終的出價順序就會亂掉。設計代理出價時,務必把整段連鎖計算視為一個不可分割的操作。
結語
併發出價這件事的難處不在技術本身——加一把鎖並不困難。難的是它不會提醒你它壞了。系統不會報錯、監控不會告警、日誌看起來一切正常,直到某天兩個買家同時來客訴。
所以我對這一塊的態度比其他部分保守很多:寧可效能差一點,也要把正確性焊死。拍賣平台賣的說到底是信任,而「同一件東西賣給兩個人」這種事,發生一次就夠讓人記很久。
如果你正在做拍賣系統,這是我會建議最優先寫測試的地方。不是因為它最容易錯,而是因為它錯了最難發現。