Backend Engineering·2026年6月15日·15 分鐘閱讀

那天記憶體在十秒內噴到 OOM:交易所即時行情的 WebSocket 推送是怎麼活下來的

L
Leo Wu

後端工程師,做過交易所即時行情推送與高併發 WebSocket 系統。

凌晨兩點的那通電話

那是一個很普通的週四凌晨,我記得很清楚,因為前一天我才剛把行情推送服務的版本更上線,本來想說可以睡個好覺。結果兩點十七分手機響了,值班的同事說行情服務的其中一台節點記憶體從穩定的 1.2 GB 在大概十秒內衝到 7 GB 然後被 OOM Killer 幹掉,然後負載飄到另一台,另一台也跟著倒。我打開監控,看到的是一條幾乎垂直的記憶體曲線,那種你只要看過一次就會做惡夢的曲線。

當下市場正在劇烈波動。某個幣的價格在三分鐘內拉了快 18%,成交筆數從平常每秒幾百筆暴增到接近每秒一萬兩千筆。我們那時候線上連著的 WebSocket 連線大概是三萬八千個,每個連線都訂閱了一到多個頻道——有人要看某個交易對的訂單簿,有人要看逐筆成交,有人只要 ticker。平常這個量級完全沒問題,服務跑得很順。但那天不一樣,那天有一群「跟不上的客戶端」,而正是這群人,把我整台機器的記憶體吃乾。

這篇文章想完整講一遍:我們的行情推送是怎麼從一個天真的 HTTP 輪詢架構,一路被生產環境打到變成現在這個樣子。裡面有很多是我自己踩過的坑,不是書上抄來的。

一開始我們是用輪詢的,然後就被打臉了

交易所最早期的版本,行情是用 HTTP 輪詢做的。前端每秒打一次 /api/orderbook,後端查一下當前快照回傳。這個方案的好處是簡單到不行,壞處是它在任何稍微認真一點的場景下都會崩。

算一筆帳就知道。假設兩萬個活躍用戶,每秒輪詢一次訂單簿,那就是每秒兩萬個 HTTP request。每個 request 都要建連線(就算有 keep-alive,也要過一輪 TLS 與框架的中介層)、要查一次當前狀態、序列化、回傳。我們當時量到的單次訂單簿快照 JSON 大概 8 到 15 KB,兩萬 QPS 光是出口流量就是兩三百 MB/s,而且這裡面絕大多數是浪費的——市場不波動的時候,連續兩次輪詢回來的資料幾乎一模一樣,使用者卻還是每秒拉一次完整快照。

更致命的是延遲。輪詢的本質是「我固定時間問一次」,所以使用者看到的價格平均會有半個輪詢週期的延遲。對一般 App 沒差,但對一個交易所來說,使用者下單時看到的價格如果比真實價格慢了一秒,那在劇烈行情裡就是直接虧錢,客訴會排到天荒地老。我們做過一次統計,光是「價格顯示落後」這一類客訴,在某次行情裡就佔了當天客訴量的四成。

於是很自然地,我們轉向 WebSocket。伺服器主動推,有變化才推,連線只建立一次。聽起來很美好。實際上,WebSocket 把問題從「請求太多」變成了「連線太多、而且每個連線都是活的、有狀態的、會出事的」。

WebSocket 連線管理:連線不是建完就沒事了

從輪詢轉到長連線,最大的心態轉變是:每一個連線都是一個你要長期負責的對象。在 HTTP 世界裡,請求來了、處理完、結束,記憶體釋放。在 WebSocket 世界裡,連線可能掛在你的伺服器上好幾個小時甚至好幾天,期間它要佔一個檔案描述符(file descriptor)、要佔讀寫兩個 goroutine(我們用 Go)、要佔一塊發送緩衝區,還要登記它訂閱了哪些頻道。

我們一台 WS 閘道(gateway)節點要扛幾萬條連線,第一個被打爆的是檔案描述符上限。Linux 預設的 ulimit 常常是 1024,你光連個兩千人就死了。這個要先調 nofile 到幾十萬。再來是每條連線兩個 goroutine 的模型,在連線數上去之後,光是 goroutine 的排程與堆疊記憶體就很可觀,我們後來把寫入這側改成集中的 writer,讀這側維持一個 goroutine,稍微壓低了一點開銷。

但這些都還是「靜態」的成本,真正會要你命的是動態的部分——當你開始往連線裡塞資料的時候。

慢消費者:一個跟不上的人,拖垮整台機器

回到開頭那個 OOM 的夜晚。事後我做了 heap profile,記憶體幾乎全部堆在每個連線的「發送緩衝」上。問題的根源,就是這篇文章裡我最想讓你記住的一個詞:慢消費者(slow consumer)。

機制是這樣的。當撮合引擎產生一個行情事件——比如訂單簿某一檔變了、或是一筆成交發生——我們要把這個事件推給所有訂閱了這個頻道的連線。推送的動作在程式碼裡看起來就是往那條連線的 socket 寫資料。問題是,寫 socket 不保證立刻送出去。TCP 有它自己的發送窗口,如果對端(客戶端)收得慢,或網路差,或客戶端的瀏覽器卡住了沒在讀,那 TCP 的發送窗口就會滿,你的寫入呼叫要嘛阻塞,要嘛(如果你用了帶緩衝的 channel 或自己的 queue)就把資料堆在你自己的記憶體裡。

平常市場不波動,事件少,沒人會注意到這件事。但那天市場瘋了,每秒上萬筆事件要 fan-out 出去。對於那些網路順、收得快的客戶端,資料推出去就沒了,記憶體不漲。但對於那一小撮——可能就幾百個——網路爛或客戶端卡住的連線,事件一個接一個堆進它們各自的發送緩衝,而我們當時的設計是用一個沒有上限的 channel 當發送佇列。沒有上限,意思就是只要生產得比消費快,它就無限漲。幾百個慢連線,每個堆個幾萬筆事件,每筆事件幾 KB,乘一乘,十秒鐘七個 GB,就這樣沒了。

這就是慢消費者問題的可怕之處:出事的不是那個慢的人,是你的伺服器。一個收不動資料的客戶端,本來頂多就是它自己看到的行情慢,結果因為我們把它的待發資料無限堆在伺服器記憶體裡,它把整台機器、連同上面其他三萬多個無辜的連線一起拖下水。

解法:每連線有界緩衝 + 跟不上就丟 + 用快照救回來

修這個問題,核心觀念是:你必須願意丟資料。這對做後端的人來說一開始有點反直覺,我們總想著資料不能掉。但在行情推送這個場景裡,如果一個客戶端已經慢到跟不上即時資料流了,那把一萬筆它早就過時的中間狀態硬塞給它,完全沒有意義——它真正需要的是「現在最新的狀態長怎樣」,不是「過去十秒每一個瞬間長怎樣」。

我們最後的設計大概是這樣幾層:

第一,每個連線的發送佇列改成有界的。我們給每條連線一個固定大小的緩衝,具體數字我們調過很多次,大概落在能容納幾百筆訊息的量級。佇列滿了不是阻塞撮合那一側(絕對不能讓慢客戶端反壓到撮合引擎,那是災難),而是觸發處置邏輯。

第二,佇列滿了之後的處置分頻道類型。對於逐筆成交這種「每一筆都重要、漏了就斷層」的流,佇列滿了我們就直接斷開這個連線,讓它重連、重新拿快照。聽起來很粗暴,但這是最誠實的做法:你跟不上,我就告訴你你掉線了,你自己重新同步。對於訂單簿、ticker 這種「我只在乎最新狀態」的流,我們不斷線,而是做合併(conflation)——緩衝裡如果已經有一筆同頻道的更新還沒送出去,新的更新直接覆蓋舊的,只留最新的那筆。對於慢客戶端,它可能會跳過很多中間狀態,但它收到的永遠是相對最新的,而且它的緩衝永遠不會爆。

第三,也是最關鍵的安全閥:任何時候只要我們對一個連線丟過資料、或它斷線重連,它都能透過「快照」把自己拉回一致狀態。這個下一段細講。

光是把無界佇列改成有界 + 合併 + 該斷就斷,那次之後類似的 OOM 再也沒發生過。記憶體曲線從一條會垂直噴的線,變成一條有天花板的平台。

快照 + 增量:重連的客戶端怎麼把自己對齊

訂單簿推送有一個很經典的設計,叫快照加增量(snapshot plus incremental diff),我覺得這是整個即時行情系統裡最優雅的一塊。

完整的訂單簿可能有上千檔買賣價位,如果每次有任何變動都推完整訂單簿,流量會很可怕。所以實務上是這樣:客戶端訂閱時,先給它一份完整快照(現在訂單簿長這樣,並附帶一個序號 sequence number,假設是 100002),之後只推增量——某一檔價位的數量從 5 變成 3、某一檔新增、某一檔被吃光消失,每一筆增量也帶一個遞增的序號。客戶端拿到快照後,把後續增量一筆一筆套上去,就能在本地維護一份和伺服器一致的訂單簿,而流量極小。

關鍵在那個序號。客戶端每套一筆增量,就檢查序號是不是連續的——它本地是 100002,下一筆增量應該是 100003。如果收到的是 100005,中間缺了 100003、100004,代表它漏資料了(可能就是因為前面講的慢消費者被合併或被丟了),這時候客戶端不能再傻傻往下套,因為它的訂單簿已經和伺服器分岔了。正確做法是:丟掉本地狀態,重新跟伺服器要一份快照,從新快照的序號重新開始。

這個機制和前面慢消費者的處置是天衣無縫地配合的。我可以放心地對訂單簿頻道做合併、可以放心在極端情況下丟資料,因為我知道客戶端有序號可以偵測到斷層,有快照可以自我修復。換句話說,「允許丟資料」之所以能成立,是因為「能偵測丟失並重新同步」這個保險墊在底下接著。沒有後者,前者就是耍流氓。

我們在序號這裡也踩過坑。早期快照和增量是兩條不同的路徑產生的,結果有 race condition:客戶端拿快照的那一瞬間,新的增量已經產生了,但客戶端訂閱晚了一點點,於是它拿到的快照序號和它開始收到的增量序號之間有個小縫,要嘛重疊要嘛缺漏。後來我們改成:客戶端一訂閱,伺服器這側就先把這條連線掛進 fan-out、開始幫它緩衝增量,然後才產生快照,快照的序號保證小於等於第一筆緩衝增量的序號,客戶端套快照時把序號小於等於快照序號的增量全部丟掉。這樣縫就補起來了。這種邊界 case 在白板上想不出來,只能在生產環境被打出來。

心跳:怎麼知道一條連線其實已經死了

長連線還有一個很陰險的問題:連線可能「看起來還活著,其實早就死了」。客戶端的網路斷了、手機進電梯、或對端程式 crash 了,但 TCP 連線在你伺服器這側可能還掛著,因為沒有任何封包告訴你對方走了。如果你不主動偵測,這些殭屍連線會一直佔著你的檔案描述符和記憶體,慢慢累積。

我們用的是 WebSocket 協定本身的 ping/pong。伺服器每隔一段固定時間(我們用過 15 到 30 秒之間的值)對每個連線送一個 ping,客戶端的 WebSocket 實作會自動回 pong。我們在伺服器記錄每條連線最後一次收到任何資料(包括 pong)的時間,如果超過某個門檻——大概是兩到三個 ping 週期——還沒任何回應,我們就判定這條連線死了,主動關掉、清掉它的所有訂閱與緩衝。

這裡有個小細節值得提:ping/pong 不只是偵測死連線,它在某些網路環境裡還順便當保活(keep-alive)用。很多中間的 NAT、負載均衡器、雲端的 LB 會對閒置的 TCP 連線設超時,行情不波動的時候連線可能幾分鐘沒有任何資料流動,中間設備就把它砍了。定期的 ping 讓連線上始終有流量,避免被中間設備誤砍。我們上雲之後就吃過這個虧,LB 的 idle timeout 預設 60 秒,行情一冷清連線就成批掉,加上 ping 之後就好了。

Fan-out:一個撮合事件怎麼分發到 N 個訂閱者

撮合引擎產生一個事件,到它出現在幾千個客戶端的螢幕上,中間這段分發叫 fan-out,它的設計直接決定你能撐多少連線。

最直覺的做法是:事件來了,我遍歷所有訂閱這個頻道的連線,一個一個把資料寫進去。問題是如果一個熱門交易對有兩萬個訂閱者,每筆事件你就要做兩萬次寫入,而熱門行情每秒上萬筆事件,這個迴圈會直接把 CPU 燒乾,而且前面講的慢消費者會在這個迴圈裡卡住其他人。

我們的做法分幾層。撮合引擎本身只負責產生事件,把事件丟到一個內部的訊息匯流排,它完全不知道有多少訂閱者,這層解耦很重要——撮合是系統裡最不能被拖慢的部分,它絕對不能因為某個客戶端慢就被反壓。然後 WS 閘道這層維護「頻道對應訂閱連線集合」的索引,事件從匯流排來,閘道查出這個頻道的訂閱者,把事件序列化一次(注意是序列化一次,然後把同一份 bytes 寫給所有人,不要每個連線各序列化一遍,這個優化省了我們很多 CPU),再寫進每條連線各自的有界發送佇列。寫進佇列是非阻塞的,佇列滿了就走前面講的合併或斷線邏輯,所以慢客戶端不會卡住 fan-out 迴圈。

把「序列化一次、寫多次」這件事做對,加上發送佇列非阻塞,我們單台閘道的 fan-out 能力大概提升了一個數量級。

橫向擴展:一台閘道不夠時怎麼長出更多台

連線數再往上加,單台機器一定不夠,你得能水平擴展 WS 閘道。這裡的架構選擇是:閘道做成無狀態的、可任意增減的一層,客戶端透過負載均衡器連到任意一台閘道。每台閘道各自管自己那批連線和訂閱關係。

那撮合事件怎麼讓所有閘道都收得到?我們在撮合和閘道之間放了一層發布訂閱(我們用過 Redis 的 pub/sub 也評估過更專門的訊息系統),撮合產生的事件發布到對應頻道,所有閘道都訂閱,各自再 fan-out 給自己連著的客戶端。這樣加一台閘道就只是多一個訂閱者,撮合那側完全無感。

這層架構也讓前面講的快照有了著落:閘道本身不存訂單簿狀態(它無狀態),客戶端要快照時,閘道去跟一個專門維護當前訂單簿狀態的服務要,或從一個快取拿。無狀態的好處是任何一台閘道掛了,客戶端重連會被 LB 導到另一台,重新訂閱、重新拿快照,序號對齊,就接回去了,使用者頂多感覺到一兩秒的小頓挫。

橫向擴展真正麻煩的不是技術,是連線的重新平衡。當你滾動更新(rolling update)閘道,被關掉那台上面的幾萬條連線會同時斷、同時重連、同時要快照,這個瞬間的衝擊很大——我們叫它驚群(thundering herd)。我們的處理是讓客戶端重連時帶一個隨機抖動(jitter)的退避,不要所有人在同一毫秒一起撞回來;伺服器側對快照請求也做了限流和快取,避免幾萬個快照請求同時打爆狀態服務。這些都是上線後被真實流量教育出來的。

寫在最後

現在回頭看那條垂直噴上去的記憶體曲線,我反而有點感激它。它逼著我們把整個即時行情系統從一個「平常跑得很順」的玩具,變成一個「在最壞的時候也死不掉」的東西。而我學到最深的一課,其實不是什麼花俏的技術,而是一個很樸素的取捨:在即時系統裡,新鮮度比完整性重要。一個跟不上的客戶端,它要的從來不是你忠實保存的每一個它早就錯過的瞬間,而是此刻最新的真相。願意丟掉過時的資料、並且設計好讓客戶端能自我修復回到一致——這個心態的轉變,比任何一行緩衝區管理的程式碼都更關鍵。我們做後端的常常下意識覺得「資料不能掉」是天條,但在某些場景,死守這條天條反而會害死你整台機器。知道什麼時候該守、什麼時候該放,大概就是把系統養大之後,生產環境一巴掌一巴掌教會我的東西。

#WebSocket#即時推送#高併發#交易所#系統設計

相關文章