前言
最近,新接手的一个 5 年前的 go 服务偶发性响应报错 “context deadline exceeded”,一旦有一个请求出现这个问题,所有向这个服务发送的请求都会返回超时错误,包括所有接口,且无法自行恢复,服务进程还存活但已经无法响应任何请求,彻底 hang 住了,只能重启服务。
前言
最近,新接手的一个 5 年前的 go 服务偶发性响应报错 “context deadline exceeded”,一旦有一个请求出现这个问题,所有向这个服务发送的请求都会返回超时错误,包括所有接口,且无法自行恢复,服务进程还存活但已经无法响应任何请求,彻底 hang 住了,只能重启服务。
服务所用的框架为 Echo,数据库为 PG,连接驱动为 jackc/pgx。
问题篇
这个问题之前也算是碰到过,不过之前的服务用的是 GoFrame V1,当时已经排查出问题是出在数据库交互逻辑,因为日志中显示请求能正常收到,唯独是查询数据库没有结果输出,当时隐约猜到是事务逻辑出了问题,在处理请求开始,就先开启了事务,这一般都是不合理的,后面用最小化原则优化了事务,在真正执行写入操作时才开启事务,写入完成就关闭,优化事务操作之后,之前的那个服务就再没出过 “context deadline exceeded” 问题,但没有继续深究原因,只能说是技术人的直觉解决了这个问题。
现在又碰到了这个问题,趁着目前业务还不算忙,正好深入研究一下,由于有之前的经验,很快就定位了也是事务问题,代码写法和之前差不多,也是收到请求后,立马就开启了事务。起初以为是 Go 连接 PG 数据库驱动的通用问题,但一想,如果是通用问题,那这种 bug 应该早就有人反馈修复了。于是在 lib/pq 和 jackc/pgx 的 issue 上搜索,也算是运气好,正好看到个三周前的 issue,完美复现了这个超时现象,并且有大佬完美解释了这个问题。
原因篇
issue 中示例代码已经很清楚了,这里再引用一下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39 // 连接池初始化,连接池默认配置 MaxConns = 10
func New(dburl string) (*pgxpool.Pool, error) {
dbpool, err := pgxpool.New(context.Background(), dburl)
if err != nil {
return nil, fmt.Errorf("failed to connect db: %w", err)
}
return dbpool, nil
}
// Repository 结构体
type Repository struct {
pgclient *pgxpool.Pool
}
// 测试方法,相当于一次请求
func (repo *Repository) Test(i int) (err error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
// 开启事务 - 获取一个连接
tx, beginErr := repo.pgclient.Begin(ctx)
if beginErr != nil {
return beginErr
}
defer func() {
if err != nil {
tx.Rollback(ctx)
return
}
tx.Commit(ctx)
}()
var c int
// 问题所在:使用连接池执行查询,而非事务对象
// 这会尝试获取另一个连接
err = repo.pgclient.QueryRow(ctx, "SELECT COUNT(*) FROM achievements").Scan(&c)
return err
}压测调用代码:
1
2
3 for i := 0; i < 10; i++ { // MaxConns = 10,恰好等于连接池大小
go repository.Test(i)
}
表面上看代码逻辑清晰:开启事务、执行查询、提交或回滚。但深入排查就会发现,这段代码会造成死锁,问题出在查询那行代码上——使用了 repo.pgclient.QueryRow() 而非 tx.QueryRow()。示例代码的 Test 方法需要两个连接才能完成操作:1、 repo.pgclient.Begin(ctx) —— 获取第一个连接,用于开启事务;2、 repo.pgclient.QueryRow(ctx, ...) —— 尝试获取第二个连接,用于执行查询。当 10 个 goroutine 同时执行 Test 方法时:
| 阶段 | 状态 | 连接池剩余 |
|---|---|---|
| 初始 | 连接池有 10 个连接 | 10 |
| 阶段一 | 每个 goroutine 调用 Begin(),各获取 1 个连接 | 0 |
| 阶段二 | 所有 goroutine 尝试 QueryRow(),需要再获取 1 个连接 | 等待中... |
每个 goroutine 都持有一个连接,等待另一个连接。但没有 goroutine 能释放连接——因为释放连接需要完成查询和事务,而完成查询需要另一个连接。这就是经典的死锁场景,相互等待。
该问题能稳定复现,主要原因也与 Go 调度器的行为有关。Go 调度器是协作式的,当一个 goroutine 执行网络 I/O(如向 PostgreSQL 发送 BEGIN)时,运行时会将其停放(park),让其他 goroutine 运行。这导致所有 goroutine 几乎同时执行到 Begin(),拿到第一个连接后同时被停放,唤醒时连接池已空,全部进入等待状态。
业务上最佳实践为:
- 事务内的查询使用同一个连接,即使用本事务对象进行查询,不要用另外的数据库对象查询;
- 无特殊情况,理论上应遵循事务最小化原则,只有在真正执行 DML 操作时,才开启事务,完成后,立马关闭事务,DQL 操作不应使用事务对象执行。
当然,数据库连接驱动也可以进一步优化:
- 可以在连接池设置超时参数,如果有进程连接时间过长,则主动释放;
- 连接等待时间过长,返回错误;
- 主动发现 context 超时后,pgclient.QueryRow 直接返回错误。
后记
这个问题本质上不是 pgx 的 bug,而是对事务使用方式的误解。事务与特定的数据库连接绑定,事务内的操作应该在同一个连接上执行,这不是 pgx 特有的规则,几乎所有数据库事务都是如此。在使用事务时,牢记一个原则:事务内部永远使用事务对象的方法。

