MySQL 乐观锁和悲观锁,基本是后端面试里绕不开的一道题。我前前后后面过不下几十个人,能把这个话题讲明白的真不多——大部分人都能背出“悲观锁就是 select ... for update,乐观锁就是版本号”,但让他现场写一条完整的扣库存更新语句,或者追问一句“为什么扣库存经常用 for update,而不是直接 update”,十有八九会卡壳。
这篇文章我就按自己平时面试候选人的思路,把乐观锁和悲观锁从头到尾拆一遍:底层原理是什么、SQL 怎么落、Java 代码怎么配、典型业务场景怎么选,再模拟几个面试官最爱追问的点。不管你是准备面试,还是正在做订单、库存、抢购这类并发比较高的业务,应该都能拿到可以直接抄作业的东西。
1. 先搞清楚:锁到底在解决什么问题
1.1 并发写乱套的三种典型情况
做后端开发的同学应该都有过这种经历:明明查出来库存是够的,下单的时候却超卖了;明明余额剩 100 块,两个人同时转账,最后账对不上。这些问题的根源,就是数据并发读写产生了冲突。
我总结一下并发写最常见的三种问题:
- 丢失更新:两个事务同时读到同一份数据,各自做修改,后提交的人把先提交的人的修改覆盖掉了。库存从 10 被两次扣减,最终却只少了 1。
- 脏读:一个事务读到了另一个事务还没提交的数据,如果对方后面回滚了,你读到的就是脏数据。
- 不可重复读 / 幻读:同一个事务里两次查询的结果不一致。第一次查到的行第二次没了,或者凭空多出一批行,业务处理起来非常难受。
MySQL 的 InnoDB 引擎解决这些问题,主要靠两套机制:一套是 MVCC(多版本并发控制),主要处理读-写并发,让普通 SELECT 不用加锁也能拿到一致性快照;另一套就是锁机制,用来处理写-写并发。今天我们聊的乐观锁和悲观锁,本质上是“怎么用锁这个武器”的两种不同策略。
1.2 拿生活中的例子理解乐观锁和悲观锁
悲观锁特别像图书馆的自习室占座:你进去之前先把门锁上,别人进不来,你独享这个房间,办完事再开门。好处是绝对安全,坏处是别人都得在外面等着,效率低。
乐观锁则有点像自助餐夹菜:你先正常夹菜,夹完之后核对一下台面上有没有被别人动过,如果发现别人动过,你就重新夹一次或者放弃。好处是正常情况下不需要等待,坏处是冲突多了你得反复夹,反而更折腾。
悲观锁的核心思路是“防”:假定一定会有并发冲突,所以一开始就把资源锁住,别人想碰都碰不到。乐观锁的核心思路是“查”:假定冲突概率不高,正常读正常改,只在提交更新的时候检查一下这段时间有没有人动过数据。
这里有个特别重要的点,很多文章讲混了:悲观锁是数据库原生支持的锁机制,You can't use it without数据库;乐观锁本质上是应用层的一种编程设计模式,数据库本身并没有专门的“乐观锁”语法。你平时听说的 version 字段、CAS 更新,都是业务代码里做出来的效果。理解这一点,后面面试官怎么追问你都不虚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 悲观锁实战:先上锁再干活
2.1 悲观锁的 SQL 灵魂:SELECT ... FOR UPDATE
悲观锁在 MySQL 里最典型的就是 SELECT ... FOR UPDATE 这条语句。FOR UPDATE 会对查询出来的行加上排他锁(X 锁),在事务提交或回滚之前,其他事务想对这些行做更新、删除,甚至想再执行 FOR UPDATE 查询,都会被阻塞。
这里有个容易混淆的点:在 InnoDB 里,普通 SELECT 是快照读,走 MVCC,不加锁;而 SELECT ... FOR UPDATE 是当前读,读取的是最新版本,并且加写锁。两者的区别一定要记牢。
除了 FOR UPDATE,还有一个 LOCK IN SHARE MODE,加的是共享锁(S 锁)。多个事务可以同时持有共享锁,但排他锁和共享锁互斥。实际业务中 FOR UPDATE 用得更多,因为我们需要的是独占修改能力。
要注意:锁要真正生效,必须满足两个硬性条件。第一个,表必须是 InnoDB 引擎,MyISAM 不支持行锁,FOR UPDATE 在它上面基本就是摆设。第二个,查询条件要能走索引,否则 InnoDB 会把所有扫描到的记录全锁上,也就是行锁升级成表锁,这是很多线上事故的根源。
2.2 完整案例:用悲观锁扣库存
先建一张最基础的商品表:
sql复制CREATE TABLE product (
id BIGINT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
stock INT NOT NULL
) ENGINE=InnoDB;
然后写一个按主键加锁查询的 SQL:
xml复制<select id="selectByIdForUpdate" resultType="com.example.Product">
SELECT id, name, stock
FROM product
WHERE id = #{id}
FOR UPDATE
</select>
Service 层的扣库存逻辑:
java复制@Transactional
public void deductStock(Long productId, Integer count) {
Product product = productMapper.selectByIdForUpdate(productId);
if (product.getStock() < count) {
throw new BusinessException("库存不足");
}
productMapper.updateStock(productId, product.getStock() - count);
}
注意,这个方法必须有 @Transactional。如果方法上没有事务注解,SELECT ... FOR UPDATE 执行完,数据库自动提交,锁立刻就释放了,那你等于白锁。这里的时序是这样的:
| 时间 | 事务A | 事务B |
|---|---|---|
| T1 | SELECT ... FOR UPDATE,获取行锁 | — |
| T2 | 业务判断库存是否充足 | SELECT ... FOR UPDATE,阻塞等待 |
| T3 | UPDATE stock 从 10 变成 9,COMMIT | — |
| T4 | — | 获取锁,读取到最新 stock=9,继续执行 |
整个“查询 + 判断 + 更新”的流程被锁保护成了一个原子区间。事务 B 必须等事务 A 提交后才能看到最新数据,这就避免了超卖。
2.3 用悲观锁必须注意的三个细节
第一个细节:必须开启事务,而且事务要尽量短。FOR UPDATE 的锁要等事务提交或回滚才释放,所以不要把远程调用、耗时操作、甚至用户输入等待都包在事务里。锁持有时间越长,后面的请求堆积越严重,数据库连接池一旦被占满,整个服务就雪崩了。一个常见反例是在事务里调第三方支付接口,一调就是好几秒,数据库连接全被占住,后面所有请求直接排队超时。
第二个细节:查询条件必须走索引,否则就是锁全表。这个坑我见得太多了。where 后面的条件字段如果没索引,或者你用了函数、隐式类型转换,MySQL 优化器可能选择全表扫描,InnoDB 会锁住所有表记录,性能直接崩。上线前务必用 EXPLAIN 看一下执行计划,确认 type 不是 ALL,确认 possible_keys 和 key 都命中了。
第三个细节:注意隔离级别与间隙锁。在 REPEATABLE READ 默认隔离级别下,FOR UPDATE 如果查询的是范围条件,或者条件不是精确主键/唯一索引,InnoDB 可能加间隙锁甚至 next-key lock,把一段范围内的数据都锁住。如果你只希望锁一行,条件就尽量用主键或唯一索引,锁的范围越小,并发度越高。
3. 乐观锁实战:先干活,提交时再检查
3.1 版本号方案:最经典的乐观锁实现
乐观锁最经典的实现就是给表加一个 version 字段,每次更新数据时带上 version 条件,更新成功后 version 加 1。建表语句里多一个字段:
sql复制CREATE TABLE product (
id BIGINT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
stock INT NOT NULL,
version INT NOT NULL DEFAULT 0
) ENGINE=InnoDB;
更新语句的核心写法是:
xml复制<update id="deductStockWithVersion">
UPDATE product
SET stock = stock - #{count},
version = version + 1
WHERE id = #{id}
AND version = #{version}
</update>
调用方的逻辑:
java复制public boolean deductStockWithVersion(Long productId, Integer count, Integer expectVersion) {
int rows = productMapper.deductStockWithVersion(productId, count, expectVersion);
return rows > 0;
}
使用的时候,先查一次拿到当前版本号,然后业务处理,最后带着旧版本号去更新:
java复制Product product = productMapper.selectById(productId);
if (product.getStock() < count) {
throw new BusinessException("库存不足");
}
boolean success = service.deductStockWithVersion(productId, count, product.getVersion());
if (!success) {
// 冲突了,需要重试或给用户提示
throw new BusinessException("系统繁忙,请重试");
}
这里有个细节一定要跟面试官讲清楚:version = version + 1 要放在数据库里做,不要先在应用层查出来 version,自己加 1 再把结果传回去。两个事务同时拿着 version=3 去更新,数据库的 UPDATE 是行级串行执行的,第一个事务更新成功把 version 变成 4,第二个事务再执行时 WHERE version=3 匹配不到任何行,返回 0 行,冲突就被精准暴露出来了。
如果你用的 MyBatis-Plus,它提供了 @Version 注解和乐观锁插件,可以自动帮你拼 version 条件,不过底层 SQL 逻辑还是上面这条。我个人建议面试前自己手写一遍原生 SQL,理解得更透。
3.2 CAS 方案:直接用业务字段做校验
除了版本号,还有一种更轻量的乐观锁实现是 CAS(Compare And Set),也就是拿业务字段本身做修改条件。最典型的就是直接用库存字段做比对:
sql复制UPDATE product
SET stock = stock - #{count}
WHERE id = #{id}
AND stock = #{expectedStock};
这个写法里,你先读出 stock=10,算出扣完是 9,执行更新时带上 AND stock = 10 这个条件。如果期间有其他事务把 stock 改成了 8,条件不成立,更新 0 行,冲突暴露。
电商里常见的原子扣减,其实也是 CAS 思想的一种变体:
sql复制UPDATE product
SET stock = stock - #{count}
WHERE id = #{id}
AND stock >= #{count};
这条 SQL 把“库存是否充足”的判断直接塞进了更新语句里,由数据库的行锁来保证原子性。两个人同时扣最后一件商品,只有一个人能成功。严格来说它不是版本号式的乐观锁,但思路是一致的:不提前抢锁,靠更新时的条件校验来兜底。面试时提到这个,会显得你平时写代码有思考。
3.3 乐观锁冲突了怎么办:重试与失败处理
很多人实现乐观锁,只写了 UPDATE 语句,没处理“返回 0 行”的情况,这等于把冲突直接吞掉了。我举一个真实的例子:扣库存接口,库存只剩 3 件,三个人同时并发下单,三人都读到 3 件,都执行 UPDATE,只有一个人成功,另外两个返回 0 行。如果你的代码不处理返回值,用户会看到“下单失败”,甚至更糟的“提示成功但库存没扣”。
冲突处理一般分两类:
第一类,快速失败。直接给用户返回“操作频繁,请重试”,或者直接提示“已售罄”。适合秒杀、限流这类本来就不想让用户无限试的场景。
第二类,重试机制。循环读取最新版本,重新做业务判断,再更新,设置最大重试次数。示例代码如下:
java复制for (int i = 0; i < 3; i++) {
Product product = productMapper.selectById(productId);
if (product.getStock() < count) {
throw new BusinessException("库存不足");
}
int rows = productMapper.deductStockWithVersion(productId, count, product.getVersion());
if (rows > 0) {
return;
}
// 冲突了,稍微等一会避免空转
Thread.sleep(50);
}
throw new BusinessException("系统繁忙,请稍后重试");
注意,重试时必须重新查最新版本,不能拿旧的 version 继续循环,否则永远成功不了。另外,重试前要确认业务操作是幂等的,或者配合唯一流水号、幂等表做去重,避免用户连续点了好几次提交,重试触发后扣了多次库存。
3.4 ABA 问题:CAS 方案最大的坑
CAS 方案有个经典问题叫 ABA。我们拿库存字段本身做条件:线程 A 读到 stock=10,线程 B 也读到 10,B 先扣了 1 变成 9,后来又因为退款加回来变成 10。这时候线程 A 再执行 UPDATE ... WHERE stock = 10,条件成立,更新成功,但 stock 其实已经经历过一次变化。如果业务里对“是否被人改过”这件事敏感,CAS 就漏判了。
version 号方案天然免疫 ABA 问题,因为 version 每次更新都会 +1。即使 stock 最终回到 10,version 早就变了,UPDATE 条件不会成立。所以,如果你的业务只需要最终扣减结果正确,用库存字段 CAS 就行;如果你要精确判断数据有没有在中间被改过,那就老老实实用 version 字段。还有一种靠时间戳代替 version 的做法,本质和版本号一样,但同一毫秒内并发时有可能失效,实际项目中还是整数 version 更稳妥。
4. 面试官最爱的追问:选型与原理
4.1 一张表看懂悲观锁和乐观锁怎么选
选型这个问题,面试官几乎必问。我建议你用下面这张表来组织回答:
| 对比维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 核心思路 | 先锁后改,冲突靠阻塞避免 | 先改后验,冲突靠检测发现 |
| 冲突频率高 | 更稳,不会大量重试 | 频繁失败,重试成本高 |
| 读多写少 | 会加无谓的锁,浪费 | 推荐,一次更新搞定 |
| 并发性能 | 锁持有期间阻塞多 | 无锁,轻量但重试有成本 |
| 数据库开销 | 长时间占用连接 | 短促更新,但重试放大压力 |
| 实现复杂度 | 简单,SQL 原生 | 需要加字段 + 处理重试 |
拿实际场景说:个人资料修改、购物车更新这类冲突概率低的场景,用乐观锁非常合适;热点商品抢购、账户余额扣减这类冲突非常集中的场景,悲观锁的稳定性更好。还有一个容易被忽略的点:悲观锁在锁持有期间会一直占着数据库连接,而连接池资源是有限的;乐观锁不持有锁,但一旦冲突率上去了,无谓的 UPDATE 次数会呈指数级上升,数据库压力同样不小。选型时要综合考虑。
4.2 追问一:UPDATE 本来就有锁,为什么还要 FOR UPDATE?
如果只做一次 UPDATE,数据库确实会加行锁,你甚至可以把“库存扣成负数就失败”这种校验塞进一条 SQL,靠原子 UPDATE 搞定,不需要 FOR UPDATE。但很多业务逻辑不是一句 UPDATE 能解决的:你得先查出数据做各种判断,比如校验用户状态、计算运费、判断账户余额,然后才更新。
问题就出在“先查后改”这个空档期。普通 SELECT 是快照读不加锁,你查出结果开始做业务判断的这段时间,另一个事务可能已经改了这条数据。两个事务都做了同样的判断,然后都去 UPDATE,就会出问题。FOR UPDATE 的作用就是把“查询 + 业务判断 + 更新”这整段过程用锁保护起来,形成一个别人插不进来的原子区间。简单总结:业务判断和更新能写进一条 SQL 时,优先用原子 UPDATE;写不进一条 SQL 时,用 FOR UPDATE 把整段时间锁住。
这个回答能把“什么时候用悲观锁”讲得很清楚,面试官基本挑不出毛病。
4.3 追问二:乐观锁能防脏读吗?跟 MVCC 什么关系?
乐观锁是应用层控制机制,它本身防不了脏读。脏不脏,由数据库的隔离级别决定。MVCC 处理的是读-写并发:读不阻塞写,写不阻塞读,普通 SELECT 读到的是某个一致性快照。乐观锁处理的是写-写冲突的检测:你基于某个快照做了修改,提交时用 version 条件判断这份快照是否已经过期。
所以两者其实是配合使用的。你平时写代码时,先普通 SELECT 拿快照,业务处理完带 version 更新,这就是 MVCC 快照读 + 乐观锁版本校验的经典组合。面试时把这个层次说清楚,比单纯背概念高一个段位。
4.4 追问三:悲观锁会不会死锁?如何避免?
会,而且 FOR UPDATE 产生的死锁很典型。两个事务各持有一行记录的锁,然后又分别去申请对方持有的那行锁,就会死锁。InnoDB 有死锁检测机制,检测到之后会回滚其中一个事务,让另一个继续,但你的应用日志里会看到 Deadlock found when trying to get lock 之类的报错。
避免死锁的核心思路有三个:
- 多个资源需要加锁时,所有事务都按相同的顺序加锁,比如先锁商品再锁订单,别一个先锁商品一个先锁订单。
- 把事务范围压到最短,减少锁的持有时间和交叉概率。
- 查询条件尽量走索引,减少锁覆盖的范围。必要时可以调整
innodb_lock_wait_timeout,让等待过久的锁快速失败,而不是一直耗着。
4.5 追问四:分布式场景下这两种锁还管用吗?
数据库的悲观锁只能锁当前数据库里的数据。在微服务、分库分表场景下,多个服务同时操作同一条数据,就得考虑分布式锁,比如 Redis 的 SET NX EX、ZooKeeper 临时节点,这是另一个量级的方案。
但乐观锁的思路在分布式下依然成立——只要把 version 条件放到 UPDATE 里,不管请求从哪个服务过来,数据库最终都会用行锁加条件校验保证只有一个事务成功。所以很多团队在分布式场景里会选择“数据库乐观锁 + 幂等设计”来替代复杂的分布式锁,能省下不少运维成本。比如电商库存,常见的方案就是用一条 UPDATE ... WHERE stock >= count 做预扣,配合 Redis 做热点数据缓存,本质上就是乐观锁/CAS 思想。面试时能说出这个落地方案,说明你有真实项目经验。
5. 实操踩坑记录:这些坑面试官也不一定知道
5.1 索引没走对,行锁变表锁
先说一个我遇到过的线上事故。某个订单表的 status 字段建了索引,但查询条件写的是 WHERE status = 1 AND order_no = 'xxx',order_no 并没有索引。MySQL 优化器评估后决定走全表扫描,这一下坏了——FOR UPDATE 直接锁住了整张表,所有订单更新请求全部卡住,线上大量报 Lock wait timeout。
排查时先 SHOW PROCESSLIST 看阻塞情况,再用 EXPLAIN 看执行计划,发现 type=ALL,key 为 NULL,问题一目了然。解决方式就是给 order_no 补索引,让锁的范围从全表回到单行。经验就一条:写 FOR UPDATE 之前,先 EXPLAIN 确认走索引,这句提醒值得写进团队的代码规范。
5.2 事务没提交,锁一直不释放
很多新手在 Navicat 里手动执行了 SELECT ... FOR UPDATE,测试完忘了 COMMIT 或者直接关掉窗口,行锁就一直挂在那边,其他会话全部被阻塞。生产环境更常见的是代码里事务注解加错了地方,或者手动开启事务之后,catch 块里没有正确处理回滚,导致锁一直不释放。
排查方法有两个:用 SHOW PROCESSLIST 看有没有长时间 Sleep 的连接;用 SELECT * FROM information_schema.INNODB_TRX\G 查看当前未提交的事务和它持有的锁。解决思路也很直接:代码统一用 @Transactional,注意指定 rollbackFor,手动事务务必在 finally 里做回滚或提交。锁这个东西,释放比获取更重要。
5.3 乐观锁更新 0 行,代码没接住
我接手过一个系统,同事用乐观锁做订单状态变更,update 返回的 int 完全没有判断。结果用户点完确认收货,界面提示成功了,但订单状态在数据库里纹丝不动。原因是乐观锁冲突后 UPDATE 返回 0 行,MyBatis 默认不报错,代码继续往下走,接口直接返回成功。
这类问题就是典型的“静默失败”。解决方式就一条:所有带乐观锁条件的 UPDATE,必须判断受影响行数,为 0 时抛异常或走重试逻辑,绝对不能让流程继续。也建议在接口层面做防重复提交,比如用 Redis 存一个用户下单的幂等 Key,几秒内的重复请求直接拦截掉,从源头减少乐观锁冲突。
5.4 版本号字段用错类型,导致并发更新失效
有人把 version 字段设计成浮点型,更新时 version = version + 0.1。结果两个并发事务同时拿到 version=1.1,更新后 1.2,浮点精度还可能让条件恒成立,版本号形同虚设。还有人把 version 做成时间戳,毫秒级并发下两个更新落在同一毫秒,条件照样成立,版本号也失效了。
正确做法是:version 用 INT 或 BIGINT,只做 version = version + 1,不要用浮点,不要依赖时间戳。更新语句里的 version+1 也尽量交给数据库计算,不要应用层先算好再传,否则并发时容易把旧值传回去覆盖掉新值。
5.5 锁等待超时与死锁怎么排查
锁等待和死锁是两码事,很多人搞混。锁等待是别人持有锁还没释放,你在这排队,超过 innodb_lock_wait_timeout(默认 50 秒)会报 Lock wait timeout exceeded。死锁是互相持有对方想要的锁,InnoDB 检测到后立刻回滚一个事务,日志里会出现 Deadlock found。
排查死锁的利器是 SHOW ENGINE INNODB STATUS\G,查看 LATEST DETECTED DEADLOCK 部分,里面会打印出两个事务各自持有的锁和正在等待的锁,能直接看到死锁链路。优化方向还是那三板斧:缩短事务、固定加锁顺序、缩小锁范围。遇到死锁不要慌,回滚重试是常规操作,只要重试逻辑做好了,对用户基本无感。
我在实际项目里形成了一套自己的习惯:能用一条原子 UPDATE 解决的绝不上锁,比如库存、余额这类简单的扣减值;必须多步判断修改的一般先用乐观锁版本号,比如订单状态流转;如果确认冲突量很大、重试成本高,才上 FOR UPDATE,并且严格约束事务范围和索引条件。面试的时候不用死记硬背,把你自己实现过的锁的完整场景讲清楚,包括用在哪、怎么用的、踩过什么坑,面试官大概率会给你加分。
最后分享一个练手思路:自己建一张 product 表,写两个线程或者两个事务去扣同一条库存,先用普通 UPDATE,再用 FOR UPDATE,再用 version 乐观锁,把每种方案跑一遍,观察阻塞、重试和成功的情况。这种动手验证的过程,比背十篇面经都管用。
