timeout 不等於 abort:一個資料庫 black-hole 殺死我爬蟲三次的覆盤
Table of Contents
前情提要:我有一個 vibe coding 出來的台股行情爬蟲,跑在 Fly.io 上、資料庫用 Turso(libSQL,SQLite over HTTP)。上一篇〈讓 AI 全面 review 我 vibe code 出來的 production 專案〉提到,我做過一次全面 review、修掉 20 個問題。兩週後,它還是在盤中死掉了。
接下來十天內它一共死了三次,每次的死法都不一樣,每次的修復都揭開下一個洞。這篇覆盤想講的核心只有一句話:timeout 不等於 abort——這五個字花了我兩次 production 事故才真正學會。
第一次:凍死 12 小時
某天 11:01,爬蟲的 log 突然安靜。沒有錯誤、沒有 crash、process 活著、機器健康——就是不再有任何輸出。一路凍到晚上快午夜,我手動重開機器才復活。
事後診斷:Turso 的遠端連線出現了 black-hole——請求送出去,永遠沒有回應,也永遠不會 reject。而爬蟲的主體是一個單一的 async 迴圈:
while (!shouldStop) {
const data = await fetchFromUpstream()
await db.batch(statements) // ← 這一行永遠不返回
}
一個永遠不 settle 的 await,就把整條迴圈扣為人質。更糟的是第二層:「停止」的實作只是把 shouldStop 旗標設成 true,等迴圈下一輪去檢查——迴圈凍死了,就永遠沒有下一輪。按了三次停止 API 全部無效,背景的重連機制和 keep-alive 還在忠實地撐著這具屍體,不讓機器休眠。
修復有兩件事。一是給每個 DB 操作包上 timeout(當時用 Promise.race);二是讓停止指令直接拆除背景資源(斷開監聽、清掉 keep-alive timer),不再等迴圈自己醒來。
export function stopCrawler() {
state.shouldStop = true
stopSideEffects() // 不等迴圈 return,直接拆
}
第二次:OOM
修完之後我以為結束了。隔天開盤,Turso 又開始 black-hole——這次 timeout 如期觸發、迴圈如期 catch、如期重試。然後機器在三分鐘後 OOM 被系統擊殺。
原因是 Promise.race 的語義:它只是「不再等」輸掉的那個 promise,並沒有取消底層的操作1。每次 timeout,那個卡死的 HTTP 請求仍然被 socket 釣著——連同已經序列化好的數 MB batch payload——GC 收不掉。迴圈每次重試又建立一個新的卡死請求。卡死的請求一個一個累積,heap 三分鐘撞頂。
這就是「timeout 不等於 abort」的第一課:不等 ≠ 中止。你以為你放手了,其實資源還抓在手上。
正確的修法要下沉一層。Turso 的協議是 SQLite over HTTP,每個 DB 操作就是一個 HTTPS POST,而 client 允許注入自訂的 fetch。所以把 timeout 做在 fetch 層,用 AbortController 真正拆掉 socket2:
const timeoutFetch: typeof fetch = async (input, init) => {
const controller = new AbortController()
const timer = setTimeout(() => {
db = null // 丟掉 client,下次重建
controller.abort(new Error(`DB request timed out after ${TIMEOUT}ms`))
}, TIMEOUT)
try {
return await fetch(input, { ...init, signal: controller.signal })
}
finally {
clearTimeout(timer)
}
}
const db = createClient({ url, authToken, fetch: timeoutFetch })
abort 會真正銷毀連線、釋放 buffer;配合冪等的寫入(ON CONFLICT DO UPDATE),「盲目重試」是安全的。這裡還藏了一個彩蛋前提:abort 可能發生在「server 已經 commit、只是回應沒送達」的瞬間——你永遠無法分辨。寫入冪等是讓你不需要分辨的設計,這個主題值得獨立一篇(見系列第四篇)。
第三次:修復本身變成兇器
fetch 層的修法部署後 12 分鐘,每一個 DB 操作開始 crash:
TypeError: Failed to parse URL from [object Request]
原因藏在抽象層的縫隙裡。libSQL 的底層協議 client(hrana)呼叫 fetch 時,傳進來的不是 URL 字串,而是 cross-fetch 的 Request 物件(Node 上是 node-fetch 的實作)。我的 timeoutFetch 把這個「外來實作」的 Request 直接轉給 Node 原生的 fetch(undici)——undici 不認得它,對它執行 new Request() → new URL(input),把整個物件字串化成 [object Request],爆炸。
修法:不讓非原生物件跨越實作邊界。把 Request 拆成原生欄位重組:
resp = await fetch(input.url, {
method: input.method,
headers: [...input.headers],
body: input.method === 'GET' ? undefined : await input.arrayBuffer(),
signal: controller.signal,
})
另外補一刀:連 response body 也要在 timer 還活著時讀完(await resp.arrayBuffer() 再重組成 Response 返回),否則「server 送了 headers 卻卡住 body」的情況會漏出 timeout 的保護傘。timeout 要涵蓋 connection → headers → body 的整段,不是只有連上為止。
這次事故是 Joel Spolsky「漏水抽象定律」的完美標本:createClient 的抽象讓你以為你只是在「用資料庫」,直到失效發生,你被迫下潛到 wire protocol 和 fetch 實作的層次。你不需要預先學會每一層,但需要在漏水時有能力往下潛一層——我最後是直接讀 node_modules 裡 hrana client 的原始碼對答案的。
尾聲:從事故到日常
三次修復全部落地後,有趣的事情發生了:log 顯示那個 black-hole 其實每天開盤都在發生——開盤後的第一發大量寫入(約 3.8 萬筆的 batch)幾乎必卡,只是現在它的結局從「凍死/OOM」變成:60 秒 timeout → abort 釋放 → 30 秒後重試 → 成功。全自動,代價 95 秒。
最後一手是把這 95 秒也消滅掉:把爬蟲的啟動時間從開盤那一刻提前五分鐘。暖機、大寫入、可能的 stall 和重試,全部在開盤前消化完畢——不是縮短損失,是把損失移出交易時段。上線隔天驗證:開盤第一分鐘資料就開始流動。
三個帶得走的原則:
- timeout 不等於 abort。
Promise.race只是不等,AbortController才是中止。不中止的 timeout 在重試迴圈裡就是記憶體洩漏製造機。 - 依賴出不出包不可控,放大倍率可控。 資料庫 black-hole 的合理代價是「outage 期間沒資料」;讓它變成「凍死 12 小時 + 手動重開」的是我自己的設計——單迴圈無 timeout、stop 依賴迴圈存活、timeout 不釋放資源,每一層都是放大器。
- 讓盲目重試是安全的,然後盡量重試。 冪等寫入 + 真正的 abort + 永不放棄的迴圈,是這個系統現在能在依賴發瘋時自癒的全部秘密。
環境資訊
- Node.js: v22.x(undici fetch)
- @libsql/client: 0.17.x / @libsql/hrana-client: 0.9.x
- Database: Turso(libSQL over HTTP)
- Runtime: Fly.io
本文撰寫時間:2026 年 7 月,技術版本可能隨時間更新,請以官方文件為準。
Reference
- Promise.race() - JavaScript | MDN — race 以第一個 settle 的 promise 決定結果,但其他 promise 仍會繼續執行,不會被取消。 ↩
- AbortController - Web APIs | MDN — 對 fetch 傳入
signal後呼叫abort(),會真正中斷請求並以 reason 拒絕該 promise。 ↩