SQL 乐观锁与悲观锁实现技巧

1次阅读

乐观锁需同时满足where version = ?和检查受影响行数,缺一不可;selectfor update在read committed及以上隔离级别才生效;redis实现须用原子操作如set ex nx或lua脚本。

SQL 乐观锁与悲观锁实现技巧

UPDATE 语句里加 WHERE version = ? 就是乐观锁?

不是。只加 WHERE version = ? 是乐观锁的必要条件,但不是充分条件——漏掉更新后检查影响行数,等于没锁。

典型错误是执行完 UPDATE 就继续往下走,不判断是否真改成功了。比如库存扣减,version 已被别人改过,你的 UPDATE 实际影响 0 行,但代码没感知,还按“扣成功”走了后续逻辑。

  • 必须用数据库驱动返回的“受影响行数”做判断,mysqlexecuteUpdate()postgresqlRETURNING 或 ORM 中的 affected_rows 都得检查
  • UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = 123 AND version = 42 —— 这条语句本身不报错,哪怕 version 不匹配,它只是安静地改了 0 行
  • 如果用 mybatis,别依赖 @SelectKey 或自动回填;要显式查 rowcountspring JDBC 可用 JdbcTemplate.update() 返回值

SELECT … FOR UPDATE 在什么事务隔离级别下才真正起作用?

只在 READ COMMITTED 或更高(如 REPEATABLE READ)下生效;设成 READ UNCOMMITTED 时,FOR UPDATE 会被忽略,不加锁。

常见陷阱是框架默认开事务但没显式设隔离级别,底层用了 READ UNCOMMITTED(比如某些旧版 MySQL 配置或测试环境),结果 SELECT ... FOR UPDATE 形同虚设。

  • MySQL InnoDB 默认是 REPEATABLE READ,这时 SELECT ... FOR UPDATE 会加记录锁 + 间隙锁,能防幻读
  • PostgreSQL 默认 READ COMMITTEDFOR UPDATE 只锁命中的行,不锁间隙,高并发下可能多扣一次(需配合唯一约束或重试)
  • Spring 的 @Transactional(isolation = Isolation.READ_COMMITTED) 要写全,别只写 @Transactional 依赖默认——不同数据库默认值不同

用 Redis 实现分布式乐观锁,为什么 incr + expire 不能直接组合?

因为 INCREXPIRE 不是原子操作,中间若进程崩溃或网络断开,key 会永久存在,导致死锁。

真实场景中,有人用 INCR 判断是否首次访问,再立刻 EXPIRE 设过期,但这两步之间没有锁保障,多个客户端可能同时通过 INCR == 1 判断,都去执行业务逻辑。

  • 必须用 Lua 脚本把判断、设置、过期三步包进一个原子操作,例如:EVAL "if redis.call('incr', KEYS[1]) == 1 then redis.call('expire', KEYS[1], ARGV[1]) end return redis.call('get', KEYS[1])" 1 order:lock:123 30
  • 别用 SETNX + EXPIRE 组合,同样非原子;应改用 SET key value EX seconds NX(Redis 2.6.12+),这是真正原子的“设值且带过期”
  • 注意:Redis 乐观锁本质是“检测变更”,不是“阻止变更”,所以仍需配合业务层的版本比对或 CAS 逻辑,不能只靠 key 是否存在

MyBatis-Plus 的 @Version 注解为啥有时不生效?

因为默认只对 updateById()update()(带 Wrapper)生效,对 updateBatchById()、自定义 xml 中的 UPDATE、或手写 SqlSession.update() 完全不触发。

更隐蔽的问题是:实体类字段类型和数据库字段类型不一致,比如 Java 用 integer,DB 用 BIGINT,MyBatis-Plus 生成的 WHERE 条件可能因类型转换失败而丢掉 version 判断。

  • 确认你调用的是 MP 自带的 update 方法,而不是自己写 @Update("UPDATE ...");后者要手动拼 WHERE version = #{version}
  • 实体类 @Version 字段必须是包装类型(Integer/Long),不能是基本类型(int/long),否则空值无法识别
  • 开启 MP 的 SQL 日志(mybatis-plus.configuration.log-impl=org.apache.ibatis.Logging.stdout.StdOutImpl),看生成的 SQL 里有没有 AND version = ?

事情说清了就结束。锁机制从来不是开关,而是和事务边界、网络稳定性、ORM 行为、甚至数据库配置深度耦合的一组约束——少盯一个点,就可能在凌晨三点收到库存超卖报警。

text=ZqhQzanResources