你的 SQL 没慢,慢的是 Go 连接池里的队伍

SQL 耗时稳定但接口 P99 飙升?问题可能出在 Go 连接池里看不见的排队。用 DB.Stats() 的等待信号定位瓶颈,在应用、数据库、代理三层边界内找到最短队伍的平衡点。

封面

慢查询日志很干净,接口 P99 却一路往上飙。P99 就是 100 个请求里最慢那个的耗时——它比平均值更能暴露尾部延迟问题。

你把 SQL 拿出来单独跑,耗时稳定在几十毫秒。查执行计划,没问题。查索引,也没问题。但接口就是慢,而且越到高峰期越慢。

这个时候,问题可能不在 SQL 本身,而在 Go 进程里那支看不见的队伍——你的请求在等一个能用的数据库连接。

Go 的 database/sql 包在你写 db.Query() 的那一刻,并不是直接去数据库执行。它先要从连接池里拿到一个连接。如果池里没有空闲连接、也不允许再开新连接,请求就只能排队等。这段等待时间不会出现在慢查询日志里,但它会原封不动地加到你的接口延迟上。

这篇文章帮你建一个排队模型——搞清楚你的接口延迟到底卡在 SQL 执行,还是卡在等连接。

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 而被关闭的连接累计数

关键在最后两个:WaitCountWaitDuration。它们是连接池排队的直接证据。如果 WaitCount 持续增长,说明你的请求在 Go 进程内部排队,而不是在数据库里排队。

换个说法:WaitDuration 增长,不是数据库拒绝你,是 Go 在自己家门口排队。

sql.DB 连接池排队模型

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 秒),避免大量连接同时到期引发重建风暴。

为什么需要强制换班?两个常见场景:

  1. 数据库前面有负载均衡器。长连接不切换,流量永远打到同一个后端节点。
  2. 数据库 wait_timeout 会主动断开长时间不活动的连接。如果 Go 侧不主动换班,可能拿到一个已经被数据库关掉的"死连接"——虽然 sql.DB 会自动重试,但那次请求的延迟已经多了一轮往返。

四个阀门管的是同一支队伍——动任何一个都影响排队长度。

四参数阀门

简单推演一下:假设你的查询平均耗时 T,并发请求数是 C,连接池上限是 N

  • 如果 N >= C:每个请求都能立即拿到连接,不排队。
  • 如果 N < CC - 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/sql driver 模拟测试(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 连接池里的队伍。

下次遇到接口延迟问题,在动参数之前,先做两步鉴别:

  1. 看 SQL 本身慢不慢:慢查询日志、APM 的 SQL 耗时——如果 SQL 本身就慢,问题不在连接池。
  2. DB.Stats().WaitDuration:如果 WaitDuration 接近零,问题也不在连接池——去查 GC、网络、上游依赖。

两步排除后,才进入连接池内部的诊断:

你看到的现象 先看哪个指标 可能的原因 处理方向
接口慢,但 SQL 不慢 WaitCountWaitDuration 连接池排队 增加 MaxOpenConns(前提:数据库有余量)或缩短查询耗时
OpenConnections 逼近 MaxOpenConns InUseIdle 连接全被占满 检查是否有长事务、未释放的 Rows、或者并发太高
周期性延迟毛刺 MaxIdleClosedMaxLifetimeClosed 空闲连接被回收后重建 调大 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/sql driver + 并发压测,隔离观察连接池等待行为

目录有独立 README,说明如何复现。无外部数据库依赖,go run . 即可运行。

原文发布于 止语Lab


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →