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/10-软删除.md
T
2026-04-28 20:56:51 +08:00

325 lines
11 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, 软删除, SoftDelete, Unscoped, DeletedAt]
create time: 2026-04-28 00:00
---
# 软删除
## 概述
软删除是一种「逻辑删除」策略——数据并没有真正消失,只是被标记为「已删除」。这是 Web 应用中处理「删除」操作的**默认推荐方式**,因为它提供了可恢复性和审计追踪能力。
```mermaid
flowchart TD
A["调用 db.Delete(&user)"] --> SoftDel["模型有 DeletedAt 字段?"]
SoftDel --> |否|"HardSQL[DELETE FROM users WHERE id = ? ⚠️ 物理删除,不可恢复]"
SoftDel --> |是|"UpdateSQL[UPDATE users SET deleted_at=NOW() WHERE id=? ✅ 保留数据]"
UpdateSQL --> Query{"常规查询?"}
Query --> |是| Filtered["WHERE deleted_at IS NULL\n已删除记录自动隐藏"]
Query --> |否| UnscopedQ["需要已删除数据?"]
UnscopedQ --> |是| IncludeAll["Unscoped() → 全部返回"]
UnscopedQ --> |否| Filtered
style SoftDel fill:#4FC08D,color:#fff
style Filtered fill:#3B82F6,color:#fff
style HardSQL fill:#EF4444,color:#fff
```
## DeletedAt 机制
### 声明式启用
在 struct 中嵌入 `gorm.Model` 或显式声明 `DeletedAt gorm.DeletedAt`:
```go
type User struct {
gorm.Model // 自动包含 ID, CreatedAt, UpdatedAt, DeletedAt
Name string `gorm:"size:64;not null"`
}
// 等价的手动声明(需 import "time")
type Product struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:128;not null"`
Price float64 `gorm:"not null"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt // 软删除字段
}
```
> [!note] gorm.DeletedAt 的本质
> `gorm.DeletedAt` 是 `time.Time` 的类型别名,但它告诉 GORM 这是一个软删除标记。只有当这个字段存在时,GORM 才会开启软删除行为。
### 软删除 vs 物理删除
```go
var user User
// ===== 软删除(推荐)=====
db.Delete(&user) // UPDATE users SET deleted_at=... WHERE id=?
// 查询时自动过滤:SELECT * FROM users WHERE deleted_at IS NULL AND id=?
// ===== 物理删除 =====
db.Unscoped().Delete(&user) // DELETE FROM users WHERE id=?
// 彻底擦除,无法恢复!
// ===== 根据条件批量物理删除(配合清理策略使用)=====
db.Unscoped().Where("deleted_at < ?", time.Now().AddDate(-90, 0, 0)).Delete(&User{})
// 彻底清除超过 90 天的软删除记录(注意用 Unscoped 否则查不到已删除行)
```
> [!tip] 关键区别
> - `db.Delete()` → **UPDATE** 语句,触发 BeforeUpdate / AfterUpdate 钩子
> - `db.Unscoped().Delete()` → **DELETE** 语句,触发 BeforeDelete / AfterDelete 钩子
> 两者走不同的钩子链,设计时需要考虑清楚业务逻辑该放在哪个钩子里。
## 查询已删除数据
### Unscoped — 忽略软删除过滤
`Unscoped()` 会关闭 GORM 自动附加的 `deleted_at IS NULL` 条件,让所有查询都能看到已删除的数据:
```go
// 查询所有用户(包括已删除的)
var allUsers []User
db.Unscoped().Find(&allUsers)
// SELECT * FROM users; -- 不再附加 deleted_at 条件
// 查找特定已删除的用户
var deletedUser User
db.Unscoped().Where("id = ? AND deleted_at IS NOT NULL", 42).First(&deletedUser)
// 统计已删除的记录数
var count int64
db.Model(&User{}).Unscoped().Where("deleted_at IS NOT NULL").Count(&count)
```
### 精确查询含/不含已删除数据
```go
// 只查正常用户
db.Where("deleted_at IS NULL").Find(&users)
// 只查已删除用户
db.Where("deleted_at IS NOT NULL").Find(&deletedUsers)
```
> [!tip] 为什么不用 Where + Unscoped?
> `Unscoped()` 是一个全局开关,一旦开启就影响当前链的所有后续操作:
> ```go
> // ❌ 意外行为 —— Unscoped 后面的 Find 也不过滤
> db.Where("name = 'john'").Unscoped().Find(&users)
> // SELECT * FROM users WHERE name = 'john'; -- 包括已删除的 john!
>
> // ✅ 精确控制
> db.Where("name = ? AND deleted_at IS NULL", "john").Find(&users)
> ```
## 软删除中的关联处理
### HasMany / BelongsTo
软删除模型的关联查询默认也会过滤已删除的关联项:
```go
type User struct {
gorm.Model
Name string
Orders []Order `gorm:"foreignKey:UserID"`
}
type Order struct {
gorm.Model
UserID uint
}
var user User
db.Preload("Orders").First(&user, 1)
// Preload 生成的 SQL 会自动加入 deleted_at IS NULL:
// SELECT * FROM orders WHERE user_id = 1 AND deleted_at IS NULL;
// 如果需要已删除的关联项,使用 Unscoped().Preload()
```
### BelongsTo(反向)
BelongsTo 端同样受过滤影响——当通过子记录查询父记录时,如果父记录已被软删除,默认也不会被加载:
```go
type Order struct {
gorm.Model
UserID uint
User User `gorm:"foreignKey:UserID"`
}
var order Order
db.Preload("User").First(&order, 1)
// 如果该 Order 对应的 User 已被软删除,User 字段将为空
```
### ManyToMany
对于多对多关联,GORM 在操作中间表时也会考虑软删除状态:
```go
// 给订单添加商品(如果商品已被软删除,默认不会关联)
product := Product{Name: "Go Programming"}
db.Model(&order).Association("Products").Append(&product)
// 如果要强制关联已删除的商品(需 import "gorm.io/gorm/clause")
db.Model(&order).Clauses(clause.OnConflict{DoNothing: true}).
Association("Products").Append(&product)
```
> [!question] 思考题
> 如果一个父记录被软删除,它的子记录(HasMany)还能被查询到吗?
>
> > **答案**:取决于查询起点。以未删除的父记录查询,子记录的软删除仍然生效;但如果你先 Unscoped 查到了已删除的父记录,再 Preload 其关联,子记录的软删除状态由 Preload 决定。
## 唯一约束与软删除冲突
这是软删除最常见的坑点——唯一约束检查在 UPDATE 之前执行,而旧记录仍然占着唯一值:
```go
type Article struct {
gorm.Model
Slug string `gorm:"uniqueIndex;not null"` // 例如:my-first-post
}
// 第一次保存成功
db.Create(&Article{Slug: "my-first-post"})
// 更新 slug
db.Model(&Article{}).Where("id = ?", 1).Update("slug", "my-updated-post")
// UPDATE articles SET slug='my-updated-post', deleted_at=NULL WHERE id=1;
// ⚠️ MySQL 不会对已删除行检查唯一约束(InnoDB bug)
// ⚠️ PostgreSQL 和 SQLite 会报错:duplicate key value
```
### 解决方案
```go
// 方案一:使用部分唯一索引(PostgreSQL 支持)
// CREATE UNIQUE INDEX idx_articles_slug ON articles(slug) WHERE deleted_at IS NULL;
// GORM 暂不直接支持部分索引,需手动执行 SQL
// 方案二:额外加一个版本字段
type Article struct {
gorm.Model
Slug string `gorm:"not null"`
SlugHash string `gorm:"uniqueIndex;not null"` // slug + 随机哈希
}
// 方案三:用复合唯一索引覆盖软删除标记
// 在数据库层面创建 (slug, is_deleted) 联合唯一约束
```
> [!tip] 最推荐的方案
> 方案一(部分唯一索引)是 PostgreSQL 用户的首选,只需一条 DDL 语句即可完美解决。MySQL 用户推荐使用**方案二**——用一个 `SlugVersion uint` 字段记录 slug 修改次数,保证同一 slug 不会同时出现在两条未删除记录中。
> [!warning] 跨数据库行为不一致
> MySQL 的 InnoDB 引擎在处理软删除行的唯一约束时存在历史缺陷,不同版本行为可能不同。**不要依赖这种行为一致性**,应在应用层做好校验。
## 恢复已删除数据
```go
// 恢复单个记录
db.Model(&User{}).Where("id = ?", 42).Update("deleted_at", nil)
// UPDATE users SET deleted_at=NULL WHERE id=42;
// 批量恢复
result := db.Model(&User{}).
Where("deleted_at IS NOT NULL AND email LIKE '%@test.com'").
Update("deleted_at", nil)
fmt.Printf("恢复了 %d 条记录\n", result.RowsAffected)
```
> [!note] 注意 UpdatedAt 的变化
> 恢复操作本质是一次 `UPDATE`,GORM 会自动将 `UpdatedAt` 设为当前时间。如果审计要求记录原始创建信息而非恢复时间,这是正常行为。如需区分「创建」与「恢复」时间,可自行增加 `RestoredAt` 字段。
## 生命周期钩子
软删除触发的是一条 UPDATE 语句,因此 **BeforeUpdate / AfterUpdate** 会正常执行:
```go
type User struct {
gorm.Model
Name string
Email string
}
func (u *User) BeforeDelete(tx *gorm.DB) error {
// db.Delete() 走的是 UPDATE,不会触发 BeforeDelete
// 如果需要在此处做逻辑(如级联标记),需用 Unscoped().Delete()
return nil
}
func (u *User) AfterUpdate(tx *gorm.DB) error {
// 软删除发生时,这条钩子会被调用
if u.DeletedAt != nil {
fmt.Printf("用户 %s 被软删除\n", u.Name)
}
return nil
}
```
> [!warning] BeforeDelete 在软删除时不会被调用
> GORM 的默认 `db.Delete()` 只发送 UPDATE SQL,不经过物理删除的钩子链。如果需要在删除前做业务校验(如检查关联记录),可以使用 `Unscoped().Delete()` 或者在应用层自行实现检查逻辑。
## 定时清理策略
软删除会导致数据无限膨胀,建议建立定期清理机制:
```go
// cron 定时任务:每月清理 90 天前的软删除记录
func CleanSoftDeletedRecords(tx *gorm.DB) error {
cutoff := time.Now().AddDate(0, 0, -90)
models := []any{&User{}, &Order{}, &Product{}}
for _, model := range models {
tx.Where("deleted_at IS NOT NULL AND deleted_at < ?", cutoff).
Delete(model)
}
return nil
}
```
## 软删除决策图
```mermaid
flowchart TD
Start["执行删除操作"] --> TypeQ{业务需求}
TypeQ --> |可恢复 / 需审计| SoftDel["软删除 db.Delete()"]
TypeQ --> |合规要求 / 不需恢复| HardDel["物理删除 Unscoped().Delete()"]
SoftDel --> AfterSoft["查询时需要已删除数据?"]
AfterSoft --> |是| UnscopedOn["db.Unscoped()"]
AfterSoft --> |否| NormalQ["普通查询\n自动过滤"]
HardDel --> AfterHard["数据永久移除"]
style Start fill:#4FC08D,color:#fff
style SoftDel fill:#3B82F6,color:#fff
style HardDel fill:#EF4444,color:#fff
style UnscopedOn fill:#F59E0B,color:#000
```
## 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| 唯一约束冲突 | 旧软删除行仍占唯一值 | 加版本字段或用部分索引 |
| Unscoped 影响后续查询 | 开关是链式的 | 及时结束链或改用显式 Where |
| 预加载跳过已删除关联 | GORM 自动加 deleted_at 过滤 | 用 Unscoped + Preload 组合 |
| 软删除导致数据膨胀 | 没有定期清理策略 | 定时任务清理过期记录 |
| First/Take 找不到已删除记录 | 自动过滤了 deleted_at | Unscoped().First() |
## 关联笔记
- [[02-模型定义]]
- [[03-CRUD 操作]]
- [[05-关联查询]]
- [[08-事务管理]]