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不會取代工程師,而是為他們騰出時間去做真正的工作:理解。