上周五,线上报警炸了。数据库连接数直接打满,订单服务彻底瘫痪。我上去一看,日志里全是 too many connections。团队里没人动过数据库配置的,最近也没发版,那这连接是哪来的?
答案是:你的代码正在悄悄泄漏连接,只是你没发现而已。更可怕的是,这个问题跟你的代码好不好一点关系都没有。
先说个反直觉的事实
你以为连接池泄漏是这样的——有人忘了写 conn.Close(),连接被耗光。但真实生产环境里,这种低级错误有,但不是主流。真正干掉你连接数的,往往是一些看起来完全正确的代码。
举个例子,下面这段代码,你敢说有问题?
func GetUserInfo(ctx context.Context, userID string) (*User, error) {
db, _ := dbPool.GetConn()
defer db.Close() // 我关了呀!
var user User
row := db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", userID)
err := row.Scan(&user.Name, &user.Email, &user.Status)
return &user, err
}
这段代码看起来完全正确吧?有 defer db.Close(),有 ctx,看起来很规范。但它有一个致命问题——你从池里拿了一个连接,然后在 defer 里关掉,但查询结果 row 可能还没完全读完。
等等,我说的是 QueryRowContext,它不是返回一个行就完了吗?
对,但问题在于 db.QueryRowContext 内部会占用一个连接,直到你 Scan 完所有数据为止。如果你的 Scan 在某个错误路径上提前返回了,而这个错误路径上还有别的资源等待释放——在 Go 里这叫栈上资源的 unwind 顺序问题。大部分时候没问题,但高并发下,某些 corner case 会让连接被持有更长时间。
真正的泄漏来自哪里
让我告诉你一个很多人不愿意承认的事实:连接池泄漏的最大元凶,是超时配置不统一。
你的数据库连接池配置大概是这样的:
MaxOpenConns: 100
MaxIdleConns: 10
ConnMaxLifetime: 1h
ConnMaxIdleTime: 5m
看起来很标准对吧?但你注意过没有,你的 HTTP 客户端的 timeout 和你的 SQL 查询的 timeout 是不是同一个值?
我见过太多这样的配置了:
// HTTP 客户端
httpClient.Timeout = 30 * time.Second
// 数据库连接
db.SetConnMaxLifetime(time.Hour)
db.SetConnMaxIdleTime(5 * time.Minute)
// 但是查询的时候...
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
row := db.QueryRowContext(ctx, "SELECT ...")
你发现了吗?你的查询超时是 3 秒,但你的连接在池里可以活 5 分钟甚至 1 小时。如果某个查询跑了 10 秒才超时(别笑,这种情况太常见了——网络抖动、锁等待、慢查询),这 10 秒里这个连接是被占用的,而且它已经不在池里了。
更骚的操作来了——很多人为了「保险」,把 QueryRowContext 的超时设得比 HTTP 超时长,觉得这样能保护上游。但实际上,这样做的后果是:慢查询会占满你的连接池,而你的服务还不知道。因为你的 HTTP 层已经超时返回 504 了,但数据库连接还在那等着那个查询跑完。
PgBouncer 救不了你,除非你理解它
很多团队发现连接不够用了,第一反应是上 PgBouncer。这玩意儿确实好使,但它不是银弹。
PgBouncer 有三种模式:
- Session 模式:客户端连进来就占一个连接,断开才释放。跟没装一样。
- Transaction 模式:每个事务一个连接,事务结束连接回池。这个最常用,但也最容易被坑。
- Statement 模式:每个语句一个连接,多语句事务直接报错。
如果你用 Transaction 模式(大多数人的选择),那你必须确保你的代码不在事务里做两次数据库操作之间夹杂了其他 I/O 操作。比如这样的代码:
tx, _ := db.Begin()
tx.Exec("UPDATE accounts SET balance = balance - 100 WHERE user_id = ?", from)
// 这里做了一个 HTTP 调用获取外部授权
resp, _ := http.Get("http://auth-service/verify?uid=" + from)
tx.Exec("UPDATE accounts SET balance = balance + 100 WHERE user_id = ?", to)
tx.Commit()
在 PgBouncer Transaction 模式下,这个 HTTP 调用期间,你的数据库连接是空闲的——但问题是,PgBouncer 会在这个间隙把连接借给别的请求。然后当你的代码继续执行 tx.Commit() 的时候,你手里的连接已经不是原来那个了。
tx.Commit() 报错,rollback 也回不到正确的连接。这种 bug 怎么测?基本靠猜。
正确配置连接池的四个铁律
说了这么多,该上硬货了。这是我们在生产环境摸爬滚打出来的经验:
第一,连接池大小不是越大越好。
很多人觉得「反正连接不够用,那就把 MaxOpenConns 调大」。错。连接数太多会导致上下文切换开销增大,每个连接都在抢 CPU。而且 PostgreSQL 的每个连接都是一个独立进程,内存消耗是按连接数乘以 work_mem 算的。你设 1000 个连接,每个 4MB,那就是 4GB 的内存开销。
一个经验公式:连接数 = (核心数 * 2) + 磁盘数。或者更简单粗暴一点:连接数 = 核心数 / 线程数 * 50。根据你的业务实际调优。
第二,连接生命周期必须小于下游超时。
你的数据库连接池的 ConnMaxLifetime 必须小于你的服务重启间隔和你的负载均衡器的连接超时。为什么要这样?因为如果一个连接比你的服务生命周期还长,你很难排查问题。更重要的是,数据库服务器端也有 idle_in_transaction_session_timeout,两边配置不一致的话,你会看到莫名其妙的连接断开。
第三,每一个涉及外部 I/O 的路径,必须显式控制超时。
不只是 SQL 查询,还有 Redis、HTTP 调用、消息队列。所有涉及网络 I/O 的地方,都要设超时。没有例外。
第四,上线前必须做连接池压测。
用 wrk 或者 ghz 压你的服务,同时监控 db_pool.Stats() 的输出。特别关注这三个指标:Waits(等待获取连接的次数)、WaitDuration(平均等待时间)、MaxIdleClosed(因空闲被关闭的连接数)。如果 Waits 不为零,你的连接池已经不够用了。
最后说一个很多人踩过的坑
连接池里的连接如果被服务器端强制关闭(比如说数据库做了主备切换,或者 DBA 手动 kill 了某个慢查询),客户端是不知道的。这个连接在你的池里显示是「可用」的,但拿去用的时候要么报协议错误,要么直接卡死。
解决方案是:定期检测连接活性。每次从池里拿连接的时候,顺手 ping 一下。大多数客户端库支持这个配置:
db, _ := sql.Open("postgres", dsn)
db.SetConnMaxLifetime(10 * time.Minute)
db.SetMaxOpenConns(50)
db.SetMaxIdleConns(10)
// 关键:验证连接的活性
db.SetConnMaxIdleTime(1 * time.Minute)
等等,SetConnMaxIdleTime 的作用不只是限制空闲时间,它还会让空闲连接在拿出去之前先做一次重连验证。如果你的库不支持这个特性,那就自己写一个连接验证函数。
总结一下
连接池的问题,说到底是两个认知偏差:
- 把「连接」当成「无害的资源」——实际上每个连接都是有成本的有状态的。
- 把「池化」当成「自动优化」——实际上池化只是让你不用关心连接的创建销毁,但什么时候创建、什么时候销毁、什么时候算泄漏,这些问题依然需要人来判断。
下次你的服务报警 too many connections 的时候,别急着加机器。先看看你的连接池配置,可能问题在那儿,不在你的代码里。