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:23:33 +08:00

271 lines
8.6 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{"模型有<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-事务管理]]