---
title: "小心連接池，serverless連接資料庫的小陷阱"
description: "在傳統後端是常識的連接池，搬到 serverless 上就變成一顆地雷。"
author: "kidneyweakx"
published: 2025-12-20T11:28:43.101Z
updated: 2026-10-29T10:17:47.595Z
lang: zh-TW
tags: ["serverless", "cloudflare", "database", "postgres"]
canonical: https://kidneyweakx.com/blog/serverless-db-connection-pitfalls
---

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

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

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

<br />

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

傳統的 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 拿來用，會直接噴：

```
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 上掛著一堆殭屍連線，下一波流量進來直接被拒絕。

<br />

**最慘的是：平常一切正常，只有流量大的時候才爆。** 而流量大的時候，剛好就是你最需要它活著的時候。

## 解法一：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`，也就沒有這篇在講的任何一個坑。

<br />

## 怎麼選

| 情境 | 建議 |
|---|---|
| 已經有 Postgres、要關聯查詢跟交易 | Hyperdrive + 每個 request 建 client |
| 新專案、資料不複雜、想少管基礎設施 | Turso / Neon 這類 HTTP driver |
| 個人站、一個月寫幾次 | 不要資料庫，KV 或 R2 + JSON 就夠 |

## 一句話總結

**連接池是為「長壽的 process」設計的。** serverless 的 process 不長壽、數量也不歸你管，所以池子要嘛交給平台（Hyperdrive），要嘛換成不需要池子的連線方式（HTTP driver），要嘛連資料庫都不要。

把這件事想清楚，就不用在流量最大的那天晚上，看著 `too many connections` 懷疑人生了。
