2026-04-28 20:23:33 +08:00
|
|
|
|
---
|
|
|
|
|
|
tags: [GORM, Go, ORM, 错误处理, ErrRecordNotFound, ErrDuplicatedKey, TransactionFinished]
|
|
|
|
|
|
create time: 2026-04-28 00:00
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
# 错误处理
|
|
|
|
|
|
|
|
|
|
|
|
## 概述
|
|
|
|
|
|
|
2026-04-28 20:56:51 +08: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 了,创建操作虽然 "成功" 了但不会被持久化。**错误检查必须和事务生命周期配合考虑**。我们后面会详细讲。
|
2026-04-28 20:23:33 +08:00
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
|
flowchart TD
|
2026-04-28 20:56:51 +08:00
|
|
|
|
Start[db.XXX() 操作] --> Result["返回 result"]
|
|
|
|
|
|
Result --> HasErr{"result.Error<br/>!= nil?"}
|
|
|
|
|
|
HasErr --> |否| Success["继续业务逻辑"]
|
2026-04-28 20:23:33 +08:00
|
|
|
|
HasErr --> |是| CheckType{判断错误类型}
|
|
|
|
|
|
|
|
|
|
|
|
CheckType --> |ErrRecordNotFound| NotFound["404 / 默认值"]
|
2026-04-28 20:56:51 +08:00
|
|
|
|
CheckType --> |ErrDuplicatedKey| Duplicate["409 / 提示修改"]
|
|
|
|
|
|
CheckType --> |TransactionFinished| TxErr["500 / 代码缺陷"]
|
|
|
|
|
|
CheckType --> |其他 errors.Is| Generic["记录日志 / 通用错误"]
|
2026-04-28 20:23:33 +08:00
|
|
|
|
|
|
|
|
|
|
style Start fill:#4FC08D,color:#fff
|
|
|
|
|
|
style Success fill:#3B82F6,color:#fff
|
|
|
|
|
|
style NotFound fill:#EF4444,color:#fff
|
2026-04-28 20:56:51 +08:00
|
|
|
|
style Generic fill:#F59E0B,color:#fff
|
2026-04-28 20:23:33 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 核心错误常量
|
|
|
|
|
|
|
|
|
|
|
|
### 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 {
|
2026-04-28 20:56:51 +08:00
|
|
|
|
// 真正的数据库错误(连接失败、SQL 语法问题等)
|
2026-04-28 20:23:33 +08:00
|
|
|
|
log.Printf("查询用户失败: %v", result.Error)
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 成功分支
|
|
|
|
|
|
log.Printf("找到用户: %s", user.Name)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-28 20:56:51 +08:00
|
|
|
|
> [!exemplar] 关键模式:先 `errors.Is` 精确判断,再兜底检查非空
|
|
|
|
|
|
> 这段代码的精髓在于「**分层判断**」:第一层处理预期内的业务场景(找不到),第二层处理意外情况(连接断了)。如果反过来先检查 `nil`,会丢失对 `ErrRecordNotFound` 的精准处理能力。
|
|
|
|
|
|
|
2026-04-28 20:23:33 +08:00
|
|
|
|
> [!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("该邮箱已被注册")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-28 20:56:51 +08:00
|
|
|
|
// 外键约束失败:尝试创建一条指向不存在用户的订单
|
2026-04-28 20:23:33 +08:00
|
|
|
|
type Order struct { UserID uint }
|
2026-04-28 20:56:51 +08:00
|
|
|
|
result = db.Create(&Order{UserID: 999})
|
2026-04-28 20:23:33 +08:00
|
|
|
|
if errors.Is(result.Error, gorm.ErrForeignKeyConstraintViolated) {
|
|
|
|
|
|
return fmt.Errorf("指定的用户不存在")
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-28 20:56:51 +08:00
|
|
|
|
> [!tip] `ErrDuplicatedKey` 和 `ErrForeignKeyConstraintViolated` 都来自数据库引擎本身(如 MySQL 的 errno 1062 / 1452),GORM 只是把它们包装成了哨兵错误。因此判断顺序没有严格要求,但一般**先判主键冲突再判外键**。
|
|
|
|
|
|
|
2026-04-28 20:23:33 +08:00
|
|
|
|
### 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 {
|
2026-04-28 20:56:51 +08:00
|
|
|
|
// 保留原始哨兵错误,方便上层继续用 errors.Is 判断
|
2026-04-28 20:23:33 +08:00
|
|
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
|
|
|
|
return nil, fmt.Errorf("用户 ID=%d 不存在: %w", id, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, fmt.Errorf("查询用户失败: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return &user, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-28 20:56:51 +08:00
|
|
|
|
> [!exemplar] `%w` 是 Go 的错误包装语法 —— 它让外层错误仍然能被 `errors.Is()` 逐层识别。如果只写 `%v` ,上层的 `errors.Is(err, gorm.ErrRecordNotFound)` 将返回 false。
|
|
|
|
|
|
|
2026-04-28 20:23:33 +08:00
|
|
|
|
### 自定义错误处理器
|
|
|
|
|
|
|
|
|
|
|
|
```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
|
2026-04-28 20:56:51 +08:00
|
|
|
|
// 1. 完全忽略错误 —— 数据丢了你都不会知道
|
2026-04-28 20:23:33 +08:00
|
|
|
|
db.Create(&user)
|
|
|
|
|
|
|
2026-04-28 20:56:51 +08:00
|
|
|
|
// 2. 只检查非 nil(会误把 ErrRecordNotFound 当成故障)
|
2026-04-28 20:23:33 +08:00
|
|
|
|
if err := db.First(&user, 1).Error; err != nil {
|
2026-04-28 20:56:51 +08:00
|
|
|
|
// ErrRecordNotFound 也会被当成一般错误,导致用户看到 500 而不是 404
|
2026-04-28 20:23:33 +08:00
|
|
|
|
log.Printf("error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-28 20:56:51 +08:00
|
|
|
|
// 3. 先判断 Error != nil 再用 == 比较 —— 可能被 fmt.Errorf("%w") 包装后漏判
|
2026-04-28 20:23:33 +08:00
|
|
|
|
err := db.First(&user, 1).Error
|
|
|
|
|
|
if err != nil && err == gorm.ErrRecordNotFound {
|
2026-04-28 20:56:51 +08:00
|
|
|
|
// 在某些 GORM 版本或复杂场景中可能漏判
|
2026-04-28 20:23:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### ✅ 正确的写法
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
2026-04-28 20:56:51 +08:00
|
|
|
|
// 模式一:errors.Is 优先(推荐日常使用)
|
2026-04-28 20:23:33 +08:00
|
|
|
|
if err := db.First(&user, 1).Error; err != nil {
|
|
|
|
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
|
|
|
|
// 单独处理
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 其他数据库错误
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-28 20:56:51 +08:00
|
|
|
|
// 模式二:switch + result.Error(适合多分支场景)
|
2026-04-28 20:23:33 +08:00
|
|
|
|
result := db.First(&user, 1)
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case errors.Is(result.Error, gorm.ErrRecordNotFound):
|
|
|
|
|
|
handleNotFound()
|
|
|
|
|
|
case result.Error != nil:
|
|
|
|
|
|
handleError(result.Error)
|
|
|
|
|
|
default:
|
|
|
|
|
|
handleSuccess(user)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-28 20:56:51 +08:00
|
|
|
|
// 模式三:简洁单路径(适合函数入口校验)
|
2026-04-28 20:23:33 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-28 20:56:51 +08:00
|
|
|
|
> [!tip] 什么时候用模式二(switch)?
|
|
|
|
|
|
> 当你的 handler 需要同时处理多种业务错误时,`switch-case` 比嵌套 `if-else` **更清晰、更易扩展**。每新增一种错误类型只需加一个 case,不会让代码块不断缩进。
|
|
|
|
|
|
|
|
|
|
|
|
## 连接错误与连接池管理
|
|
|
|
|
|
|
|
|
|
|
|
### PingContext 探测
|
2026-04-28 20:23:33 +08:00
|
|
|
|
|
|
|
|
|
|
```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] 结合健康检查
|
2026-04-28 20:56:51 +08:00
|
|
|
|
> 在生产环境中,应该定期执行 `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 错误号来做差异化重试策略时。
|
2026-04-28 20:23:33 +08:00
|
|
|
|
|
|
|
|
|
|
## 错误处理决策流程图
|
|
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
|
flowchart TD
|
2026-04-28 20:56:51 +08:00
|
|
|
|
Start[db 操作返回结果] --> CheckErr{"result.Error<br/>非空?"}
|
2026-04-28 20:23:33 +08:00
|
|
|
|
CheckErr --> |否| Success["操作成功"]
|
2026-04-28 20:56:51 +08:00
|
|
|
|
CheckErr --> |是| FirstCheck{errors.Is...?}
|
2026-04-28 20:23:33 +08:00
|
|
|
|
|
2026-04-28 20:56:51 +08:00
|
|
|
|
FirstCheck --> |ErrRecordNotFound| Handler1["404 / 默认值"]
|
|
|
|
|
|
FirstCheck --> |ErrDuplicatedKey| Handler2["409 / 提示重复"]
|
|
|
|
|
|
FirstCheck --> |ErrForeignKeyConstraintViolated| Handler3["400 / 检查关联"]
|
|
|
|
|
|
FirstCheck --> |ErrTransactionFinished| Handler4["500 / 代码缺陷"]
|
|
|
|
|
|
FirstCheck --> |都不匹配| DefaultHandler["500 / 通用数据库错误"]
|
2026-04-28 20:23:33 +08:00
|
|
|
|
|
|
|
|
|
|
style Start fill:#4FC08D,color:#fff
|
|
|
|
|
|
style Success fill:#3B82F6,color:#fff
|
2026-04-28 20:56:51 +08:00
|
|
|
|
style DefaultHandler fill:#F59E0B,color:#fff
|
2026-04-28 20:23:33 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 常见坑点速查
|
|
|
|
|
|
|
|
|
|
|
|
| 问题 | 原因 | 解决方案 |
|
|
|
|
|
|
|------|------|---------|
|
|
|
|
|
|
| 忘记检查 `.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 |
|
2026-04-28 20:56:51 +08:00
|
|
|
|
| 空闲连接被 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" 模式,避免二次提交。
|
2026-04-28 20:23:33 +08:00
|
|
|
|
|
|
|
|
|
|
## 关联笔记
|
|
|
|
|
|
|
|
|
|
|
|
- [[03-CRUD 操作]]
|
|
|
|
|
|
- [[08-事务管理]]
|
|
|
|
|
|
- [[16-日志与调试]]
|