QUEST LOG · December 20, 2025· UPDATED October 29, 2026

小心連接池,serverless連接資料庫的小陷阱

在傳統後端是常識的連接池,搬到 serverless 上就變成一顆地雷。

在〈客家工程師的生存法則〉那篇稍微提過這件事,但寫完覺得只講了「會爆」,沒講「為什麼爆、怎麼寫才不會爆」。這篇就專心把這個坑挖開,順便把能直接抄的寫法整理好。


# 連接池的前提:你的程式會一直活著

傳統的 Node / Go / Java 後端,連接池大概長這樣:

ts
// 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 拿來用,會直接噴:

code
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 裡關。

ts
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:

jsonc
"hyperdrive": [{ "binding": "HYPERDRIVE", "id": "<your-hyperdrive-id>" }]

這樣 DB 看到的連線數,就只剩 Hyperdrive 那個池子的大小,不會跟著 Worker 實例數一起暴漲。

# 解法二:走 HTTP 的資料庫 driver

另一條路是乾脆不用長連線。像 Turso(libSQL)、Neon 的 serverless driver,都可以讓每一次查詢就是一個 HTTP 請求:

ts
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 懷疑人生了。