数据库没慢,是连接池在排队:一条公式算清你的池子该开多大

2026-11-06 3 0

先说一个你大概率见过的现场:接口耗时从 5ms 涨到 30s,CPU 才 15%,机器还有一半内存空着。你去翻日志,发现那条 SQL 本身只执行了 2ms。数据库没慢,SQL 没慢,网络没慢——那 28 毫秒去哪了?

它在等一个东西:数据库连接。

你以为你的服务直连数据库,其实它直连的是一个"排队的中间人"。这个中间人就是连接池。而连接池最反直觉的地方在于:池子越大,往往越慢。这篇就聊透这件事。


一、排队论:你写的不是"连接池",你写的是一条队列

任何一个连接池,本质都是一个 M/M/c 排队系统:c 个服务员(连接),前面排着 N 个请求(业务线程)。关键结论是——

当利用率 ρ = 到达率 × 平均服务时间 / c 趋近 1 时,等待时间不是线性增长,而是指数级爆炸。

假设单次查询 2ms,你的服务并发 200。利用率 ρ = 200×2ms / 池大小。若池=20,ρ=1,理论上永远在排队。若池=200,ρ=0.01,理论上瞬间返回。

但现实是:数据库能同时扛住的活跃查询只有 CPU 核数 × 2 左右(PostgreSQL 官方 wiki 就是这么建议的:connections = (core_count * 2) + effective_spindle_count)。你把连接池开到 500,带来的不是"500 路并行",而是 500 个连接一起在数据库内部抢锁、抢 buffer pool 锁、抢 CPU,还顺带把 max_connections 打爆,让别的应用连不进来。

这就是那句经典建议的来源:连接池不是越大越好,而是"刚好让数据库不闲、也不打架"。

二、公式:用 Little's Law 算出你的池子该多大

别拍脑袋。Little's Law:系统中的平均数量 = 到达率 × 平均停留时间。翻译成人话:

pool_size = 目标并发数 × (1 + 平均等待时间 / 目标响应时间)

举例:你的接口目标 P99 是 100ms,当前平均等待连接的时间是 40ms,业务并发峰值 200。

pool_size = 200 × (1 + 40/100) = 200 × 1.4 = 280

关键在于那个系数 1 + wait/target:等待时间占目标响应时间 40%,就意味着你要留出 40% 的"排队冗余"来吸收突发。如果目标是 P99=100ms 而等待已经 200ms(说明池严重不足),系数就是 3,池得是 600——但这时候更该做的是先把等待时间压下去,而不是无脑扩池。公式是自洽的诊断,不是无限的借口。

三、代码落地:三种语言三种脾气

// Go:database/sql 自带池,注意的是 SetMaxIdleConns 默认只有 2
db.SetMaxOpenConns(100)
db.SetMaxIdleConns(100)   // 不设就是 2,连接建了扔,比不池还惨
db.SetConnMaxLifetime(30 * time.Minute)

// Java:HikariCP,看这三个数就够了
// maximumPoolSize = 核心线程数 + CPU 核数 + 磁盘 spindle 数(PostgreSQL 建议的翻版)
spring.datasource.hikari.maximum-pool-size=25
spring.datasource.hikari.connection-timeout=3000   // 等池的超时,必须小于上游网关超时
spring.datasource.hikari.leak-detection-threshold=5000

// Python:SQLAlchemy
engine = create_engine(
    "postgresql://...",
    pool_size=20, pool_max_overflow=10,   # 峰值可临时超,但要有上限
    pool_pre_ping=True,                     # 防止拿到被中间件掐掉的死连接
    pool_recycle=1800,
)

注意 leak-detection-threshold:HikariCP 会在连接借出超过指定时间还没归还时,打印出完整的堆栈,告诉你连接是在哪行代码被借走却没还的。这是我见过最值的 5000 毫秒配置。

四、坑一:连接泄漏——池子被"借走"了,但没人还

// ❌ 忘记 Close,连接永久不归还
rows, err := db.Query("SELECT ...")
for rows.Next() { ... }
// rows.Close() ???

// ✅ 一定 defer,并检查 error
rows, err := db.Query("SELECT ...")
if err != nil { return err }
defer rows.Close()
for rows.Next() {
    if err := rows.Err(); err != nil { return err }
}

典型症状:运行几小时后开始全面变慢,重启即恢复。池的 active 长期等于 max,但数据库那边看连接数也在涨,而且大量处于 idle 状态——因为连接明明空着却借不出来(已泄漏的连接被业务逻辑持有,池子认为它在用)。

Java 侧的等价翻车现场是:Connection 拿了不关、Statement 不关、或者在 try 块里抛异常后走了另一条分支没关 ResultSet。老老实实用 try-with-resources。

五、坑二:多副本 × 大池 = 你亲手打爆数据库

设想你有 20 个 Pod,每个配了 maximum-pool-size=50。扩容到 50 个 Pod 时,数据库瞬时收到 2500 个连接请求,而 PG 默认 max_connections=100。结果不是"数据库变慢",是新连接全部被拒,你的服务开始大面积报 too many connections。

这是扩容时代最经典的自伤现场。池大小必须按"集群总连接数 ÷ 实例数"来定,而不是每个实例拍一个值。

解法是引入 PgBouncer / ProxySQL 这类连接池中间件。但 transaction 模式有明确的禁区,用错会出灵异问题:

  • 临时表:事务一提交就消失
  • 会话级 GUC:SET search_path、SET timezone 会丢
  • advisory lock(pg_advisory_lock):PgBouncer 不感知,立即失效——而你可能正靠它做分布式锁
  • LISTEN/NOTIFY:需要会话亲和,会被路由到别的连接收不到通知
  • PREPARE 语句跨事务复用:会报 prepared statement does not exist

所以正确姿势是:普通业务走 transaction 模式省连接,涉及会话状态的走 session 模式(按 usename 路由),并且上线前用 pg_stat_activity 确认自己没踩这些雷。很多团队是切了 PgBouncer 之后,"偶发"的锁失效才开始出现的。

六、坑三:连接明明在数据库里,却"借不出来"

三种典型:

1) 元数据锁等待。有人执行 DDL(哪怕只是 ALTER TABLE ADD COLUMN),表被锁住,后续所有查询排队等 MDL。连接在池里被占用、在数据库里在等锁,池迅速耗尽。DDL 前先 LOCK TABLE ... IN ACCESS EXCLUSIVE MODE 排队并设置 lock_timeout,别让 DDL 无限期挂着。

2) idle in transaction。事务开启后忘了提交,连接被长期占住。设置 idle_in_transaction_session_timeout = 60s 让数据库自己掐掉,这是兜底中的兜底。

3) 长事务 + 长快照。一个跑了 10 分钟的事务持有旧版本,导致 VACUUM 无法回收、表膨胀。

-- 找占用元数据锁的会话
SELECT pid, wait_event_type, wait_event, state, query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';

-- 找 idle in transaction
SELECT pid, now()-xact_start AS xact_age, state, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_age DESC;

-- 找出最贵的语句(pg_stat_statements)
SELECT calls, mean_exec_time, query
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;

七、坑四:超时报错了,但你以为它报的是数据库超时

池等待超时(connection-timeout / acquire_timeout)和数据库执行超时是两个东西,但错误日志长得几乎一样。你看到 "timeout" 就去查慢查询,方向直接跑偏。

排查顺序应该是:

  1. 先看池指标:Active 是否长期贴着 Max?是 → 池满,不是数据库慢
  2. 池满 → 看 Pending(等待获取连接的线程数),有就是池容量问题
  3. 池不满但查询真的慢 → 才是 SQL / 索引 / 锁的问题

还有一个隐蔽的一致性陷阱:池等待超时必须小于上游超时。如果池等 30s、网关 5s 就断了,那 25 秒的等待纯属浪费——而且这些等待还占着业务线程,把整个服务的并发能力拖垮。网关上 5s,池等待就该设 1s。

八、一份可以直接抄的检查清单

  • 池大小按 Little's Law 算,别拍脑袋,也别盲目扩
  • 总连接数 = 实例数 × 池大小,必须小于数据库 max_connections 的 70%
  • MaxIdleConns 要设成和 MaxOpenConns 一样,否则省下的建连开销白付
  • 开启 leak detection,或至少监控 active/max 比值
  • 设置 idle_in_transaction_session_timeout 和 lock_timeout 兜底
  • DDL 前先拿锁排队,别在生产直接 ALTER
  • 用了 PgBouncer transaction 模式 → 逐条核对上面那五个禁区
  • 池等待超时 < 网关超时 < 数据库查询超时,形成递减链

九、池子满了,除了扩容还有三条路

扩容是最直觉的动作,但也是最容易把问题推给下游的动作。真到了必须做决策的时候,按这个顺序考虑:

1) 缩短占用时间。 连接从借出到归还的全过程耗时 = 连接获取 + 查询执行 + 结果读取 + 结果处理。注意最后那项——如果业务代码在持有连接的情况下去调了远程 HTTP 接口、发了个消息、或者 sleep 了一下,那这段"结果处理"时间全算在连接头上。正确姿势是先查出来,把连接还了,再去处理数据:

// ❌ 连接被业务逻辑攥在手里
rows, _ := db.Query("SELECT ...")
defer rows.Close()
for rows.Next() {
    data := scan(rows)
    callRemoteAPI(data)   // 30ms 的网络调用,连接一直不还
}

// ✅ 先把数据全部取出来并释放连接,再做慢活
rows, _ := db.Query("SELECT ...")
data := make([]Item, 0)
for rows.Next() { data = append(data, scan(rows)) }
rows.Close()             // 立刻归还在池
for _, d := range data { callRemoteAPI(d) }

这个改法通常能把连接占用时间砍掉一半以上,而且零成本。在扩容之前,先看一眼你的业务逻辑是不是在数据库连接里开远程调用——这类代码在代码评审里极难发现,因为语法上完全正常。

2) 加熔断和排队上限。 池子满了以后,请求会无限排队,最终把上游也拖死(经典的线程池/连接池雪崩)。正确做法是:池满时快速失败(fail fast),配合熔断器让流量快速降级,而不是让所有线程都堵在取连接上。数据库已经过载时,快速失败是善意的。

3) 读写分离和查询下沉。 报表类、长查询类的大结果集查询,本质上是在用短查询的资源做长查询的事。把它们路由到只读副本,既缓解主库压力,也让主库的池子专注服务核心链路。


写这篇的时候我又想起那个老问题:为什么几乎所有"数据库偶发超时"的工单,最后查出来是连接池? 因为瓶颈很少出现在你盯着的那一层。SQL 是慢的元凶只在少数情况,剩下的是排队、锁、连接数、以及配置之间互相打架。

调池子的手感,和调线程池、调 HTTP 连接池是一模一样的:先量,再算,最后动刀。上来就调大,最常见的结局是把压力从自己身上转嫁给数据库,然后发现"优化"完更慢了,还多了个新故障模式。

如果你正在排查连接相关的问题,记住那句老话——先看池子,再看数据库。大部分人会反过来,因为数据库看起来更"可疑"。

相关文章

库存还剩 1,却卖出了 3 单:MySQL 行锁、间隙锁和一次真实死锁的现场复盘
你的 Go 服务在偷偷漏 goroutine:context 取消、channel 阻塞,以及那些看不见的泄漏
凌晨三点,所有副本同时醒来:定时任务惊群实战解剖
别再 read 一遍 write 一遍了:文件传输零拷贝,Linux 帮你省掉的那几个 GB
你的 API 一升级,老版本 App 就地瘫痪:接口向后兼容的六条军规
你的 3 秒超时,实际让用户等了 27 秒:重试放大、预算丢失与超时黑洞

发布评论