GORM乐观锁需显式WHERE version=?并检查RowsAffected()==0,仅Save/Updates生效且Version字段须为int型一级字段,重试应独立查询而非事务内循环,高频计数等场景应避免使用。

UPDATE 必须带 WHERE version = ?,且必须检查 RowsAffected()
GORM 的乐观锁不是“写了 Version 字段就自动生效”的魔法。它只在 Save() 和 Updates()(非 UpdateColumns()、非 map[string]interface{})中触发,前提是模型字段名严格为 Version、类型为 int/int64,且该字段是结构体一级字段(不能嵌套)。但最关键的实操点是:即使满足所有条件,你也**不能只靠 err == nil 判断更新成功**。
MySQL 和 PostgreSQL 在 WHERE version = ? 不匹配时,err 仍为 nil,仅返回 RowsAffected() == 0。漏掉这一步,等于完全放弃冲突检测。
- 错误写法:
db.Model(&u).Where("id = ? AND version = ?", u.ID, u.Version).Updates(...); if err == nil { /* 以为成功了 */ } - 正确写法:
res := db.Model(&u).Where(...).Updates(...); if res.Error != nil { ... }; if res.RowsAffected == 0 { return ErrOptimisticLock } - 手写 SQL 更可控:
UPDATE users SET name = ?, version = version + 1 WHERE id = ? AND version = ?,执行后立刻检查RowsAffected()
别把重试逻辑塞进事务里
乐观锁的重试本质是「读-改-写」循环,每次重试都应独立发起一次 SELECT + UPDATE,而不是在一个长事务里反复尝试。把重试包在 db.Transaction() 中,会让事务长时间持锁(哪怕只是行锁),违背乐观锁轻量、无阻塞的设计初衷,还可能引发死锁或拖慢吞吐。
- 每次重试前,必须重新
db.First()或db.Take()当前行,获取最新Version和业务字段值,不能复用旧对象 - 重试次数必须硬限制(推荐 ≤3 次),避免高并发下雪崩;间隔建议指数退避(如 10ms → 30ms → 100ms)
- 事务只用于真正需要原子性的多步操作(如扣库存 + 创建订单),乐观锁更新本身不依赖事务隔离级别
GORM 自动版本校验的生效边界很窄
很多人以为加了 Version int 字段,GORM 就会“自动”帮你处理所有更新。实际上,它只对极少数调用路径生效:
- ✅ 生效:
Save()、Updates()(传 struct 或 map[string]interface{},且 map key 包含"version") - ❌ 绕过:
UpdateColumns()、Exec()、Model(&u).Updates(map[string]interface{}{"name": "x"})(没显式更新 version) - ❌ 无效:字段叫
version_num、类型是string、定义在嵌入的BaseModel里、数据库列类型为BIGINT(v1.25+ 可能不递增) - 迁移时务必确认:MySQL 用
INT,PostgreSQL 用INTEGER,不能用TIMESTAMP或VARCHAR
高频小字段更新别硬套 version
像点赞数、阅读数这种只增不查业务逻辑的字段,直接用数据库原生表达式更高效、更安全:db.Model(&post).Update("likes", gorm.Expr("likes + 1"))。引入 Version 不仅增加冗余字段和更新开销,还让简单计数变得复杂——你得先查再算再带 version 更新,纯属自找麻烦。
真正需要乐观锁的场景,是那些「读取后需业务判断、再决定是否修改」的操作,比如库存扣减、订单状态流转、文章编辑提交。这些操作天然包含业务校验(如 if product.Quantity ),version 冲突只是其中一环。忽略业务逻辑只谈锁,容易在错误的地方用力过猛。











