面试官:高并发场景下,MySQL 乐观锁和悲观锁谁更优?
面试官:高并发场景下,MySQL 乐观锁和悲观锁谁更优?
大家好,我是牛哥。
做后端开发的,谁没被并发问题坑过?秒杀时库存明明够,却卖超了;转账时自己卡上钱扣了,对方账户没到账;甚至改个订单状态,刷新一下变成了两个完全不同的结果。
这些头疼的问题,往往都和 MySQL 锁机制有关。而乐观锁与悲观锁,就是解决这类问题的两把钥匙。
很多人会纠结 “到底哪个锁更优”,但实际开发中,从来没有绝对的更好,只有更合适 —— 锁的选择,永远要跟着业务场景走。
今天牛哥就带你搞懂两种锁的逻辑。
先搞懂本质,再谈高并发
两种锁的核心逻辑
乐观锁和悲观锁的本质,是面对 “并发冲突” 的两种不同态度,就像我们日常生活中处理事情的两种风格:
1.乐观锁:“先干再说,有问题再调整”
乐观锁代表了生活中的 “乐天派”:做事默认 “顺利是常态,意外是少数”,不提前纠结可能出现的麻烦。
就像出门赶地铁,直接往站台走,默认能赶上当前班次;真没赶上,再等下一班就行,不会因为怕错过就提前半小时在站台蹲着。
对应到 MySQL 中,乐观锁的思路也是一样的:
假设并发冲突是 “小概率事件”,所以操作数据时从不提前加锁占着资源,先让业务流程顺畅跑起来;等最后提交数据时,才通过特定机制检查:这段时间里,数据有没有被其他线程修改过?
- 要是没被动过,说明没冲突,直接提交修改,流程结束;
- 若是被动过了,也不慌,业务层重试 2-3 次,或者给用户返回 “操作稍忙,稍后再试” 的提示就行。
它就像一个信任优先的处理者,用事后补救代替事前设防,核心目标是减少不必要的等待,提升系统吞吐量。
2.悲观锁:“先防风险,再动手做事”
悲观锁则像生活里的 “谨慎派”:习惯先把风险想在前头,做好防护再行动。
比如出差赶飞机,会提前查好路况、留足值机时间,默认 “路上可能堵车、值机可能排队”,用提前准备避免误机,哪怕最后一路顺畅,也觉得多做准备总没错。
在 MySQL 中,悲观锁的思路是:
假设并发冲突是 “必然事件”,所以操作数据前,先通过数据库锁机制 “锁住” 要修改的数据;在锁释放前,其他线程想操作这条数据,只能排队等待。
- 要是成功锁定数据,就可以安心执行业务逻辑(如扣库存、转余额),等操作完后再释放锁;
- 要是未抢到锁,要么排队等待,要么直接返回 “系统繁忙”。
它就像一个风险前置的守护者,用提前占坑避免冲突,核心目标是确保数据一致性,哪怕牺牲一点效率。
通过一张表,更能理解他们的核心差异:
| 对比维度 | 乐观锁(Optimistic Lock) | 悲观锁(Pessimistic Lock) |
|---|---|---|
| 核心假设 | 并发冲突很少发生 | 并发冲突一定会发生 |
| 操作逻辑 | 先操作,后校验(事后解决冲突) | 先加锁,后操作(事前避免冲突) |
| 依赖机制 | 业务逻辑(版本号 / 时间戳) | MySQL 底层锁(行锁 / 表锁) |
| 线程状态 | 不阻塞,冲突时重试 | 阻塞,需等待锁释放 |
| 性能特点 | 低冲突时效率高,无锁等待开销 | 高冲突时更稳定,有锁等待开销 |
| 实现难度 | 业务层实现,需处理重试、降级 | 依赖数据库,需关注锁粒度、超时 |
总之,乐观锁赌冲突少,用 “重试” 换 “效率”;悲观锁怕冲突多,用 “排队” 换 “安全”。
两种锁的落地
光理解思路不够,真正落地时,两种锁的代码写法差异很明显。我们以电商场景中最常见的 “商品库存扣减” 为例:给 goods 表中 id=1001 的商品做库存扣减,当前的库存是 100,最终要把库存降到 99
看看它们在实际开发中是怎么用的。
1. 乐观锁:靠业务代码自己验
乐观锁不需要 MySQL 提供特殊功能,全靠业务代码就能实现。核心思路很简单:
给每条数据发一张身份卡,卡上的 “版本编号” 就是它的身份标识。
每次修改数据,这张身份卡的编号就会变;要是编号变了,就知道被人改过了,要是编号没变,就说明没人动过;
操作分为三步:
第一步:给表加版本编号,相当于给每条数据发个身份卡
-- 给商品表加版本编号字段,每次修改数据,编号就+1
ALTER TABLE goods ADD COLUMN version INT DEFAULT 1 COMMENT '版本编号,修改一次+1';第二步:查询数据,记下当前数据身份卡
-- 查:获取id=1001商品的库存和版本编号,重点记住version的值
SELECT stock, version FROM goods WHERE id = 1001;假设查到结果:库存 100 个,版本编号 1。这个 1 就是当前商品的 “身份卡号”,后面修改时必须用它做校验。
第三部:校验身份卡,改数据并更新编号
只有编号一致,才能扣减库存。同时还要把身份卡的编号 +1,明确告诉后续操作的人:“这条数据已经被我改过了”。
-- 改:库存从100减到99,版本编号从1变成2,但只改版本编号还是1的商品
UPDATE goods
SET stock = stock - 1, version = version + 1 -- 库存减1,版本号从1变成2(标记已修改)
WHERE id = 1001 AND version = 1; -- 条件:必须和刚才记的版本编号1一致才执行执行完UPDATE后,只要看看这次操作 “影响了几行数据”,就知道有没有人同时改这个商品:
- 影响 1 行:说明这段时间没人碰过,版本编号还是 1,扣减成功;
- 影响 0 行:说明有人抢先修改了商品,版本编号已经从 1 变成 2 了,和之前记的对不上,扣减失败。
如果扣减失败,业务代码里就得做处理:要么重试 2-3 次,重新查最新的库存和版本号;要么告诉用户 “当前人太多,稍候再试”,避免用户困惑。
2. 悲观锁:靠 MySQL 帮着锁数据
悲观锁的实现则完全依赖 MySQL 的底层锁机制,核心思路很直接:“操作数据前先把它锁起来”。InnoDB 引擎里最常用 “行锁”,只锁要改的那一行,通过 SELECT ... FOR UPDATE 语句就能触发。
还是以 “库存扣减” 为例,全程分四步,每一步都和锁的状态紧密相关:
第一步:开启事务,准备 “拿锁”
-- 开启事务:相当于宣布“我要开始占座了”
BEGIN;第二步:加锁查询,把数据 “锁牢”
-- 加锁查:查库存的同时,把id=1001这行数据锁死
-- 其他线程想改这行?得等我这个事务结束才行
SELECT stock FROM goods WHERE id = 1001 FOR UPDATE;
-- 假设查到库存=100,此时这行数据已被锁定,别人动不了第三步:安心修改,不怕被人干扰
-- 改数据:反正数据被锁了,放心扣库存
UPDATE goods SET stock = stock - 1 WHERE id = 1001;第四步:提交事务,释放锁让别人用
-- 提交事务:相当于“我用完了,大家可以用了”,锁释放
COMMIT;和乐观锁看 “影响行数” 不一样,悲观锁抢锁的结果直接体现在 SQL 执行上:
- 抢到锁了:SQL 会顺利执行,接着改数据、提交事务就行;
- 没抢到锁:线程会进入 “排队等锁” 状态,直到持有锁的人释放;如果等太久,就会报错 “锁等待超时”,这时候业务代码得接住这个错误,告诉用户 “系统有点忙,稍后再试”。
悲观锁看着简单,但用不好会让系统变卡,还有两个细节得特别注意:
细节 1:查询没走索引,锁会从小锁变大锁
SELECT ... FOR UPDATE 本来加的是 “行锁”,只锁要修改的那一行数据。但有个关键前提:查询时用的条件必须能命中索引。
要是查询没走索引,MySQL 就找不到具体要锁哪一行。这时候它会 “无奈” 地把整个 goods 表都锁住。本来只想占一个座位,结果把整个车厢都封了,所有要操作这个表的请求都得排队,系统一下就变慢了。
细节 2:事务执行太久,锁会占着不放
要是加锁之后,在事务里做了耗时的操作,锁就会一直被占着不放。后面的线程想操作数据,只能干等着,等久了还会超时报错。
所以用悲观锁的时候要速战速决。
高并发场景下,两种锁的对决
实战选锁,选最好的不如选最对的
