数据库连接架构:池化、代理与 Serverless
应用怎么连数据库,决定了能扛多少并发。本文讲清三种连接架构:应用内池(Prisma/node-pg-pool)、连接代理、Serverless 连接池(PgBouncer transaction 模式),各自的适用与并发瓶颈,以及 Serverless 场景的特殊解法(Neon/SUPABASE pooler)。
本文要点
- 主题聚焦:DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
把一个跑得好好的 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 吞吐的天花板。
二、架构一:应用内连接池
既然连接贵,就该复用。所有像样的驱动都内置了连接池,核心机制就一句话:预热一批连接放池子里,请求来了借一条、用完还回去,而不是用完就关、下次再建。
// node-pg(pg)——Pool 就是连接池
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // 池大小
idleTimeoutMillis: 30000, // 空闲 30s 回收
connectionTimeoutMillis: 2000,
});
// Prisma —— 用 connection_limit 控制每个实例的连接数
// postgresql://user:pass@host:5432/db?connection_limit=10&pool_timeout=10
# HikariCP(Java/Spring 默认池)
hikari:
maximum-pool-size: 10
connection-timeout: 2000
idle-timeout: 30000
应用内池的拓扑长这样:
[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 之间加一层连接代理:它在客户端面前装作是数据库,在数据库面前只用很少的连接。
[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 借一次,连多语句事务都不支持。限制太大,极少用。
# 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 )把连接问题推到了一个新高度,因为它的执行模型彻底颠覆了"长驻进程"假设:
- 函数短命:一个 Lambda 实例可能只活几百毫秒,处理完请求就被冻结或销毁。
- 实例数 = 并发数:流量一涨,平台瞬间拉起几百个并发实例,每个实例各自建池,你的连接数随并发线性爆炸。
- 冷启动:每次冷启动都要重新建连,前面说的 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 时有个必须配的参数:
# 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 内部建池,等于每次调用都新建,白瞎了复用机制。
五、连接数怎么算 + 池大小怎么定
连接数是一道必须会做的算术题。任何架构都要满足:
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 pooler | DB 侧 | Lambda / Edge / 函数计算 | 长事务、复杂迁移 |
实战中三者还会叠加:应用内放一个小池 → 连到 PgBouncer → 再到 PG。这不是过度设计,而是各管一段:应用池管建连开销,PgBouncer 管连接收敛。
常见坑(都是真实事故):
- 连接泄漏:用了底层
client却忘了release()。池子里的连接被借光不归还,新请求全卡在pool_timeout。务必用try/finally或框架的托管 API,别手动管生命周期。 - 池太小:池只有 3 条,4 个并发请求立刻排队,P99 直接飙。监控里看
wait_time/pool_timeout这两个指标。 - Serverless 直连爆连接:Lambda / Edge 直连 PG 是头号反面教材,必须走 pooler(PgBouncer / Neon pooler / Supabase 6543)。没有例外。
- transaction 模式 + prepared statements 冲突:Prisma / node-pg 默认会 prepared,PgBouncer transaction 模式下报错。解法是
pgbouncer=true(Prisma)或?pgbouncer=true,让驱动关掉 prepared。 - 迁移走了 pooler:DDL、长事务、
CREATE INDEX CONCURRENTLY这类必须直连 PG,不能走 transaction 模式的 PgBouncer(会话状态会被切来切去,DDL 容易断)。Supabase、Neon 都建议:业务流量走 pooler 端口,迁移走直连端口。
小结
- 连接是重资源,不是免费品:PG 每连接一进程,几百条就吃光资源;建连本身就要几十毫秒。连接数就是 PG 吞吐的天花板。
- 三种架构各管一段:应用内池管单进程内复用(长驻服务);连接代理管多实例收敛(PgBouncer transaction 模式扛高并发);Serverless pooler 管短命函数的连接收敛(Neon / Supabase / PlanetScale)。
- Serverless 必须走 pooler:函数实例数 = 并发数,直连必爆。Lambda 里还要把 pool 声明在模块顶层做单例,别在 handler 里建池。
- 连接数是算术题:
pool_size × 实例数 + 运维 ≤ max_connections × 80%,超了就上 PgBouncer 收敛。 - 池大小 10~30 够,不是越大越好:连接一多,锁竞争和 context switch 反而拖慢 PG。按"够用"配,不按"安全感"配。
金句:**应用怎么连数据库,决定了能扛多少并发。**真正的瓶颈往往不是 CPU、不是 IO,而是那条看起来最不起眼的连接——选对架构,几百块的小机器也能稳;选错,顶级硬件照样半夜被 OOM。
Related
相关推荐
文章
Docker 部署 Next.js 社区项目的注意事项
围绕 DevOps、Next.js 的技术趋势、工程实践和独立开发视角。
文章
OpenTelemetry 如何帮助你观察 AI 应用?
围绕 DevOps、AI 的技术趋势、工程实践和独立开发视角。
文章
长运行工具调用:Operation、Poll 与 Cancel 的终态协议
初始请求超时后,工具可能仍在服务端运行;发出取消也不等于副作用已停止。本文为单次长运行工具调用建立 operation 身份、轮询水位、取消竞态和唯一终态接受点。
文章
Agent 记忆删除传播:从用户遗忘请求到可验证清除
删除主表一行不会清掉向量、摘要、缓存和恢复后的备份。本文用删除状态机、代际墓碑、派生血缘与端到端 canary 验证真正遗忘。