
面试题考点分析能否用一句话讲清乐观锁与悲观锁的核心区别而不是背概念是否理解 MySQL InnoDB 实现悲观锁的底层锁机制以及SELECT ... FOR UPDATE的真实含义是否掌握乐观锁版本号机制、CAS 思想与 SQL 原子性之间的关系能否结合库存扣减、账户转账等真实业务场景说明技术选型是否了解两种锁的优缺点、死锁成因、锁升级和重试策略等实战细节。一、标准回答如果面试官直接问「MySQL 的乐观锁和悲观锁是什么」我通常会这样回答乐观锁和悲观锁是并发控制中两种不同策略核心区别在于对数据冲突概率的假设不同。悲观锁假设「冲突一定会发生」因此在读取数据时就通过数据库锁机制把数据锁住等事务提交后再释放保证操作串行化乐观锁假设「冲突很少发生」读取数据时不加锁只在真正执行UPDATE时通过版本号或时间戳校验数据是否被别人改过如果被改过就放弃更新或重试。MySQL 中悲观锁主要依靠 InnoDB 的行锁和SELECT ... FOR UPDATE实现乐观锁则通常借助version字段配合带条件的UPDATE实现。回答时我会再补三句话把它讲完整作用两者都用于解决多事务并发修改同一行数据时产生的「丢失更新」问题保证数据一致性。悲观锁特点加锁后其他事务必须阻塞等待能强一致地保证数据不被并发篡改但会降低吞吐量并且存在死锁风险。乐观锁特点不加锁、不阻塞读取性能高、吞吐量大但更新失败时需要业务侧重试且存在 ABA 问题需要版本号来兜底。二、核心原理要真正答好这道题不能只停留在「一个加锁、一个不加锁」还要能讲清两种锁在 MySQL 中到底是怎么工作的。2.1 悲观锁的底层原理InnoDB 的行锁与 Next-Key Lock悲观锁在 MySQL 中主要依托InnoDB 存储引擎的锁机制。根据 MySQL 8.0 官方文档《InnoDB Locking and Transaction Model》InnoDB 的锁可以分为三类Record Lock记录锁锁住单条索引记录防止其他事务修改或删除该行Gap Lock间隙锁锁住索引记录之间的间隙防止其他事务在此区间插入新记录Next-Key Lock临键锁记录锁 当前记录前面的间隙锁是「左开右闭」区间锁用于解决可重复读隔离级别下的幻读问题。当执行SELECT ... FOR UPDATE时InnoDB 会对扫描到的索引记录加排他锁。注意两个关键点锁的是索引记录不是「数据行」本身。如果查询条件没有命中索引InnoDB 需要全表扫描可能会把扫描到的所有记录都锁住极端情况下甚至会升级为表锁。所以悲观锁查询条件必须走索引否则锁范围会远超预期。在可重复读RR隔离级别下会加 Next-Key Lock即不仅锁住命中的记录还锁住它前面的间隙从而同时防止「修改当前行」和「插入幻影行」。悲观锁的并发控制可以理解为数据库层面的「排队机制」事务 A 未提交时一直持有行锁事务 B 尝试更新同一行就会阻塞在锁等待队列中直到事务 A 提交或回滚释放锁B 才能继续执行。2.2 乐观锁的底层原理版本号 CAS 思想的 SQL 原子校验乐观锁并不依赖数据库行锁它的本质是CASCompare And Swap比较并交换思想的数据库实现。官方角度可以理解为把「校验数据未变化」和「执行更新」两步合并到一条带有WHERE条件的UPDATE语句中利用 SQL 语句的原子性完成无锁并发控制。典型实现是给表加一个version字段更新时执行UPDATE product SET stock stock - 1, version version 1 WHERE id 100 AND version 3;这条 SQL 的执行逻辑可以分解为事务先读出当前记录拿到version 3执行 UPDATE 时数据库在行锁保护下原子地判断「当前 version 是否仍然等于 3」如果相等说明数据未被其他事务修改执行扣减并把 version 加 1如果不相等说明版本已经变化受影响行数为 0本次更新失败由业务层决定重试或抛出异常。这里「判断版本」和「更新数据」在一条 SQL 中被 InnoDB 的行锁瞬时保护因此不会出现两次操作之间被插入其他修改的窗口这正是乐观锁能保证一致性的关键。三、应用场景技术选型不能脱离业务面试中经常被问「你们项目里什么时候用乐观锁、什么时候用悲观锁」。可以分两个层面回答。3.1 日常开发中的典型判断读多写少、冲突概率低优先用乐观锁。例如用户资料编辑、文章内容修改、商品详情信息维护这些场景大多数情况下不同用户操作不同数据即使偶尔冲突重试一次代价也很低。写多并发高、冲突概率高优先用悲观锁。例如秒杀库存扣减、账户余额转账、订单状态流转这些场景多个请求同时改同一行乐观锁会频繁失败导致重试风暴悲观锁的串行化反而更直接可靠。3.2 企业真实场景举例业务场景推荐方式原因电商库存扣减悲观锁FOR UPDATE同一 SKU 并发扣减冲突极高需强一致且避免超卖账户余额转账悲观锁资金操作必须串行化不允许重试造成金额异常订单状态流转悲观锁或状态机乐观锁状态冲突可控时可用乐观锁高并发下建议悲观锁用户资料修改乐观锁version同一用户短时间内重复提交概率低失败重试成本小文章点赞计数乐观锁或原子 SQL本质上可以用UPDATE count count 1原子完成无需显式版本号这里有一个容易踩坑的点点赞数这类只做简单自增/自减、不关心旧值的计数场景其实用原子 SQLUPDATE ... SET count count 1即可它天然不会被并发覆盖并不需要引入 version 字段而库存扣减、余额变更等需要判断旧值是否足够的业务才真正需要悲观锁或乐观锁保护。四、使用方式下面分别给出 Java 中两种锁的落地代码并说明执行流程和注意事项。4.1 悲观锁SELECT ... FOR UPDATE悲观锁必须在事务内使用否则FOR UPDATE加的锁会在语句执行完立即释放起不到保护作用。以库存扣减为例import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; Service public class StockService { private final StockMapper stockMapper; public StockService(StockMapper stockMapper) { this.stockMapper stockMapper; } Transactional(rollbackFor Exception.class) public void deductStock(Long productId, int count) { // 1. 开启事务后锁定库存记录其他事务将阻塞在此处 ProductStock stock stockMapper.selectForUpdate(productId); if (stock null) { throw new BusinessException(商品不存在); } // 2. 在锁保护下进行业务判断 if (stock.getStock() count) { throw new BusinessException(库存不足); } // 3. 扣减库存并更新 stock.setStock(stock.getStock() - count); stockMapper.updateById(stock); // 4. 方法正常返回事务提交行锁被释放 } }对应的 MyBatis SQLselect idselectForUpdate resultTypecom.example.entity.ProductStock SELECT id, product_id, stock, version FROM product_stock WHERE product_id #{productId} FOR UPDATE /select执行流程事务开启后第一个事务执行SELECT ... FOR UPDATE拿到行锁第二个事务再执行相同的 SELECT 会阻塞等待直到第一个事务提交或回滚。这样库存校验和扣减之间不会插入其他事务的修改从根源上避免超卖。注意事项查询条件必须命中索引否则可能锁住过多记录甚至升级为表锁锁等待时间受innodb_lock_wait_timeout控制默认 50 秒超时会抛出Lock wait timeout exceeded事务要尽量短避免长事务持有锁拖垮整个系统多个表操作要保持一致的加锁顺序防止互相等待形成死锁。4.2 乐观锁version 字段 带条件更新乐观锁的核心是 version 字段MyBatis-Plus 提供了Version注解自动处理这里先看手动实现便于理解原理import org.springframework.stereotype.Service; Service public class ProductService { private static final int MAX_RETRY_TIMES 3; private final ProductMapper productMapper; public ProductService(ProductMapper productMapper) { this.productMapper productMapper; } public boolean deductStockWithRetry(Long productId, int count) { for (int i 0; i MAX_RETRY_TIMES; i) { // 1. 读取记录拿到当前版本号不加锁 Product product productMapper.selectById(productId); if (product null) { throw new BusinessException(商品不存在); } if (product.getStock() count) { throw new BusinessException(库存不足); } // 2. 扣减业务字段保留旧版本号 product.setStock(product.getStock() - count); // 3. 执行带版本条件的更新 // UPDATE product SET stock ?, version version 1 // WHERE id ? AND version ? int rows productMapper.updateByIdWithVersion(product); if (rows 0) { return true; } // 4. rows 0 说明版本号已变化数据被其他事务修改进入重试 sleepBeforeRetry(i); } throw new OptimisticLockException(系统繁忙请稍后重试); } private void sleepBeforeRetry(int retryIndex) { try { // 采用小步长退避避免大量请求同时重试造成“重试风暴” Thread.sleep(20L * (retryIndex 1)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BusinessException(重试被中断); } } }对应的 MyBatis SQL 手动实现update idupdateByIdWithVersion parameterTypecom.example.entity.Product UPDATE product SET stock #{stock}, version version 1 WHERE id #{id} AND version #{version} /update执行流程每个事务先无锁读取数据并记录 version更新时把 version 作为 WHERE 条件。只有版本号未变化的线程能更新成功受影响行数为 1版本号已变化的线程受影响行数为 0表示乐观锁冲突进入重试逻辑。注意事项重试必须设置上限并且冲突睡眠时间要「递增退避」否则高并发下失败线程同时立即重试会放大数据库压力version 字段建议用BIGINT避免用时间戳因为时间戳在同一毫秒内无法可靠区分两次修改且可能受服务器时钟回拨影响乐观锁失败后重试的代码中业务判断要重新执行不能沿用旧数据如果使用 MyBatis-Plus可以给实体类的 version 字段加Version注解框架自动在 UPDATE 时拼上WHERE version ?并把 version 加 1但原理完全相同。五、扩展延伸5.1 乐观锁与悲观锁全面对比对比维度悲观锁乐观锁核心假设冲突必然发生冲突很少发生实现机制数据库行锁/表锁version 字段 条件更新是否阻塞阻塞等待锁释放不阻塞失败后重试并发吞吐量相对较低相对较高数据一致性强一致最终一致靠重试保证主要风险死锁、锁等待、锁升级ABA、重试风暴、版本号溢出适用场景写多、冲突高、强一致读多写少、冲突低5.2 优缺点详解悲观锁优点一致性强业务逻辑在锁保护下简单直接不需要额外处理重试适合对数据准确性要求极高的场景。缺点是锁竞争激烈时吞吐量骤降事务持有锁期间其他事务全部阻塞并且多个事务交叉加锁容易产生死锁。乐观锁优点不加锁、无阻塞读性能好系统吞吐量高在冲突低的场景下几乎无额外开销。缺点是一旦冲突频繁大量更新失败需要业务侧编写重试逻辑重试会放大数据库压力且纯版本号方案存在 ABA 问题。5.3 实际开发注意事项悲观锁不要滥用在「读多写少」的系统里无条件加FOR UPDATE会让数据库连接被长时间占用拖垮整体性能。索引是悲观锁的生命线条件不走索引会导致锁范围扩大出现「锁表」假象线上排查时优先看执行计划。事务边界要清晰悲观锁的锁生命周期等于事务生命周期事务中不要放远程调用、文件 IO 等耗时操作。避免死锁的通用原则所有事务按相同顺序访问资源、缩小锁粒度、缩短事务时长、必要时用SELECT ... FOR UPDATE SKIP LOCKED跳过已锁定行。乐观锁要处理 ABA 问题单纯比较版本号无法发现「数据被改成 B 又改回 A」的情况需要保证 version 单调递增复杂场景可引入全局唯一 ID 或状态字段辅助判断。考虑混合方案真实系统中常见做法是「悲观锁保护核心写操作 乐观锁保护低冲突读改写 缓存/消息队列削减流量」而不是全表统一用一种锁。六、面试追问追问 1SELECT ... FOR UPDATE 锁的是行还是表什么时候会锁表回答思路先说明锁的是「索引记录」再说明什么情况会扩大锁范围。考查点在于是否理解「锁的是索引」而不是「锁的是数据行」。标准答案SELECT ... FOR UPDATE默认锁的是索引记录本质是 InnoDB 在索引上加排他锁。如果查询条件能用索引定位通常只锁住命中的索引记录及其间隙RR 隔离级别下是 Next-Key Lock如果查询条件不走索引InnoDB 需要全表扫描会把扫描到的所有记录都加锁表现为接近表锁的效果。另外没有显式主键或合适的索引时锁粒度也可能被放大所以生产环境要求FOR UPDATE的条件必须命中索引。追问 2乐观锁的 ABA 问题是什么如何解决回答思路先用一句话解释 ABA再说明 MySQL 中版本号单调递增如何天然规避大部分 ABA 场景最后提复杂业务下的补充方案。标准答案ABA 问题是指线程 1 读取时值为 A线程 2 把它改成 B线程 3 又改回 A线程 1 更新时发现值仍是 A于是误以为数据没变。MySQL 乐观锁常用的 version 字段是单调递增的每次更新都会加 1不会「改回原值」所以版本号方案在数据库层面基本不会出现经典 ABA。真正需要警惕的反而是如果业务把 version 换成时间戳或可回退的状态字段就可能出现 ABA。更复杂的场景可以增加全局唯一 requestId 或修改序号做二次校验确保识别出「曾经被改过」这一事实。追问 3悲观锁和乐观锁到底怎么选回答思路不要给绝对答案要给出决策维度冲突概率、一致性要求、事务时长、吞吐量需求。标准答案我主要看四个维度。第一冲突概率同一行数据被并发修改的概率高用悲观锁低则用乐观锁。第二一致性要求资金、库存等不能接受「失败重试导致延迟」的场景用悲观锁用户资料等可接受短暂失败的用乐观锁。第三事务时长事务短、锁持有时间可控用悲观锁事务长、读改写链路复杂用乐观锁减少锁占用。第四吞吐量读多写少的系统用乐观锁换取高并发写密集型核心链路用悲观锁保证稳定性。实际项目里也可以混合使用比如扣库存用悲观锁、改商品描述用乐观锁。追问 4MySQL 使用悲观锁时死锁是怎么产生的如何避免回答思路先讲清死锁的四个必要条件在数据库中的具体表现再给出工程上的预防手段。标准答案死锁的本质是两个事务互相持有对方需要的锁。典型场景是事务 A 锁定账户 1 后要更新账户 2事务 B 锁定账户 2 后要更新账户 1双方都等待对方释放最终触发 InnoDB 死锁检测。避免手段包括统一加锁顺序例如所有转账都先锁小 ID 账户再锁大 ID 账户、缩短事务时长减少锁持有时间、缩小锁粒度只锁必要行条件命中索引、合理设置锁等待超时、以及必要时用SKIP LOCKED跳过已锁定行而不是阻塞等待。追问 5乐观锁更新失败后重试逻辑应该注意什么回答思路重点讲无限重试的危害、退避策略和业务判断的重新执行。标准答案有三点必须注意。第一重试必须设上限超过上限后抛出明确异常不能让请求无限循环。第二重试要退避失败后不能立即重试否则高并发下所有失败线程同时再次更新会形成「重试风暴」打满数据库一般用固定间隔或指数退避。第三每次重试必须重新读取数据并重新执行业务判断不能沿用上一次的旧数据否则可能基于过期状态做出错误决策。必要时应结合唯一流水号做幂等防止极端情况下重复扣减。总结这道题看似基础实际能拉开差距的地方在于能否从「锁的是索引记录」讲清悲观锁原理能否从「带条件的 UPDATE 原子校验」讲清乐观锁原理并且最终落到真实业务的选型和工程细节上。面试回答时建议用「一句话对比 底层原理 场景举例 一个实战坑」的结构既体现深度也体现落地经验。