<p>GORM v2 钩子不执行主因是签名、接收者、包位置三者未严格匹配:接收者须为 User,参数须为 gorm.DB,且方法必须定义在模型同包;Save() 不触发 BeforeCreate/BeforeUpdate,应改用 Create() 或 Update();软删除 Delete() 实际触发 BeforeDelete→AfterUpdate;AfterFind 在 Preload 前执行,无法访问关联数据;事务中钩子内禁止写操作,且 After* 返回 error 不回滚事务。</p>

BeforeCreate/BeforeUpdate 钩子不执行?先盯住这三处
钩子写了却没跑,不是 GORM 抽风,而是签名、接收者、包位置三者中至少一个没对上。GORM v2 只认严格匹配的指针方法,其余一律静默忽略。
- 接收者必须是
*User,不能是User(值接收者改的是副本,原结构体不受影响) - 参数必须是
tx *gorm.DB,少一个星号、换名成db或漏掉参数都会失效 - 方法必须定义在模型 struct 所在的同一个 package 下,写在
hooks/目录或另一个package hooks里,GORM 就当它不存在
常见现象:BeforeCreate 里设置了 u.CreatedAt = time.Now(),但插入后数据库仍是零值——大概率是字段类型为 time.Time(非指针),或者接收者用了值类型。
Save() 为什么绕过所有钩子?用 Update() 替代
Save() 是 GORM 的“兜底更新”方法,它不区分新增还是修改,也不触发 BeforeCreate 或 BeforeUpdate,只走 BeforeSave 和 AfterSave。多数人想自动更新时间戳却用了 Save(),结果字段始终不变。
- 想触发
BeforeCreate:用db.Create(&u) - 想触发
BeforeUpdate:用db.Model(&u).Update("status", "done")或db.Updates(&u),别用Save() - 软删除场景下,
Delete()实际走的是BeforeDelete→AfterUpdate,不是AfterDelete;真要物理删,得加db.Unscoped().Delete(&u)
字段类型也关键:UpdatedAt *time.Time 才能在钩子里赋值生效;若用 time.Time,必须配合 tx.Statement.SetColumn("updated_at", time.Now()),否则直接赋值只是改了副本。
立即学习“go语言免费学习笔记(深入)”;
AfterFind 读不到 Preload 关联数据?时机错了
AfterFind 在单条记录从数据库取出后**立即执行**,此时 GORM 还没开始加载 Preload 或 Joins 的关联字段。你在里面访问 u.Profile,基本就是 nil 或零值。
- 需要处理关联数据,要么把逻辑移到业务层(查完再手动处理),要么用
AfterFind+ 显式调用tx.Preload("Profile").First(&u)(不推荐,易循环) - 更稳妥的做法:放弃
AfterFind,改用封装好的查询函数,在db.Preload("Profile").Find(&users)后统一处理 - 注意
AfterFind对批量查询(Find())也会逐条触发,不是只调一次
它适合做轻量级字段转换(如解密敏感字段)、打日志、填充非关联的计算字段,不适合依赖外键关系的数据组装。
事务里写钩子,最容易踩的两个坑
GORM 没有“事务提交后回调”这种内置钩子,所有所谓“事务回调”都得手动组织。最常翻车的是在 BeforeUpdate 里调 tx.Create(),或在 defer 里无条件发消息。
- 钩子内禁止调用
tx.Create()、tx.Delete()等写操作——会打断当前事务上下文,可能引发死锁或无限递归 - 想实现“仅在事务成功后通知”,得用
defer+ 显式committed标志位,不能只靠defer;tx.Commit()后才设标志,且回调逻辑必须放在这之后 -
After*类钩子返回error不会回滚事务(SQL 已执行完),只能靠补偿;只有Before*返回非nil错误才会中断并回滚
钩子本质是同步阻塞逻辑,运行在事务同一上下文中。它不抽象、不隔离、不延迟——你写的每一行,都在影响当前 SQL 的生成和执行流。


















