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外面要包一层事务,而且事务的粒度要尽量小。因为锁是从加上的那一刻开始持有的,直到事务结束才释放
