MySQL 乐观锁是什么?原理、实现、踩坑与实战完整指南

前言

在开发扣库存、账户余额变更、订单状态流转这类并发业务时,如果不加控制,高并发下很容易出现超卖、数据覆盖、余额错乱问题。

很多同学第一反应想到用 MySQL 悲观锁 select ... for update,但是悲观锁会加行锁,并发高时容易出现锁等待、死锁、性能下降。
这时就有了另一种方案:乐观锁

很多人听过乐观锁,但是搞不清楚:

  • 乐观锁到底是数据库自带功能吗?
  • 版本号怎么写SQL?
  • 时间戳做乐观锁有什么坑?
  • 和悲观锁、事务隔离级别是什么关系?

本文结合业务实战讲透 MySQL 乐观锁。

说明:乐观锁并不是 MySQL 内置的锁机制,它是一种业务层实现的并发控制思想,数据库本身没有 lock optimistic 这类语法。

一、乐观锁与悲观锁核心思想对比

悲观锁

悲观的认为:并发一定会发生冲突,查询数据的时候就直接上锁,其他人必须等待锁释放才能操作。

  • 实现:SELECT ... FOR UPDATE,行排他锁
  • 特点:先加锁,后修改;冲突时阻塞等待;
  • 缺点:高并发场景锁竞争激烈,容易超时、死锁,吞吐量低。

乐观锁

乐观的认为:大部分时候不会发生并发冲突,不上锁,直接执行业务修改;提交更新的时候再检查,看看数据有没有被别人修改过

  • 实现:业务版本号 / 时间戳,基于 update where 条件做版本校验
  • 特点:不加锁,读无阻塞;冲突发生时直接返回更新行数0,业务捕获后做重试或者报错;
  • 缺点:需要业务代码处理冲突重试;不能解决脏读问题。
类型锁机制并发冲突处理性能典型实现
悲观锁数据库行锁阻塞等待并发高较差select … for update
乐观锁业务逻辑,无数据库锁更新判断,失败抛异常/重试并发高较好version版本号

二、乐观锁两种主流实现方案

方案1:版本号 version(推荐,生产首选)

核心原理:给业务表增加一个 version 版本字段,数字类型。

  1. 查询时,把当前 version 一并读出来;
  2. 更新的时候,where 条件带上 version = 查询出来的旧版本号
  3. 更新成功同时 version = version + 1
  4. 如果这条SQL影响行数为0,代表期间已经被其他线程修改过,发生冲突。
建表示例(库存表)
CREATE TABLE `goods_stock` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `stock` int NOT NULL DEFAULT '0' COMMENT '库存',
  `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
业务执行流程
  1. 查询库存与版本号
SELECT id, stock, version FROM goods_stock WHERE id = 1;
-- 返回 stock=10,version=1
  1. 业务判断库存充足,执行扣减,更新条件带上旧version
UPDATE goods_stock 
SET stock = stock -1, version = version +1 
WHERE id = 1 AND version = 1;

关键点:

  • 如果更新返回 affected_rows = 1:更新成功;
  • 如果更新返回 affected_rows = 0:说明在查询和更新间隙,有其他请求已经修改过这条数据,version已经改变,本次操作冲突失败。
Python伪代码示例
# 1 查询
row = db.query("SELECT id,stock,version FROM goods_stock WHERE id=1")
stock = row["stock"]
old_version = row["version"]

if stock <= 0:
    print("库存不足")

# 2 扣库存更新
affect_rows = db.execute(
    "UPDATE goods_stock SET stock=stock-1,version=version+1 WHERE id=%s AND version=%s",
    (1, old_version)
)

if affect_rows == 0:
    # 乐观锁冲突,需要重试或者返回提示用户
    raise Exception("并发冲突,请重试")

方案2:使用更新时间戳 update_time 实现(不推荐优先使用)

利用数据的修改时间作为校验依据,不需要额外增加version字段。
表字段:update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP

执行逻辑:

-- 查询拿到旧的更新时间
SELECT id,stock,update_time FROM goods_stock WHERE id=1;

-- 更新带上旧时间戳
UPDATE goods_stock 
SET stock = stock -1 
WHERE id =1 AND update_time = '2026-08-19 10:00:00';

时间戳方案重大缺陷:

  1. datetime 只到秒级别,如果同一秒内多次修改,时间戳不变,乐观锁失效,出现数据覆盖
  2. 如果业务代码手动修改了update_time字段,会直接破坏乐观锁逻辑。

✅建议:优先使用数字version版本号。

三、乐观锁常见坑点

坑1:只在代码做版本判断,没有放到update where条件中

❌错误写法

# 先查询version,代码里面if判断
row = query(...)
if row.version == old_version:
    execute("update goods_stock set stock=stock-1 where id=1")

查询和update是两条SQL,中间存在时间窗口,并发下会出现竞态条件,乐观锁完全失效。
版本校验必须写在update的where条件里面,交给数据库原子执行。

坑2:乐观锁不能解决ABA问题

ABA场景:
线程A读取version=1;
线程B修改数据version变为2,又改回version=1;
线程A执行更新,where version=1条件命中,更新成功。
业务数据实际发生过变更,但是版本号回到原值,更新直接放行。

  • version自增方案:天然规避ABA,每次修改version必然+1,不会回到旧值
  • 时间戳方案:会存在ABA风险。

大部分互联网业务场景ABA并不多见,version自增可以解决绝大多数场景。

坑3:乐观锁只针对行级别,无法处理批量更新

如果需要批量更新多条记录,乐观锁每一行都要携带version条件;
批量并发很高的场景,大量更新返回0,大量冲突重试,CPU压力上升。

坑4:乐观锁不能替代事务

乐观锁依然需要事务保证业务一致性。比如扣库存同时生成订单,需要事务包裹保证多条SQL要么全部成功,要么全部回滚。
乐观锁解决并发覆盖问题,不解决原子性问题

坑5:高并发下大量冲突,疯狂重试

秒杀场景大量请求同时竞争同一条库存记录,乐观锁大量返回影响行数0,业务不断重试,会把数据库压垮。

优化:上层做限流、队列削峰,不要单纯依靠乐观锁硬抗大并发。

四、乐观锁 vs 悲观锁 该怎么选

使用乐观锁场景

  1. 读多写少,冲突概率不高;
  2. 追求高吞吐量,不想出现锁等待;
  3. 冲突发生允许业务重试,比如扣库存、用户信息修改。

使用悲观锁场景

  1. 写冲突概率非常高,乐观锁大量冲突重试代价更大;
  2. 业务不能接受失败重试,必须保证一定拿到最新数据;
  3. 锁持有时间很短。

举例子:
商品库存秒杀:读多写少,优先乐观锁;
金融账户转账,强一致性,冲突概率高,可考虑悲观锁。

五、MyBatis-Plus 中乐观锁简单了解(拓展)

很多Java开发使用MP内置乐观锁插件,原理和我们手写SQL完全一样:

  1. 实体增加 @Version 注解标记version字段;
  2. 开启乐观锁插件;
  3. 更新实体对象,MP自动拼接 WHERE version = ? 并且自动 version+1

底层依然只是封装SQL,数据库本身没有特殊锁。

六、完整业务流程模板

1.开启事务
2.查询业务数据,同时读取version版本号
3.业务逻辑校验(库存是否大于0等)
4.执行update set xxx=xxx,version=version+1 where id=? and version=?
5.判断update返回影响行数
    - 等于1:commit提交事务,业务成功
    - 等于0:rollback回滚,发生并发冲突,抛出异常或重试

重试小提示:重试不要无限循环,设置最大重试次数3~5次,防止死循环。

总结

  1. 乐观锁不是MySQL数据库自带锁,是业务层并发控制思想,依靠version版本号+update where条件实现;
  2. 数字自增version是最优方案;时间戳datetime存在秒级冲突漏洞,谨慎使用;
  3. 版本校验必须写在update的where条件,不能在代码内存中判断;
  4. version自增可以规避ABA问题;
  5. 乐观锁适合读多写少场景,冲突时返回影响行数0,业务需要处理冲突与重试;
  6. 乐观锁不能替代事务;高并发热点数据需要配合限流、消息队列,避免大量重试压垮数据库;
  7. 和悲观锁区别:乐观锁不上锁,冲突检测;悲观锁先上锁,阻塞等待。

记住一句核心:乐观锁本质就是利用数据库 update 的原子性,把「读取-判断-修改」的判断逻辑放到数据库单条SQL内部执行。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值