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 主动淘汰旧连接,让“坏连接”根本留不到借出那一刻。

关联