Files
cs-note/hhs/GORM/14-错误处理.md
T
2026-05-24 11:42:38 +08:00

402 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [GORM, Go, ORM, 错误处理, ErrRecordNotFound, ErrDuplicatedKey, TransactionFinished]
create time: 2026-04-28 00:00
---
# 错误处理
## 概述
GORM **从不 panic**——所有错误都通过 `result.Error` 字段返回。这种设计让业务层可以在不中断程序的情况下优雅地处理异常,但也意味着开发者需要养成「始终检查 Error」的习惯。
核心要点:
- **每个 GORM 方法返回 `*gorm.DB`**(链式调用),真正的结果在 `.Error` 字段里 —— 忘记检查 `.Error` 是新手最常见的坑。
- **哨兵错误用 `errors.Is` 判断**,不要用 `==` ,因为 GORM 内部会用 `%w` 包装错误。
- **数据库错误分三层**:应用层(RecordNotFound / DuplicateKey)、事务层(TransactionFinished)、基础设施层(连接断开 / SQL 错误)—— 每一层的处理策略不同。
> [!question] 思考:如果 `db.Create(&user).Error` 是 nil,是不是就代表数据一定写入了?
>
> 不一定。如果当前事务处于回滚状态,或者事务最终 Rollback 了,创建操作虽然 "成功" 了但不会被持久化。**错误检查必须和事务生命周期配合考虑**。我们后面会详细讲。
```mermaid
flowchart TD
Start[db.XXX() 操作] --> Result["返回 result"]
Result --> HasErr{"result.Error<br/>!= nil?"}
HasErr --> |否| Success["继续业务逻辑"]
HasErr --> |是| CheckType{判断错误类型}
CheckType --> |ErrRecordNotFound| NotFound["404 / 默认值"]
CheckType --> |ErrDuplicatedKey| Duplicate["409 / 提示修改"]
CheckType --> |TransactionFinished| TxErr["500 / 代码缺陷"]
CheckType --> |其他 errors.Is| Generic["记录日志 / 通用错误"]
style Start fill:#4FC08D,color:#fff
style Success fill:#3B82F6,color:#fff
style NotFound fill:#EF4444,color:#fff
style Generic fill:#F59E0B,color:#fff
```
## 核心错误常量
### gorm.ErrRecordNotFound
```go
var user User
result := db.First(&user, 1)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
// 记录不存在 —— 可以返回 404 或设置默认值
return http.NotFound(w, r)
} else if result.Error != nil {
// 真正的数据库错误(连接失败、SQL 语法问题等)
log.Printf("查询用户失败: %v", result.Error)
return err
}
// 成功分支
log.Printf("找到用户: %s", user.Name)
```
> [!exemplar] 关键模式:先 `errors.Is` 精确判断,再兜底检查非空
> 这段代码的精髓在于「**分层判断**」:第一层处理预期内的业务场景(找不到),第二层处理意外情况(连接断了)。如果反过来先检查 `nil`,会丢失对 `ErrRecordNotFound` 的精准处理能力。
> [!tip] 为什么用 errors.Is 而不是 ==?
> `gorm.ErrRecordNotFound` 是一个 sentinal error(哨兵错误),Go 标准库的 `errors.Is()` 能正确处理包装过的错误链。直接用 `==` 在复杂场景中可能失效。
### gorm.ErrDuplicatedKey / gorm.ErrForeignKeyConstraintViolated
```go
result := db.Create(&User{Email: "alice@example.com"})
if errors.Is(result.Error, gorm.ErrDuplicatedKey) {
// 唯一约束冲突 —— 通常是 Email 已注册
return fmt.Errorf("该邮箱已被注册")
}
// 外键约束失败:尝试创建一条指向不存在用户的订单
type Order struct { UserID uint }
result = db.Create(&Order{UserID: 999})
if errors.Is(result.Error, gorm.ErrForeignKeyConstraintViolated) {
return fmt.Errorf("指定的用户不存在")
}
```
> [!tip] `ErrDuplicatedKey` 和 `ErrForeignKeyConstraintViolated` 都来自数据库引擎本身(如 MySQL 的 errno 1062 / 1452),GORM 只是把它们包装成了哨兵错误。因此判断顺序没有严格要求,但一般**先判主键冲突再判外键**。
### gorm.TransactionFinished
```go
tx := db.Begin()
tx.Commit() // 先提交
tx.Rollback() // ❌ 再回滚会报错!
// result.Error == gorm.ErrTransactionFinished
```
> [!warning] 常见触发场景
> 这个错误最常见于 `defer Rollback()` 模式——如果 Commit 成功了但 defer 仍会执行 Rollback:
> ```go
> func Example() error {
> tx := db.Begin()
> defer func() {
> if r := recover(); r != nil {
> tx.Rollback()
> panic(r)
> }
> }()
>
> // ... 操作 ...
>
> if err := tx.Commit().Error; err != nil {
> tx.Rollback()
> return err
> }
> // 到达这里说明 Commit 成功 —— defer 不会进 panic 分支
> return nil
> }
> ```
### 常用错误常量汇总
| 常量 | 含义 | 典型 HTTP 状态码 | 处理建议 |
|------|------|-----------------|---------|
| `gorm.ErrRecordNotFound` | 查询结果为空 | 404 | 返回友好提示或默认值 |
| `gorm.ErrDuplicatedKey` | 唯一约束冲突 | 409 | 提示用户修改输入 |
| `gorm.ErrForeignKeyConstraintViolated` | 外键约束失败 | 400 | 检查关联数据是否存在 |
| `gorm.ErrInvalidData` | 数据无效 | 400 | 校验输入后重试 |
| `gorm.ErrTransactionFinished` | 事务已提交/回滚 | 500 | 代码逻辑 BUG,修复代码 |
| `gorm.ErrUnsupportedDriver` | 不支持的驱动 | 配置错误 | 检查 dsn 和驱动导入 |
| `gorm.ErrInvalidTransaction` | 无效的事务操作 | 500 | 检查事务上下文是否正确 |
## 自定义错误判断
### 包装业务错误
```go
func GetUserByID(db *gorm.DB, id uint) (*User, error) {
var user User
if err := db.First(&user, id).Error; err != nil {
// 保留原始哨兵错误,方便上层继续用 errors.Is 判断
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("用户 ID=%d 不存在: %w", id, err)
}
return nil, fmt.Errorf("查询用户失败: %w", err)
}
return &user, nil
}
```
> [!exemplar] `%w` 是 Go 的错误包装语法 —— 它让外层错误仍然能被 `errors.Is()` 逐层识别。如果只写 `%v` ,上层的 `errors.Is(err, gorm.ErrRecordNotFound)` 将返回 false。
### 自定义错误处理器
```go
// 统一错误解析器,适合中间件或框架层使用
func ParseDBError(err error) (int, string) {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
return http.StatusNotFound, "资源未找到"
case errors.Is(err, gorm.ErrDuplicatedKey):
return http.StatusConflict, "数据冲突,请检查输入"
case errors.Is(err, gorm.ErrForeignKeyConstraintViolated):
return http.StatusBadRequest, "关联数据无效"
case errors.Is(err, gorm.ErrTransactionFinished):
return http.StatusInternalServerError, "系统内部错误"
default:
log.Printf("未分类数据库错误: %v", err)
return http.StatusInternalServerError, "数据库操作失败"
}
}
// 在 Gin handler 中使用
func HandleCreateUser(c *gin.Context) {
err := createUserService(c.Request)
if err != nil {
status, msg := ParseDBError(err)
c.JSON(status, gin.H{"error": msg})
return
}
c.JSON(201, gin.H{"message": "创建成功"})
}
```
## 错误检查的正确姿势
### ❌ 错误的写法
```go
// 1. 完全忽略错误 —— 数据丢了你都不会知道
db.Create(&user)
// 2. 只检查非 nil(会误把 ErrRecordNotFound 当成故障)
if err := db.First(&user, 1).Error; err != nil {
// ErrRecordNotFound 也会被当成一般错误,导致用户看到 500 而不是 404
log.Printf("error: %v", err)
}
// 3. 先判断 Error != nil 再用 == 比较 —— 可能被 fmt.Errorf("%w") 包装后漏判
err := db.First(&user, 1).Error
if err != nil && err == gorm.ErrRecordNotFound {
// 在某些 GORM 版本或复杂场景中可能漏判
}
```
### ✅ 正确的写法
```go
// 模式一:errors.Is 优先(推荐日常使用)
if err := db.First(&user, 1).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 单独处理
} else {
// 其他数据库错误
}
}
// 模式二:switch + result.Error(适合多分支场景)
result := db.First(&user, 1)
switch {
case errors.Is(result.Error, gorm.ErrRecordNotFound):
handleNotFound()
case result.Error != nil:
handleError(result.Error)
default:
handleSuccess(user)
}
// 模式三:简洁单路径(适合函数入口校验)
if result := db.First(&user, 1); result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("user not found")
}
return nil, fmt.Errorf("db error: %w", result.Error)
}
```
> [!tip] 什么时候用模式二(switch)?
> 当你的 handler 需要同时处理多种业务错误时,`switch-case` 比嵌套 `if-else` **更清晰、更易扩展**。每新增一种错误类型只需加一个 case,不会让代码块不断缩进。
## 连接错误与连接池管理
### PingContext 探测
```go
// 检测数据库是否可达
sqlDB, err := db.DB()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := sqlDB.PingContext(ctx); err != nil {
log.Printf("数据库连接异常: %v", err)
// 可以尝试重连、切换从库等
return fmt.Errorf("database unavailable")
}
```
> [!tip] 结合健康检查
> 在生产环境中,应该定期执行 `PingContext` 作为 K8s liveness/readiness probe,确保服务知道数据库是否可用。Gin 项目里可以注册一个 `/health` 路由,里面做这一步即可。
### 连接池脏连接问题(重要!)
即使 Ping 通过了,**从连接池借出的连接也可能已断开**——这被称为 "stale connection" 或 "dead connection"。典型场景:MySQL 的 `wait_timeout` 默认 8 小时,空闲连接被服务端主动关闭,但 Go 端连接池不知情。
GORM v2 内置了 stale connection replacer(v2.0.9+),会自动重试一次死连接。但你仍需合理配置连接池参数:
```go
sqlDB, _ := db.DB()
// 最大空闲连接数 —— 设太高会浪费资源
sqlDB.SetMaxIdleConns(10)
// 最大打开连接数 —— 包括正在使用的 + 空闲的
sqlDB.SetMaxOpenConns(100)
// 每个连接的存活时间 —— 必须小于 MySQL wait_timeout(默认 8h)
// 建议设为 5min 以避免服务端超时踢掉连接
sqlDB.SetConnMaxLifetime(5 * time.Minute)
// 每个连接的闲置时间 —— 超过此时间的连接会被回收
sqlDB.SetConnMaxIdleTime(5 * time.Minute)
```
> [!warning] `SetConnMaxLifetime` vs `SetConnMaxIdleTime`
> - `SetConnMaxLifetime`: 连接的总寿命,超过后新请求不会再用这个连接。**主要用来避免服务端侧的超时断开**。
> - `SetConnMaxIdleTime`: 连接闲置多久后被回收。主要用于控制空闲连接数量,节省资源。
>
> 两者都建议设置,且 IdleTime < Lifetime < 数据库 wait_timeout。
### SQL 层级错误码判断
有时你需要更细粒度地判断错误来源(比如区分 "表不存在" 和 "字段类型不匹配"):
```go
// 引入 MySQL 驱动以获取底层错误类型
import "github.com/go-sql-driver/mysql"
// 细粒度错误码判断
result := db.Exec("ALTER TABLE users ADD COLUMN email VARCHAR(255)")
if mysqlErr, ok := result.Error.(*mysql.MySQLError); ok {
switch mysqlErr.Number {
case 1060: // Duplicate column name
log.Println("列已存在,跳过")
case 1146: // Table doesn't exist
log.Println("表不存在,需要初始化")
default:
log.Printf("MySQL error %d: %v", mysqlErr.Number, mysqlErr.Message)
}
}
```
> [!exemplar] 什么时候用 SQL 层错误码?
> 一般业务逻辑不需要用到这一步。通常只在以下场景需要:**数据库迁移脚本**(幂等执行)、**建表/建库的自动初始化逻辑**、或者你需要区分不同 MySQL 错误号来做差异化重试策略时。
## 错误处理决策流程图
```mermaid
flowchart TD
Start[db 操作返回结果] --> CheckErr{"result.Error<br/>非空?"}
CheckErr --> |否| Success["操作成功"]
CheckErr --> |是| FirstCheck{errors.Is...?}
FirstCheck --> |ErrRecordNotFound| Handler1["404 / 默认值"]
FirstCheck --> |ErrDuplicatedKey| Handler2["409 / 提示重复"]
FirstCheck --> |ErrForeignKeyConstraintViolated| Handler3["400 / 检查关联"]
FirstCheck --> |ErrTransactionFinished| Handler4["500 / 代码缺陷"]
FirstCheck --> |都不匹配| DefaultHandler["500 / 通用数据库错误"]
style Start fill:#4FC08D,color:#fff
style Success fill:#3B82F6,color:#fff
style DefaultHandler fill:#F59E0B,color:#fff
```
## 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| 忘记检查 `.Error` | 以为方法直接返回 error | 养成 `if err := xxx().Error; err != nil` 习惯 |
| 用 `==` 而非 `errors.Is` | 哨兵错误被 fmt.Errorf("%w") 包装后丢失 | 统一使用 `errors.Is(err, gorm.xxx)` |
| Commit 后再 Rollback | defer 无条件执行回滚 | 只在 panic 分支中 Rollback |
| 把所有错误当 RecordNotFound | 没做错误类型分层 | 先用 errors.Is 精确判断 |
| 数据库断开时没检测到 | 连接池复用旧连接 | PingContext 定期探测 + SetConnMaxLifetime |
| 空闲连接被 MySQL 回收 | wait_timeout 与 Go 连接池不匹配 | 设置 ConnMaxLifetime < MySQL wait_timeout |
## 实战:GORM Interceptor 统一错误处理
在 Gin 项目中,可以通过 GORM 的 [Interceptor](https://gorm.io/docs/advanced.html#Interceptor) 机制或自定义 Middleware 统一处理数据库错误,避免在每个 handler 里重复写错误解析逻辑。
```go
// DBErrorLogger 记录所有非 NotFound 的数据库错误
func DBErrorLogger(logger *log.Logger) db.Interceptor {
return &interceptor{logger: logger}
}
type interceptor struct {
logger *log.Logger
}
func (i *interceptor) Before(name string) db.Interceptor {
return i
}
func (i *interceptor) After(name string, result gorm.ResultInfo) gorm.ResultInfo {
// 注意: Interceptor 的 result.Error 已经是方法调用后的最终结果
if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) {
i.logger.Printf("[DB Error] operation=%s error=%v", name, result.Error)
}
return result
}
```
```go
// 注册到全局配置
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: customLogger,
})
_ = db.Use(DBErrorLogger(log.New(os.Stdout, "[GORM]", log.LstdFlags)))
```
> [!tip] Interceptor vs Handler 层错误处理
> - **Interceptor(全局)**:负责日志记录和监控告警 —— "出错了我要知道"
> - **Handler(业务层)**:负责业务语义转换 —— "这个错对用户意味着什么"
>
> 两者互补:Intercepter 让你不会错过任何异常,Handler 层决定怎么回复客户端。
## 最佳实践清单
1. **统一入口检查**:在 service 层入口处统一做 `err != nil` 检查,不要散落在业务逻辑中。
2. **保留哨兵错误**:使用 `%w` 包装错误,让上层可以继续用 `errors.Is` 判断。
3. **HTTP 状态码映射**:用 switch-case 把错误类型映射为 HTTP 状态码,参考 `ParseDBError` 函数模式。
4. **日志分级**:`ErrRecordNotFound` 等预期内错误记 `info` / `debug`,真正故障记 `error`。
5. **连接池参数**:务必设置 `ConnMaxLifetime` 和 `ConnMaxIdleTime`,避免脏连接引发随机报错。
6. **事务安全**:优先使用 "Commit 成功才 return nil + defer panic only rollback" 模式,避免二次提交。
## 关联笔记
- [[03-CRUD 操作]]
- [[08-事务管理]]
- [[16-日志与调试]]