GORM软删除需DeletedAt为* time.Time类型、嵌入gorm.Model或加gorm:"index"标签,否则Delete()执行物理删除;默认查询自动过滤软删记录,查全部须Unscoped()前置。

软删除在 Gin + GORM 项目里不是加个字段就自动生效,它依赖 DeletedAt 字段的类型、标签、嵌入方式三者同时正确;恢复失败几乎全是 Unscoped() 位置错、字段类型错或用了 Save() 导致的。
DeletedAt 字段必须是 *time.Time,不能是 time.Time
定义成 DeletedAt time.Time 会导致新插入记录的 DeletedAt 是零值 0001-01-01 00:00:00 +0000 UTC,GORM 会把它当已删除,刚 Create() 的数据在普通查询里就查不到。
正确写法只有两种:
- 直接嵌入
gorm.Model(推荐):它自带ID、CreatedAt、UpdatedAt和指针型DeletedAt - 手动声明:
DeletedAt *time.Time `gorm:"index"`——gorm:"index"不是必须,但没索引时WHERE deleted_at IS NULL查询会变慢
错误写法包括:DeletedAt time.Time、DeletedAt sql.NullTime、DeletedAt bool。这些都会让 Delete() 变成物理删除,且恢复逻辑无法工作。
Delete() 没走软删?先检查结构体和主键
调用 db.Delete(&user) 却看到日志里执行的是 DELETE FROM 而非 UPDATE,说明软删除根本没启用。原因通常有:
- 结构体里没有有效识别的
DeletedAt字段(名字大小写不对、类型不对、没加gorm:"index"或没嵌入gorm.Model) - 传入对象主键为零值(如
user.ID = 0),GORM 会生成无条件语句,高风险下可能误删全表 - 用了自定义字段名(比如
IsDeleted),GORM 不识别,得自己写Where("is_deleted = ?", false)
单条删除务必先 First() 查出再 Delete();批量删用 Where().Delete(&User{}),别依赖实例主键。
恢复软删除记录只能用 Unscoped().Update("deleted_at", nil)
user.DeletedAt = nil; db.Save(&user) 一定不生效 —— Save() 不更新 DeletedAt 字段,且可能覆盖 UpdatedAt 等时间戳。
正确恢复姿势只有一种:
db.Unscoped().Where("id = ?", 123).Update("deleted_at", nil)
注意三点:
-
Unscoped()必须放在Where()之前,写成Where().Unscoped().Update()就失效 -
Update()第二个参数必须是nil,不是time.Time{}(零值时间仍被视为已删) - 如果主键不是
ID(比如复合主键),Where()条件必须完整匹配,否则RowsAffected == 0
更安全的做法是恢复前先查一次:db.Unscoped().Where("id = ? AND deleted_at IS NOT NULL", 123).First(&user),避免重复恢复引发业务歧义。
Unscoped() 不透传到 Preload 关联查询
很多人以为 db.Unscoped().Preload("Orders").Find(&users) 就能加载出用户及其已软删的订单,结果发现 Orders 仍是空的 —— 因为 Unscoped() 只作用于主查询,不会自动透传到预加载的关联模型。
要连带查出已删的关联记录,必须显式给 Preload 加 Unscoped():
db.Unscoped().
Preload("Orders", func(db *gorm.DB) *gorm.DB {
return db.Unscoped()
}).
Find(&users)
这点在带多层关联的后台接口里极易被忽略,尤其是回收站、审计日志等场景。默认 Preload 会严格过滤掉软删除的关联记录,这是 GORM 的设计行为,不是 bug。











