乐观锁必须用 update ... set ... where version = ?,因为其本质依赖数据库原子性与版本字段校验,更新时需精确匹配当前版本号,否则无法检测并发修改。

乐观锁为什么必须用 UPDATE ... SET ... WHERE version = ?
Go 里没有“内置乐观锁”这回事,它本质是靠 SQL 的原子性 + 版本字段(或时间戳)实现的。核心逻辑是:更新时带上当前读到的 version 值,数据库只在行未被修改的前提下才执行更新,并返回影响行数。如果返回 0,说明已被其他事务抢先改过。
常见错误是把乐观锁当成 Go 层的互斥控制——比如加 sync.Mutex 或用 atomic,这完全没用,因为并发来自不同进程/实例,锁只在单进程内有效。
- 必须在表中加一个
version(INT)或updated_at(TIMESTAMP)字段 - 每次更新都要显式写
WHERE version = ?,不能依赖 ORM 自动生成(很多 ORM 默认不带这个逻辑) - 更新后必须检查
sql.Result.RowsAffected()是否为 1,不是就说明冲突
用 database/sql 手动写 SQL 最可靠
ORM(如 GORM)虽然支持乐观锁插件或钩子,但行为不透明、版本字段命名耦合强、升级易破。直接写原生 SQL 更可控,也更容易调试。
示例:更新用户余额并校验版本
tx, _ := db.Begin()
defer tx.Rollback()
<p>var currentVersion int
err := tx.QueryRow("SELECT version, balance FROM users WHERE id = ?", userID).Scan(¤tVersion, &balance)
if err != nil {
return err
}</p><p>newBalance := balance + deposit
res, err := tx.Exec("UPDATE users SET balance = ?, version = version + 1 WHERE id = ? AND version = ?", newBalance, userID, currentVersion)
if err != nil {
return err
}
rows, _ := res.RowsAffected()
if rows == 0 {
return errors.New("optimistic lock failed: version mismatch")
}</p><p>return tx.Commit()
</p>
-
WHERE id = ? AND version = ?是关键,缺一不可 - 务必在事务中做 SELECT + UPDATE,否则可能读到脏数据
-
version = version + 1要在 SQL 层自增,避免 Go 层读+算+写导致竞态
GORM 中启用乐观锁要手动干预 Update 条件
GORM v2 默认不启用乐观锁,即使你定义了 Version 字段。它只会在调用 Save 时自动增加 version,但不会在 WHERE 子句中校验——除非你显式传入 map[string]interface{} 或用 Select 指定字段。
在 Go 中使用 google/wire 实现编译时依赖注入——wire.NewSet、wire.Build、wire.Bind(接口→实现)、wire.Struct、wire.Value、wire.Interface
- 正确做法:用
db.Model(&u).Where("version = ?", u.Version).Updates(map[string]interface{}{"balance": newBalance, "version": u.Version + 1}) - 错误做法:
db.Save(&u)—— 它会忽略旧 version 值,直接覆盖 - 注意 GORM 的
RowsAffected在Updates后需用result.RowsAffected获取,不是result.Error
时间戳做版本号要注意 MySQL 的 ON UPDATE CURRENT_TIMESTAMP
用 updated_at 替代 version 字段可行,但必须关掉数据库自动更新,否则每次 UPDATE 都会刷新时间戳,导致 WHERE 条件永远失败。
建表时写:
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -- 不要加 ON UPDATE CURRENT_TIMESTAMP
更新时显式设置:
UPDATE users SET balance = ?, updated_at = ? WHERE id = ? AND updated_at = ?
- Go 层需用
time.Now().Truncate(time.Second)对齐精度,避免因纳秒差异导致匹配失败 - MySQL 5.6+ 的
TIMESTAMP默认精确到秒,但 Go 的time.Time默认带纳秒,不截断就查不到 - 相比整数 version,时间戳更难调试(比如时区、夏令时),建议优先用
INT类型 version 字段
实际中最容易漏的是:没检查 RowsAffected 就直接 commit,或者把 version 字段设成 DEFAULT 0 却忘了首次 INSERT 也要初始化为 0。这些点一错,乐观锁就形同虚设。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










