乐观锁与悲观锁怎么实现?
乐观锁与悲观锁怎么实现?
zhangchangsheng面试考察点
- 概念理解度:面试官不仅仅是想知道你听过这两种锁的名字,更是想知道你是否理解它们的核心思想差异—— “先加锁再访问” vs “先访问再加锁”。
- 实践应用能力:考察你是否在实际项目中使用过这两种锁,能否根据业务场景(读多写少 vs 写多读少)选择合适的锁策略。
- 问题诊断能力:是否了解乐观锁的 ABA 问题、悲观锁的死锁风险,以及各自的解决方案。
核心答案
| 对比维度 | 乐观锁 | 悲观锁 |
|---|---|---|
| 核心思想 | 假设冲突很少,不加锁,更新时检测冲突 | 假设冲突频繁,操作前先加锁 |
| 实现方式 | version 字段条件更新,靠影响行数判断 | SELECT … FOR UPDATE / FOR SHARE |
| 冲突检测时机 | 提交更新时检测,失败则重试 | 读取时就加锁,冲突方阻塞等待 |
| ABA 问题 | version 方案天然不存在(版本号只增不减);用业务字段做条件可能有 | 不存在(强锁串行化) |
| 风险/代价 | 冲突多时重试开销大、自旋浪费 CPU | 吞吐低、死锁风险、where 不走索引会锁表 |
| 适用场景 | 读多写少、冲突少 | 写多、冲突频繁、临界区复杂 |
一句话总结:乐观锁适合 “冲突少、读多写少” 的场景,悲观锁适合 “冲突多、写频繁” 的场景。
深度解析
一、乐观锁实现方案
乐观锁在 MySQL 中主要通过 版本号机制 或 时间戳机制 实现,核心是 “比较再交换”(Compare And Swap)的思想。
1. 版本号机制
实现步骤:
flowchart TB
A["1. 查询获取版本<br/>SELECT id, name, version FROM table WHERE id = 1"]
B["2. 计算业务逻辑<br/>业务层计算出新值"]
C["3. 更新比较版本<br/>UPDATE table SET name = '新值', version = version + 1<br/>WHERE id = 1 AND version = 查到的版本值"]
D["4. 判断影响行数<br/>affected_rows 大于 0:成功<br/>否则:重试或报错"]
A --> B --> C --> D
上图展示了乐观锁版本号机制的完整流程:
- 第一步(查询):先从数据库读取数据,同时获取当前版本号
version - 第二步(计算):在业务层进行逻辑计算,准备新值(此时不加锁,其他事务可以同时读取)
- 第三步(更新):执行更新语句,
WHERE条件中携带之前查到的版本号,同时将版本号 +1 - 第四步(判断):检查
affected_rows(影响行数),如果大于 0 说明更新成功;如果等于 0 说明版本号已被其他事务修改,需要重试或报错
表结构设计:
-- 商品表,version 字段作为乐观锁版本号 |
Java 代码示例:
|
Mapper XML:
<select id="selectById" resultType="com.example.Product"> |
2. 时间戳机制
与版本号类似,用 update_time 字段替代 version 字段:
-- 更新时比较时间戳 |
⚠️ 注意:时间戳方案存在精度问题(毫秒级并发可能冲突),生产环境推荐使用版本号。
3. 条件更新(简化版乐观锁)
对于库存扣减这种”数值增减”场景,可以简化为:
-- 直接在 SQL 中判断库存是否充足 |
这种方式不需要额外的版本号字段,本质上是利用数据库的原子性保证一致性。
二、悲观锁实现方案
悲观锁在 MySQL 中通过 SELECT ... FOR UPDATE 实现,利用数据库的排他锁(X 锁)机制。
1. FOR UPDATE 语法
| 对比维度 | 乐观锁 | 悲观锁 |
|---|---|---|
| 基本假设 | 假设不会发生冲突 | 假设会发生冲突 |
| 加锁方式 | 读取时不加锁 | 先加锁再读取 |
| 冲突处理 | 更新时校验版本号 / CAS | 独占访问,阻塞等待 |
| 失败策略 | 更新失败后重试 | 等待对方释放锁后执行 |
| 适用场景 | 读多写少,如博客点赞、商品浏览 | 写多冲突多,如秒杀、抢购 |
上图展示了悲观锁的执行流程:
- 事务 A 首先执行
SELECT ... FOR UPDATE,数据库会对查询到的记录加排他锁(X 锁) - 事务 B 同时也想对同一行执行
FOR UPDATE,由于锁被事务 A 持有,事务 B 会阻塞等待 - 事务 A 完成更新并
COMMIT后,锁被释放 - 事务 B 此时才能获取到锁,继续执行
Java 代码示例:
|
Mapper XML:
<!-- 悲观锁查询:FOR UPDATE --> |
2. 锁的范围与索引
⚠️ 重要:FOR UPDATE 的锁范围与索引密切相关:
| 场景 | 锁范围 | 风险 |
|---|---|---|
| 通过主键/唯一索引查询 | 只锁匹配的行 | ✅ 推荐 |
| 通过普通索引查询 | 锁索引匹配的所有行 + 间隙 | ⚠️ 可能扩大锁范围 |
| 无索引查询 | 锁整张表 | ❌ 严重性能问题 |
-- ✅ 推荐:通过主键查询,只锁一行 |
3. 死锁预防
悲观锁可能导致死锁,需要遵循以下原则:
/** |
三、乐观锁 vs 悲观锁对比
flowchart TB
subgraph TA["事务 A"]
A1["BEGIN"] --> A2["SELECT ... FOR UPDATE<br/>(获取排他锁)"] --> A3["UPDATE ... SET ..."] --> A4["COMMIT"]
end
subgraph TB2["事务 B"]
B1["等待...(阻塞)"] --> B2["获取锁, 继续执行"]
end
A2 -->|"加锁"| B1
A4 -->|"释放锁"| B2
选择建议:
| 业务场景 | 推荐锁类型 | 原因 |
|---|---|---|
| 博客点赞、评论数 | 乐观锁 | 读远多于写,冲突概率极低 |
| 商品库存扣减(低并发) | 乐观锁 | 冲突较少,避免锁开销 |
| 秒杀抢购(高并发) | 悲观锁 | 冲突频繁,乐观锁重试成本高 |
| 账户余额操作 | 悲观锁 | 资金安全第一,强一致性 |
| 订单状态流转 | 悲观锁 | 状态一致性要求高 |
四、常见问题与解决方案
1. 乐观锁的 ABA 问题
问题描述:线程 1 读取版本 A,线程 2 将 A→B→A,线程 1 更新时误以为没有变化。 解决方案:使用更细粒度的版本号(如雪花算法 ID)或额外状态字段。
// 使用时间戳 + 随机数组合作为版本,避免 ABA |
2. 悲观锁的死锁问题
检测死锁:
-- 查看当前锁等待情况 |
解决方案:
- 设置锁等待超时:
innodb_lock_wait_timeout = 50(默认 50 秒) - 按固定顺序加锁
- 避免长事务
- 使用小事务
面试高频追问
- 追问一:秒杀场景用乐观锁还是悲观锁? 高并发秒杀建议用悲观锁或分布式锁。乐观锁在冲突率极高时,大量请求需要重试,反而降低吞吐量。实际生产中常用 Redis 预扣库存 + 消息队列异步落库。
- 追问二:乐观锁更新失败后怎么处理? 两种策略:
- 重试机制:使用 Spring Retry 或自定义重试逻辑,设置最大重试次数
- 直接失败:返回错误提示让用户重新操作(如”数据已被修改,请刷新后重试”)
- 追问三:
FOR UPDATE和LOCK IN SHARE MODE的区别?FOR UPDATE:加排他锁(X 锁),其他事务不能读也不能写LOCK IN SHARE MODE:加共享锁(S 锁),其他事务可以读但不能写
常见面试变体
- “如何解决并发更新数据的一致性问题?”
- “数据库行锁和表锁的区别?什么时候会锁表?”
- “什么场景下乐观锁比悲观锁性能更好?”
- “MySQL 的 MVCC 机制是什么?和乐观锁有什么关系?”
记忆口诀
乐观锁:先查后比再更新,版本不对就重试,适合读多写少场景 悲观锁:先锁后改再提交,别人只能干等着,适合写多冲突场景
总结
乐观锁通过版本号机制在更新时检测冲突,适合读多写少、冲突率低的场景;悲观锁通过 FOR UPDATE 加排他锁强制串行化,适合写多冲突、一致性要求高的场景。生产环境需根据业务特点选择,高并发场景可结合 Redis 分布式锁优化。
声明:本文转载自小哈的博客 《乐观锁与悲观锁怎么实现?》,版权归原文作者所有。转载请保留本声明与原文链接。
