gorm软删除需模型含*time.time类型且带gorm:"index"标签的deletedat字段,否则delete()执行硬删除;默认查询自动过滤软删记录,恢复须用unscoped().update("deleted_at", nil)。

物理删除和逻辑删除不能靠字段名自动区分
GORM 的软删除行为完全由模型定义触发,不是靠 Delete() 方法参数或调用方式决定的。只要模型里有合法的 DeletedAt 字段(*time.Time 类型 + gorm:"index"),所有 Delete() 默认就是逻辑删除;没这个字段,才是物理删除。
常见错误是以为“加个 Where("deleted_at IS NULL") 就能切回物理删”,其实不行——Delete() 本身不看 WHERE 条件是否含 deleted_at,它只看模型有没有启用软删除机制。
- 模型没定义
DeletedAt:任何Delete()都是物理删除 ✅ - 模型有
DeletedAt *time.Time `gorm:"index"`:默认全部走逻辑删除 ❌(哪怕你Where("deleted_at IS NOT NULL")) - 想物理删带软删字段的模型?必须显式加
Unscoped()✅
混合使用必须靠 Unscoped() 显式切换上下文
Unscoped() 不是“开关”,而是“绕过过滤层”的一次性通行证。它必须放在链式调用最前面,且影响后续所有操作,包括 Where、Preload、Delete。
比如你想批量物理删除草稿文章,但模型带 DeletedAt:
db.Unscoped().Where("status = ?", "draft").Delete(&Post{})
注意这行代码里:Unscoped() 后面的 Where 和 Delete 全部跳过软删除拦截——Where 会查出含已软删记录的草稿,Delete 也会直接 DELETE FROM 物理清除。
- 错:把
Unscoped()放在Where后面 →db.Where(...).Unscoped().Delete(),无效 - 错:在事务里只对某一步加
Unscoped(),却忘了关联预加载也受波及 →db.Unscoped().Preload("Author").Find(&posts)会把已软删的 Author 也查出来 - 对:物理删除前先确认业务意图,比如“清空回收站”才用
Unscoped(),普通删除一律不加
恢复软删记录时别传 time.Time{}
用 Update("deleted_at", time.Time{}) 恢复,结果记录依然查不到——因为 time.Time{} 是零值,GORM 会把它序列化为 '0001-01-01 00:00:00',仍满足 deleted_at IS NOT NULL,还是被过滤掉。
正确做法只有传 nil:
db.Unscoped().Model(&user).Update("deleted_at", nil)
- 传
nil→ SQL 中设为NULL→ 下次普通查询就能命中 ✅ - 传
time.Time{}或任意非-niltime.Time→ 仍是“已删除”状态 ❌ - 如果模型用的是
gorm.Model嵌入,DeletedAt字段本身就是gorm.DeletedAt(即*time.Time),更不能误写成值类型
索引缺失会让混合查询变慢甚至失效
没给 DeletedAt 加 gorm:"index",不代表软删除不能用,但会导致两个问题:
一是 WHERE deleted_at IS NULL 查询全表扫描,大数据量下接口明显卡顿;二是某些数据库(如 MySQL 5.7+)在复合条件里,优化器可能因缺失索引而放弃使用该条件做 early filter,导致本该被过滤的软删记录意外出现在结果集里(尤其配合 ORDER BY 或 LIMIT 时)。
- 建表时确保
DeletedAt字段带索引:用gorm:"index"标签,或手动CREATE INDEX idx_users_deleted_at ON users(deleted_at); - 不要依赖“反正数据量小”,上线后数据增长快,索引缺失问题会突然爆发
- 混合场景下(比如一个接口既要查“未删活跃用户”,又要查“最近3天软删记录”),
DeletedAt索引是共用的,不需额外建其他索引
Unscoped() —— 它像一把没有保险的枪,开一次就影响整条链,而且关联查询里根本看不出它是否生效。线上出过太多因 Unscoped().Preload() 导致“用户列表页意外展示已删订单”的事故。











