AI News HubLIVE
站內改寫4 分鐘閱讀

我用AI搭建了一個即時交易平臺,然後時鐘開始說謊

作者用Claude作為AI副駕,構建了一個OTC利率互換的即時價格匹配平臺。上線執行一個月後,出現會話時長異常延長、連線斷開、資料閃爍等問題。透過分析錯誤分佈(連續倍數而非離散倍數),作者診斷出是非同步事件迴圈中的競爭條件:一個慢速客戶端阻塞了廣播迴圈,導致計時器被飢餓。修復方案是使用絕對截止時間計時器代替迴圈計數,並將廣播改為併發執行加超時。這揭示了'非同步'並不自動帶來併發,理解系統行為比程式碼生成更重要。

來源Hacker News AI作者: fawraw

在之前的文章中,我描述了給AI賦予對我生產基礎設施的只讀SSH訪問許可權,用於審計、文件和監控。邏輯上的下一步就是讓它不僅能讀取,還能構建。於是我用Claude作為AI副駕,構建了一個真實的應用程式:一個面向場外利率互換的價格匹配平臺。這個平臺具備即時性、WebSocket連線、單點登入認證,以及一個能自動匹配多個對手方互補興趣的引擎。技術棧包括FastAPI、asyncio和SQLite。該平臺已投入生產,由一位試點經紀商及其背後的約十五家銀行對手方使用。

上線後的第一個月一切正常,但隨後經紀商向我反饋了幾個奇怪的問題:會話在時鐘還有剩餘時間時被切斷;交易員的持倉名義金額閃爍,出現又消失;會話計時異常,執行時間過長。我的時鐘開始說謊了。以下是我如何發現原因,以及這個經歷教會我關於“非同步”一詞的真諦。

症狀:會話無法按時結束

平臺的核心是一個固定時長的匹配會話。例如配置為十分鐘:一個倒計時,然後是一個匹配視窗,最後結束。所有參與者看到同一個時鐘。但在生產環境中,配置為600秒的會話實際持續了1202秒(兩倍),另一個持續了1803秒(三倍)。回顧大約四十個會話,我沒找到乾淨的整數倍數,而是發現了以下分佈:1.0倍、1.2倍、1.41倍、1.5倍、2.0倍、2.5倍、2.67倍、3.0倍、3.5倍……最高達5.51倍。一個本應持續十分鐘的會話可能執行將近一小時。沒有人能解釋原因,包括我在內。

第一次錯誤轉向:“是計時器的問題”

直覺反應:計時器程式碼有bug,比如重複觸發或忘記停止。我將其隔離並單獨測試:兩秒倒計時、三秒匹配,結果精確無誤,一次轉換,沒有重複觸發。計時器在隔離下是完美的。更奇怪的是(或者說更好的是),在生產中倒計時是精確的:30秒,分毫不差。只有匹配階段出現了偏移。區別是什麼?倒計時期間幾乎無事發生,而匹配階段非常活躍:訂單透過WebSocket到達、每個客戶端每三秒輪詢一次API、價格推送每秒多次推送資料、伺服器持續向所有人廣播狀態。

計時器沒有壞,它被飢餓了。

關鍵線索:錯誤形狀

這個細節徹底改變了診斷方向,並從此成為我的法則:錯誤分佈的形狀告訴你bug的型別。離散bug(如計時器重置或重複計數)會產生離散錯誤:乾淨的整數倍數,如×2、×3,絕不會出現×2.67。但我看到的是連續分佈:1.2倍、1.41倍、2.67倍、3.5倍、5.51倍——一個連續的斜坡。連續斜坡不像邏輯錯誤,而更像是競爭:負載越大,速度越慢,且呈比例關係。拉伸係數與會話的活動量相關。從此,我不再尋找計時器bug,而是尋找阻塞事件迴圈的東西。

原因:一個慢客戶端阻塞了所有人

asyncio執行在單執行緒上。整個伺服器——計時器、訂單、廣播、WebSocket心跳——共享一個事件迴圈。這個迴圈是協作式的:直到一段程式碼透過實際的await交出控制權,其他程式碼才能執行。我的計時器透過計數await asyncio.sleep(1)呼叫來工作。理論上,每次迴圈等於一秒。但實際上,sleep(1)只有在迴圈有空呼叫它時才會恢復。如果迴圈忙於其他事務,計時器的每一“秒”都會延長一秒加上延遲。累積足夠多的延遲,十分鐘的會話就會變成五十分鐘。

罪魁禍首是廣播函式。它被宣告為async,但卻是逐個傳送給每個客戶端,沒有超時,並且在每次計時器滴答和每次交易時都被等待。只要有一個慢速或半死的客戶端(例如凍結的標籤頁、過期的令牌、網路側TCP緩衝區滿),向該客戶端傳送資料就會阻塞整個廣播迴圈。而只要這個迴圈被阻塞,計時器滴答就要等待,所有其他客戶端的心跳也都要等待。一個壞掉的客戶端拖慢了整個平臺。

真相大白的那一刻

一個真正的根本原因在於它能解釋所有症狀,而不僅僅是一個。“會話執行過長” → 計時器被飢餓拉伸;“在時鐘還有時間時斷開” → WebSocket心跳到達太晚,瀏覽器認為連線已死並斷開,寬限後客戶端被踢出,時鐘凍結在最後一刻;“名義金額閃爍” → 廣播到達延遲且亂序;日誌中大量“令牌過期” → 慢請求引發客戶端重試迴圈,進一步加重迴圈負載。五個不同的抱怨,一個共同的疾病。我不是有五個bug,而是隻有一個,戴了五副面具。

修復分兩部分

  1. 使用牆上時鐘,而非迴圈計數。我不再依賴計數sleep(1)。在每個階段開始時,我固定一個絕對截止時間:deadline = time.monotonic() + duration。每次滴答時,剩餘時間為ceil(deadline - now),當now >= deadline時階段立即結束。如果迴圈飽和導致一次滴答延遲,截止時間不會移動:會話會按時結束。迴圈的滯後不再拉伸時間,而是被追趕。(使用time.monotonic()而非datetime.now():需要一個不會倒退、不受NTP調整影響的時鐘。)
  1. 併發廣播,每個客戶端有界。我將廣播重寫為並行傳送(asyncio.gather),每次傳送包裹在asyncio.wait_for(..., timeout=2s)中。慢客戶端?該輪訊息跳過,不斷開連線——它只是慢,不是死。真正死的客戶端?移除。結果:廣播最壞情況約2秒,而不是所有傳送時間的總和。

教訓可以用一句話概括:async def並不帶來併發。一個迴圈中的await send()仍然是順序的。併發需要顯式的gather。

驗證後才宣佈勝利

我不願“部署後再祈禱”。我編寫了一個測試,在每次滴答中注入一秒同步阻塞,製造飢餓,可復現。修復前(迴圈計數):匹配時間變為兩倍。修復後(牆上時鐘):無論注入多少阻塞,時長精確。第二個測試用三個客戶端:一個快速、一個慢速(5秒)、一個死亡,檢查有界廣播行為:慢速的被跳過,死亡的被移除,快速的不等待任何人。兩個測試透過後,我部署了。

我的收穫

“非同步”並不意味著“併發”。這是整個故事的核心概念錯誤。asyncio給了你併發的可能性,但並不免費。一個逐次等待每個操作的迴圈仍然是順序的,只是禮貌地阻塞。永遠不要讓一個客戶端的慢速影響共享狀態。任何涉及網路的操作都必須有超時。一個對端的背壓絕不能劫持所有人的時鐘。用時鐘測量時間,而不是迴圈次數。計數sleep(1)呼叫是賭迴圈永遠不會延遲,但延遲總是會發生。

錯誤的形狀是診斷工具:乾淨的整數倍數 → 尋找離散邏輯錯誤;連續分佈 → 尋找競爭。這個簡單的區分為我節省了數天時間。

最後,回到本部落格的主題:一位非開發出身的IT主管使用AI將平臺投入生產。AI編寫了大部分程式碼。但這個bug並非透過程式碼生成解決,而是透過推理:追蹤連續分佈、形成飢餓假設、在“牆上時鐘”和“計數器”之間做出選擇。AI是優秀的夥伴,用於檢測、測量和編寫修復程式碼,但理解問題根本原因仍然需要人類。這正像我三個月前所說的:AI不會取代工程師,而是為他們騰出時間去做真正的工作:理解。