MySQL 乐观锁与悲观锁怎么实现?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

面试官提出这个问题,主要希望考察以下几个维度:

  1. 对锁机制核心思想的理解: 候选人是否能清晰阐述乐观锁与悲观锁这两种并发控制思想的根本区别(“乐观地认为冲突很少发生” vs “悲观地认为冲突经常发生”)。
  2. 具体的工程实现能力: 不仅仅知道概念,更要知道在 MySQL 数据库层面如何具体实现这两种锁。这是从理论到实践的关键。
  3. 对数据库特性的掌握程度: 实现方式涉及对数据库事务、SQL 语法(如 SELECT ... FOR UPDATE)、以及 MVCC 等机制的理解。
  4. 场景化分析与权衡能力: 能否根据不同的业务场景(如读多写少 vs 写多读少,冲突频率高低)正确地选择和应用合适的锁机制,并说明其优劣。

核心答案

  • 乐观锁: 在数据库表中增加一个版本号(version)字段或时间戳字段。更新数据时,需同时检查并更新此版本号。只有在当前读取的版本号与数据库中的实际版本号一致时,更新才会成功。这是一种无锁的、基于数据版本的并发控制。
  • 悲观锁: 直接利用数据库提供的锁机制。在事务中,使用 SELECT ... FOR UPDATE 语句查询目标数据,MySQL 会对这些数据行加上排他锁(X 锁),从而在事务提交或回滚前,阻止其他事务对这些数据进行读写。这是一种先加锁,后访问的机制。

深度解析

原理/机制

  • 乐观锁: 其核心思想借鉴了 CAS(Compare-And-Swap)。它假设并发操作不太会引起冲突,因此让事务先执行,只在提交时检测数据是否被其他事务修改过。检测依据就是那个额外的版本字段。整个过程不需要数据库底层加锁,依赖的是应用程序的逻辑控制。
  • 悲观锁: 其核心思想是“防患于未然”。它假设并发操作很可能会冲突,因此在访问数据前,先通过数据库自身的锁机制获取锁,确保在持有锁的期间,数据不会被其他事务修改。这依赖于数据库(如 InnoDB)的行锁、间隙锁等锁的实现。

代码示例

以下是一个简单的下单扣减库存的例子:

1. 乐观锁实现

首先,商品表需要有 version 字段。

-- 1. 查询商品信息,获取当前版本号
SELECT stock, version FROM product WHERE id = 1;

-- 假设查询到 stock = 100, version = 5
-- 2. 更新库存,并增加版本号。此更新操作是原子的。
UPDATE product
SET stock = stock - 1,
    version = version + 1
WHERE id = 1
  AND version = 5; -- 关键:确保版本号未变

-- 3. 检查更新影响的行数(rows affected)
-- 如果返回1,表示更新成功,无人竞争。
-- 如果返回0,表示更新失败,数据已被其他事务修改,需要回滚或重试。

在 Java 代码中,如果更新失败,通常会进行重试。

int retryCount = 0;
while (retryCount < MAX_RETRY) {
    Product product = productDao.selectById(productId);
    // ... 业务校验 ...
    int affectedRows = productDao.updateStockWithVersion(productId, product.getVersion());
    if (affectedRows > 0) {
        // 更新成功,跳出循环
        break;
    }
    retryCount++;
    // 可选:短暂休眠,避免活锁
    Thread.sleep(50);
}
if (retryCount == MAX_RETRY) {
    throw new RuntimeException("库存更新失败,请重试");
}

2. 悲观锁实现

-- 开启事务
BEGIN;

-- 1. 查询商品并锁定该行数据(排他锁)
SELECT stock FROM product WHERE id = 1 FOR UPDATE;

-- 2. 执行业务逻辑(例如判断库存>0)
-- 3. 更新库存
UPDATE product SET stock = stock - 1 WHERE id = 1;

-- 提交事务,释放锁
COMMIT;

在事务提交前,其他执行 SELECT ... FOR UPDATE 或试图更新此行的事务都会被阻塞。

对比分析与最佳实践

特性乐观锁悲观锁
锁机制无锁,基于应用层逻辑有锁,基于数据库底层锁
实现方式增加版本字段,在 UPDATEWHERE 子句中校验使用 SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODE
并发性能。无锁竞争,适合读多写少、冲突概率低的场景。。串行化操作可能成为性能瓶颈,但能保证强一致性。
适用场景秒杀抢购(配合库存前置校验)、账户余额更新(冲突较少时)商品库存扣减(强一致性要求高)、银行转账、订单状态流转
数据冲突由应用层处理(如重试、返回错误)由数据库层处理(事务阻塞或等待超时)

最佳实践与注意事项

  1. 场景驱动选择读远大于写,且冲突概率,用乐观锁写频繁,且冲突概率,要求强一致性,用悲观锁
  2. 乐观锁的重试策略: 必须设计合理的重试机制(次数上限、指数退避)和失败回滚逻辑,避免无限重试(活锁)或用户体验差。
  3. 悲观锁的粒度与时间: 使用 FOR UPDATE 时,务必缩小锁定范围(通过索引精准定位行),并尽快提交事务,以减小锁竞争和死锁风险。
  4. 乐观锁的 ABA 问题: 在纯数值版本号下,版本号从 5 变到 6 再变回 5,乐观锁会误判为未修改。但在业务系统中,只要有状态变更(如库存减少),版本号递增即可避免,通常无需引入复杂的戳记(如时间戳+计数器)。

常见误区

  • 滥用 SELECT ... FOR UPDATE: 在不必要的情况下使用悲观锁,严重损害系统并发能力。
  • 忽略乐观锁更新失败: 代码中不检查 UPDATE 的返回值,导致数据不一致。
  • 版本字段更新错误: 在乐观锁 UPDATE 语句中,忘记将 version = version + 1 写进 SET 子句。

总结

乐观锁通过应用层版本号实现无锁并发控制,适合低冲突场景;悲观锁通过数据库锁机制实现强一致性,适合高冲突场景。选择哪一种,取决于具体的业务并发模型和对数据一致性的要求强度。