QUEST LOG · December 20, 2025· UPDATED October 29, 2026
小心連接池,serverless連接資料庫的小陷阱
在傳統後端是常識的連接池,搬到 serverless 上就變成一顆地雷。
在〈客家工程師的生存法則〉那篇稍微提過這件事,但寫完覺得只講了「會爆」,沒講「為什麼爆、怎麼寫才不會爆」。這篇就專心把這個坑挖開,順便把能直接抄的寫法整理好。
# 連接池的前提:你的程式會一直活著
傳統的 Node / Go / Java 後端,連接池大概長這樣:
// server.ts —— 開機時建一次,之後所有 request 共用
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 })
app.get('/posts', async () => {
const { rows } = await pool.query('select * from posts')
return rows
})
它能運作,靠的是一個沒寫出來的前提:這個 process 會活很久。開機時花點時間把十條 TCP 連線建好,之後一萬個 request 輪流借用、用完歸還,DB 那邊永遠只看到十條連線。
serverless 把這個前提整個拆掉了。
# 在 Workers 上會怎麼壞
我第一次把上面那種寫法搬到 Cloudflare Workers,直覺就是「module scope 建一次 pool,跟以前一樣」。結果會撞到三件事:
1. 連線不能跨 request 重用
Workers 的 I/O 物件(包含 TCP socket)是綁在建立它的那個 request 上的。上一個 request 開的連線,下一個 request 拿來用,會直接噴:
Error: Cannot perform I/O on behalf of a different request.
也就是說,module scope 的 pool 在 Workers 上根本沒辦法照原本的方式共用。
2. 實例數量不是你決定的
就算是能跨 request 保持連線的 serverless 平台,流量一來,平台會同時長出一堆實例,每個實例都有自己的 pool。十個實例 × 每個 10 條,就是一百條連線。Postgres 預設的 max_connections 是 100,還沒算上你的後台、migration 跟 cron。
3. 實例被回收時,連線不一定有好好關
實例說收就收,連線沒 end() 就留在 DB 那邊,等 timeout 才會被清掉。尖峰過後,DB 上掛著一堆殭屍連線,下一波流量進來直接被拒絕。
最慘的是:平常一切正常,只有流量大的時候才爆。 而流量大的時候,剛好就是你最需要它活著的時候。
# 解法一:Hyperdrive,把連接池搬到 DB 旁邊
如果你一定要用 Postgres,Cloudflare 的 Hyperdrive 是最省事的解法。它在 Cloudflare 那邊幫你維護一個連接池,Worker 每個 request 只要連到 Hyperdrive,而不是直接連 DB。
關鍵是:client 在 request 裡面建,用完在 waitUntil 裡關。
import postgres from 'postgres'
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
// 每個 request 自己建 client;真正的連接池在 Hyperdrive 那一側
const sql = postgres(env.HYPERDRIVE.connectionString, { max: 5, fetch_types: false })
const posts = await sql`select id, title from posts order by published_at desc limit 20`
// 回應送出後再關連線,不擋住使用者
ctx.waitUntil(sql.end())
return Response.json(posts)
},
}
wrangler.jsonc 加一個 binding:
"hyperdrive": [{ "binding": "HYPERDRIVE", "id": "<your-hyperdrive-id>" }]
這樣 DB 看到的連線數,就只剩 Hyperdrive 那個池子的大小,不會跟著 Worker 實例數一起暴漲。
# 解法二:走 HTTP 的資料庫 driver
另一條路是乾脆不用長連線。像 Turso(libSQL)、Neon 的 serverless driver,都可以讓每一次查詢就是一個 HTTP 請求:
import { createClient } from '@libsql/client/web'
export default {
async fetch(request: Request, env: Env) {
const db = createClient({ url: env.TURSO_URL, authToken: env.TURSO_TOKEN })
const { rows } = await db.execute('select id, title from posts limit 20')
return Response.json(rows)
},
}
沒有 TCP 連線要管,也就沒有連接池的問題。代價是每次查詢多一點 HTTP 的延遲,而且要把交易(transaction)的寫法改成那個 driver 支援的方式。這個部落格之前接 Turso 就是走這條路。
# 解法三:根本不要資料庫
最後一條路最暴力,但對很多個人專案其實最合適:資料量小、寫入頻率低,就不要資料庫。
這個部落格現在就是這樣:文章列表是 R2 上的一個 index.json,內文是一堆 Markdown 檔,前台只拿得到 R2 的唯讀權限。沒有連線、沒有池子、沒有 max_connections,也就沒有這篇在講的任何一個坑。
# 怎麼選
| 情境 | 建議 |
|---|---|
| 已經有 Postgres、要關聯查詢跟交易 | Hyperdrive + 每個 request 建 client |
| 新專案、資料不複雜、想少管基礎設施 | Turso / Neon 這類 HTTP driver |
| 個人站、一個月寫幾次 | 不要資料庫,KV 或 R2 + JSON 就夠 |
# 一句話總結
連接池是為「長壽的 process」設計的。 serverless 的 process 不長壽、數量也不歸你管,所以池子要嘛交給平台(Hyperdrive),要嘛換成不需要池子的連線方式(HTTP driver),要嘛連資料庫都不要。
把這件事想清楚,就不用在流量最大的那天晚上,看著 too many connections 懷疑人生了。
