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是否长期接近上限。WaitCount和WaitDuration是否持续增长。- 查询延迟升高时数据库 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 不能直接扫描到普通 string 或 time.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,通常可以忽略。它保证中途任何返回路径都尝试回滚。
事务中不能混用 db 和 tx 查询,否则操作可能落到不同连接并脱离事务。
隔离级别由业务一致性要求和数据库实现共同决定。更高隔离级别通常意味着更多锁冲突,不能机械设置为最高。
事务辅助函数
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 性能优化流程
- 记录慢查询及其调用路由、耗时和返回行数。
- 使用
EXPLAIN或数据库执行计划确认扫描方式。 - 检查过滤、关联和排序字段是否有合适索引。
- 避免
SELECT *,只查询实际需要的列。 - 对列表接口使用稳定排序和游标分页。
- 控制事务范围,不在事务内执行远程 HTTP 调用。
- 结合数据库 CPU、I/O、锁等待和连接池等待判断瓶颈。
不要根据 SQL 文本猜测性能。数据规模、分布、索引选择性和统计信息都会影响执行计划。
超时、重试与幂等性
每个查询都应继承请求 Context,并设置符合业务预算的超时。超时后不要在底层无限重试。
只有满足以下条件时才考虑自动重试:
- 错误明确是瞬时错误,例如可识别的死锁或连接切换。
- 操作具有幂等性,或使用唯一键、幂等键防止重复写入。
- 重试次数和总耗时有上限,并采用退避与抖动。
事务提交结果不明确时,直接重试可能重复执行,需要业务幂等机制判断最终状态。
测试策略
- Repository 单元测试可以使用小接口替身验证上层逻辑。
- SQL 语句、约束和事务必须在真实数据库集成测试中验证。
- 每个测试使用独立数据库、Schema 或事务隔离数据。
- 测试正常提交、业务回滚、Context 超时和数据库错误。
- 对热点查询准备接近生产规模的数据并执行基准测试。
生产检查清单
- 启动时是否通过
PingContext验证数据库连接。 - 连接池总量是否与数据库容量和服务副本数匹配。
- 是否监控连接等待、查询延迟、慢查询和锁等待。
- 所有查询是否使用 Context 和参数化 SQL。
- 多行查询是否关闭 Rows 并检查迭代错误。
- 事务是否包含完整一致性边界且不混用
db。 - 是否避免 N+1、无限列表和超大事务。
- 重试是否仅用于可识别的瞬时错误并具备幂等保护。
- DSN、日志和错误响应是否避免泄露凭据。
数据库性能是应用连接池、SQL、索引、事务和数据库资源共同作用的结果。先建立可观测性,再根据真实等待位置调整参数,才能避免把问题从应用层转移到数据库层。