Golang数据库操作中的错误处理策略

Go中database/sql错误判断需区分sql.ErrNoRows等预期错误与其他系统错误,事务Rollback()必须检查返回值,context超时错误应归类为临时故障,自定义错误类型比字符串匹配更可靠。

Go 中 database/sql 的错误判断不能只看 err != nil

很多刚写 Go 数据库逻辑的人会直接写 if err != nil 就 panic 或返回,但这样会漏掉关键状态:比如查询无结果、连接断开、事务已提交失败等。Go 的 database/sql 包把“业务无数据”和“系统出错”都塞进 error 接口,必须区分。

  • sql.ErrNoRows 是唯一可预期的“非错误错误”,表示 QueryRow 没查到任何行 —— 它不是 bug,通常该走空值逻辑(如赋默认值或返回零结构)
  • 其他错误(如 driver: bad connectioncontext deadline exceeded)才需要重试、记录或中断流程
  • 别用 errors.Is(err, sql.ErrNoRows) 判断后还继续用扫描变量,因为 Scan() 本身已失败,变量未被赋值

事务中遇到错误时,tx.Rollback() 必须检查其返回值

tx.Rollback() 不是“一定会成功”的兜底操作 —— 如果事务早已因网络中断、数据库崩溃或超时被服务端自动清理,再次调用 Rollback() 可能返回新错误(如 sql: transaction has already been committed or rolled back)。忽略它会导致误判事务状态。

  • 总要像处理主逻辑一样处理 Rollback() 的 error:
    if err := tx.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) {
        log.Printf("rollback failed: %v", err)
    }
  • sql.ErrTxDone 是唯一可安全忽略的 rollback 错误,表示事务确实已经结束(无论 commit 还是 rollback)
  • 不要在 defer 中无条件调用 tx.Rollback(),除非你明确知道事务还没 commit,否则可能覆盖真正的 commit 结果

使用 context.Context 控制查询生命周期,但注意驱动兼容性

传入 ctx 是防止查询卡死的最有效方式,但不同驱动对 context 的支持程度不一。比如 mysql 驱动从 v1.7+ 才完整支持 cancel;postgrespgx 默认支持,但原生 lib/pq 已归档且部分 context 行为不一致。

  • db.QueryContext(ctx, ...) 替代 db.Query(...),并确保 ctx 带 timeout:
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    rows, err := db.QueryContext(ctx, "SELECT * FROM users WHERE id = $1", id)
  • 若 ctx 超时,错误通常是 context deadline exceeded 或驱动特有提示(如 MySQL: driver: query canceled due to context deadline),这类错误应归类为临时故障,适合重试
  • SQLite 驱动(如 mattn/go-sqlite3)不响应 cancel,context 在那里只是摆设,需靠语句级 timeout(如 PRAGMA busy_timeout)补位

自定义错误类型比字符串匹配更可靠

strings.Contains(err.Error(), "duplicate key") 判断唯一约束冲突,既脆弱又难维护 —— 字段名、驱动版本、语言环境都可能导致错误信息变化。应该把数据库错误映射到应用层语义错误。

  • PostgreSQL 错误码(如 23505)稳定,可用 pgxpgconn.PgError 提取:
    var pgErr *pgconn.PgError
    if errors.As(err, &pgErr) && pgErr.Code == "23505" {
        return ErrDuplicateEmail
    }
  • MySQL 错误号(如 1062)也可通过 mysql.MySQLError 类型断言获取,比字符串安全得多
  • 所有数据库错误最终应转成你应用定义的错误变量(如 ErrNotFoundErrConflict),上层不用关心底层驱动细节

实际项目里最容易被跳过的,是 rollback 后对错误的再判断,以及把 SQL 层错误硬编码进业务分支。这两处一旦出问题,日志看不出原因,监控也抓不到异常路径。