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_connections | 100 |
| 预留给运维/迁移/监控 | 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 Server | WriteTimeout | 15s | 写响应超时 |
| handler | context.WithTimeout | 3s | 一次请求的业务预算 |
| 单次查询 | statement_timeout | 1s | 服务端强制中断慢 SQL |
| 单次锁等待 | lock_timeout | 500ms | 避免等锁拖垮事务 |
| 建连 | connect_timeout | 2s | 连不上就快速失败 |
为什么每层都要有?因为它们的失效模式不同:
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=100ms | 110ms | PgError,SQLSTATE=57014 | 能,数据库自己中断 |
context 80ms | 86ms | context 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_timeout500ms;context与statement_timeout职责不同,都要有。 - 实测
statement_timeout回SQLSTATE 57014,context回context deadline exceeded,应用层要分开处理。 - 服务端三个超时默认全是
0(不限),必须用ALTER ROLE显式设置。
连接有池、查询有超时,但多个写操作之间的边界还没定:哪些操作必须在同一个事务里、事务该在哪一层开、并发冲突怎么处理——这是下一节的主题。
阅读导航:上一节:5.3 OAuth2/OIDC 与第三方登录 · 下一节:6.2 事务边界与并发控制 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。