#DevOps

数据库连接架构:池化、代理与 Serverless

应用怎么连数据库,决定了能扛多少并发。本文讲清三种连接架构:应用内池(Prisma/node-pg-pool)、连接代理、Serverless 连接池(PgBouncer transaction 模式),各自的适用与并发瓶颈,以及 Serverless 场景的特殊解法(Neon/SUPABASE pooler)。

本文要点

  • 主题聚焦:DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年3月9日·更新于 2026年7月23日

把一个跑得好好的 Node API 从 ECS 迁到 Lambda,流量没涨,当晚数据库就报 too many connections。排查发现:Lambda 并发数就是实例数,冷启动一来,每个执行环境都各自建一个连接池,几百个并发函数瞬间在 PG 上砸出上千条连接,max_connections=100 当场被打穿。这个问题在长驻服务上根本不会出现——Serverless 把一个原来不是问题的问题变成了头号难题。

本文把"应用怎么连数据库"这件事拆成三种架构讲透:应用内池、连接代理、Serverless 连接池。搞不清这三者的适用边界,迟早要在连接数上栽跟头。

一、连接为什么是瓶颈

很多新手把数据库连接当成"一个普通的 TCP socket",以为便宜。但 PostgreSQL 的架构决定了它的连接非常贵:每来一个连接,PG 就 fork 一个独立的 backend 进程。不是线程,是进程。

这条连接的代价是实打实的:

  • 内存:每个 backend 进程有独立的 work_mem、缓存上下文、临时空间。一个空连接占 5~10MB,跑复杂查询轻松几十 MB。300 条连接光进程元数据就几 GB。
  • CPU:进程间不共享缓存,context switch、TLB miss 的开销随连接数线性上升。
  • 建连慢:TCP 握手 + 认证 + fork + backend 初始化,新建一条连接几十毫秒起。高并发下这个延迟直接顶到 P99。

而连接建立本身就是个大开销:TCP 三次握手 + TLS(如果有)+ 数据库认证(密码校验、SCRAM)+ fork 进程 + 会话状态初始化。这个过程在 PG 上动辄 30~100ms。如果你每次请求都新建连接,等于每个请求先白等 50ms 才开始干活。

所以 PG 的官方默认 max_connections=100 不是抠门,是经验值。MySQL 是线程模型,连接相对轻量;PG 的进程模型决定了它对连接数天然敏感——几百条连接,就能让一台不错的机器疲于奔命。连接数,就是 PG 吞吐的天花板。

二、架构一:应用内连接池

既然连接贵,就该复用。所有像样的驱动都内置了连接池,核心机制就一句话:预热一批连接放池子里,请求来了借一条、用完还回去,而不是用完就关、下次再建。

js
// node-pg(pg)——Pool 就是连接池
const { Pool } = require('pg');
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,                  // 池大小
  idleTimeoutMillis: 30000, // 空闲 30s 回收
  connectionTimeoutMillis: 2000,
});
ts
// Prisma —— 用 connection_limit 控制每个实例的连接数
// postgresql://user:pass@host:5432/db?connection_limit=10&pool_timeout=10
yaml
# HikariCP(Java/Spring 默认池)
hikari:
  maximum-pool-size: 10
  connection-timeout: 2000
  idle-timeout: 30000

应用内池的拓扑长这样:

text
[App 实例 A] ── pool(10) ─┐
[App 实例 B] ── pool(10) ─┼──> [PostgreSQL  max_connections=100]
[App 实例 C] ── pool(10) ─┘

适用场景:长驻服务(传统 ECS / 容器部署、PM2、systemd 进程)。进程起来就建池,整个生命周期复用,连接建立成本摊薄到接近零。

但它有个绕不开的局限:每个实例是独立池,实例之间不共享。总连接数 = pool_size × 实例数。上面这个拓扑,3 个实例各 10 条,加起来就是 30 条。实例一扩到 15 个,就是 150 条——已经爆了 PG 的 100。**应用池解决的是"单进程内复用",解决不了"多实例横向扩展"。**实例数一上来,瓶颈立刻从 CPU 转移到连接数。

三、架构二:连接代理(PgBouncer / ProxySQL)

当应用实例很多、或者多个服务共享一个库时,应用内池就力不从心了。解法是在应用和 DB 之间加一层连接代理:它在客户端面前装作是数据库,在数据库面前只用很少的连接。

text
[App A] ──┐                          ┌── backend conn 1 ──┐
[App B] ──┼──> [PgBouncer] ──────────┼── backend conn 2 ──┼──> [PostgreSQL]
[App C] ──┘    客户端连接 1000+       └── ... 共 25 条 ────┘    max_connections=100

左边可以挂上千个客户端连接,右边只对 PG 开 25 条真正的 backend 连接。这层把"客户端连接数"和"数据库连接数"解耦了——这是它最大的价值。

PG 生态的事实标准是 PgBouncer,MySQL 那边对应的是 ProxySQL。PgBouncer 有三种池化模式,选哪个是关键决策:

  • session 模式:客户端一条连接 = 一条 backend 连接,整个会话期间绑定。省的只是建连开销,不省连接数。基本没用。
  • transaction 模式(重点):一个事务期间借一条 backend 连接,事务一结束(COMMIT/ROLLBACK)立刻还回池子,下一个事务再借给别的客户端。这才是真正能扛高并发的模式,一条 backend 连接可以服务成百上千个客户端。
  • statement 模式:每条 SQL 借一次,连多语句事务都不支持。限制太大,极少用。
ini
# pgbouncer.ini —— transaction 模式核心配置
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
pool_mode = transaction        # 关键:每事务借连接
max_client_conn = 1000         # 客户端连接上限(可以很大)
default_pool_size = 25         # 每个 db/user 组合的 backend 连接数
reserve_pool_size = 5          # 高峰时临时加拨

transaction 模式的代价:会话级状态不能跨事务保留。SET、临时表、LISTEN/NOTIFY、服务端 prepared statements 这些会话级特性在 transaction 模式下会失效或踩坑。其中最常见的就是 prepared statements——很多驱动默认用 prepared statements 缓存执行计划,但 PgBouncer transaction 模式下,一条 prepared statement 在事务 A 借的连接上创建,下一个事务 B 可能借到另一条连接,于是 statement 找不到,直接报错。后面坑那一节会讲怎么配。

适用场景:多服务共享一个库、高并发短请求、连接数吃紧。一个 PgBouncer 实例可以让上百个应用进程共享几十条 DB 连接,这是应用内池做不到的。

四、架构三:Serverless 连接

Serverless( Lambda / Edge Functions / Cloudflare Workers / Vercel )把连接问题推到了一个新高度,因为它的执行模型彻底颠覆了"长驻进程"假设:

  1. 函数短命:一个 Lambda 实例可能只活几百毫秒,处理完请求就被冻结或销毁。
  2. 实例数 = 并发数:流量一涨,平台瞬间拉起几百个并发实例,每个实例各自建池,你的连接数随并发线性爆炸。
  3. 冷启动:每次冷启动都要重新建连,前面说的 50ms 建连开销被反复支付。

应用内池在这里直接失效:你根本无法预估实例数,也就没法把 pool_size × 实例数 控制在 max_connections 以内。直连 PG 的 Serverless 应用,基本等于在 PG 上玩俄罗斯轮盘赌。

解法有两条路,核心都是把池化下沉到数据库那一层:

解法一:自建或托管 PgBouncer(transaction 模式)。让所有 Lambda 实例连到 PgBouncer,由它统一收敛连接。AWS RDS 现在内置了 RDS Proxy,本质就是托管的 PgBouncer-like 代理,开启即可。

解法二:用 Serverless 原生数据库自带的 pooler。这是这几年的趋势,主流 Serverless PG 都内置了 pooler endpoint:

  • Neon:计算存储分离,默认提供 -pooler 后缀的连接端点,开箱即用 transaction 模式。Serverless 函数连 pooler 端点是官方推荐姿势。
  • Supabase:用 Supavisor(自研)提供 pooler,端口 6543(transaction 模式)与 5432(直连)分开,Serverless 走 6543。
  • PlanetScale:底层是 Vitess,本来就是 proxy 架构,Serverless 走它的 serverless driver / database.js,连接问题几乎透明。

Prisma 连这类 pooler 时有个必须配的参数:

text
# Supabase transaction 模式:端口 6543 + pgbouncer=true + prepared statements 关掉
DATABASE_URL="postgresql://user:[email protected]:6543/postgres?pgbouncer=true&connection_limit=1"

pgbouncer=true 会让 Prisma 关掉 prepared statements,绕开前面说的 transaction 模式坑;connection_limit=1 是因为 Lambda 里每个执行环境一个连接就够了——多设无益,反而占 pooler 的客户端配额。

Serverless 还有个细节:别在 handler 里 new Pool()。要把 pool 声明在模块顶层做单例,Lambda 复用执行环境时就能复用这个 pool(以及它的连接),大幅减少建连。在 handler 内部建池,等于每次调用都新建,白瞎了复用机制。

五、连接数怎么算 + 池大小怎么定

连接数是一道必须会做的算术题。任何架构都要满足:

text
sum( 每应用 pool_size × 实例数 )  +  cron/迁移/BI/复制  ≤  max_connections × 80%

留 20% 给 superuser、运维、监控、复制连接。算超了,就等着半夜被翻。比如 PG max_connections=100,80% 是 80 条;你部署 8 个应用实例,每个池 10 条就是 80——已经顶满,没有余量给迁移和 cron。这时候要么上 PgBouncer 收敛,要么降实例数,要么调高 max_connections(但调高不是免费的,见下面池大小)。

**池大小是最反直觉的参数:不是越大越好,大了反而慢。**原因:

  • 连接一多,PG 内部锁竞争(ProcArrayLock 等)加剧,context switch 频繁,CPU 全耗在调度上。
  • 真正干活的是磁盘 IO,几百条连接同时争抢 IO,排队比串行还慢。
  • HikariCP 官方和 PG 社区都有经验公式:池大小 ≈ (核心数 × 2) + 有效磁盘数,通常落在 10~30 这个区间。绝大多数业务,10 条连接足够。

我见过太多人把池开到 100 "以防万一",结果 PG 扛不住、P99 反而升高。**池大小应该按"够用"配,不是按"安全感"配。**PgBouncer 的 default_pool_size 同理,25 左右够大多数场景,别一上来开 200。

六、选型与常见坑

三种架构的适用边界,一张表收口:

架构池在哪适合不适合
应用内池进程内长驻后端、实例数稳定实例数多/横向扩展
连接代理(PgBouncer)中间件层多服务共享、高并发短请求强依赖会话级特性
Serverless poolerDB 侧Lambda / Edge / 函数计算长事务、复杂迁移

实战中三者还会叠加:应用内放一个小池 → 连到 PgBouncer → 再到 PG。这不是过度设计,而是各管一段:应用池管建连开销,PgBouncer 管连接收敛。

常见坑(都是真实事故):

  1. 连接泄漏:用了底层 client 却忘了 release()。池子里的连接被借光不归还,新请求全卡在 pool_timeout。务必用 try/finally 或框架的托管 API,别手动管生命周期。
  2. 池太小:池只有 3 条,4 个并发请求立刻排队,P99 直接飙。监控里看 wait_time / pool_timeout 这两个指标。
  3. Serverless 直连爆连接:Lambda / Edge 直连 PG 是头号反面教材,必须走 pooler(PgBouncer / Neon pooler / Supabase 6543)。没有例外。
  4. transaction 模式 + prepared statements 冲突:Prisma / node-pg 默认会 prepared,PgBouncer transaction 模式下报错。解法是 pgbouncer=true(Prisma)或 ?pgbouncer=true,让驱动关掉 prepared。
  5. 迁移走了 pooler:DDL、长事务、CREATE INDEX CONCURRENTLY 这类必须直连 PG,不能走 transaction 模式的 PgBouncer(会话状态会被切来切去,DDL 容易断)。Supabase、Neon 都建议:业务流量走 pooler 端口,迁移走直连端口。

小结

  1. 连接是重资源,不是免费品:PG 每连接一进程,几百条就吃光资源;建连本身就要几十毫秒。连接数就是 PG 吞吐的天花板。
  2. 三种架构各管一段:应用内池管单进程内复用(长驻服务);连接代理管多实例收敛(PgBouncer transaction 模式扛高并发);Serverless pooler 管短命函数的连接收敛(Neon / Supabase / PlanetScale)。
  3. Serverless 必须走 pooler:函数实例数 = 并发数,直连必爆。Lambda 里还要把 pool 声明在模块顶层做单例,别在 handler 里建池。
  4. 连接数是算术题:pool_size × 实例数 + 运维 ≤ max_connections × 80%,超了就上 PgBouncer 收敛。
  5. 池大小 10~30 够,不是越大越好:连接一多,锁竞争和 context switch 反而拖慢 PG。按"够用"配,不按"安全感"配。

金句:**应用怎么连数据库,决定了能扛多少并发。**真正的瓶颈往往不是 CPU、不是 IO,而是那条看起来最不起眼的连接——选对架构,几百块的小机器也能稳;选错,顶级硬件照样半夜被 OOM。

Related

相关推荐