快速解答: 即時競標系統的核心是 WebSocket 長連線,讓伺服器能主動推送最新出價給所有瀏覽同一拍品的買家。搭配原子鎖處理併發、Append-Only 的出價記錄設計,就能做到毫秒級即時更新且不遺漏任何一筆出價。
即時競標系統的網站架構:WebSocket 技術解析
你有沒有在拍賣網站上,看著最後 10 秒倒數,手指放在出價按鈕上等待,結果按下去之後頁面轉了好幾秒才顯示結果——然後告訴你「已被超越」?這種體驗就是因為背後用的是傳統 HTTP 輪詢,而不是 WebSocket。
我花了不少時間研究各家拍賣平台的技術實作,從補破網的 GraphQL Subscriptions 到國際大站的架構設計都看過。今天就把即時競標系統的關鍵技術拆解給你,不管你是要自己架站還是跟工程團隊溝通,看完都會更有底。
WebSocket 到底是什麼?為什麼拍賣需要它?
WebSocket 是一種讓瀏覽器和伺服器之間建立持久雙向通道的通訊協定,跟傳統的 HTTP 請求-回應模式完全不同。
用生活化的比喻來說:HTTP 就像寄信,你寄一封(請求)、對方回一封(回應),每次都要重新寫地址貼郵票。WebSocket 就像打電話,接通之後雙方可以隨時講話,不用每句話都重新撥號。
傳統 HTTP 輪詢的問題
早期的拍賣網站用的是「短輪詢」——瀏覽器每隔 1-3 秒就問一次伺服器「有新出價嗎?」。這有幾個嚴重問題:
- 延遲太高:如果設定每 2 秒問一次,你可能最多晚 2 秒才知道有人出價。在結標前的激烈時刻,2 秒就是天差地別
- 伺服器壓力大:假設一個拍品有 200 人在看,每人每 2 秒發一次請求,光一個拍品伺服器每秒就要處理 100 個無效請求(大部分時候沒有新出價)
- 流量浪費:90% 以上的請求回來都是「沒有新資料」,完全是浪費頻寬
WebSocket 怎麼解決這些問題
WebSocket 連線建立後,伺服器可以主動在有新出價的瞬間就推送給所有連線中的用戶端。延遲通常可以控制在 50-200 毫秒,而且只在有資料時才傳輸,沒事就靜靜待著不佔頻寬。
實測數據:一個有 500 人同時觀看的熱門拍品,用 HTTP 輪詢每秒要處理 250 個請求;改用 WebSocket 後,只有在真正有人出價時才會產生一次廣播,伺服器負載降低了 95% 以上。
競標系統的核心架構長什麼樣?
一套完整的即時競標系統至少包含四個核心模組:連線管理、出價處理、廣播推送、防狙擊機制。 下面一個一個拆解。
連線管理:Channel 的設計
在 WebSocket 的世界裡,「Channel」是一個很重要的概念。你可以把它想像成聊天室——每個拍品就是一個 Channel,只有正在瀏覽這個拍品的人會加入。
以 Laravel Reverb(一個原生的 WebSocket 伺服器)為例,Channel 通常設計成這樣:
- 公開 Channel:
auction.{auctionId}— 任何人都能加入,接收出價更新 - 私有 Channel:
private-bidder.{userId}— 只有本人能加入,接收個人通知(被超越、得標等)
連線認證很關鍵。私有 Channel 必須驗證用戶身分,確保 A 買家收不到 B 買家的專屬通知。這通常透過 HTTP 端點做 token 驗證,確認後才允許加入。
出價處理:原子鎖搞定併發
拍賣最怕什麼?兩個人幾乎同時出價,結果系統搞混了。
想像這個場景:目前最高價 1,000 元,A 和 B 在相差 3 毫秒內都按下「出價 1,100 元」。如果沒有適當的併發控制,系統可能會:
- 同時讀到最高價是 1,000 元
- 兩邊都判斷 1,100 > 1,000,合法
- 同時寫入兩筆 1,100 元的出價
- 搞不清楚誰才是合法的出價者
解法是用 原子鎖(Atomic Lock)。以 Redis 的 Cache::lock() 為例,處理邏輯是:
1. 取得鎖 → lock("bid:{auctionId}", 5秒)
2. 讀取當前最高價
3. 驗證出價是否合法(>= 最高價 + 最低加價幅度)
4. 寫入出價記錄
5. 釋放鎖
鎖的持有時間通常設 3-5 秒,如果超時沒釋放就自動過期,避免死鎖。整個流程在 10-30 毫秒內完成,用戶幾乎無感。
出價記錄:Append-Only 設計
這是一個很聰明的設計決策——出價表(bids table)只新增、不更新、不刪除。
為什麼?因為拍賣涉及金錢和法律責任,每一筆出價都是一份承諾。如果允許修改或刪除記錄,出了糾紛根本查不清楚。Append-Only 設計讓每筆出價都有完整的時間戳和金額記錄,形成不可篡改的出價歷程。
資料表大概長這樣:
| 欄位 | 說明 |
|---|---|
| id | 唯一編號 |
| auction_id | 拍品編號 |
| user_id | 出價者 |
| amount | 出價金額(以「分」儲存) |
| created_at | 出價時間 |
注意金額用「分」為單位儲存(integer),不用 decimal。1,000 元就存 100000。這樣做是為了避免浮點數運算的精度問題——你不會想在拍賣裡看到什麼 1000.000001 元的鬼東西。
防狙擊機制怎麼做?
「狙擊」是拍賣界最惹人厭的策略之一——在最後幾秒突然出價,讓其他買家來不及反應。 好的拍賣系統一定要有防狙擊機制。
目前業界最常見的做法是「條件式延長」:
結標前 3 分鐘內,如果有 2 個(含)以上不同買家出價,就自動延長結標時間 5 分鐘。
這個規則看起來簡單,實作起來有幾個細節要注意:
- 計數器的準確性:要精確統計「最後 3 分鐘內有幾個不同用戶出過價」,不能只看出價次數,因為同一個人連續加價不算
- 延長後的重新計算:延長 5 分鐘後,新的「最後 3 分鐘」起算點也要跟著變。如果延長後又有人狙擊,就再延長一次,理論上可以無限延長
- 前端倒數器的同步:延長結標時間後,所有連線中的用戶端都要即時更新倒數計時器。這就是 WebSocket 的用武之地——伺服器推一個
auction.extended事件,前端收到就重設計時器
以補破網拍賣為例,他們的防狙擊延長規則就是上述的「3 分鐘 + 2 人 + 延長 5 分鐘」,實測效果不錯,既保護一般買家權益,又不會把拍賣時間拖得太長。
廣播推送:讓每個人都看到最新狀態
WebSocket 廣播的重點在於「選擇性推送」——不是什麼資料都推給所有人。 這直接影響系統效能和用戶體驗。
一筆新出價進來後,系統需要推送的事件大概有這些:
| 事件類型 | 推送對象 | 內容 |
|---|---|---|
bid.placed |
該拍品所有觀看者 | 新出價金額、出價者暱稱、出價時間 |
bid.outbid |
被超越的前最高出價者 | 你的出價已被超越,目前最高價 |
auction.extended |
該拍品所有觀看者 | 新的結標時間 |
auction.ended |
該拍品所有觀看者 + 得標者 | 最終成交價、得標者 |
注意「被超越通知」要送到私有 Channel,只有那個被超越的人收得到。如果推到公開 Channel,大家都知道誰被超越了,這涉及隱私。
為什麼要用 ShouldBroadcastNow?
在 Laravel 的架構裡,事件廣播預設會丟到 Queue(佇列)排隊處理。一般場景沒問題,但拍賣不行——Queue 可能有其他任務在排隊(發 Email、處理圖片等),等輪到你的出價事件時可能已經過了好幾秒。
ShouldBroadcastNow 跳過 Queue 直接廣播,確保延遲在毫秒等級。這個設計取捨很值得學:關鍵路徑上不要排隊,非關鍵路徑才用 Queue。 出價廣播是關鍵路徑,得標後的 Email 通知就可以排隊慢慢來。
技術選型怎麼挑?
市面上做 WebSocket 的技術方案不少,我整理幾個跟拍賣系統相關的:
| 方案 | 適合場景 | 優勢 | 考量 |
|---|---|---|---|
| Laravel Reverb | Laravel 生態系 | 原生整合、設定簡單、不需外部服務 | 相對新,社群資源還在累積 |
| Pusher / Ably | 不想自架 WebSocket 伺服器 | 全託管、穩定、有 SLA | 按連線數和訊息數收費 |
| Socket.io + Node.js | 前端團隊熟 Node | 生態豐富、彈性大 | 需自己搞部署和擴展 |
| GraphQL Subscriptions | 已用 GraphQL 的專案 | 跟現有 API 一致 | 學習曲線、技術複雜度較高 |
如果你是 Laravel 技術棧,我真心建議直接用 Reverb。不用額外裝服務、不用額外付費、跟 Livewire 的整合也很滑順。用 Livewire 的 #[On('echo-private:...')] 裝飾器就能直接監聽 WebSocket 事件,前端幾乎不用寫 JavaScript。
對響應式設計和行動裝置優化有興趣的話,WebSocket 在行動端的處理又是另一個話題了,特別是行動網路的斷線重連機制。
壓力測試:你的系統撐得住嗎?
做即時競標系統不能只管功能做出來就好,你一定要知道系統的極限在哪裡。我見過不少拍賣平台在大場次(同時上百件結標)時直接掛掉,就是因為沒做過壓力測試。
幾個關鍵指標要測:
- 同時連線數:你的 WebSocket 伺服器能撐多少條同時連線?Reverb 預設單台大概能撐 10,000-20,000 條
- 出價吞吐量:每秒能處理多少筆出價?原子鎖是瓶頸,單一拍品大約能做到每秒 30-50 筆
- 廣播延遲:從出價寫入到所有用戶端收到更新,P99 延遲要低於 500 毫秒
- 斷線重連:模擬網路不穩,確認用戶端能自動重連且不會漏接出價
如果你預期熱門拍品會有超過 1,000 人同時觀看,建議做水平擴展——部署多台 WebSocket 伺服器,用 Redis Pub/Sub 做節點間的訊息同步。
常見問題 FAQ
Q:WebSocket 連線會一直佔著伺服器資源嗎? 會,但佔的很少。一條 idle 的 WebSocket 連線大概只佔 2-5 KB 記憶體。1 萬條連線也就 20-50 MB,對現代伺服器來說不算什麼。真正吃資源的是頻繁的訊息廣播,不是連線本身。
Q:用戶關掉瀏覽器再打開,會漏接出價嗎? 設計得好的話不會。用戶重新連線後,前端應該主動發一個 HTTP 請求拉取最新的拍品狀態(包含當前最高價、剩餘時間等),然後再接上 WebSocket 繼續聽後續更新。這叫「初始狀態 + 增量更新」模式。
Q:手機上的 WebSocket 穩定嗎? 比桌機差一點,主要是行動網路切換(4G/5G/WiFi 切換時)可能斷線。解法是前端實作 heartbeat 檢測 + 自動重連機制,通常每 30 秒送一次 ping,如果 3 次沒回應就判定斷線並嘗試重連。
Q:不用 WebSocket,用 SSE(Server-Sent Events)行不行? 可以用來做「伺服器推送出價更新」這一段,但 SSE 是單向的——只能伺服器推給客戶端,客戶端沒辦法透過 SSE 送出出價。所以你還是需要搭配 HTTP API 來處理出價動作。如果只需要「看」不需要「出價」的場景(例如圍觀頁面),SSE 確實夠用而且更簡單。