前言
在开发扣库存、账户余额变更、订单状态流转这类并发业务时,如果不加控制,高并发下很容易出现超卖、数据覆盖、余额错乱问题。
很多同学第一反应想到用 MySQL 悲观锁 select ... for update,但是悲观锁会加行锁,并发高时容易出现锁等待、死锁、性能下降。
这时就有了另一种方案:乐观锁。
很多人听过乐观锁,但是搞不清楚:
- 乐观锁到底是数据库自带功能吗?
- 版本号怎么写SQL?
- 时间戳做乐观锁有什么坑?
- 和悲观锁、事务隔离级别是什么关系?
本文结合业务实战讲透 MySQL 乐观锁。
说明:乐观锁并不是 MySQL 内置的锁机制,它是一种业务层实现的并发控制思想,数据库本身没有
lock optimistic这类语法。
一、乐观锁与悲观锁核心思想对比
悲观锁
悲观的认为:并发一定会发生冲突,查询数据的时候就直接上锁,其他人必须等待锁释放才能操作。
- 实现:
SELECT ... FOR UPDATE,行排他锁 - 特点:先加锁,后修改;冲突时阻塞等待;
- 缺点:高并发场景锁竞争激烈,容易超时、死锁,吞吐量低。
乐观锁
乐观的认为:大部分时候不会发生并发冲突,不上锁,直接执行业务修改;提交更新的时候再检查,看看数据有没有被别人修改过。
- 实现:业务版本号 / 时间戳,基于
update where条件做版本校验 - 特点:不加锁,读无阻塞;冲突发生时直接返回更新行数0,业务捕获后做重试或者报错;
- 缺点:需要业务代码处理冲突重试;不能解决脏读问题。
| 类型 | 锁机制 | 并发冲突处理 | 性能 | 典型实现 |
|---|---|---|---|---|
| 悲观锁 | 数据库行锁 | 阻塞等待 | 并发高较差 | select … for update |
| 乐观锁 | 业务逻辑,无数据库锁 | 更新判断,失败抛异常/重试 | 并发高较好 | version版本号 |
二、乐观锁两种主流实现方案
方案1:版本号 version(推荐,生产首选)
核心原理:给业务表增加一个 version 版本字段,数字类型。
- 查询时,把当前
version一并读出来; - 更新的时候,
where条件带上version = 查询出来的旧版本号; - 更新成功同时
version = version + 1; - 如果这条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;
业务执行流程
- 查询库存与版本号
SELECT id, stock, version FROM goods_stock WHERE id = 1;
-- 返回 stock=10,version=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';
时间戳方案重大缺陷:
- datetime 只到秒级别,如果同一秒内多次修改,时间戳不变,乐观锁失效,出现数据覆盖;
- 如果业务代码手动修改了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 悲观锁 该怎么选
使用乐观锁场景
- 读多写少,冲突概率不高;
- 追求高吞吐量,不想出现锁等待;
- 冲突发生允许业务重试,比如扣库存、用户信息修改。
使用悲观锁场景
- 写冲突概率非常高,乐观锁大量冲突重试代价更大;
- 业务不能接受失败重试,必须保证一定拿到最新数据;
- 锁持有时间很短。
举例子:
商品库存秒杀:读多写少,优先乐观锁;
金融账户转账,强一致性,冲突概率高,可考虑悲观锁。
五、MyBatis-Plus 中乐观锁简单了解(拓展)
很多Java开发使用MP内置乐观锁插件,原理和我们手写SQL完全一样:
- 实体增加
@Version注解标记version字段; - 开启乐观锁插件;
- 更新实体对象,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次,防止死循环。
总结
- 乐观锁不是MySQL数据库自带锁,是业务层并发控制思想,依靠version版本号+update where条件实现;
- 数字自增version是最优方案;时间戳datetime存在秒级冲突漏洞,谨慎使用;
- 版本校验必须写在update的where条件,不能在代码内存中判断;
- version自增可以规避ABA问题;
- 乐观锁适合读多写少场景,冲突时返回影响行数0,业务需要处理冲突与重试;
- 乐观锁不能替代事务;高并发热点数据需要配合限流、消息队列,避免大量重试压垮数据库;
- 和悲观锁区别:乐观锁不上锁,冲突检测;悲观锁先上锁,阻塞等待。
记住一句核心:乐观锁本质就是利用数据库 update 的原子性,把「读取-判断-修改」的判断逻辑放到数据库单条SQL内部执行。

2万+

被折叠的 条评论
为什么被折叠?



