44.12 连接池
一句话
连接池不是“越大越好”,它是一个排队系统:池大小决定你能同时打到数据库的并发上限,
maxWait决定排队多久放弃——池大小应该等于数据库能承受的并发,而不是应用的 QPS。
五个参数,各管一段
| 参数 | 管什么 | 给错的后果 |
|---|---|---|
maxOpen(最大连接) | 并发上限 | 太小排队;太大把数据库打满 |
maxIdle(最大空闲) | 常驻连接数 | 太小频繁建连(TCP+TLS+认证);太大占 DB 连接配额 |
maxWait / timeout | 等连接的最长时间 | 太长:线程被拖住;太短:高峰期大量超时 |
idleTimeout | 空闲多久关闭 | 太长:DB 侧超时后被对端 RST,拿到“坏连接” |
maxLifetime | 连接最长寿命 | 太长:跨过 DB 重启/主从切换/NAT 超时 |
🔑 用 Little 定律 算池大小的下界:
并发 = QPS × 平均持有时间。2000 QPS × 5ms = 10 个连接;再留 2 倍余量 = 20。先算这个数,再决定上限——不是拍 100。
代码:三种池大小,同一负载下的等待与超时
package main
import (
"fmt"
"sort"
)
const (
nReqs = 20000 // 请求数
intervalMs = 0.5 // 每 0.5ms 来一个 = 2000 QPS
serviceMs = 5.0 // 每条 SQL 持有连接 5ms
maxWaitMs = 1000.0 // 等连接超过 1 秒就超时
)
func simulate(poolSize int) (avgWait, p99, timeouts float64, ok int) {
// 每个连接下一次可用的时间
free := make([]float64, poolSize)
lat := make([]float64, 0, nReqs)
for i := 0; i < nReqs; i++ {
t := float64(i) * intervalMs
// 找最早空闲的连接(简化的“最小堆”)
best := 0
for j := 1; j < poolSize; j++ {
if free[j] < free[best] {
best = j
}
}
start := t
if free[best] > start {
start = free[best]
}
wait := start - t
if wait > maxWaitMs {
timeouts++
continue
}
ok++
avgWait += wait
free[best] = start + serviceMs
lat = append(lat, wait+serviceMs)
}
if ok > 0 {
avgWait /= float64(ok)
}
sort.Float64s(lat)
if len(lat) > 0 {
p99 = lat[len(lat)*99/100]
}
return
}
func main() {
fmt.Printf("负载:%d 请求、%.0f QPS、每条 SQL 持有 %.0fms、等待上限 %.0fms\n\n",
nReqs, 1/intervalMs*1000, serviceMs, maxWaitMs)
fmt.Printf("%-10s %-12s %-12s %-12s %-12s\n", "池大小", "完成请求", "平均等待ms", "p99 延迟ms", "超时数")
for _, size := range []int{5, 10, 20, 50, 100} {
avg, p99, to, ok := simulate(size)
fmt.Printf("%-10d %-12d %-12.1f %-12.1f %-12.0f\n", size, ok, avg, p99, to)
}
fmt.Println("\n理论容量 = 池大小 / 持有时间(5);2000 QPS 需要 ≥ 10 个连接")
}
本机实测(macOS,Apple M1,Go 1.27):
负载:20000 请求、2000 QPS、每条 SQL 持有 5ms、等待上限 1000ms
池大小 完成请求 平均等待ms p99 延迟ms 超时数
5 11000 908.9 1005.0 9000
10 20000 0.0 5.0 0
20 20000 0.0 5.0 0
50 20000 0.0 5.0 0
100 20000 0.0 5.0 0
理论容量 = 池大小 / 持有时间(5);2000 QPS 需要 ≥ 10 个连接
读数:
| 池大小 | 结论 |
|---|---|
| 5 | 理论容量 5/5ms = 1000 QPS < 2000 → 9,000 个请求超时,平均等待 909ms |
| 10 | 恰好等于容量(2000 QPS):确定性模型下等待为 0,但零余量——慢查询或 GC 抖动立刻排队 |
| 20 | 2 倍余量,等待仍为 0,能吸收抖动 |
| 50 / 100 | 本地看延迟不变甚至更差(调度开销),但数据库侧并发被放大 5–10 倍 |
🔑 池的上限不是“应用想要多少”,而是“数据库能同时处理多少”。把池从 20 调到 100,本地等待确实消失,但数据库的连接数、内存(每连接有缓冲区)、锁竞争同时上升——问题只是从应用层搬到了数据库层。
连接数与数据库容量的关系
| 数据库 | 每连接内存(量级) | 建议总连接上限 |
|---|---|---|
| MySQL | 每连接线程栈 + 缓冲,几 MB 到几十 MB | 数百(按内存算,不是按 CPU 核数) |
| PostgreSQL | 每连接一个进程,几 MB 起 | 几百,超出要用 PgBouncer |
| 云托管数据库 | 有显式 max_connections 配额 | 按配额 ÷ 应用实例数 |
⚠️ 最容易被忽略的一条:连接数要按“应用实例数 × 每实例池上限”算总量。10 个实例 × 池 100 = 1000 个连接,很可能直接超过数据库配额,表现为“部分实例连不上”,而不是“慢”。
先算建连成本:不池化时吞吐只剩 1/5
池化的收益可以精确量出来。抓一次 MySQL 建连的包:三次握手 + 账号密码校验约 4ms,而一条普通 SQL 约 1ms。
| 方式 | 一次操作耗时 | 串行 1 秒能做的查询 | 建连占比 |
|---|---|---|---|
| 每次新建连接 | 4ms 建连 + 1ms 查询 = 5ms | 200 次 | 80% |
| 连接池复用 | 1ms 查询 | 1000 次 | ~0 |
所以“给应用加机器”往往不如“把连接池配上”见效——瓶颈不在 CPU,在重复的 TCP + 认证。
池的取连接逻辑只有两个参数(最小、最大),但分支顺序反直觉:
| 条件 | 动作 |
|---|---|
| 当前连接数 < 最小连接数 | 建新连接(先判这个,保证常驻连接足够) |
| 有空闲连接 | 复用 |
| 无空闲且 < 最大连接数 | 建新连接 |
| 已达最大连接数 | 按 checkoutTimeout 排队等待 |
| 等待超时 | 抛错给业务 |
线上经验值:最小 10、最大 20–30,且必须小于数据库的 max_connections(按“实例数 × 池上限”算总量,见上一节)。
失效连接:两种静默断连,两种探活
| 断连原因 | 现象 |
|---|---|
| 用域名连库,IP 变了 | 池里全是旧 IP 的连接,全部失败 |
数据库 wait_timeout 到了 | 空闲连接被服务端单方面关闭,客户端不知道 |
探活有两派,别选错:
| 做法 | 代价 | 建议 |
|---|---|---|
后台线程定期发 SELECT 1(C3P0 的做法) | 一次极轻查询 | 推荐,开销可忽略 |
借出前校验(DBCP 的 testOnBorrow) | 每次借连接都多一次往返 | 高并发下明显拉高延迟,线上别开 |
同一个模型,两种对象:数据库连接池 vs 线程池
两者都是“复用昂贵资源 + 排队”,但扩容策略相反,选错就是事故:
| 池 | 策略 | 适合 |
|---|---|---|
JDK ThreadPoolExecutor | 先填 core → 再进队列 → 队列满才扩到 max | CPU 密集型(任务不等 IO) |
| Tomcat 线程池 | 超过 core 直接扩到 max → 再排队 | IO 密集型(请求大多在等下游) |
⚠️ JDK 线程池配无界队列(
LinkedBlockingQueue不给容量)是经典事故:任务只进不出地堆积,内存涨、Full GC 频繁,最后整机不可用。队列必须有界,且拒绝策略要明确;重启后记得预热 core 线程,否则前几百个请求全是慢请求。
反模式
| 反模式 | 后果 |
|---|---|
| 池大小拍一个 100 | 实例一扩,DB 连接数爆配额 |
没有 maxLifetime | 跨过 DB 重启/主从切换,拿到失效连接报错 |
| 长事务/慢查询占着连接 | 连接被少数请求占住,其他请求排队超时(44.9) |
| 池里的事务里做 RPC/用户交互 | 连接持有时间从毫秒变秒级 |
maxWait 设成无限 | 线程全部卡在等连接,服务假死 |
| 连接泄漏(忘记 close) | 池逐渐耗尽,重启才恢复 |
自检
Q1 一个服务 3000 QPS,每条 SQL 平均持有连接 8ms,池大小设多少合理?
▸ 答案:Little 定律下界 3000 × 0.008 = 24;留 2 倍余量约 50。但必须校验数据库侧:50 × 应用实例数 ≤ DB 的 max_connections,且总连接的内存开销在 DB 规格内。如果实例多(如 20 个),50 × 20 = 1000 连接就过大,需要降低单实例池(如 10)或引入连接代理(PgBouncer/ProxySQL)。
Q2 为什么“池调大后,应用 p99 变好,但数据库 CPU 打满”?
▸ 答案:因为池是并发闸门:调大等于允许更多请求同时进数据库。应用侧的等待消失了(不用排队拿连接),但数据库要同时处理更多并发查询,锁竞争、上下文切换、缓存失效都上升,CPU 与延迟在数据库侧恶化。总吞吐可能不升反降(过载时吞吐随并发下降)。正确做法是让池大小匹配数据库的最佳并发点,而不是匹配应用的到达率。
Q3 maxLifetime 为什么必须设?设多长?
▸ 答案:因为连接的“另一端”会消失或失效:数据库重启、主从切换(VIP 漂移)、NAT/LB 的空闲连接回收、云厂商的维护窗口。这些事件发生时,池里的旧连接看起来还“可用”,实际已经断了,下一次使用就报错。maxLifetime 应短于“最短的对端断连时间”,通常设 30 分钟到几小时,并配合健康检查(取连接时 ping 或 SELECT 1)。没有它,故障后会出现“部分请求反复失败、重启才恢复”的诡异现象。
Q4 为什么 “池里明明有空闲连接”,数据库却报 “连接数打满”?
▸ 答案:因为 “打满” 的是数据库侧总连接数,不是单个池:多个服务/多个实例各自开池,加起来超过 max_connections。也可能是 “连接泄漏”(借出未归还)导致池内可用连接为 0。排查要看 数据库的 Threads_connected 与各服务的池指标,而不是只看一个池。
Q5 连接池大小与数据库 max_connections 应该怎么配比?
▸ 答案:先按 “池大小 × 实例数 × 服务数 ≤ max_connections × 0.8” 留余量(剩下给运维/迁移/监控),再按 “Little 定律” 用 QPS × 持有时间估池大小。常见错误是 “池调大” 解决排队——实际是把压力推给数据库 CPU,反而更慢(见 44.12 连接池 正文的排队模型)。
Q6 一个服务建连要 4ms、查一条 SQL 要 1ms,不做连接池会怎样?
▸ 答案:串行吞吐从 1000 次/秒掉到 200 次/秒——80% 的时间花在建连上(三次握手 + 认证)。这时加机器是在为“重复握手”买单,先把池配上才是正解。想验证自己系统有没有这个问题:tcpdump -nn -tttt port 3306 看建连间隔,或直接看池的 等待次数 / 建连次数 指标。
Q7 testOnBorrow(借出前校验)为什么不能在生产开?
▸ 答案:它把“探活”摊到每次借连接上,每条 SQL 前多一次往返(同机房百微秒级、跨机房毫秒级)。QPS 一高,这笔开销会直接体现在 P99 上。正确做法是把探活挪到后台定时任务(SELECT 1),并用 maxLifetime/idleTimeout 主动淘汰旧连接,让“坏连接”根本留不到借出那一刻。