--- 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-事务管理]]