跳至内容
database/sql 实战:连接池、事务边界与慢查询排查

database/sql 实战:连接池、事务边界与慢查询排查

2026年6月18日·
yanlong

*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,
)

排查时把应用追踪、数据库慢查询日志和执行计划对齐:

  1. 看 Context 截止时间与池等待是否主导延迟。
  2. 确认是否 N+1、一次读取过多行或忘记索引。
  3. 对真实参数执行 EXPLAIN (ANALYZE, BUFFERS) 一类工具,但注意它会实际运行语句。
  4. 检查事务是否过长、锁等待是否被误判为“查询慢”。

动态拼接值会带来注入风险,值应使用占位符;表名、排序字段等不能参数化的标识符,要通过白名单映射。预编译语句是否提升性能取决于驱动和数据库,不应把 Prepare 当作默认优化。

进一步阅读:管理连接池执行事务取消数据库操作