1. 从面试题说起:锁的本质是“并发控制策略”
有一类面试题特别有意思:它表面上在问“怎么实现”,实际上在考“你踩没踩过坑”。MySQL的乐观锁和悲观锁就是这么个典型。网上能搜到几百篇讲法,但大多数都停留在“乐观锁用version字段,悲观锁用select for update”这种层面。我见过不少候选人能把这两句话背得很熟,可一问到“那乐观锁更新失败怎么办”“for update在索引失效的情况下会锁住全表你知道吗”,立刻就卡住了。
先把这个问题的坐标轴定清楚。悲观锁和乐观锁不是MySQL里两个具体的功能开关,而是两种处理并发冲突的思想模型。悲观锁的出发点是:我觉得别人大概率会来抢数据,所以我干脆先把资源锁住,你们谁也别动,等我干完再说。乐观锁的出发点是:我觉得正常情况下没人跟我抢,我先直接改,改完提交的时候再确认一下,这期间有没有被别人捷足先登。两种思路对应到代码层面,就是截然不同的两套写法。
这篇文章我打算用一条线把它讲透:先看实现原理,再看InnoDB层怎么落地,然后上真实案例聊锁失效和死锁,最后说清楚面试官问你这个问题的时候到底期待听到什么。如果你同时也在做项目里的并发接口优化,后面几节的实战经验可以直接对照着查。
提示:本文讲的是InnoDB存储引擎下的锁机制。MyISAM那种只支持表级锁的老引擎不在讨论范围里,现在新上线的业务基本没人拿MyISAM做并发写入场景了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先统一认知:事务隔离级别与锁的关系
很多面试者栽在乐观锁和悲观锁上,不是不懂这两个词,而是把锁机制和事务隔离级别搞成了两个割裂的知识点。实际上,隔离级别的底层实现依赖的就是锁机制,你要讲清楚锁,就得先讲清楚它是在什么隔离级别下生效的。
MySQL的InnoDB默认隔离级别是可重复读(Repeatable Read)。这个默认值本身就很有信息量——Oracle默认是读已提交,MySQL之所以敢把默认级别设在可重复读,核心原因是InnoDB的间隙锁(Gap Lock)和next-key lock能够在此级别下有效防止幻读,让RR级别达到接近串行化的数据一致性。这也是很多资深开发者对MySQL锁机制认知容易出偏差的地方:同样的SQL,在RR和RC两种级别下的加锁范围完全不同。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | InnoDB默认加锁行为 |
|---|---|---|---|---|
| Read Uncommitted | 可能 | 可能 | 可能 | 极少加锁 |
| Read Committed | 不会 | 可能 | 可能 | 只锁住匹配行(记录锁) |
| Repeatable Read | 不会 | 不会 | 可能(但InnoDB通过间隙锁规避) | 记录锁 + 间隙锁 |
| Serializable | 不会 | 不会 | 不会 | 所有读都转锁 |
判断一个锁方案是否合理,首先得明确当前会话的事务隔离级别。我见过有人在RR级别下用乐观锁,代码里version字段也加了,逻辑看着挺严谨,但压测时就是频繁出现死锁。后来排查发现是某个高频查询走了范围条件,在RR下触发了间隙锁,跟并发插入的数据产生了锁冲突。这个问题在RC级别下可能压根不会出现。
另外要注意一个细节:锁是在事务里才真正有意义的。很多初学MySQL的人写悲观锁代码,把select for update和后续的update放在两个独立事务里执行,或者干脆没有显式开启事务,那for update执行完,锁就自动释放了,后面再update就跟没锁一样。这种属于“假悲观锁”,代码review时一眼就能看出来。
关于隔离级别,还有一个必须记住的结论:MySQL在RR级别下,普通的select是快照读,不加锁——它走的是MVCC(多版本并发控制),读的是历史版本数据。像select for update、update、delete这些操作走的是当前读,必须拿最新的数据并加锁。乐观锁之所以能用version字段实现,依赖的就是快照读拿到的旧版本数据和更新时当前读查出来的最新版本做对比。这个底层差异,后面讲乐观锁的CAS机制时会反复用到。
3. 悲观锁的落地细节:for update的边界、间隙与锁范围
3.1 悲观锁的执行路径与代码形态
悲观锁落到MySQL上,最常见的实现就是select ... for update。操作流程如下:
sql复制-- 开启事务(以商品库存为例)
START TRANSACTION;
-- 对需要操作的行加排他锁
SELECT stock FROM product WHERE id = 1 FOR UPDATE;
-- 在业务代码里判断库存是否充足,然后做扣减
UPDATE product SET stock = stock - 1 WHERE id = 1;
COMMIT;
这里最关键的一行就是FOR UPDATE。它走的是当前读,会对查出来的行加排他锁(X锁)。排他锁的含义是:其他事务如果想对同一行数据加锁,不管是共享锁还是排他锁,都会被阻塞,直到当前事务提交或回滚。
在真实项目里,这段SQL外面要包一层事务,而且事务的粒度要尽量小。因为锁是从加上的那一刻开始持有的,直到事务结束才释放。事务里如果还包含了远程调用、文件读写、复杂计算这类耗时操作,锁的持有时间会变得很长,高并发下其他请求就会大量堆积在锁等待上,表现就是接口RT飙高、数据库连接池被打满。
还有一点容易被忽略:加了悲观锁之后,读出来的数据不能再基于旧值做运算。比如SELECT stock INTO :s FROM product WHERE id = 1 FOR UPDATE查出stock是10,事务里这段代码如果跑了很久,等到你要执行update时,中间很可能已经被其他事务提交了新的库存变化。所以update不能写成SET stock = :s - 1,应该直接在SQL里写SET stock = stock - 1,让数据库在当前行锁保护下做原子操作。这也是悲观锁场景下最容易出逻辑错误的地方。
3.2 锁的范围比你想象的大:索引与间隙
悲观锁有个非常核心的底层规则:InnoDB的锁是作用在索引记录上的。如果查询条件走了主键索引,那锁精确作用在该主键对应的那条记录上;如果走了二级索引,InnoDB除了锁二级索引记录,还会回表锁住对应的主键记录;但如果查询条件没有命中任何索引,InnoDB会对聚簇索引里的所有记录加上锁——也就是说,整张表被锁住了。
打个比方,这就像你本来只想锁自己家的房门,但保安不认识你是谁,为了确保你不要跑出去,直接把整栋楼的大门都关了。功能上安全了,代价是整个楼的人都出不去了。
还有更隐蔽的:在RR隔离级别下,FOR UPDATE如果查的是一个范围(比如WHERE status = 0或者WHERE id > 100),InnoDB不光会给满足条件的记录加行锁,还会在记录之间的间隙加间隙锁,防止其他事务往这个范围内插入新数据。这个机制本身是为了解决幻读,但代价是并发插入能力明显下降。同一张表里,一个范围查询的悲观锁,可能让其他事务的合法插入全部卡死。
实操时我是这么处理的:能用主键等值查询,就绝不写范围查询;必须处理范围条件时,先评估并发量能不能接受间隙锁带来阻塞;再不行就改走乐观锁或者消息队列串行化,别硬扛。
3.3 一个真实死锁案例:两张表的加锁顺序冲突
悲观锁还有一个让人头疼的问题,死锁。我拿之前排查过的一个案例来讲。业务场景是下单后要扣减商品库存、同时写订单表。代码逻辑大概长这样:
java复制// 线程A
tx1: select * from product where id = 100 for update; // 锁商品记录
tx1: insert into order(...) ...; // 尝试插入订单
// 线程B
tx2: select * from order where user_id = 888 for update; // 锁订单记录(走user_id索引)
tx2: update product set stock = stock - 1 where id = 100; // 尝试锁商品记录
如果线程A先锁了product表id=100那行,线程B先锁了order表某条记录,然后线程A又等order表的插入,线程B又等product表的锁,两个事务就会互相等对方释放锁,数据库检测到死锁后,会回滚其中一个事务,并抛出Deadlock found的异常。
解决办法倒不复杂:所有事务都按同一个顺序加锁。比如约定先锁product再锁order,这样线程B也会先尝试锁product,如果product被线程A锁着,B会直接进入锁等待,而不是拿着order的锁再去抢product,天然打破了循环等待。这属于开发规范层面的约束,比任何数据库参数调优都有效。
4. 乐观锁的实现方式:版本号、CAS与重试策略
4.1 版本号方案的三个关键步骤
乐观锁在MySQL里的实现,最经典的是版本号(version)机制。核心思路是:数据行上有一个版本号字段,每次更新都把版本号加一。更新前先读出当前版本号,更新时在where条件里带上这个版本号,如果版本号变了,说明数据被人改过,更新影响行数为0,然后业务代码决定是重试还是报错。
sql复制-- 1. 读取数据,拿到当前版本号
SELECT id, stock, version FROM product WHERE id = 1;
-- 2. 业务计算出新的库存数量后,带版本号执行更新
UPDATE product
SET stock = 150, version = version + 1
WHERE id = 1 AND version = 3;
判断成功与否,靠的不是SQL报错,而是影响行数。如果这个UPDATE匹配的行数为0,说明执行update前那一瞬间,行记录的version已经不是3了,被其他事务抢先改过。这时候就得走重试逻辑:重新查一次最新数据,把业务计算再做一遍,再带着新版本号去更新。
除了version字段,还有一种做法是直接拿“旧值”本身作为校验条件,比如:
sql复制UPDATE product
SET stock = stock - 1
WHERE id = 1 AND stock = 10;
查出来stock是10,update时也要求当前stock还是10才执行扣减。这种写法少一个version字段,但适合字段本身就是业务数值的场景。坏处是被修改的字段如果恰好是那种高并发下频繁变化的值,重试率会非常高。
4.2 乐观锁的底层依据:为什么没有锁却能防冲突
很多面试官会追问一层:乐观锁也是先select再update,中间不也可能被改吗,凭什么说它安全?
这就得回到前面说过的当前读与快照读的差异。执行UPDATE时,MySQL用的是当前读,读的一定是数据库里最新已提交版本的数据。所以下面的执行序列:
code复制事务A: select version=3,计算库存
事务B: select version=3,计算库存
事务A: update ... set version=4 where version=3 -- 成功,影响1行
事务B: update ... set version=4 where version=3 -- 影响0行,因为version已变成4
事务B的update虽然没有阻塞,但它因为影响行数为0,确认了自己更新失败,这就等价于保留了一个“锁被抢走”的信号。从效果上看,它和悲观锁的区别只是把“等待”换成了“失败后重试”。
这里要补充一个很重要的前提:乐观锁依赖的是更新操作的原子性。上面那条UPDATE语句里,version = version + 1和WHERE version = 3必须在同一条SQL里完成,由数据库保证整个操作原子化。如果业务代码里先select再在Java里算出version+1再拼SQL去更新,中间多了一步网络通信,原子性就被拉长了,冲突的概率会更大。
4.3 乐观锁的常见坑:ABA问题与重试风暴
乐观锁没有绝对完美。最经典的理论坑是ABA问题,虽然它更多是在讲CAS时被提起,但MySQL的version机制同样存在:事务A读出version=3,中间事务B把数据改了两次,version从3变成4又变回5或者某个事务把version置回了3,事务A在update时发现version还是3,就以为数据没被动过,直接覆盖了事务B的中间修改。
不过在大多数业务场景里,ABA问题的实际危害有限。因为如果version只增不减,就不会出现ABA——version永远单调递增,改过就回不去。问题只出现在有人手动去重置version字段,或者用时间戳/数值本身做版本号且数值可能回退的情况。我在实际项目中,version字段一律要求:只增不减,不允许业务代码主动赋值,由UPDATE语句统一维护。
另一个实际项目里更常见的坑是重试风暴。如果一个热点数据在并发高峰期频繁被更新,乐观锁的失败率会急剧上升,大量线程同时进入“查询-计算-更新失败-再查询-再计算”的循环。这种循环本身就是对数据库的重复压力,而且失败重试如果不做退避,几毫秒内就会把数据库连接数打满。
我常用的限制手段有这么几种:设置最大重试次数(一般1~3次就够了);失败后先随机休眠几毫秒再重试;实在冲突频繁,就直接退化为悲观锁或队列表串行处理。乐观锁适合的是“冲突概率低”的场景。如果一个接口的乐观锁冲突率高得离谱,那说明你选错方案了,不是代码写错了。
5. 选哪个不只看并发量:从应用场景倒推锁方案
5.1 三层对比:数据库、缓存和应用层
很多人觉得“读多写少选乐观锁,写多选悲观锁”,这个说法太粗了。选型时至少要同时看三个层面的因素:
| 评估维度 | 悲观锁适用场景 | 乐观锁适用场景 |
|---|---|---|
| 冲突频率 | 高冲突场景,避免反复重试 | 低冲突场景,重试代价可接受 |
| 锁持有时间 | 事务内操作要足够短 | 可以接受较长的业务计算时间 |
| 扩展性 | 受数据库连接数制约明显 | 对连接占用小,水平扩展更友好 |
| 一致性要求 | 实时性强,当前读是必须的 | 允许一定程度的冲突后重试 |
| 典型场景 | 库存秒杀、账户余额扣减、防重复下单 | 文章点赞、配置更新、任务状态流转 |
第一层是数据库自身层面。MySQL的悲观锁本质是行锁,多个请求同时修改同一行,就是排队执行。这条链路天然能保证一致性,但瓶颈也在这里——锁等待时间越长,数据库的连接池被占用的时间就越长,系统整体的吞吐量会明显下降。乐观锁在数据库层面不占连接,但更新失败后需要业务代码辅助重试,等于把压力转移到了应用层。
第二层是缓存介入后的场景。很多高并发“抢购”类需求,会在Redis里预扣库存。这种情况下MySQL的乐观锁或悲观锁通常都不直接参与每一次用户请求,而是作为异步落库时保证最终一致性的手段。选什么锁,取决于异步写入时数据冲突的可能性。
第三层才是纯粹应用层的取舍。比如一个后台管理系统的配置编辑页,同一个配置项一天都不一定有人改一次,用乐观锁完全够了;又如转账接口,同一账户同一时刻可能有多个并发请求,必须用悲观锁或者Redis分布式锁做强串行,别指望乐观锁重试能兜住。
5.2 三种典型业务场景的取舍笔记
场景一:秒杀减库存。秒杀这种东西并发集中、冲突概率极高。如果你用乐观锁,同一个SKU的库存更新可能每秒打进来几千个请求,几乎每个请求都会更新失败,然后疯狂重试,数据库压力成倍增加。正确的做法是直接在数据库层用悲观锁串行扣减,或者更进一步:先在Redis层做流量控制和预扣,真正打到数据库的写请求已经被削到很低的量级了。
场景二:点赞数累加。点赞这个动作本身对实时一致性要求不高,用户A和用户B同时点赞,谁先谁后无所谓,只要最终总数对就行。这种情况直接用乐观锁或者干脆UPDATE ... SET like_count = like_count + 1做原子自增即可。原子自增本质上也是一种“数据库帮你搞定的特殊乐观锁”,你连version字段都不用维护了。
场景三:核心账户余额扣减。账户余额直接关联钱,任何一次并发扣减都不能出错。类似场景我倾向于能用悲观锁就用悲观锁,即便冲突不频繁,for update的行锁能把所有扣减操作排队执行,逻辑上最简单直接,不容易出幺蛾子。对于金融相关系统,代码可读性和正确性永远比那几毫秒的性能重要得多。
5.3 混合使用的思路
面试中还容易出现一个误区:认为一个系统只能选一种方案。实际上乐观锁和悲观锁完全可以混用,按数据维度区分就行。
比如订单服务里,订单创建和支付回调这种强一致性的操作,用悲观锁;订单备注修改这种低冲突的弱操作,用乐观锁。同一个订单表,不同字段、不同接口,方案可以完全不同。MySQL不会因为你某个事务用了for update,就要求所有事务都走悲观锁。
我在项目里常见的分法是:金额变动类走悲观锁,状态机流转类走乐观锁 + 状态字段校验,计数统计类走原子更新,不同算法对应不同并发等级,没有必要一个方案打天下。
6. 面试官真实想听的回答路径(附实战说辞)
这个标题里挂着“面试官”三个字,最后必须聊聊面试场景里怎么答这道题。很多候选人不是不懂,而是回答结构太散,想到哪说到哪,面试官抓不住重点。我建议按下面这个思路组织语言:
6.1 第一层:先整体定性,别急着贴代码
可以这么开头:“乐观锁和悲观锁本质上是两种并发控制策略。悲观锁认为冲突一定会发生,所以在操作前先加锁,阻塞其他并发操作;乐观锁认为冲突不会频繁发生,所以不加锁,在更新提交时通过版本号或条件校验来检测冲突。MySQL里两者都依赖InnoDB的事务和锁机制来实现。”
这段话的潜台词是告诉面试官:我不是背概念的,我知道它们在并发控制中的坐标。
6.2 第二层:讲具体实现,把关键语法和原理串起来
接着讲悲观锁落地:“悲观锁在MySQL里通常用select for update实现,走的是InnoDB当前读,对命中的索引记录加排他锁,事务提交或回滚后锁才释放。这里要特别注意,并发控制有一个前提,就是查询条件必须能命中索引,否则会在RR隔离级别下可能锁住大量记录,导致并发能力急剧下降,这也是为什么我写悲观锁时优先用主键等值条件。”
再把乐观锁接上:“乐观锁不需要数据库加锁,我在表设计时会加一个version字段。更新时通过update table set ..., version = version + 1 where id = ? and version = ?来保证版本未被其他事务修改。如果影响行数为0,说明冲突了,可以在业务代码里做有限次重试。”
6.3 第三层:讲方案对比和选型依据
面试官如果继续追问“那你到底用哪个”,很多候选人会愣住。这里需要展现的是项目经验和决策力。可以参考这个表述:“我的取舍标准是看冲突概率和一致性强弱。像秒杀减库存这种高冲突、强一致的场景,我会优先select for update把并发排队化;像文章状态更新这种低冲突的场景,我用版本号乐观锁。另外还要看扩展性,如果一个服务将来要大规模水平扩展,数据库悲观锁容易把压力聚集在数据库连接上,这时候会把部分核心操作往Redis分布式锁迁移,或者用MQ串行化。”
6.4 第四层:抛出自己的避坑经验,拉高印象分
这时候可以补充一两个真实踩坑的例子。比如前面的死锁案例,把加锁顺序冲突的来龙去脉讲清楚。也可以讲讲for update里包含大事务的危害。这些细节才是面试官判断你“做过”还是“背过”的关键。
注意:回答时最好别把“乐观锁比悲观锁好”或者“悲观锁比乐观锁好”说死。真正的答案是“看场景”。能在两个方案之间准确切换的候选人,比只拥护某一个方案的候选人,印象分明显高出一截。
7. 这些问题没解决,锁方案上线必踩雷
即使理解了原理和选型思路,真正落到代码里还是会遇到一堆边角问题。这里集中梳理几个我反复踩过、也帮别人排查过的高频问题。
第一个雷:忘了把select for update放在事务里。 前面提过一次,但值得展开说。有些框架下,如果没手动声明事务,每条SQL是自动提交的。select for update执行完,事务立刻结束,锁也立即释放,后面再跟update时完全没有锁保护。判断自己有没有踩这个坑,最简单的办法是开启两个数据库连接,手动模拟两个并发请求,看第二个连接的for update会不会被阻塞。如果完全不阻塞,八成就是事务没生效。
第二个雷:乐观锁更新失败后静默吞掉。 有些代码执行完update,不看影响行数,也不抛错,接口照样返回成功。用户看到的是更新没生效,但系统没任何报错,排查起来特别费劲。正确做法是:影响行数为0时要么抛出明确提示“操作过于频繁,请重试”,要么直接返回冲突信息给前端。千万不要让一个失败操作无声无息地消失。
第三个雷:版本号放在where里,同时update了别的字段没带条件。 有些开发者在UPDATE语句里只把version放进了set子句,忘了放到where里,相当于每次都直接更新,乐观锁完全失效。列一个对照版本:
sql复制-- 错误示例:version只在SET里,没有任何校验效果
UPDATE product SET stock = 150, version = 3 WHERE id = 1;
-- 正确示例:必须让version同时出现在WHERE条件中
UPDATE product SET stock = 150, version = version + 1 WHERE id = 1 AND version = 3;
第四个雷:自增主键和version混淆。 很多新手把主键id当成版本号来用,每次更新都用id作为唯一条件。问题是id在并发下永远不变,where里带不带id都拦不住别人的修改,必须引入独立的version字段或者用业务字段做条件校验。
第五个雷:过分依赖乐观锁,把长事务也往里面塞。 乐观锁的冲突检测只发生在最后那一条update语句上,但如果你在事务里先更新了A表,再去做远程调用,再回来更新B表,整个事务期间的锁虽然没显式加,可一旦update执行成功,事务没提交之前,行上的排他锁一样会存在。也就是说,乐观锁的代码写不好,照样会出现长事务和锁等待问题,别把乐观锁和“无锁”混为一谈。
第六个雷:没有索引的范围条件导致全表锁。 前面讲悲观锁时重点提过这个,乐观锁也要注意。如果UPDATE的where条件是一个无法走索引的字段,比如WHERE status = 1 AND version = 3,而status字段上没有索引,更新时就可能扫描并锁住大量记录。在高并发场景下,这种更新会把整张表拖到接近死锁状态。建议对update里频繁使用的where条件字段建立合适的索引。
8. 一个具体可执行的MySQL命令验证清单
理论讲再多,不如自己动手验证一遍。这里给出一套实操清单,你可以在本地数据库环境里按顺序执行,用来加深对两种锁的理解。
实验一:验证悲观锁的阻塞效果
sql复制-- 连接会话A
START TRANSACTION;
SELECT * FROM product WHERE id = 1 FOR UPDATE;
-- 连接会话B(另开一个会话执行)
START TRANSACTION;
SELECT * FROM product WHERE id = 1 FOR UPDATE;
-- 此时B会一直阻塞,直到A提交或回滚
在会话A里执行COMMIT;,会话B的for update会立刻返回结果。这个实验直观展示了排他锁的阻塞机制。注意执行完实验记得把两个事务都提交或回滚,避免锁积累在测试库里。
实验二:验证版本号字段的乐观锁效果
sql复制-- 准备数据
INSERT INTO product(id, stock, version) VALUES (100, 10, 0);
-- 模拟两个并发事务
-- 事务A:
START TRANSACTION;
SELECT version FROM product WHERE id = 100; -- 读到0
UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = 100 AND version = 0;
COMMIT;
-- 事务B(并发执行):
START TRANSACTION;
SELECT version FROM product WHERE id = 100; -- 读到0(A提交前)
UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = 100 AND version = 0;
-- 如果A先提交,这里影响行数是0,说明冲突
COMMIT;
实验三:验证无索引字段的锁范围放大
sql复制START TRANSACTION;
-- 假设product表的category字段没有索引
SELECT * FROM product WHERE category = 'electronics' FOR UPDATE;
在另一个会话里尝试插入一条category='electronics'的记录,你会发现即便这条记录不存在,插入也可能被阻塞。这说明锁的范围已经覆盖到了间隙。这个实验建议在测试库跑,生产环境千万别随手执行。
实验四:查看当前锁等待和事务状态
sql复制-- 在不提交事务的情况下,另开会话查询
SELECT * FROM performance_schema.data_lock_waits\G;
SELECT * FROM sys.innodb_lock_waits\G;
遇到线上死锁或者锁等待时间过长时,这两个视图是排查的第一入口,能直接看到哪个事务在等哪个事务的什么锁。建议平时就写几条常用SQL存起来,比出问题时临时查文档快得多。
这套清单做完,你对乐观锁悲观锁的理解深度会有实打实的提升。书上看一百遍原理,不如亲手造一次锁等待、解一次死锁来得管用。
回到开头那个面试题,如果把这道题的答案浓缩成一句最有价值的话,我会说:乐观锁和悲观锁不是两个非此即彼的选项,而是一把尺子上的两个刻度,你真正需要学会的是判断当前业务应该站在尺子的哪个位置。面试官想看到的,是你拿着这把尺子的手感。
