核心是update必须带where version = ?且检查rowsaffected()是否为0,因mysql/postgresql在条件不匹配时不报错而仅返回0行影响,仅靠err == nil会漏判冲突。

Go 用 version 字段做乐观锁,核心就一条:UPDATE 必须带 WHERE version = ?,且执行后必须检查 RowsAffected() 是否为 0 —— 否则你根本不知道更新有没有被并发覆盖。
为什么 RowsAffected() == 0 才是冲突的唯一信号
MySQL 和 PostgreSQL 都不会因为 WHERE 条件不匹配而报错;err 依然是 nil。只靠 if err != nil 判断,等于默认放弃所有冲突检测。
- 常见错误写法:
db.Model(&u).Where("id = ? AND version = ?", u.ID, u.Version).Updates(...); if err == nil { /* 就当成功了 */ } - 正确做法:必须紧接着调用
res.RowsAffected(),== 0就说明别人抢先改了,当前版本已失效 - 别依赖
updated_at:精度低、可伪造、时钟不同步时直接崩,version必须是单调递增整数(INT或INTEGER)
GORM 的 Version 字段不是“写了就行”
GORM 自动乐观锁只在极窄条件下触发,稍有偏差就静默失效。
- 字段名必须叫
Version(首字母大写),类型必须是int/int64/uint等整型,不能是string或version_num - 必须是结构体一级字段,不能嵌套在
BaseModel里或用匿名结构体包裹 - 只对
Save()、Updates()生效;Exec()、UpdateColumns()、map[string]interface{}写法会绕过版本校验 - 迁移时数据库列类型要严格匹配:
MySQL用INT,PostgreSQL用INTEGER;BIGINT在 GORM v1.25+ 中可能不递增
重试逻辑不能塞进事务,也不能无限制循环
乐观锁本身不依赖事务隔离级别,加事务反而拖慢吞吐、增加死锁风险;重试次数必须硬限制,否则高并发下容易雪崩。
- 每次重试前,必须重新
SELECT当前行(含最新version),不能复用旧值 - 推荐最多重试 3 次,间隔建议用指数退避(如 10ms → 30ms → 100ms)
- 别把重试包在
db.Transaction()里——读-改-写流程若跨多次 DB 往返,事务会长时间持锁,违背乐观锁轻量初衷 - 高频小字段更新(如点赞数)应直接用
UPDATE SET count = count + 1,没必要引入 version
手动 SQL 比 GORM 自动逻辑更可控,也更容易调试
尤其当你需要跨字段更新、条件复杂,或想明确看到生成的语句时,手写 SQL 反而更可靠。
- 典型语句:
UPDATE users SET name = ?, version = version + 1 WHERE id = ? AND version = ? - 执行后立刻接
res.RowsAffected(),== 0就返回自定义错误(如ErrOptimisticLock) - 如果用 PostgreSQL,可加
RETURNING version:没返回行就说明失败,省一次查询 - 别给
version列建唯一索引——它本就不唯一,也没必要
真正容易被忽略的是:version 字段的递增动作必须由数据库完成(SET version = version + 1),而不是 Go 层读出来再加一。否则 goroutine 并发时仍可能覆盖。只要 SQL 层保证原子性,Go 层就只管检查和重试。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











