deletedat 必须是 *time.time 类型,gorm 通过是否为 nil 判断软删除状态;使用 db.delete() 默认仅更新 deletedat,需 unscoped() 才物理删除;preload 关联时需手动加条件过滤软删数据;常规查询默认自动添加 deleted_at is null 条件。

DeletedAt 字段类型不对,软删除就失效
软删除不是加个字段就行,DeletedAt 必须是 *time.Time(指针类型),不能是 time.Time(值类型)。因为 GORM 靠是否为 nil 来判断“删没删”:指针为 nil 表示未删除,非 nil 才算已软删;而 time.Time{} 的零值是 0001-01-01 00:00:00 +0000 UTC,GORM 会把它当成“已删除”,导致新插入的记录一创建就查不到。
- ✅ 正确做法:嵌入
gorm.Model(它自带DeletedAt gorm.DeletedAt,本质就是*time.Time) - ❌ 错误写法:
DeletedAt time.Time或DeletedAt string - ⚠️ 即使自己定义字段,也必须写成
DeletedAt *time.Time,并加gorm:"index"标签,否则Unscoped()外的查询可能忽略该字段逻辑
Delete() 不等于物理删除,别在迁移脚本里乱用
调 db.Delete(&user) 默认只是更新 DeletedAt,不会发 DELETE FROM 语句。很多人在清测试数据时直接写 Delete(),结果表越跑越大,还以为删成功了。
- ✅ 软删除:
db.Delete(&user)→UPDATE ... SET deleted_at = NOW() - ✅ 真删(硬删):
db.Unscoped().Delete(&user)→DELETE FROM ... - ⚠️ 批量操作同理:
db.Where("status = ?", "draft").Delete(&Post{})是软删;漏掉Unscoped()就永远删不掉行 - ⚠️ 硬删前务必确认无活跃外键引用,尤其日志、审计类子表——
Unscoped().Delete不走关联逻辑,也不触发BeforeDelete钩子
Preload 关联数据时,软删除默认不生效
db.Preload("Orders").Find(&user) 会把用户所有订单都拉出来,哪怕 Orders 表也启用了软删除。GORM 不自动递归过滤关联模型的 DeletedAt,这是设计行为,不是 bug。
- ✅ 想让预加载也遵守软删规则,必须显式加条件:
db.Preload("Orders", func(db *gorm.DB) *gorm.DB { return db.Where("deleted_at IS NULL") }) - ✅ 如果还要查被软删的订单,得换用
db.Unscoped().Where("deleted_at IS NOT NULL") - ⚠️
HasOne/HasMany的外键本身不受软删影响:主表软删后,子表记录还在,只是DeletedAt被设值;子表若没定义DeletedAt,它根本不会被软删
查不到软删数据?不是没删成,是默认被过滤了
所有常规查询方法(Find、First、Where)默认自动加 WHERE deleted_at IS NULL。你在后台做“回收站”页面,直接 Find() 肯定为空——这不是异常,是 GORM 的默认行为。
- ✅ 查全部(含软删):
db.Unscoped().Where(...).Find(&users) - ⚠️
Unscoped()是链式方法,必须放在最前面;一旦加了,后续所有条件都不再受软删过滤保护 - ⚠️ 统计总数、导出报表等场景容易漏
Unscoped(),导致结果少算——比如db.Model(&User{}).Count(&count)返回的是“存活用户数”,不是总记录数 - ? 替代方案:不用
Unscoped()也能手动绕过,比如db.Where("deleted_at IS NULL OR deleted_at IS NOT NULL"),但语义不如前者清晰
软删除真正难的不是加字段或调函数,是在查询、关联、批量、钩子这四个层面,每个地方都有一个“默认过滤”和“显式覆盖”的开关。漏掉任意一个,业务逻辑就可能静默错位。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











