Go 1.27 的 database/sql 回归:closingMutex 丢唤醒,Rows 卡死并掏空连接池
生产环境有一种问题最难排查:应用没有返回错误,也没有 panic,一切看起来正常,但从某个时间点起数据库连接池被慢慢掏空,接口延迟一路走高,最后新请求全部发不出去。重启后恢复,过一阵又复发。DBA 看了慢查询,数据库本身没有瓶颈,问题回到应用侧。
如果服务最近升到了 Go 1.27,代码里又满是带 Context 超时的数据库查询,那很可能撞上了 Go 1.27 引入的一个并发回归:database/sql 内部换了一把新锁,这把锁在特定时序下会丢唤醒,让 Rows 永久卡死。Go 1.27.1 已经修复,但这个问题坏得没有声音,不升级很难联想到工具链。
Next 读、Close 写:一把锁协调读行与关闭
先看 database/sql 的正常使用:
func fetchUsers(ctx context.Context) ([]User, error) {
rows, err := db.QueryContext(ctx, `SELECT id, name FROM users`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
if err := rows.Scan(&u); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
defer rows.Close() 配 for rows.Next() 是常见写法。这里容易被忽略的是:QueryContext 接收的是带超时的 Context,一旦超时触发,database/sql 内部名为 awaitDone 的 goroutine 会并发地去关闭 Rows,而主 goroutine 此时可能正卡在 rows.Next() 上。
也就是说,读行和关行天然发生在两个 goroutine 里。为此 Rows 内部需要一把读写锁协调:Next() 持读锁,Close() 持写锁,避免读到一半底层游标被释放。
Go 1.27 之前,这把锁直接用标准库的 sync.RWMutex。Go 1.27 做了一次内部重构:此前标准读写锁在个别重入场景下会卡死,于是关闭协调逻辑被换成了一把自实现的锁 closingMutex,并且为了让 Close() 能及时打断读循环,加了 writer-priority 语义。思路本身没问题,问题出在自实现锁的细节里。
丢唤醒:一个毫秒窗口里的竞态
closingMutex 用一个 int 打包状态:
0:空闲,且没有写者在等待1:空闲,但有写者在排队- 正数:当前持有读锁的读者数量
- 负数:写锁被持有
最低位就是“是否有写者等待”的标记。
写者 Lock() 大致流程是:先读一次状态,如果空闲就直接 CAS 抢锁;抢不到就把自己登记成“等待的写者”,然后在 sync.Cond 上睡眠,等最后一个读者离开时把自己唤醒。
竞态正好藏在“读状态”和“置写者等待位”之间的空档:
写者: 读到 state = 0(空闲)
读者: 拿到读锁,执行后释放 -> state 回到 0
写者: 用刚才那个已经过期的 state=0 去 CAS,把“有写者等待”置上 -> state = 1
写者: 睡进 sync.Cond
问题在于:写者把 state 从 0 改成 1 时,已经没有读者在跑了。RUnlock 只在最后一个读者离开时广播唤醒写者,但读者早就走完,广播发生在写者入睡之前,所以写者永远错过唤醒,一直睡下去。
更糟的是,state=1(有写者等待)时新读者会被拒绝进入。于是不止写者卡住,之后所有想执行 Next() 读行的调用也全部排队堵死。一个 Rows 从此不再有任何进展。
关键触发条件是 GOMAXPROCS > 1。整个竞态依赖“写者读状态”和“读者释放”发生在不同线程上,单核下时序很难碰上。这可以解释为什么本地开发或单核 CI 几乎复现不出来,而多核线上服务会中招。并且它是时序敏感的,可能运行几秒就卡死,也可能跑几天才撞上一次。
Rows 卡死之后的连锁反应
Rows 卡死不只是泄漏一个 goroutine,后面还有一串影响:
- 卡住的是
Rows关闭流程所在的 goroutine,而连接归还连接池正是由关闭逻辑完成的。连接不回来,池子就少一个可用连接。 database/sql的连接池惰性增长,业务压力大时会继续新建连接,问题会被掩盖一阵子。- 当池中可用连接全部被卡死的
Rows占住,又没法新建连接时(达到SetMaxOpenConns),所有新查询开始排队等连接,接口延迟飙升。 - 全程没有 error 返回,也没有日志。goroutine 只是安静地 park 在
sync.Cond.Wait里。
线上如果 go tool pprof 抓到的 goroutine dump 里,大量 goroutine 停在 database/sql.(*closingMutex).Lock 或 .RLock,同时数据库本身负载很低,就可以高度怀疑是这个回归。
需要强调的是,触发这个问题的并不是“错误的并发用法”。发起关闭的是 database/sql 自己的 awaitDone goroutine,每一个带 Context 的查询都会启动它;超时一到,它就调用 Rows.Close()。所以即使业务代码老老实实写了 defer rows.Close(),只要请求在遍历结果集的过程中超时,就可能踩中。
升级到 Go 1.27.1
这个 bug 在 2026 年 8 月下旬被报告,9 月 1 日随 Go 1.27.1 修复。修复方式很克制:Lock() 在首次抢锁失败后、置写者等待位之前重新读一次状态,确保只有确实还有读者在跑、并且未来的 RUnlock 一定会来唤醒自己时,才睡进 Cond。这样把“改状态”和“等待唤醒”绑定成一对,堵住丢唤醒的窗口。
如果服务还在 Go 1.27.0,建议尽快升级:
go install golang.org/dl/go1.27.1@latest
go1.27.1 download
go1.27.1 version
升级前如果线上已经观察到连接池异常,先抓一份 goroutine dump 留证,升级后对比确认 closingMutex 相关的卡死消失。
Go 1.26 及更早版本不受影响,旧版用的是标准库读写锁,没有这段自实现逻辑。这个案例也提醒我们:标准库为边界场景把手写锁替换掉 sync.RWMutex 时,即使目的只是让关闭更及时,也值得认真 review。并发的锅,往往就藏在“顺手优化”里。
排查时可以对照这组条件:
- 升级到 Go 1.27 之后,服务有大量带超时 Context 的 DB 查询
- 多核部署
- 连接池偶发耗尽
- 无错误日志
- 重启后自愈,但会复发
如果同时满足,先不要急着怀疑慢查询或连接泄漏,确认一下实际运行的 Go 版本是不是 1.27.0,然后升到 1.27.1,再跑一天看连接池曲线是否恢复平稳。