GORM默认软删除仅识别DeletedAt字段,无法自动添加status、tenant_id等业务条件;Unscoped()仅绕过DeletedAt过滤,不处理其他业务逻辑;Scope是封装带条件软删除的正确方式,支持组合复用且需与Unscoped()按序配合。

GORM 默认的软删除过滤只认 DeletedAt 字段,且无法按业务字段(如 status、tenant_id)自动加 WHERE 条件;想实现“带条件的软删除”,必须手动干预查询链路,不能依赖开箱即用行为。
为什么 Unscoped() 不够用
很多人以为加了 Unscoped() 就能自由控制过滤逻辑,其实它只是关掉 GORM 对 DeletedAt 的自动 WHERE 过滤,对其他字段完全无感。你仍得自己写 Where("status = ? AND deleted_at IS NULL", "active"),否则查出来的数据可能包含已停用但未软删的记录,或漏掉本该保留的草稿。
-
Unscoped()只绕过deleted_at IS NULL,不绕过你业务里任何其他隐含规则 - 如果模型没嵌入
gorm.Model或没定义DeletedAt *time.Time,Unscoped()根本不生效——因为压根没软删除逻辑 - 在
Preload()关联查询中,Unscoped()不透传,关联表仍走默认过滤,容易造成主从数据状态不一致
Scope 是唯一能封装“带条件软删除”的方式
GORM v2 支持通过 Scope 注入全局查询条件,这才是实现可复用、可维护的带条件过滤机制的正解。比如要求所有查询必须同时满足 tenant_id = ? 且 deleted_at IS NULL,就该用它。
- 定义 scope 函数:
func TenantScope(tenantID uint) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { return db.Where("tenant_id = ? AND deleted_at IS NULL", tenantID) } } - 使用时链式调用:
db.Scopes(TenantScope(123)).Find(&users),比每次手写 Where 更安全 - scope 可组合:
db.Scopes(TenantScope(123), StatusScope("active")).Find(&users) - 注意:scope 不能替代
Unscoped(),要查回收站数据时,仍需先Unscoped()再套 scope,否则deleted_at IS NOT NULL的记录永远进不来
别在模型字段上硬塞业务逻辑
有人试图把 status 字段也标成 gorm.DeletedAt 类型,或者重命名 DeletedAt 为 StatusAt,这是错的。GORM 只识别固定字段名 + 固定类型,其他一律当普通字段处理。
-
DeletedAt必须是*time.Time,且字段名大小写敏感;写成deleted_at或IsDeleted都不会触发软删除 - 业务状态(如 draft / active / archived)应独立于软删除生命周期;混在一起会导致恢复逻辑混乱——比如恢复一条
status = "archived"的记录,到底该改DeletedAt还是改status? - 真正需要的是分层过滤:底层用
DeletedAt控制“是否还存在”,上层用Where()或Scope控制“是否可见”
物理删除前务必二次确认条件
调用 Unscoped().Delete() 会跳过所有软删除过滤,但如果 WHERE 条件写错,可能误删整张表。尤其在批量操作时,风险极高。
- 永远显式指定主键或唯一条件:
db.Unscoped().Where("id IN ?", ids).Delete(&User{}),别裸用Delete(&User{}) - 生产环境建议加
gorm.Config{DryRun: true}先看 SQL,确认无误再执行 - 软删除恢复也一样:用
Unscoped().Where("id = ? AND deleted_at IS NOT NULL", id).Update("deleted_at", nil),缺了AND deleted_at IS NOT NULL可能覆盖刚插入的新记录
最常被忽略的一点:Scope 和 Unscoped() 的执行顺序会影响最终 SQL。Unscoped() 必须放在 Scopes() 之前,否则 scope 里写的 deleted_at IS NULL 会和 Unscoped 冲突,结果不可预测。


















