必须用 db.begin() 显式开启事务并在事务对象上调用 queryrow 或 query 执行 select ... for update,裸查无效;需固定加锁顺序、缩短事务粒度、捕获死锁错误并重试。

SELECT ... FOR UPDATE 在 Go 中怎么写才安全
直接用 db.QueryRow 或 db.Query 执行 SELECT ... FOR UPDATE 是常见误区——它只在事务内生效,且必须显式开启事务。裸查(非事务)下加 FOR UPDATE 不会报错,但锁根本不会生效,还可能被数据库 silently 忽略(如 MySQL 的 autocommit=1 模式)。
实操建议:
- 必须用
db.Begin()显式开启事务,再在事务对象上调用QueryRow或Query - 确保后续的
UPDATE或业务逻辑也在同一事务中执行,否则锁会在事务提交/回滚时释放,中间存在竞态窗口 - MySQL 默认隔离级别是
REPEATABLE READ,SELECT ... FOR UPDATE会对匹配行加**记录锁 + 间隙锁**,注意可能引发死锁或锁升级 - PostgreSQL 行为略有不同:只锁匹配行(无间隙锁),但需注意
FOR UPDATE和FOR NO KEY UPDATE的语义差异
示例片段:
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback() // 注意不是 defer tx.Commit()
var balance int
err = tx.QueryRow("SELECT balance FROM accounts WHERE id = $1 FOR UPDATE", accountID).Scan(&balance)
if err != nil {
return err
}
_, err = tx.Exec("UPDATE accounts SET balance = $1 WHERE id = $2", newBalance, accountID)
if err != nil {
return err
}
return tx.Commit()
如何避免 Go 中 FOR UPDATE 导致的死锁
死锁不是 Go 层能捕获的逻辑错误,而是数据库检测到循环等待后主动中断某一方事务,Go 收到的是类似 ERROR: deadlock detected(PostgreSQL)或 ErrDeadlock(MySQL 驱动返回的 *mysql.MySQLError,SQLState='40001')。
关键应对点:
- 所有涉及多行加锁的操作,**固定加锁顺序**(例如按主键 ID 升序排序后再查),避免 A 先锁 1 再锁 2、B 先锁 2 再锁 1
- 事务粒度尽量小,
FOR UPDATE后尽快完成更新并提交,不要在事务里做 HTTP 调用、文件 IO 等耗时操作 - 对 MySQL,可设置
innodb_lock_wait_timeout缩短等待时间;对 PostgreSQL,调整deadlock_timeout仅影响检测频率,不解决根本问题 - Go 层需重试逻辑:捕获死锁错误后 sleep 随机毫秒(防重试风暴),再重试整个事务
用 sqlx 或 gorm 做悲观锁要注意什么
第三方库封装了 SQL 构建,但容易掩盖底层锁行为细节。
sqlx 场景:
- 它本身不提供专用锁方法,仍需手写
"SELECT ... FOR UPDATE"字符串,传给tx.Select或tx.Get - 注意
sqlx.DB和sqlx.Tx的区别:只有后者才能保证语句在事务上下文中执行
gorm 场景:
-
db.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, id)是正确写法(v2+),生成SELECT ... FOR UPDATE - 避免用
db.Unscoped().Where(...).Lock().First(...)这类过时链式调用,v2 已弃用Lock() - gorm 的
Session机制若未绑定事务(如db.Session(&gorm.Session{NewDB: true})),锁同样失效
为什么有时候 FOR UPDATE 像没起作用
最常被忽略的点:**事务未提交,但连接被归还到连接池,导致锁提前释放或不可见**。
典型表现:
- 本地测试单 goroutine 看起来正常,压测时大量并发更新出错
- 日志显示事务已
Commit(),但另一事务仍能读到旧值(其实是读已提交隔离级别下的正常现象,不是锁失效) - 用
SHOW ENGINE INNODB STATUS查不到对应锁记录 —— 很可能事务早已因 panic、忘记 commit/rollback 或连接超时被数据库自动回滚
排查重点:
- 检查是否所有
defer tx.Rollback()后都有明确的tx.Commit()路径,尤其注意 error 分支是否遗漏 - 确认数据库连接池配置:
SetMaxOpenConns过小会导致连接争抢,间接拉长事务等待时间 - MySQL 中,如果查询条件未命中索引,
FOR UPDATE可能升级为表锁,极大降低并发能力
锁的行为高度依赖数据库引擎和隔离级别,同一段 Go 代码在 MySQL 和 PostgreSQL 上表现可能完全不同。别只看 Go 层有没有报错,得去数据库查锁状态。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











