This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/GORM/14-错误处理.md
T

272 lines
8.3 KiB
Markdown
Raw Normal View History

2026-04-28 20:23:33 +08:00
---
tags: [GORM, Go, ORM, 错误处理, ErrRecordNotFound, ErrDuplicatedKey, TransactionFinished]
create time: 2026-04-28 00:00
---
# 错误处理
## 概述
GORM **从不 panic**——所有错误都通过 `.Error` 字段返回。这种设计让业务层可以在不中断程序的情况下优雅地处理异常,但也意味着开发者需要养成「始终检查 Error」的习惯。
```mermaid
flowchart TD
Result["db.XXX() → result"] --> HasErr{"result.Error != 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
```
## 核心错误常量
### 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 {
// 真正的数据库错误
log.Printf("查询用户失败: %v", result.Error)
return err
}
// 成功分支
log.Printf("找到用户: %s", user.Name)
```
> [!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("指定的用户不存在")
}
```
### 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 {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("用户 ID=%d 不存在: %w", id, err)
}
return nil, fmt.Errorf("查询用户失败: %w", err)
}
return &user, nil
}
```
### 自定义错误处理器
```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 也会被当成一般错误处理
log.Printf("error: %v", err)
}
// 3. 先判断 Error != nil 再用 == 比较
err := db.First(&user, 1).Error
if err != nil && err == gorm.ErrRecordNotFound {
// 在某些情况下可能漏判
}
```
### ✅ 正确的写法
```go
// 模式一:errors.Is 优先
if err := db.First(&user, 1).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 单独处理
} else {
// 其他数据库错误
}
}
// 模式二:先赋值再判断
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)
}
```
## 连接错误与超时处理
```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,确保服务知道数据库是否可用。
## 错误处理决策流程图
```mermaid
flowchart TD
Start[操作返回结果] --> CheckErr{"result.Error 非空?"}
CheckErr --> |否| Success["操作成功"]
CheckErr --> |是| FirstCheck{errors.Is...}
FirstCheck --> |ErrRecordNotFound| Handler1["处理: 返回默认值/404"]
FirstCheck --> |ErrDuplicatedKey| Handler2["处理: 提示重复"]
FirstCheck --> |ErrForeignKeyConstraintViolated| Handler3["处理: 检查关联"]
FirstCheck --> |ErrTransactionFinished| Handler4["处理: 代码缺陷修复"]
FirstCheck --> |都不匹配| DefaultHandler["处理: 通用错误/500"]
style Start fill:#4FC08D,color:#fff
style Success fill:#3B82F6,color:#fff
style DefaultHandler fill:#EF4444,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 |
## 关联笔记
- [[03-CRUD 操作]]
- [[08-事务管理]]
- [[16-日志与调试]]