271 lines
8.6 KiB
Markdown
271 lines
8.6 KiB
Markdown
|
|
---
|
|||
|
|
tags: [GORM, Go, ORM, 软删除, SoftDelete, Unscoped, DeletedAt]
|
|||
|
|
create time: 2026-04-28 00:00
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 软删除
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
软删除是一种「逻辑删除」策略——数据并没有真正消失,只是被标记为「已删除」。这是 Web 应用中处理「删除」操作的**默认推荐方式**,因为它提供了可恢复性和审计追踪能力。
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart TD
|
|||
|
|
A[调用 db.Delete(&user)] --> SoftDel{"模型有<br/>DeletedAt 字段?"}
|
|||
|
|
|
|||
|
|
SoftDel --> |否| HardSQL["DELETE FROM users WHERE id = ?<br/>⚠️ 物理删除,不可恢复"]
|
|||
|
|
SoftDel --> |是| UpdateSQL["UPDATE users SET deleted_at = NOW() WHERE id = ?<br/>✅ 软删除,数据保留"]
|
|||
|
|
|
|||
|
|
UpdateSQL --> Query{常规查询?}
|
|||
|
|
Query --> |是| Filtered["WHERE deleted_at IS NULL<br/>已删除记录自动隐藏"]
|
|||
|
|
Query --> |否| UnscopedQ{需要已删除数据?}
|
|||
|
|
UnscopedQ --> |是| IncludeAll["Unscoped() → 全部返回"]
|
|||
|
|
UnscopedQ --> |否| Filtered
|
|||
|
|
|
|||
|
|
style Start 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"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 等价的手动声明
|
|||
|
|
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 — 忽略软删除过滤
|
|||
|
|
|
|||
|
|
`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;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### ManyToMany
|
|||
|
|
|
|||
|
|
对于多对多关联,GORM 在操作中间表时也会考虑软删除状态:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 给订单添加商品(如果商品已被软删除,默认不会关联)
|
|||
|
|
product := Product{Name: "Go Programming"}
|
|||
|
|
db.Model(&order).Association("Products").Append(&product)
|
|||
|
|
|
|||
|
|
// 如果要强制关联已删除的商品
|
|||
|
|
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) 联合唯一约束
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!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)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!important] 恢复后记得重新设置 UpdatedAt
|
|||
|
|
> 恢复操作本身也是一次 UPDATE,所以 `UpdatedAt` 会自动更新。如果你希望保留原始时间戳,需要在更新前保存到临时变量。
|
|||
|
|
|
|||
|
|
## 定时清理策略
|
|||
|
|
|
|||
|
|
软删除会导致数据无限膨胀,建议建立定期清理机制:
|
|||
|
|
|
|||
|
|
```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["软删除<br/>db.Delete()"]
|
|||
|
|
TypeQ --> |合规要求/不需要恢复| HardDel["物理删除<br/>db.Unscoped().Delete()"]
|
|||
|
|
|
|||
|
|
SoftDel --> AfterSoft[查询时需要已删除数据?]
|
|||
|
|
AfterSoft --> |是| UnscopedOn["db.Unscoped()"]
|
|||
|
|
AfterSoft --> |否| NormalQ["普通查询<br/>自动过滤"]
|
|||
|
|
|
|||
|
|
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-事务管理]]
|