database/sql 实战:连接池、事务边界与慢查询排查
*sql.DB 不是一条数据库连接,而是一个并发安全的连接池句柄。许多线上问题——连接打满、事务失效、请求卡住——都来自把它当成“连接”来理解。
先建立正确的模型
flowchart LR
R1["请求 A"] --> P["sql.DB 连接池"]
R2["请求 B"] --> P
R3["请求 C"] --> P
P --> C1["连接 1"]
P --> C2["连接 2"]
P -->|等待可用连接| Q["等待队列"]
C1 --> DB[(数据库)]
C2 --> DB
sql.Open 通常只校验参数并建立池,并不保证数据库可达。应用启动时可以用带超时的 PingContext 做就绪检查:
db, err := sql.Open("postgres", dsn)
if err != nil { return err }
db.SetMaxOpenConns(40)
db.SetMaxIdleConns(20)
db.SetConnMaxIdleTime(5 * time.Minute)
db.SetConnMaxLifetime(30 * time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil { return err }MaxOpenConns 是应用对数据库的并发上限,也是一个信号量。设置过大可能把压力推给数据库,过小则让请求在池内排队。总预算应按“实例数 × 每实例上限”,并为迁移、运维和其他服务预留连接。
MaxIdleConns 太小会频繁重连;生命周期太短会制造连接抖动。连接回收值应与数据库、代理和网络层的空闲超时协调,而不是凭感觉设成一分钟。
每次查询都要有退出路径
func listUsers(ctx context.Context, db *sql.DB) ([]User, error) {
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
rows, err := db.QueryContext(ctx, `
SELECT id, name FROM users WHERE status = $1 ORDER BY id LIMIT 100
`, "active")
if err != nil { return nil, err }
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name); err != nil { return nil, err }
users = append(users, u)
}
if err := rows.Err(); err != nil { return nil, err }
return users, nil
}三个容易漏掉的点:Rows 要关闭;遍历结束要检查 rows.Err();QueryRowContext 的错误会推迟到 Scan 才返回。Context 取消能否及时中断数据库执行,还取决于驱动和数据库协议,但调用方仍应建立明确预算。
事务边界应包住业务不变量
“用了 BeginTx”不等于“事务生效”。事务中的所有 SQL 必须通过同一个 *sql.Tx 执行;夹杂 db.ExecContext 会跑到另一条连接,直接逃出事务。
func transfer(ctx context.Context, db *sql.DB, from, to int64, amount int64) (err error) {
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil { return err }
defer tx.Rollback() // Commit 后返回 sql.ErrTxDone,可忽略
res, err := tx.ExecContext(ctx,
`UPDATE accounts SET balance = balance - $1
WHERE id = $2 AND balance >= $1`, amount, from)
if err != nil { return err }
n, err := res.RowsAffected()
if err != nil { return err }
if n != 1 { return ErrInsufficientBalance }
if _, err := tx.ExecContext(ctx,
`UPDATE accounts SET balance = balance + $1 WHERE id = $2`, amount, to); err != nil {
return err
}
return tx.Commit()
}事务应尽量短:不要在持有锁时调用外部 HTTP,也不要等待用户输入。隔离级别应由业务不变量决定,并确认目标驱动和数据库是否真的支持。可重试的序列化冲突要重跑整个事务函数,不能只重跑最后一条 SQL。
sequenceDiagram
participant App as 应用
participant Tx as 事务连接
participant DB as 数据库
App->>Tx: BeginTx
Tx->>DB: 扣减余额(条件更新)
Tx->>DB: 增加余额
alt 全部成功
App->>Tx: Commit
else 任一步失败
App->>Tx: Rollback
end
慢查询不只看 SQL 执行时间
请求在 db.QueryContext 上耗时 800ms,可能有三段:等待连接、数据库执行、读取结果。DB.Stats() 能暴露池等待:
s := db.Stats()
logger.Info("db pool",
"open", s.OpenConnections,
"in_use", s.InUse,
"idle", s.Idle,
"wait_count", s.WaitCount,
"wait_duration", s.WaitDuration,
)排查时把应用追踪、数据库慢查询日志和执行计划对齐:
- 看 Context 截止时间与池等待是否主导延迟。
- 确认是否 N+1、一次读取过多行或忘记索引。
- 对真实参数执行
EXPLAIN (ANALYZE, BUFFERS)一类工具,但注意它会实际运行语句。 - 检查事务是否过长、锁等待是否被误判为“查询慢”。
动态拼接值会带来注入风险,值应使用占位符;表名、排序字段等不能参数化的标识符,要通过白名单映射。预编译语句是否提升性能取决于驱动和数据库,不应把 Prepare 当作默认优化。