客戶網站的訂閱與發文都走同一個雲端資料庫。上線沒多久,我們注意到一個規律:只要一段時間沒人操作,下一個寫入請求就會卡很久、甚至直接回 500。使用者按下「訂閱」,轉了快一分鐘,最後拿到一個錯誤——這是最不該出現在「轉換動作」上的體驗。
把後端日誌撈出來,畫面大概長這樣:
cannot connect to Postgres: Connection terminated due to connection timeout Connection terminated unexpectedly POST /subscribe 500 in 88s # 使用者就這樣等了 88 秒
只有「閒置後的第一個請求」會中招;重試一次往往就正常了。這是最典型的暫時性故障——偶發、可自癒、卻剛好砸在使用者臉上。
Neon 這類 serverless Postgres 會在閒置後自動暫停來省資源。暫停後的第一個查詢要先把資料庫喚醒、重新建立連線,這段冷啟動可能要數十秒;而連線池裡原本握著的連線這時早已失效,直接拿來用就會噴連線錯誤。
所以這不是 bug,是 serverless 的本質。我們沒辦法消滅冷啟動,但可以讓它對使用者隱形。
我們加了一個包裝函式 withDbRetry(),把「取得連線+這次查詢」整段包起來。重點只有三個:只重試連線類的暫時錯誤、用指數退避給資料庫醒來的時間、以及設一個嘗試上限讓最壞情況的延遲有界。
async function withDbRetry(fn) { for (let n = 1; n <= ATTEMPTS; n++) { try { return await fn(); } catch (err) { // 不是連線錯誤、或用完次數 → 立刻拋,不要硬撐 if (!isTransientDbError(err) || n === ATTEMPTS) throw err; await sleep(BASE * 2 ** (n - 1)); // 0.5s → 1s → 2s } } }
重試很容易寫,但寫錯方向反而更糟。這幾條是我們在客戶站上實際踩過、才補起來的:
err.cause / originalError 一路挖進去。Serverless 的冷啟動消滅不了,但可以讓它對使用者隱形。一層只針對暫時性連線錯誤、帶退避與上限的重試,就能把偶發的 500 變成「慢一兩秒但成功」。
這是我們在這個旅遊部落格客戶專案裡,一連串「serverless 很方便、但邊角要自己收」的其中一坑。之後幾篇會接著寫我們在同一個站上遇到的其他問題。





