← 部落格

為什麼我們不再自己架 WebSocket 伺服器

· Hibira

自己架的即時伺服器一遇到訊息尖峰就會塞住,還得自己追 PHP2Shell 這類漏洞的修補。我們踩過的坑、自己架要補上的東西,以及託管服務替你承擔了什麼。

在做 Hibira 之前,我們在過去的專案裡自己維運過 WebSocket 伺服器,當時是用 PHP 架的,而且和應用程式一起單體部署。平常一切正常,訊息量一大就出事:伺服器忙著處理手上的訊息,新的請求在後面排隊,延遲一路往上爬。等訊息終於送到使用者手上,內容已經過時了。報價已經變了、比分早就更新,「對方正在輸入」的提示在對方送出訊息之後才出現。

這篇文章整理那次的經驗:即時伺服器為什麼會在尖峰時塞住、自己架的話要補上哪些東西,以及什麼時候交給託管服務比較划算。

塞住是怎麼發生的

即時伺服器做的事情看起來很單純:收到一則事件,送給所有訂閱的連線。問題在於這件事的工作量會被放大,而且只要有一段慢下來,後面的全部都得等。

你的後端尖峰時大量發布
HTTP API
WebSocket 伺服器處理不完,開始排隊
越來越晚
使用者收到已過時的訊息
處理速度跟不上時,每一則新訊息都排在所有舊訊息後面

進來的比處理的快,佇列只會變長

這是排隊的基本規則:只要訊息進來的速度超過伺服器處理的速度,佇列就會一直變長,每一則新訊息都得等前面所有的訊息處理完。

用一個簡單的數字算算看。假設伺服器每秒能處理 5,000 則訊息,尖峰時每秒進來 6,000 則,持續 10 秒。這 10 秒會累積 10,000 則處理不完的訊息。就算尖峰結束、新訊息完全停止,伺服器也還要再花 2 秒才能清完,佇列最後面那則訊息會晚 2 秒才送出。如果尖峰後還有正常流量進來,清得更慢。

對網頁來說,慢 2 秒是體驗差一點;對即時功能來說,2 秒前的報價、位置或遊戲狀態,常常就是錯的資料。

一則事件,乘上每一位訂閱者

發布一次不等於送出一次。一個有 2,000 人訂閱的頻道,每發布一則事件,伺服器就要寫出 2,000 次。所以實際的工作量是「每秒發布次數 × 訂閱人數」。直播聊天室、比賽比分、營運儀表板這類一對多的情境,最容易在人數變多的那一刻突然超過負荷。

一條慢的連線,拖住所有人

手機在電梯裡、在捷運上,網路會變得很慢,甚至暫時收不到資料。如果伺服器是一條一條連線依序寫出、而且每次都等寫完才換下一條,只要有一條連線卡住,排在它後面的每一位使用者都會跟著延遲。這就是我們遇到的「阻塞」:問題不在大多數使用者,而在少數幾條慢連線,把整個廣播拖慢了。

等待會互相傳染

伺服器一忙,負責發布事件的 HTTP 請求也開始變慢。你的後端在等它回應,自己的請求也跟著堆積;請求逾時之後如果自動重試,又在已經過載的伺服器上多加一份工作。原本只是即時伺服器慢,最後變成整個系統都慢。

單體部署讓問題放大

我們當時遇到的狀況,主要是因為採用單體部署:WebSocket 伺服器和應用程式的其他部分跑在一起、一起部署。即時訊息一多,吃掉的是整個應用程式的資源,連一般的 API 請求也跟著排隊;反過來,其他功能一慢,即時訊息也一起被拖住。想擴充即時的部分,只能把整個應用程式一起加機器。而在單體部署裡,每次部署應用程式,通常也會讓所有 WebSocket 連線斷開重連。

另一個原因:執行環境的安全更新

效能之外,讓我們決定不再自己維運的另一個原因是安全。我們當時也遇過 PHP 的安全漏洞,只能自己追著修補。自己架伺服器,作業系統、語言執行環境、函式庫的漏洞都得自己盯,一個都不能漏。

最近的例子是 2026 年 10 月 21 日 PHP 官方修補的兩個嚴重漏洞,被稱為「PHP2Shell」:

  • CVE-2026-109618:處理記憶體不足(OOM)時發生的記憶體損壞。
  • CVE-2026-109619:請求之間殘留的狀態造成的 use-after-free。

兩者的 CVSS v4.0 分數都是 9.2,可能讓未經驗證的攻擊者遠端執行程式碼。受影響的是處理網頁請求的 PHP,例如 PHP-FPM、Apache mod_php 和 php-cgi。修補版本是 8.2.35、8.3.36、8.4.27 和 8.5.12,8.1 以前的版本已經沒有官方的例行更新。

這兩個漏洞出在處理網頁請求的 PHP 上,不一定直接影響常駐的 WebSocket 程序。但道理是一樣的:漏洞公開的那一刻,你得自己確認哪些伺服器受影響、排時間升級、重新部署,而且越快越好。

自己架的話,要補上這些

這些問題都有解法,只是每一項都是持續的工作,不是設定一次就好:

  • 和應用程式分開部署。 把即時伺服器從單體中拆出來,讓它可以獨立擴充、獨立部署,尖峰時才不會拖垮其他功能。
  • 每條連線各自的有上限佇列。 寫出不能互相等待。每條連線有自己的佇列,滿了就代表這個用戶端跟不上,應該中斷它,而不是讓它拖慢別人。
  • 把收訊息和送訊息分開。 發布用的 API 不應該等所有訂閱者都送到才回應,否則廣播一慢,你的後端也跟著慢。
  • 水平擴充。 單台伺服器總有上限。要加機器,就需要讓所有機器共享頻道和線上狀態,並在前面放負載平衡器。
  • 以尖峰來測容量。 用平均流量做的壓力測試會告訴你「沒問題」。要測的是尖峰的好幾倍,以及大量用戶端同時重新連線的情況。
  • 監控佇列與延遲,而不只是 CPU。 CPU 看起來還有空間時,佇列可能已經在變長。要能看到訊息排了多久、有多少連線因為太慢被中斷,並在變差時收到告警。
  • 有人值班。 尖峰通常就是你最重要的時刻:活動開賣、比賽最後一分鐘、促銷開始。伺服器出事時要有人能處理。
  • 追安全更新。 作業系統、語言執行環境和函式庫的漏洞一公開,就要評估影響、升級並重新部署。

依序寫出

新事件
發布
伺服器
先寫給 B
連線 B網路很慢
等 B 寫完
其他所有連線一起延遲

每條連線各自的佇列

新事件
發布
伺服器
各自的佇列
其他所有連線準時收到
新事件
發布
伺服器
佇列滿了
連線 B中斷後自動重連
解法的核心:每條連線各自排隊,慢的只影響自己

託管服務替你承擔了什麼

使用託管的即時服務,上面這些工作就由服務商負責。以 Hibira 為例:

  • 慢的用戶端不會拖慢別人。 每條連線有自己的有上限佇列,寫入不會互相等待。佇列滿了或寫出逾時的用戶端會以 Pusher 協定的 4100 錯誤碼中斷,pusher-js 收到後會自動重新連線。
  • 你看得到發生了什麼。 這類中斷會出現在應用程式的錯誤卡片上,標示為「過慢的用戶端被中斷」,你不用自己去翻伺服器記錄。
  • 容量由我們規劃。 伺服器組成叢集,共享頻道與線上狀態,可以水平擴充。方案的上限是明確的硬上限,不會在帳單上給你意外。
  • 使用的是 Pusher 協定。 你現有的 pusher-js、Laravel Echo 和伺服器端函式庫都能直接使用,換服務只是改主機位址和金鑰。
  • 修補由我們負責。 WebSocket 伺服器和它底下執行環境的安全更新由我們處理。你仍然要更新自己的應用程式,但要盯的東西少了一項。

代價也要說清楚:超過免費額度後要付月費,事件會經過第三方,而且要接受方案的上限。

什麼時候自己架仍然合理

託管服務不是唯一的答案。以下情況,自己架可能更適合:

  • 流量不大、穩定,而且沒有明顯的尖峰。
  • 事件內容不能離開你的基礎架構。
  • 你已經有維運團隊、監控和值班制度,多一個常駐服務不是負擔。

如果你用 Laravel,自己架 Reverb 還是用託管服務有更具體的比較。因為兩邊都使用 Pusher 協定,之後改變主意也只是改設定。

我們的結論

所以我們決定重建:把即時通訊從單體中拆出來,做成獨立的服務。前面放負載平衡器,依負載自動橫向擴容,流量瞬間湧入時就加開伺服器分擔,而不是讓一台伺服器硬撐。我們先在內部使用,之後也開放給外部,這就是 Hibira。

自己架 WebSocket 伺服器,難的不是讓它跑起來,而是讓它在最忙的那幾分鐘依然準時。那正好是使用者最在意、也最容易出事的時候。如果你的團隊想把時間花在產品上,而不是佇列和值班上,託管服務是比較省力的選擇。

Hibira 的免費方案不需要信用卡,足夠開發和小型應用使用。已經在用 Pusher 的話,可以照著從 Pusher Channels 遷移的步驟,不改程式就換過來。