软删除在事务中不可回滚,必须用Unscoped().Delete()才能保证原子性;恢复需Unscoped().Update("deleted_at", nil);DeletedAt字段必须为gorm.DeletedAt类型并加索引。

软删除必须用 Unscoped().Delete() 才进事务
GORM 的 Delete() 默认是软删除,但它在事务中不会自动回滚已写的 DeletedAt 值——因为 UPDATE 操作本身已提交到数据库。如果你在事务里调用 db.Delete(&user) 后发生错误并 tx.Rollback(),DeletedAt 字段仍会被更新,数据“逻辑上已删”,但事务本应整体失效。
正确做法是:显式用 Unscoped() 包裹,并确保整个操作链都在事务作用域内:
tx := db.Begin()
if err := tx.Unscoped().Delete(&user).Error; err != nil {
tx.Rollback()
return err
}
// 其他操作...
return tx.Commit()
-
Unscoped()必须在事务对象上调用,不能写成db.Unscoped().Delete()—— 那会脱离事务 - 物理删除(即真删)才真正参与事务原子性;软删除本质是 UPDATE,GORM 不把它当作“可回滚的删除语义”
- 如果业务要求“软删除也必须可回滚”,只能手动在事务中先查再 UPDATE + 条件判断,或改用自定义钩子拦截
BeforeDelete 回调才是安全更新的入口
想在软删除前校验权限、记录操作人、同步更新状态字段(比如把 Status 改为 "deleted"),不能靠外部逻辑先后调用,必须用 BeforeDelete 回调。否则并发下可能查到旧状态,或更新被覆盖。
示例:
func (u *User) BeforeDelete(tx *gorm.DB) error {
// 拦截禁止删除已付费用户
var count int64
tx.Model(&Order{}).Where("user_id = ? AND status = ?", u.ID, "paid").Count(&count)
if count > 0 {
return errors.New("cannot delete user with paid orders")
}
// 同步更新关联字段
return tx.Model(&u).Update("status", "deleted").Error
}
-
BeforeDelete在软删除 UPDATE 执行前触发,且共享同一事务上下文 - 回调里用
tx.Model(...).Update()是安全的,它和后续的DeletedAt更新在同一个 SQL 事务中 - 别在回调里调用
db.Create()或其他非事务绑定操作,容易漏掉回滚
恢复软删除记录时,DeletedAt = nil 必须 Unscoped()
把已软删记录“还原”不是简单赋值 user.DeletedAt = nil 就完事。GORM 的 Save() 和 Updates() 默认仍受软删除 scope 影响,查不到该记录,自然也无法更新。
必须用 Unscoped() 定位并清空时间戳:
err := db.Unscoped().
Model(&user).
Where("id = ?", user.ID).
Update("deleted_at", nil).Error
- 漏掉
Unscoped()会导致Update找不到行,返回ErrRecordNotFound - 不要用
Save(&user)恢复:它会尝试全量写入,可能覆盖其他字段,且同样受 scope 过滤 - 如果要批量恢复(如回收站批量上架),用
Unscoped().Where(...).Update(),避免逐条查再更新
软删除字段类型和索引是硬性前提,不是可选项
哪怕你把事务、回调、恢复都写对了,只要结构体里 DeletedAt 不是 gorm.DeletedAt 类型,或没加 gorm:"index",GORM 就不认这是软删除字段——Delete() 会直接物理删除,事务里也救不回来。
- 正确声明:
DeletedAt gorm.DeletedAt `gorm:"index"`(gorm:"index"不能省,否则非Unscoped()查询可能漏行) - 嵌入
gorm.Model是最省心方式,它自带带索引的DeletedAt - 用
*time.Time或sql.NullTime替代?不行。GORM 只识别gorm.DeletedAt这个特定类型 - 字段名大小写敏感:
deleted_at或SoftDeletedAt都无效


















