数据库访问层最佳实践

同样的数据库,接入方式不同,稳定性可以相差一个量级。本章汇总应用侧访问数据库时最容易踩坑、也最值得规范化的几个点,示例以 Go 为主,但原则通用。

连接池

连接是昂贵资源。池太小会排队、池太大压垮数据库。经验公式:

单实例所需连接数 ≈ 平均QPS × 平均查询耗时(秒) + 余量
全局连接数      = 应用实例数 × 每实例最大连接数
约束            = 全局连接数 < 数据库 max_connections × 0.8
sqlDB, _ := db.DB()

sqlDB.SetMaxOpenConns(25)                        // 最大打开连接
sqlDB.SetMaxIdleConns(25)                        // 最大空闲连接(建议 = MaxOpen)
sqlDB.SetConnMaxLifetime(5 * time.Minute)        // 连接最长存活,避免被单方断开
sqlDB.SetConnMaxIdleTime(2 * time.Minute)        // 空闲多久回收
Warning

SetMaxIdleConns 小于 SetMaxOpenConns 时,高并发后大量连接会被关闭又重建,产生「连接抖动」。两者保持一致通常是更稳的选择。SetConnMaxLifetime 必须设置,否则中间件(如云数据库代理)单方面断开空闲连接会导致偶发 invalid connection

超时与 context

每一条查询都应带超时。没有超时的查询会在数据库卡顿时耗尽调用方资源,引发雪崩。

// 单次查询超时
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
row := db.QueryRowContext(ctx, "SELECT ... WHERE id = ?", id)

// 事务超时:整个事务共享一个 deadline
ctx, cancel = context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
tx, err := db.BeginTx(ctx, nil)

分层超时应自上而下收紧或一致:HTTP 请求超时 ≥ 数据库操作超时,避免上游早已放弃、下游仍在苦算。

重试与幂等

只重试可安全重试的操作,并对瞬时错误重试:

可重试(瞬时)不可重试
连接超时、连接中断语法错误、约束冲突
死锁、锁等待超时权限/认证失败
主从切换导致的失败数据校验失败
限流/过载(带退避)业务逻辑错误
func withRetry(ctx context.Context, attempts int, fn func() error) error {
    var err error
    for i := 0; i < attempts; i++ {
        if err = fn(); err == nil {
            return nil
        }
        if !isRetryable(err) {
            return err
        }
        // 指数退避 + 抖动,避免惊群
        backoff := time.Duration(1<<i) * 50 * time.Millisecond
        jitter := time.Duration(rand.Int63n(int64(backoff / 2)))
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(backoff + jitter):
        }
    }
    return err
}
Tip

重试必须配合幂等。对于「创建订单」「扣款」等写操作,用幂等键(Idempotency Key)保证重复执行不产生副作用:唯一约束 + INSERT ... ON CONFLICT DO NOTHING,或先查幂等表再执行。否则重试会变成重复下单。

参数化与语句复用

  • 始终使用占位符(? / $n),杜绝拼接。
  • 复用 prepared statement 可减少解析开销:
stmt, err := db.PrepareContext(ctx, "INSERT INTO orders (user_id, amount) VALUES (?, ?)")
if err != nil {
    return err
}
defer stmt.Close()

for _, o := range orders {
    if _, err := stmt.ExecContext(ctx, o.UserID, o.Amount); err != nil {
        return err
    }
}

事务管理

tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return err
}
defer tx.Rollback() // 已 Commit 后是空操作,作为安全网

// ... 执行语句,任一失败直接 return err 触发回滚
if err := doWork(ctx, tx); err != nil {
    return err
}
return tx.Commit()

原则:

  • 短小:事务内只做数据库操作,绝不包含网络调用、文件 IO、消息发送。
  • 固定加锁顺序:多行/多表更新按统一顺序,降低死锁概率。
  • 失败即返回:让 defer Rollback 兜底,避免忘记回滚导致长事务。

错误处理

err := db.QueryRowContext(ctx, q, id).Scan(&u.ID, &u.Name)
switch {
case errors.Is(err, sql.ErrNoRows):
    return nil, ErrNotFound        // 业务语义,非系统错误
case err == nil:
    // 正常
case isDeadlock(err):
    // 可重试
default:
    return nil, err
}
  • sql.ErrNoRows正常的「无数据」,必须单独处理。
  • 死锁/锁等待可按驱动错误码识别并重试。
  • errors.Is/As 而非字符串匹配错误信息。

批量与流式

// 批量插入:一条 SQL 多值,减少往返
db.CreateInBatches(&orders, 500)   // GORM

// 大结果集流式读取,避免一次性载入内存
rows, err := db.QueryContext(ctx, "SELECT ... FROM big_table")
defer rows.Close()
for rows.Next() {
    // 逐行处理
}
return rows.Err()

健康检查与优雅关闭

  • 启动时 Ping 验证连通性;就绪探针可复用轻量查询。
  • 停机时先停止接收新请求,等待在途请求完成,再关闭连接池。
  • 为连接池设置合理的 ConnMaxLifetime,配合数据库滚动升级。

小结

  • 连接池按公式估算并设上限,MaxIdle=MaxOpen,必须设 ConnMaxLifetime
  • 每条查询带超时;分层超时自上而下收敛。
  • 只重试瞬时错误,重试必须幂等(幂等键 + 唯一约束)。
  • 一律参数化、必要时复用 prepared statement;事务短小、失败即返回。
  • 正确区分 ErrNoRows 与系统错误;批量/流式处理大结果集。