《Go 语言编程实战》6.1 连接池与超时

数据访问层出问题,十有八九先出在连接池和超时上。本节给 TaskHub 定下 database/sql 四个池参数,实测并发 20 时 MaxOpenConns 从 4 到 20 让墙钟由 298ms 降到 78ms、排队由 16 次降到 0,给出按 max_connections 与实例数反推池大小的算法,并把超时预算逐层落成代码。

6.1 连接池与超时

前五章 TaskHub 的接口、认证、隔离都齐了,但所有查询都还停在「能跑就行」:默认连接池、默认超时、默认一切。线上第一次出现「接口偶发变慢」时,你会发现根因往往不在 SQL 写得不好,而在池太小排队或某个慢查询没有超时,把连接全占死。

本节把 TaskHub 推进到:数据库连接池参数按容量公式定死,超时预算从 HTTP 入口一路传到 SQL,慢查询有服务端兜底。

6.1.1 一个数据库连接有多贵

先建立成本直觉。一个 Postgres 连接在服务端是一个独立进程(不是线程),有自己的内存、文件描述符、快照。实测本机一个 postgres:17-alpine 实例,空闲连接也占着:

并发 8 时 pg_stat_activity 里当前库连接数=8(含查询自身)

8 个并发查询就是 8 个连接。如果每个请求都新建连接,代价是 TCP 握手 + TLS 协商 + 认证(SASL/SCRAM 要做多轮哈希)+ 后端进程 fork。在局域网里这可能只要几毫秒,但它会随并发线性放大,而且服务端的 max_connections 是硬上限,默认通常 100——超过就直接拒连。

所以连接必须复用。database/sql 的 *sql.DB 不是「一个连接」,而是一个连接池。这个认知很重要:它意味着 sql.Open 几乎不做事(不建连),db.Close 是关整个池,而 db 可以并发安全地共享。

6.1.2 四个池参数

池的行为由四个参数决定,缺一个都会在生产里咬你:

参数含义设错的后果
SetMaxOpenConns池中最大连接数(含使用中+空闲)太小 → 请求排队;太大 → 打爆数据库
SetMaxIdleConns保留的空闲连接数太小 → 频繁建连;太大 → 空占服务端资源
SetConnMaxLifetime单个连接最长存活时间不设 → 连到被中间件掐断的死连接
SetConnMaxIdleTime空闲多久后关闭不设 → 长期空占内存

TaskHub 的配置:

db.SetMaxOpenConns(20)
db.SetMaxIdleConns(20)          // 与 MaxOpen 相等,避免「用完就关、来了再建」的抖动
db.SetConnMaxLifetime(30 * time.Minute) // 短于任何中间件/LB 的空闲超时
db.SetConnMaxIdleTime(5 * time.Minute)

两个经验规则:

  • MaxIdleConns 设成等于 MaxOpenConns。默认值是 2,这意味着峰值过后 18 条连接会被关掉,下次高峰再重建。既然你已经决定允许 20 条并发,就没理由把空闲的提前关掉。
  • ConnMaxLifetime 必须短于「链路上任何组件的空闲超时」。云数据库、PgBouncer、NAT 网关都会掐掉长时间空闲的连接,而且往往不发 FIN——于是客户端以为连接还在,发出去才发现是死连接。设一个 30 分钟的生命周期,让 Go 主动换新连接,比事后重试可靠。

6.1.3 实测:池太小会怎样

空谈「池太小会排队」不如看数字。本机 Postgres 17.11,20 个并发查询、每个查询 pg_sleep(0.05)(50ms),只改 MaxOpenConns,各跑一次(测量前先把连接建好,避免把建连开销算进去):

MaxOpenConns=4   并发=20 每查询 50ms -> 墙钟=298ms     OpenConns=4  InUse=0 Idle=4  WaitCount=16 WaitDuration=2.367s
MaxOpenConns=20  并发=20 每查询 50ms -> 墙钟=78ms      OpenConns=20 InUse=0 Idle=20 WaitCount=0  WaitDuration=0s

读这组数字:

  • 池 4:20 个请求抢 4 条连接,要分 5 批,理论下界 5 × 50ms = 250ms,实测 298ms(差额是调度与事务开销)。WaitCount=16 说明有 16 次请求排队等连接,WaitDuration 累计 2.367 秒。
  • 池 20:一轮跑完,理论下界 50ms,实测 78ms。WaitCount=0,没有任何排队。

结论不是「池越大越好」——池 20 之所以更快,是因为数据库本身能同时处理 20 个这样的查询。如果数据库只有 4 个 CPU 核,池 20 只会让 20 个查询互相抢 CPU,每个都变慢,墙钟可能反而更长。这就是下一节要算的账。

顺带记住 WaitCount 和 WaitDuration 这两个指标:它们是「池不够用」的唯一直接证据。第 10 章会把它们导出成 Prometheus 指标并配告警——WaitDuration 的增速突然变大,就是扩容或优化 SQL 的信号。

6.1.4 池该开多大

不要拍脑袋。反向推:池大小 × 实例数 ≤ 数据库 max_connections − 预留。假设:

项值
数据库 max_connections100
预留给运维/迁移/监控10
TaskHub 实例数6
其他服务共用该库占用 30

那么 TaskHub 总共可用 100 - 10 - 30 = 60,分到 6 个实例,每实例 60 / 6 = 10。所以池应该设 10,不是 20。

这解释了一个常见事故:本地开发时 MaxOpenConns 设 20 跑得好好的,上了 10 个实例就变成 200 条连接,直接把数据库打挂。池大小是「单实例值」,但它的约束是「集群级总量」。

还有一层:池大小不该超过数据库的并行处理能力。经验公式(来自 PostgreSQL 社区)是:

最优连接数 ≈ CPU 核数 × 2 + 有效磁盘数

对一个 8 核的数据库,这个数约 18——远小于很多人默认的 100。超过这个数,连接只是在排队抢 CPU,吞吐不升反降。所以 TaskHub 的顺序是:先算服务端容量上限,再算实例数分摊,最后取两者较小值。

6.1.5 把池的状态暴露出来

池参数设完了不代表万事大吉——你无法管理没有测量的东西。database/sql 的 db.Stats() 直接给出全部池状态,不需要额外依赖:

type DBStats struct {
	MaxOpenConnections int           // 池上限
	OpenConnections    int           // 已建立(使用中 + 空闲)
	InUse              int           // 正在被占用
	Idle               int           // 空闲可复用
	WaitCount          int64         // 累计「等连接」次数
	WaitDuration       time.Duration // 累计等待时长
	MaxIdleClosed      int64         // 因 MaxIdleConns 太小被关掉的连接数
	MaxIdleTimeClosed  int64         // 因 ConnMaxIdleTime 到期被关掉的连接数
	MaxLifetimeClosed  int64         // 因 ConnMaxLifetime 到期被换掉的连接数
}

几个字段的读法值得记住:

字段健康信号异常信号
InUse / MaxOpenConnections平时 30%~60%长期贴着 100% → 池太小或有慢查询
WaitCount 增速接近 0持续增长 → 请求在排队
WaitDuration 增速远小于请求耗时接近请求耗时 → 大部分时间在等连接
MaxIdleClosed很小很大 → MaxIdleConns 设得太小

第 10 章会把这几个值导成 Prometheus 指标。现在可以先在 /debug 里打出来观察:

func handleDBStats(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		st := db.Stats()
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(map[string]any{
			"open": st.OpenConnections, "in_use": st.InUse, "idle": st.Idle,
			"wait_count": st.WaitCount, "wait_ms": st.WaitDuration.Milliseconds(),
		})
	}
}

一个容易误判的点:WaitCount 和 WaitDuration 是进程生命周期内的累计值,不是瞬时值。所以判据不是「大于 0」,而是「增速」。实测里池 20 那次 WaitCount=0,而池 4 那次是 16——差异只有把两次测量的差值算出来才看得清。

6.1.6 超时预算:每层都要有

连接池解决「连接不够」,超时解决「连接被占死」。一个没有超时的慢查询会一直占着连接,池很快耗尽,整个服务瘫痪——这是最典型的级联故障。

超时必须是分层的预算,从外到内逐层收紧,而不是在某一层设一个大值:

层超时值作用
网关 / LB请求总超时30s兜底,防止连接堆积
HTTP ServerWriteTimeout15s写响应超时
handlercontext.WithTimeout3s一次请求的业务预算
单次查询statement_timeout1s服务端强制中断慢 SQL
单次锁等待lock_timeout500ms避免等锁拖垮事务
建连connect_timeout2s连不上就快速失败

为什么每层都要有?因为它们的失效模式不同:

  • context 超时是客户端侧的:Go 取消等待并返回 context deadline exceeded,但SQL 可能还在数据库里跑(除非驱动发了 cancel)。
  • statement_timeout 是服务端侧的:数据库自己中断查询并回 57014,这是唯一能保证「SQL 真的停了」的机制。

两者都要有,缺一不可。handler 的代码形态:

func (s *TaskService) ListTasks(ctx context.Context, tenantID string) ([]Task, error) {
	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()
	rows, err := s.db.QueryContext(ctx, listTasksSQL, tenantID)
	if err != nil {
		return nil, fmt.Errorf("list tasks: %w", err)
	}
	defer rows.Close()
	// ...
}

关键点:ctx 必须从 handler 一路透传,不能在中途换成 context.Background()。一旦换掉,上游取消就传不下来,超时链断了。

建连超时不在池参数里,而在 DSN 里。pgx 支持在连接串上声明,未指定时用的是操作系统的 TCP 默认值(可能长达数十秒):

dsn := "postgres://taskhub_app@db.internal:5432/taskhub" +
	"?sslmode=require" +
	"&connect_timeout=2" +                  // 建连最多 2s
	"&statement_timeout=1000" +             // 单语句 1s(毫秒)
	"&lock_timeout=500" +                   // 等锁 500ms
	"&idle_in_transaction_session_timeout=30000"

放在 DSN 里的好处是对每个新连接都生效,不依赖连接建立后补 SET,也不怕连接池复用把参数串味。缺点是这些值就写死在部署配置里了——所以 TaskHub 的做法是「DSN 里给安全默认值,配置中心可以覆盖」,第 3 章的分层配置正好接上。

6.1.7 实测:两种超时的不同报错

在同一台 Postgres 上分别触发服务端超时与客户端超时,实测:

statement_timeout=100ms 跑 pg_sleep(1): 耗时=110ms SQLSTATE=57014
  message="canceling statement due to statement timeout"
context 80ms 跑 pg_sleep(1): 耗时=86ms err=timeout: context deadline exceeded

对比这两行:

机制耗时错误形态能否确认 SQL 已停
statement_timeout=100ms110msPgError,SQLSTATE=57014能,数据库自己中断
context 80ms86mscontext deadline exceeded不保证(依赖驱动发 cancel)

两个细节:statement_timeout 报的是 110ms 而不是 100ms,因为超时检查发生在语句执行的检查点,不是精确的 100ms。57014 是 query_canceled 的 SQLSTATE,应用层可以按这个码识别「超时」并回 503 或 504,而不是笼统地当 500。

pgx 驱动在 context 取消时会尝试向服务端发 cancel 请求,所以实际上 SQL 通常也会停。但这是驱动的行为,不是协议保证——别把正确性建立在驱动实现上,服务端超时该设还是要设。

6.1.8 服务端默认值:全是 0

这是最容易被忽略的一点。Postgres 的这几个超时默认值实测如下:

statement_timeout                    = "0"
lock_timeout                         = "0"
idle_in_transaction_session_timeout  = "0"

0 表示「不限」。也就是说:一个 select 可以跑到天荒地老,一个等锁的事务可以永久等待,一个开了事务却忘记提交的连接会一直占着资源。默认配置是「信任应用层」,而生产环境不该这么信任。

TaskHub 在连接建立时统一设置(用 SET 会影响会话,注意与连接池复用的关系):

ALTER ROLE taskhub_app SET statement_timeout = '1s';
ALTER ROLE taskhub_app SET lock_timeout = '500ms';
ALTER ROLE taskhub_app SET idle_in_transaction_session_timeout = '30s';

用 ALTER ROLE 而不是在每个连接上 SET,好处是对连接池友好——连接复用时参数不会串味,也不用在每次 Acquire 后补一句 SET。idle_in_transaction_session_timeout 尤其重要:它能自动清掉「事务开着但应用崩了」的连接,这类连接会一直持有行锁,是死锁与锁等待的常见来源(下一节展开)。

6.1.9 小结

  • *sql.DB 是连接池不是连接;连接在服务端是进程,代价高,必须复用。
  • 四个池参数都要设:MaxIdleConns = MaxOpenConns,ConnMaxLifetime 短于链路空闲超时。
  • 实测:池 4 时 20 并发耗时 298ms、排队 16 次;池 20 时 78ms、零排队。
  • 池大小 = min(服务端容量 / 实例数, CPU 核数 × 2 + 磁盘数);别超过数据库并行能力。
  • 超时分层预算,从网关 30s 收到 lock_timeout 500ms;context 与 statement_timeout 职责不同,都要有。
  • 实测 statement_timeout 回 SQLSTATE 57014,context 回 context deadline exceeded,应用层要分开处理。
  • 服务端三个超时默认全是 0(不限),必须用 ALTER ROLE 显式设置。

连接有池、查询有超时,但多个写操作之间的边界还没定:哪些操作必须在同一个事务里、事务该在哪一层开、并发冲突怎么处理——这是下一节的主题。

阅读导航:上一节:5.3 OAuth2/OIDC 与第三方登录 · 下一节:6.2 事务边界与并发控制 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练