
慢查询日志很干净,接口 P99 却一路往上飙。P99 就是 100 个请求里最慢那个的耗时——它比平均值更能暴露尾部延迟问题。
你把 SQL 拿出来单独跑,耗时稳定在几十毫秒。查执行计划,没问题。查索引,也没问题。但接口就是慢,而且越到高峰期越慢。
这个时候,问题可能不在 SQL 本身,而在 Go 进程里那支看不见的队伍——你的请求在等一个能用的数据库连接。
Go 的 database/sql 包在你写 db.Query() 的那一刻,并不是直接去数据库执行。它先要从连接池里拿到一个连接。如果池里没有空闲连接、也不允许再开新连接,请求就只能排队等。这段等待时间不会出现在慢查询日志里,但它会原封不动地加到你的接口延迟上。
这篇文章帮你建一个排队模型——搞清楚你的接口延迟到底卡在 SQL 执行,还是卡在等连接。

1. sql.DB 不是一个连接,是一个排队系统
很多人第一次用 sql.Open() 的时候,以为拿到的是一个数据库连接。
不是。sql.DB 是一个连接池。它内部管理着一组连接,按需创建、按需复用、按规则回收。你每次调 db.Query() 或 db.Exec(),它做的第一件事不是执行 SQL,而是从池里找一个能用的连接。
池里的连接只有两种状态:
- InUse(正在使用):某个 goroutine 正在用这个连接执行查询或事务。
- Idle(空闲可用):连接已经建好,没人在用,下一个请求可以直接拿走。
但请求还有第三种状态:排队等待。如果你设了 MaxOpenConns,InUse + Idle 已经达到上限,新来的请求拿不到连接,就只能排队。排队的是请求,不是连接——连接数没有增加,但等着用连接的 goroutine 越来越多。
DB.Stats() 能把这些状态数字化:
| 字段 | 含义 |
|---|---|
OpenConnections |
当前建立的连接总数(InUse + Idle) |
InUse |
正在被 goroutine 使用的连接数 |
Idle |
空闲等待复用的连接数 |
WaitCount |
历史累计等待获取连接的次数 |
WaitDuration |
历史累计等待获取连接的时间(所有请求各自等待时间的累加值) |
MaxIdleClosed |
因超过 MaxIdleConns 而被关闭的连接累计数 |
MaxLifetimeClosed |
因超过 ConnMaxLifetime 而被关闭的连接累计数 |
关键在最后两个:WaitCount 和 WaitDuration。它们是连接池排队的直接证据。如果 WaitCount 持续增长,说明你的请求在 Go 进程内部排队,而不是在数据库里排队。
换个说法:WaitDuration 增长,不是数据库拒绝你,是 Go 在自己家门口排队。

2. 四个参数,管的是同一支队伍
database/sql 提供了四个方法来控制连接池行为。它们控制的是同一个排队系统的四个阀门。
SetMaxOpenConns:决定能同时打开多少车道
这是连接池的硬上限。一旦 InUse + Idle 达到这个数,后续请求必须排队。
不设的话,Go 默认无上限——需要连接就创建,创建到数据库服务端拒绝为止。听起来很自由,但也意味着突发流量可能瞬间打满数据库的 max_connections。
设了的话,你等于在 Go 侧主动限流:宁可让请求在应用进程内排队,也不要把数据库打挂。Go 官方文档也明确说了:设置这个限制后,数据库操作的行为类似获取信号量(semaphore),配置不当甚至可能导致死锁。
SetMaxIdleConns:决定保留多少空车道
空闲连接的好处是下次请求来了不用重新建连接(TCP 握手 + 认证 + 协议协商)。默认保留 2 个。注意:如果 MaxIdleConns 设置大于 MaxOpenConns,Go 会自动将其降到 MaxOpenConns。
设太小:每次高峰过后空闲连接被关掉,下次高峰又得重建——你看到的现象是周期性的连接建立延迟。
设太大:一堆连接在池里闲着,占着数据库的连接配额,但没人用。如果数据库 max_connections 本来就不富裕,这些空闲连接就在浪费名额。
SetConnMaxIdleTime:空车道闲太久就关掉
控制空闲连接的最长存活时间。超过这个时间没被用过的连接会被池回收。
它和 MaxIdleConns 配合使用:高峰期连接池扩到 50 个,高峰过后流量降回 5 个,多余的 45 个空闲连接不必永远留着——ConnMaxIdleTime 让它们在闲置一段时间后自动关闭,释放数据库侧的连接名额。
SetConnMaxLifetime:连接定期换班
不管连接忙不忙,只要活过这个时间就关掉重建。生产中建议给 ConnMaxLifetime 加一点随机抖动(比如 5 分钟 ± 30 秒),避免大量连接同时到期引发重建风暴。
为什么需要强制换班?两个常见场景:
- 数据库前面有负载均衡器。长连接不切换,流量永远打到同一个后端节点。
- 数据库
wait_timeout会主动断开长时间不活动的连接。如果 Go 侧不主动换班,可能拿到一个已经被数据库关掉的"死连接"——虽然sql.DB会自动重试,但那次请求的延迟已经多了一轮往返。
四个阀门管的是同一支队伍——动任何一个都影响排队长度。

简单推演一下:假设你的查询平均耗时 T,并发请求数是 C,连接池上限是 N。
- 如果
N >= C:每个请求都能立即拿到连接,不排队。 - 如果
N < C:C - N个请求必须等。直觉上理解:32 个请求只有 1 条车道,你平均排在第 16 位,等 16 × 50ms = 800ms。实际由于到达时间不均匀,尾部请求等得更久。
这不是精确公式,但它解释了一件事:连接池排队的长度,取决于并发数与连接数的比值,以及每个连接被占用的时间。 要缩短队伍,要么增加连接数(开更多车道),要么缩短查询耗时(让每辆车过得更快),要么减少并发(让进入收费站的车变少)。
3. 我跑了一组实验:SQL 没变,等待时间变了
数据比模型诚实。
我写了一个自定义的 database/sql driver,每次 Query 固定 sleep 50ms——模拟一个耗时稳定的 SQL 查询。然后用 32 个并发 goroutine 发 160 个请求,改变 MaxOpenConns,记录每个请求的端到端延迟和 DB.Stats() 的等待指标。
实验的目的不是测某个数据库的性能,而是隔离观察一件事:当 SQL 耗时不变,连接池配置如何改变你看到的接口延迟。
注意表中的"累计 WaitDuration"是所有请求各自等待时间的总和,不是测试的墙上时钟时间。比如 160 个请求每个等 1.4s,累计就是 227s——远超实际运行的 8s。
以下数据基于自定义
database/sqldriver 模拟测试(Go 1.26.2 darwin/arm64),Query 固定 sleep 50ms。不代表真实数据库查询性能。生产中通常配合context.WithTimeout使用,超时请求会快速失败(返回context.DeadlineExceeded)而非如实验中无限等待。此外,真实查询耗时有方差,少数慢查询会链式放大排队延迟,实际 P99 劣化可能比本实验更严重。
| MaxOpen | P50 | P95 | P99 | WaitCount | 累计 WaitDuration | 平均等待 | 吞吐 req/s |
|---|---|---|---|---|---|---|---|
| 1 | 1.18s | 4.28s | 4.60s | 159 | 227.70s | 1.43s | 19.6 |
| 2 | 612ms | 1.58s | 2.55s | 158 | 110.0s | 696ms | 39.3 |
| 4 | 289ms | 966ms | 1.55s | 156 | 51.9s | 332ms | 77.3 |
| 8 | 153ms | 408ms | 561ms | 152 | 22.1s | 145ms | 156.9 |
| 16 | 102ms | 204ms | 255ms | 144 | 7.3s | 51ms | 313.6 |
| 64 | 51ms | 52ms | 53ms | 0 | 0ms | 0ms | 620.4 |

几个关键观察:
SQL 固定 50ms,但 P99 可以差两个数量级。 MaxOpen=1 时,P99 达 4.60s——是查询本身耗时的 92 倍。原因很简单:32 个并发共用 1 个连接,每个请求平均要等前面 31 个执行完。
WaitDuration 是延迟的主要来源。 MaxOpen=1 的累计 WaitDuration 是 227.70s,平均每次等 1.43s。到 MaxOpen=64 时,WaitDuration 归零——所有延迟都是 SQL 本身的 50ms。
连接池等待不会报错。 所有场景的 errors 都是 0。WaitCount 高达 159,但没有一个请求失败。这意味着:如果你只看错误率,你根本发现不了连接池排队——它安静地把等待时间加到了每个请求的延迟里。
调大连接数不是万能的。 从 1 增到 16,P99 从 4.60s 降到 255ms,改善巨大。但从 16 到 64,P99 只从 255ms 降到 53ms——因为 16 个连接已经覆盖了 32 并发的一半请求,等待队列已经很短。到 64 时,WaitCount 归零,继续加连接不再改善延迟,因为瓶颈已经不在排队,而在查询本身的 50ms。
如果队伍已经不排了,开再多车道也不会让车跑得更快。
4. Go 参数调完了?还有两层要看
实验在隔离环境下证明了连接池等待会进入应用延迟。但生产环境不是只有应用侧一个变量。
一个前提:本文的诊断方向在 WaitDuration > 0 时成立。如果 WaitCount 始终为零,延迟来源在连接池之外——检查 SQL 本身、GC 停顿、网络抖动。
连接池调优要同时看三层:
第一层:应用边界
你的 Go 服务能承受多少并发数据库请求?MaxOpenConns 该设多大?
答案取决于:你的并发请求数、每个请求持有连接的时间、以及你能接受的排队延迟。
这里有个容易忽视的放大器:事务。从 tx.Begin() 到 tx.Commit() 之间,连接一直被独占。如果事务中间夹了 RPC 调用、业务逻辑甚至等用户确认,连接持有时间可能从几毫秒膨胀到几秒。一个诊断信号:InUse 很高但单条 SQL 耗时不长——先检查是不是有长事务在占连接。
诊断入口:看 DB.Stats().WaitDuration / WaitCount。如果平均等待时间超过你能接受的阈值,要么加连接数,要么缩短查询耗时(或事务范围),要么在应用层做限流。
一个最小的监控起点——把 DB.Stats() 定时打出来:
go func() {
for range time.Tick(10 * time.Second) {
s := db.Stats()
log.Printf("pool: open=%d inUse=%d idle=%d waitCount=%d waitDur=%s",
s.OpenConnections, s.InUse, s.Idle, s.WaitCount, s.WaitDuration)
}
}()
生产环境建议注册为 Prometheus metrics,这里只展示最基本的打印方式。
第二层:数据库边界
MaxOpenConns 不能只看 Go 侧。数据库有自己的连接上限(MySQL 的 max_connections,PostgreSQL 的 max_connections)。
如果你的 Go 服务有 10 个实例,每个 MaxOpenConns=50,那数据库侧最多面对 500 个连接。如果数据库 max_connections=200,你已经超了。
而且数据库连接不是免费的。每个连接占内存、占文件描述符、占认证会话。MySQL 默认 max_connections=151,PostgreSQL 默认 100。在没有中间代理的情况下,Go 侧的连接池配置必须留出余量,不能把数据库打到上限。
第三层:代理和驱动边界
如果你的数据库前面有连接代理(如 PgBouncer、ProxySQL、RDS Proxy),连接池的语义会变。
代理层自己也有连接池。它可能把你 Go 侧的 50 个连接复用成到数据库的 20 个连接。这时候 Go 侧的 MaxOpenConns 控制的是"Go 到代理"的连接数,不是"Go 到数据库"的连接数。
另外,极少数基于 CGO 的驱动(如某些 Oracle 或嵌入式数据库驱动)内部可能有自己的连接管理机制,SetMaxOpenConns 的效果和预期不同。主流 MySQL(go-sql-driver)和 PostgreSQL(pgx、lib/pq)驱动均为纯 Go 实现,不受此影响。
这三层中的任何一层,都可能让你在 Go 侧的调参失效。所以连接池调优不是只调 Go 参数——它是一个跨层的诊断问题。

先看指标,再调参数
回到标题:你的 SQL 没慢,慢的是 Go 连接池里的队伍。
下次遇到接口延迟问题,在动参数之前,先做两步鉴别:
- 看 SQL 本身慢不慢:慢查询日志、APM 的 SQL 耗时——如果 SQL 本身就慢,问题不在连接池。
- 看
DB.Stats().WaitDuration:如果WaitDuration接近零,问题也不在连接池——去查 GC、网络、上游依赖。
两步排除后,才进入连接池内部的诊断:
| 你看到的现象 | 先看哪个指标 | 可能的原因 | 处理方向 |
|---|---|---|---|
| 接口慢,但 SQL 不慢 | WaitCount、WaitDuration |
连接池排队 | 增加 MaxOpenConns(前提:数据库有余量)或缩短查询耗时 |
OpenConnections 逼近 MaxOpenConns |
InUse、Idle |
连接全被占满 | 检查是否有长事务、未释放的 Rows、或者并发太高 |
| 周期性延迟毛刺 | MaxIdleClosed、MaxLifetimeClosed |
空闲连接被回收后重建 | 调大 MaxIdleConns 或拉长 ConnMaxIdleTime |
| 连接数正常但偶尔超时 | 数据库侧连接数 | Go 侧配置超过了数据库上限 | 降低 MaxOpenConns,或引入连接代理 |
| 上了代理后行为变了 | 代理侧的连接池指标(如 PgBouncer 的 SHOW POOLS) |
代理层改变了连接复用语义 | 分别监控 Go→代理 和 代理→数据库 两段连接 |
InUse 持续上升不回落 |
InUse 趋势 + 数据库侧连接状态 |
连接泄漏(Rows/Tx 未关闭) |
代码审查 defer rows.Close() / defer tx.Rollback() |

WaitDuration 增长,说明队伍在变长——但先别急着调大连接数。先问:数据库 max_connections 还有多少余量?有没有长事务在占着连接不放?并发数能不能在应用层控制?
连接池调优是一个排队系统的调参。你能做的,是看清队伍排在哪、为什么排、排多长——然后在三个边界之内,找到那个让队伍最短的平衡点。
附录:实验代码
本文连接池等待实验的代码已开源:
GitHub:zhiyulab-evidence/go-database-connection-pool
connpool-wait-demo/:自定义database/sqldriver + 并发压测,隔离观察连接池等待行为
目录有独立 README,说明如何复现。无外部数据库依赖,go run . 即可运行。
原文发布于 止语Lab