Go 数据库实战:连接池、事务与性能优化

Go 的 database/sql 是数据库访问抽象,*sql.DB 不是单个连接,而是并发安全的连接池。生产问题往往不在一条 SQL 能否执行,而在连接池是否匹配数据库容量、事务是否保持一致、查询是否可取消,以及慢查询是否可观测。

初始化连接池

以下示例使用 MySQL 驱动,PostgreSQL 等数据库的连接池原则相同:

import (
	"context"
	"database/sql"
	"fmt"
	"time"

	_ "github.com/go-sql-driver/mysql"
)

type DBConfig struct {
	DSN             string
	MaxOpen         int
	MaxIdle         int
	ConnMaxLifetime time.Duration
	ConnMaxIdleTime time.Duration
}

func OpenDB(ctx context.Context, config DBConfig) (*sql.DB, error) {
	db, err := sql.Open("mysql", config.DSN)
	if err != nil {
		return nil, fmt.Errorf("open database: %w", err)
	}

	db.SetMaxOpenConns(config.MaxOpen)
	db.SetMaxIdleConns(config.MaxIdle)
	db.SetConnMaxLifetime(config.ConnMaxLifetime)
	db.SetConnMaxIdleTime(config.ConnMaxIdleTime)

	pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()
	if err := db.PingContext(pingCtx); err != nil {
		db.Close()
		return nil, fmt.Errorf("ping database: %w", err)
	}

	return db, nil
}

sql.Open 通常不会立即建立连接,因此启动时应调用 PingContext 验证驱动、网络和认证。

DSN 可能包含密码,错误日志和配置输出必须脱敏。

连接池参数如何确定

MaxOpenConns

限制当前进程同时打开的连接数。所有服务实例的总连接上限不能超过数据库允许连接数,还要为迁移、管理和监控工具保留空间。

例如数据库可安全接受 200 个应用连接,服务有 10 个副本,可以先从每个实例 15 个连接开始,而不是每个实例都设置 200。

连接数过小会造成等待,过大则会增加数据库并发和上下文切换,甚至让数据库过载。

MaxIdleConns

保留空闲连接可以减少频繁建连开销,通常不应大于 MaxOpenConns。流量稳定的 API 可以保留接近常用并发量的空闲连接。

ConnMaxLifetime

限制连接复用总时长。该值应短于数据库、代理或负载均衡器回收连接的时间,并加入随机抖动可避免所有实例同时重连。

ConnMaxIdleTime

清理长时间空闲连接,适合流量波动明显的服务。

监控连接池

stats := db.Stats()
logger.Info("database pool",
	"open", stats.OpenConnections,
	"in_use", stats.InUse,
	"idle", stats.Idle,
	"wait_count", stats.WaitCount,
	"wait_duration", stats.WaitDuration,
	"max_idle_closed", stats.MaxIdleTimeClosed,
	"max_lifetime_closed", stats.MaxLifetimeClosed,
)

重点关注:

  • InUse 是否长期接近上限。
  • WaitCountWaitDuration 是否持续增长。
  • 查询延迟升高时数据库 CPU、锁等待和连接等待分别占多少。

盲目增加连接池可能暂时减少应用等待,却把瓶颈推到数据库。

查询单行数据

type User struct {
	ID        int64
	Name      string
	Email     string
	CreatedAt time.Time
}

func FindUser(ctx context.Context, db *sql.DB, id int64) (User, error) {
	const query = `
		SELECT id, name, email, created_at
		FROM users
		WHERE id = ?
	`

	var user User
	err := db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Name,
		&user.Email,
		&user.CreatedAt,
	)
	if errors.Is(err, sql.ErrNoRows) {
		return User{}, ErrUserNotFound
	}
	if err != nil {
		return User{}, fmt.Errorf("find user %d: %w", id, err)
	}
	return user, nil
}

Repository 将 sql.ErrNoRows 转换为稳定领域错误,上层不需要依赖具体存储技术。

所有外部输入必须使用占位参数,不能拼接进 SQL 字符串。

查询多行数据

func ListUsers(ctx context.Context, db *sql.DB, limit int) ([]User, error) {
	const query = `
		SELECT id, name, email, created_at
		FROM users
		ORDER BY id DESC
		LIMIT ?
	`

	rows, err := db.QueryContext(ctx, query, limit)
	if err != nil {
		return nil, fmt.Errorf("query users: %w", err)
	}
	defer rows.Close()

	users := make([]User, 0, limit)
	for rows.Next() {
		var user User
		if err := rows.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt); err != nil {
			return nil, fmt.Errorf("scan user: %w", err)
		}
		users = append(users, user)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate users: %w", err)
	}
	return users, nil
}

必须关闭 rows 并在循环结束后检查 rows.Err()。否则网络中断等迭代错误可能被忽略,连接也可能迟迟无法归还连接池。

处理可空字段

数据库 NULL 不能直接扫描到普通 stringtime.Time

var nickname sql.NullString
if err := row.Scan(&nickname); err != nil {
	return err
}
if nickname.Valid {
	fmt.Println(nickname.String)
}

也可以扫描到指针。选择方式取决于领域语义:NULL 是“未知”“未设置”还是空值,应在 Repository 边界转换为明确模型。

事务:把一致性边界写清楚

func Transfer(ctx context.Context, db *sql.DB, fromID, toID int64, amount int64) error {
	tx, err := db.BeginTx(ctx, &sql.TxOptions{
		Isolation: sql.LevelReadCommitted,
	})
	if err != nil {
		return fmt.Errorf("begin transaction: %w", err)
	}
	defer tx.Rollback()

	result, err := tx.ExecContext(ctx,
		`UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?`,
		amount, fromID, amount,
	)
	if err != nil {
		return fmt.Errorf("debit account: %w", err)
	}

	affected, err := result.RowsAffected()
	if err != nil {
		return fmt.Errorf("read debit result: %w", err)
	}
	if affected != 1 {
		return ErrInsufficientBalance
	}

	if _, err := tx.ExecContext(ctx,
		`UPDATE accounts SET balance = balance + ? WHERE id = ?`,
		amount, toID,
	); err != nil {
		return fmt.Errorf("credit account: %w", err)
	}

	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit transfer: %w", err)
	}
	return nil
}

defer tx.Rollback() 在提交成功后会返回 sql.ErrTxDone,通常可以忽略。它保证中途任何返回路径都尝试回滚。

事务中不能混用 dbtx 查询,否则操作可能落到不同连接并脱离事务。

隔离级别由业务一致性要求和数据库实现共同决定。更高隔离级别通常意味着更多锁冲突,不能机械设置为最高。

事务辅助函数

func WithinTx(ctx context.Context, db *sql.DB, options *sql.TxOptions, fn func(*sql.Tx) error) error {
	tx, err := db.BeginTx(ctx, options)
	if err != nil {
		return fmt.Errorf("begin transaction: %w", err)
	}

	defer func() {
		_ = tx.Rollback()
	}()

	if err := fn(tx); err != nil {
		return err
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit transaction: %w", err)
	}
	return nil
}

辅助函数可以统一生命周期,但业务操作仍应显式使用传入的 *sql.Tx

预处理语句

频繁执行同一 SQL 时可以准备 Statement:

statement, err := db.PrepareContext(ctx, `UPDATE users SET last_seen_at = ? WHERE id = ?`)
if err != nil {
	return err
}
defer statement.Close()

*sql.Stmt 并发安全,可以长期复用,但它可能在连接池中的多个连接上准备数据库语句。是否提升性能取决于驱动、数据库和代理行为,应通过 Benchmark 与监控验证。

避免 N+1 查询

先查询 100 个订单,再为每个订单单独查询用户,会产生 101 次往返。可选方案:

  • 使用 JOIN 一次返回所需字段。
  • 收集外键后使用 WHERE id IN (...) 批量查询。
  • 在请求生命周期内对重复键做批量加载。
  • 合理反规范化只读模型。

减少 SQL 次数不等于返回无限数据。分页、字段裁剪和索引仍然必要。

批量写入

大量单行 INSERT 会产生多次网络往返。可根据数据库能力选择:

  • 多值 INSERT。
  • 数据库原生批量导入协议。
  • 在事务内复用语句。
  • 按固定批次提交,避免单个事务过大。

批次大小需要压测。过大可能增加锁持有时间、日志体积和失败重试成本。

SQL 性能优化流程

  1. 记录慢查询及其调用路由、耗时和返回行数。
  2. 使用 EXPLAIN 或数据库执行计划确认扫描方式。
  3. 检查过滤、关联和排序字段是否有合适索引。
  4. 避免 SELECT *,只查询实际需要的列。
  5. 对列表接口使用稳定排序和游标分页。
  6. 控制事务范围,不在事务内执行远程 HTTP 调用。
  7. 结合数据库 CPU、I/O、锁等待和连接池等待判断瓶颈。

不要根据 SQL 文本猜测性能。数据规模、分布、索引选择性和统计信息都会影响执行计划。

超时、重试与幂等性

每个查询都应继承请求 Context,并设置符合业务预算的超时。超时后不要在底层无限重试。

只有满足以下条件时才考虑自动重试:

  • 错误明确是瞬时错误,例如可识别的死锁或连接切换。
  • 操作具有幂等性,或使用唯一键、幂等键防止重复写入。
  • 重试次数和总耗时有上限,并采用退避与抖动。

事务提交结果不明确时,直接重试可能重复执行,需要业务幂等机制判断最终状态。

测试策略

  • Repository 单元测试可以使用小接口替身验证上层逻辑。
  • SQL 语句、约束和事务必须在真实数据库集成测试中验证。
  • 每个测试使用独立数据库、Schema 或事务隔离数据。
  • 测试正常提交、业务回滚、Context 超时和数据库错误。
  • 对热点查询准备接近生产规模的数据并执行基准测试。

生产检查清单

  1. 启动时是否通过 PingContext 验证数据库连接。
  2. 连接池总量是否与数据库容量和服务副本数匹配。
  3. 是否监控连接等待、查询延迟、慢查询和锁等待。
  4. 所有查询是否使用 Context 和参数化 SQL。
  5. 多行查询是否关闭 Rows 并检查迭代错误。
  6. 事务是否包含完整一致性边界且不混用 db
  7. 是否避免 N+1、无限列表和超大事务。
  8. 重试是否仅用于可识别的瞬时错误并具备幂等保护。
  9. DSN、日志和错误响应是否避免泄露凭据。

数据库性能是应用连接池、SQL、索引、事务和数据库资源共同作用的结果。先建立可观测性,再根据真实等待位置调整参数,才能避免把问题从应用层转移到数据库层。