【大白话说Java面试题 第113题】【并发篇】第13题:说一下乐观锁的优点和缺点?

📌 微服务架构基于Spring Cloud Alibaba的分布式事务处理:Seata AT模式与Sentinel协同实现高并发下数据最终一致性

第13题:说一下乐观锁的优点和缺点?

📚 回答:

  • 核心考点 : 乐观锁不是"不加锁",而是一种冲突检测策略 。大厂面试不会只问"优缺点",而是深入考察 乐观锁与悲观锁的适用边界 (冲突概率的量化判断)、CAS 与版本号两种实现路径的差异 (CPU 级 vs 应用级)、ABA 问题的业务级危害 、以及 高并发下的退避策略与性能模型。面试官真正想判断的是:你是否能根据业务场景的读写比例、冲突概率、延迟要求,做出合理的锁选型决策。
1. 乐观锁的本质与实现路径
  • 1.1 核心思想

    乐观锁假设"冲突是少数",更新时不阻塞其他线程,而是在提交时检测冲突。如果数据已被修改,则回滚并重试。

    与悲观锁的本质区别

    维度 乐观锁 悲观锁
    假设 冲突概率低 冲突概率高
    时机 提交时检测 访问时加锁
    阻塞 不阻塞,失败重试 阻塞等待
    开销 冲突时重试开销 锁的持有开销
    适用 读多写少 写多读少
  • 1.2 两大实现路径

    路径一:CAS(CPU 级乐观锁)

    java 复制代码
    // Java 层面的 CAS(底层是 lock cmpxchg)
    AtomicInteger counter = new AtomicInteger(0);
    counter.incrementAndGet();  // 失败则自旋重试
    • 粒度:单个变量(32/64 位);
    • 冲突检测:硬件指令级,纳秒级延迟;
    • 重试策略:自旋(CPU 空转)或指数退避。

    路径二:版本号/时间戳(应用级乐观锁)

    sql 复制代码
    -- MySQL 版本号乐观锁
    UPDATE account 
    SET balance = balance - 100, version = version + 1
    WHERE id = 1 AND version = 5;  -- 提交时检测版本
    java 复制代码
    // Hibernate @Version
    @Entity
    public class Account {
        @Version
        private Long version;
        private BigDecimal balance;
    }
    • 粒度:整行记录或业务对象;
    • 冲突检测:数据库/应用层,毫秒级延迟;
    • 重试策略:业务层重试、抛异常给用户、或合并冲突。
2. 乐观锁的优点深度分析
  • 2.1 无阻塞高并发读取

    乐观锁的读取完全无锁,多个线程可以同时读取同一数据,不会互相阻塞。这在读多写少的互联网场景中至关重要:

    • 商品详情页浏览(读 : 写 ≈ 1000 : 1);
    • 新闻 Feed 流刷新;
    • 配置中心读取。

    性能对比(读多写少场景,1000 并发):

    方案 吞吐量(QPS) 平均延迟 CPU 使用率
    悲观锁(SELECT FOR UPDATE) ~5,000 15ms 30%(大量线程阻塞)
    乐观锁(版本号) ~50,000 2ms 60%(无阻塞,轻量重试)
    无锁(纯读取) ~80,000 1ms 40%
  • 2.2 避免死锁

    悲观锁因加锁顺序不当或循环依赖容易产生死锁,需要死锁检测和超时回滚。乐观锁天然无死锁,因为不持有锁:

    复制代码
    悲观锁死锁场景:
    线程 A: 锁定账户 1 → 请求锁定账户 2
    线程 B: 锁定账户 2 → 请求锁定账户 1
    → 循环等待,死锁!
    
    乐观锁解决:
    线程 A: 读取账户 1、2 → 提交时检测版本 → 版本未变,成功
    线程 B: 读取账户 1、2 → 提交时检测版本 → 版本已变,重试
    → 无死锁,只有重试
  • 2.3 细粒度冲突检测

    版本号乐观锁可以精确到字段级别:

    sql 复制代码
    -- 只检测 balance 字段的版本
    UPDATE account 
    SET balance = balance - 100, balance_version = balance_version + 1
    WHERE id = 1 AND balance_version = 5;
    
    -- 即使 name 字段被其他线程修改,balance 的更新仍可成功

    悲观锁的 SELECT FOR UPDATE 会锁定整行,任何字段修改都会阻塞。

  • 2.4 水平扩展友好

    悲观锁依赖数据库的锁管理器或分布式锁(如 Redis、ZooKeeper),跨分片时锁协调复杂。乐观锁天然无中心,每个节点独立检测版本,非常适合微服务和分库分表架构。

3. 乐观锁的缺点深度分析
  • 3.1 高竞争下的"重试风暴"

    当冲突概率 > 30% 时,乐观锁的性能会断崖式下降

    冲突概率 平均重试次数 吞吐量下降 推荐方案
    < 5% ~1.05 < 5% 乐观锁
    5%~20% ~1.25 15%~30% 乐观锁 + 退避
    20%~50% ~2.0 40%~60% 悲观锁或队列化
    > 50% > 5.0 > 80% 必须改用悲观锁

    典型场景:秒杀库存扣减(万人抢 100 件商品,冲突概率 99.9%),乐观锁会导致几乎所有请求重试,数据库压力暴增。

  • 3.2 写偏斜(Write Skew)问题

    乐观锁无法检测读取后其他事务的写入,导致逻辑错误:

    sql 复制代码
    -- 场景:医生值班系统,至少一名医生值班
    -- 事务 A 读取:Dr. Smith 值班 = true, Dr. Jones 值班 = true
    -- 事务 B 读取:Dr. Smith 值班 = true, Dr. Jones 值班 = true
    
    -- 事务 A 更新:Dr. Smith 值班 = false(version 检测通过,因为 Smith 的版本未变)
    -- 事务 B 更新:Dr. Jones 值班 = false(version 检测通过,因为 Jones 的版本未变)
    
    -- 结果:两名医生都不值班!违反业务规则!

    根因 :乐观锁只检测直接修改的行 的版本,不检测读取但未修改的行 的变化。

    解决:Serializable 隔离级别或业务层加锁。

  • 3.3 ABA 问题(CAS 路径)

    详见第 10 题。在数据库乐观锁中,版本号递增天然解决 ABA;但在 Java CAS 中,裸 AtomicReference 存在 ABA 风险。

  • 3.4 重试的语义复杂性

    业务层重试不是简单的"再试一次",可能引入:

    • 副作用累积:重试导致短信发送两次、库存扣减两次;
    • 状态漂移:第一次读取的数据在重试时已过期,基于旧数据的计算结果错误;
    • 超时风险:多次重试累积延迟,超出用户容忍度。
    java 复制代码
    // ❌ 错误:重试导致副作用累积
    public void deductStock(Long productId) {
        for (int i = 0; i < 3; i++) {
            Product p = productDao.selectById(productId);
            if (p.getStock() > 0) {
                // 每次重试都发送短信!
                smsService.send("库存扣减成功");
                if (productDao.updateStock(productId, p.getVersion()) > 0) return;
            }
        }
    }
  • 3.5 版本号字段的维护成本

    • 数据库表需增加 version 字段(或 update_time);
    • ORM 框架需配置 @Version 注解;
    • 批量更新时版本号管理复杂(如 UPDATE ... WHERE id IN (...) AND version = ?)。
4. 乐观锁 vs 悲观锁的选型决策树
复制代码
是否需要强一致性(如金融转账)?
├── 是 → 悲观锁(SELECT FOR UPDATE)或 Serializable
└── 否 → 读写比例如何?
          ├── 读 >> 写(> 10:1)→ 乐观锁
          │         └── 冲突概率 > 20%?
          │               ├── 是 → 乐观锁 + 退避/队列化
          │               └── 否 → 纯乐观锁
          ├── 读 ≈ 写 → 混合策略(读乐观 + 写悲观)
          └── 写 >> 读 → 悲观锁 + 分片降低冲突
5. 乐观锁的优化策略
  • 5.1 指数退避重试

    java 复制代码
    public boolean optimisticUpdate(Long id, int expectedVersion, Consumer<Product> updater) {
        int maxRetries = 5;
        int backoffMs = 10;
        for (int i = 0; i < maxRetries; i++) {
            Product p = productDao.selectById(id);
            updater.accept(p);
            if (productDao.updateWithVersion(id, p.getVersion(), p) > 0) {
                return true;
            }
            // 指数退避
            Thread.sleep(backoffMs);
            backoffMs = Math.min(backoffMs * 2, 1000);
        }
        return false;  // 超过重试次数,抛异常或降级
    }
  • 5.2 队列化串行化(秒杀场景)

    高冲突场景下,将并行请求改为队列串行处理:

    java 复制代码
    // 使用 Disruptor 或内存队列,单线程顺序扣减
    RingBuffer<SeckillEvent> ringBuffer = disruptor.getRingBuffer();
    // 单线程消费者顺序处理,天然无冲突
  • 5.3 分段乐观锁(LongAdder 思想)

    将单一库存拆分为多个子库存,分散冲突:

    sql 复制代码
    -- 100 件库存拆分为 10 个子库存,每个 10 件
    UPDATE stock_segment 
    SET stock = stock - 1, version = version + 1
    WHERE segment_id = ? AND stock > 0 AND version = ?
    LIMIT 1;  -- 随机选择一个有库存的子段
  • 5.4 读已提交 + 乐观锁的混合

    sql 复制代码
    -- 读取不加锁(RC 隔离级别)
    SELECT balance, version FROM account WHERE id = 1;
    
    -- 业务计算
    
    -- 提交时检测版本并更新
    UPDATE account SET balance = ?, version = version + 1 
    WHERE id = 1 AND version = ?;

    兼顾读取性能和更新安全。

6. 生产环境避坑指南
  • 6.1 严禁在重试中执行副作用操作

    短信、日志、外部通知等副作用操作必须放在确认更新成功后执行,或采用幂等设计。

  • 6.2 设置重试上限和降级策略

    java 复制代码
    if (!optimisticUpdate(id, version, updater)) {
        // 降级:发送 MQ 异步处理,或返回"系统繁忙请重试"
        mqSender.send(new DelayedUpdateMessage(id, updater));
        throw new OptimisticLockException("更新冲突,已加入重试队列");
    }
  • 6.3 监控冲突率

    java 复制代码
    // 埋点监控
    meterRegistry.counter("optimistic.lock.conflict", 
        Tags.of("table", "account")).increment();
    
    // 告警:冲突率 > 20% 触发告警,提示改用悲观锁
  • 6.4 版本号字段的索引

    WHERE id = ? AND version = ? 中,version 必须参与索引或主键,避免全表扫描。

  • 6.5 避免大事务中的乐观锁

    大事务持有版本号时间长,冲突概率剧增。应将乐观锁操作放在事务末尾,缩短持有时间。

7. 面试官追问与高分回答模板
  • 追问 1:"乐观锁有哪些优点和缺点?"

    • 低分回答:"优点是高性能无阻塞,缺点是高冲突下重试开销大。"(没有量化分析和场景)
    • 高分回答 : "乐观锁的优点可从三个维度分析:
      1. 读取性能:完全无锁,多线程并发读取不阻塞,适合读多写少场景(如商品详情页,读:写≈1000:1);
      2. 死锁免疫:不持有锁,天然无死锁,避免了悲观锁的循环等待问题;
      3. 扩展性:无中心锁管理器,分库分表和微服务架构下天然友好。

      缺点同样有三个维度:

      1. 重试风暴:冲突概率 > 30% 时吞吐量断崖下降,如秒杀场景(冲突率 99.9%)几乎不可用;
      2. 写偏斜:只检测修改行的版本,无法检测读取行的变化,可能导致业务逻辑错误(如医生值班系统);
      3. 副作用风险:重试可能导致短信发送两次等副作用,需配合幂等设计。

      选型核心指标:冲突概率。< 20% 用乐观锁,> 50% 必须用悲观锁或队列化。"

  • 追问 2:"乐观锁和 CAS 有什么区别?"

    • 高分回答 : "乐观锁是设计思想 ,CAS 是实现手段之一。两者的关系:
      • CAS:CPU 级的乐观锁实现,操作单个变量,纳秒级延迟,自旋重试;
      • 版本号:应用级的乐观锁实现,操作整行记录,毫秒级延迟,业务层重试;
      • 时间戳:类似版本号,但依赖时钟同步,分布式环境下有精度风险。

      在 Java 中,AtomicInteger 是 CAS 实现的数据库乐观锁是版本号实现。两者可以结合:Java 层用 CAS 做本地缓存更新,数据库层用版本号做持久化冲突检测。"

  • 追问 3:"高并发下乐观锁冲突严重,怎么优化?"

    • 高分回答 : "优化路径分四层:
      1. 退避策略:指数退避重试,降低同时冲突的概率;
      2. 队列化:将并行请求改为单线程队列处理(如 Disruptor),彻底消除冲突;
      3. 分段分散:将单一热点拆分为多个子单元(如库存分段),降低单个单元的冲突率;
      4. 降级悲观锁 :冲突率 > 50% 时,果断改用 SELECT FOR UPDATE 或分布式锁。

      典型案例:秒杀系统。纯乐观锁会导致 99% 请求重试,数据库 CPU 打满。正确方案是:Redis 预减库存(乐观)+ MQ 异步下单(队列化)+ 数据库最终一致性(版本号兜底)。"

  • 追问 4:"乐观锁的写偏斜问题怎么解决?"

    • 高分回答 : "写偏斜(Write Skew)是乐观锁的经典问题:事务读取了多行数据,只修改其中一部分,提交时只检测修改行的版本,未检测读取行的变化,导致逻辑错误。

      解决方案:

      1. Serializable 隔离级别:数据库自动检测写偏斜,但性能最差;
      2. 谓词锁(Predicate Lock):锁定满足条件的所有行,PostgreSQL 的 Serializable 使用 SSI(Serializable Snapshot Isolation)实现;
      3. 业务层补偿:读取时记录所有相关行的版本号,提交时一并检测;
      4. 悲观锁兜底 :对写偏斜敏感的业务(如库存+订单一致性),改用 SELECT FOR UPDATE

      实际工程中,写偏斜场景较少,一旦出现通常意味着业务逻辑需要重新审视------是否应该用事务包裹更大的范围。"

  • 追问 5:"数据库乐观锁的版本号字段,用自增 int 还是时间戳?"

    • 高分回答 : "优先用自增 int/bigint
      1. 确定性:自增版本号严格单调递增,时间戳依赖系统时钟,分布式环境下 NTP 同步误差可能导致版本回退;
      2. 精度:时间戳毫秒级,高并发下同一毫秒多次更新可能冲突;
      3. 存储:int(4 字节)比 timestamp(8 字节)更省空间。

      时间戳的适用场景:

      • 需要记录"最后修改时间"的业务审计;
      • 乐观锁只是附加功能,不想单独维护 version 字段。

      注意:版本号 int 在高频更新下可能溢出(21 亿次),需用 bigint 或处理回绕。"

  • 追问 6:"如果乐观锁重试超过上限,应该怎么处理?"

    • 高分回答 : "重试超限的处理策略取决于业务容忍度:
      1. 立即失败 :抛 OptimisticLockException,前端提示'操作过于频繁,请稍后重试'。适合非关键操作(如点赞);
      2. 异步降级:将操作放入 MQ 延迟队列,后台异步重试。适合可延迟的最终一致性场景(如积分发放);
      3. 悲观锁兜底 :最后一次尝试改用 SELECT FOR UPDATE,确保成功。适合必须成功的操作(如扣款);
      4. 合并冲突:如果是编辑场景,展示冲突内容让用户选择(如 Git 的 merge conflict)。

      无论哪种策略,都必须监控冲突率。冲突率持续 > 20% 说明乐观锁选型错误,应改用悲观锁或架构优化。"

8. 方案选型速查表
场景 冲突概率 推荐方案 不推荐方案
商品详情页浏览 < 1% 无锁读取 任何锁
库存查询 < 5% 乐观锁(版本号) 悲观锁
账户余额更新 5%~20% 乐观锁 + 指数退避 纯乐观锁
秒杀库存扣减 > 99% Redis 预减 + MQ 队列化 数据库乐观锁
转账(强一致性) 任意 悲观锁 / 分布式事务 乐观锁
配置中心读取 < 1% 无锁 + 本地缓存 乐观锁
并发计数器 LongAdder(分段 CAS) AtomicLong
医生值班系统 Serializable / 谓词锁 纯乐观锁

💡 面试官想要的满分总结

乐观锁不是"不加锁",而是一种冲突检测策略 ,其核心权衡是"无阻塞读取的收益 vs 冲突重试的成本"。

选型的唯一金标准是冲突概率:< 20% 时乐观锁性能碾压悲观锁,> 50% 时乐观锁的重试风暴会导致系统崩溃。读多写少、无强一致性要求的场景(如商品浏览、配置读取)是乐观锁的主战场;写多读少、强一致性场景(如转账、秒杀)必须用悲观锁或队列化。

工程实践中,乐观锁必须配合重试上限降级策略冲突率监控三板斧。高冲突场景下,指数退避、分段分散、队列化串行化是三大优化手段。最后记住:乐观锁的写偏斜问题和副作用风险不容忽视,版本号字段用自增 int、重试中严禁副作用、超限必须降级------这些细节决定了生产环境的稳定性。


觉得对您有帮助,麻烦 点点关注啦 ,您的关注是我创作的最大动力~ 🎯

相关推荐
Mahir081 小时前
HashMap 底层原理深度解密:从数据结构到 JDK1.7/1.8 演进全解
java·后端·面试·hashmap
uhakadotcom1 小时前
get_event_loop(),和 get_running_loop() + ThreadPoolExecutor 有啥区别
后端·面试·github
小马爱打代码1 小时前
Spring Boot 自动装配流程
java·spring boot·后端
我登哥MVP1 小时前
SpringCloud 核心组件解析:分布式配置管理
java·spring boot·分布式·spring·spring cloud·java-ee·maven
lihao lihao1 小时前
linux线程
java·开发语言·jvm
满怀冰雪1 小时前
第13篇-栈算法入门-括号匹配-表达式与单调栈基础
java·算法
我是一颗柠檬1 小时前
【Java项目技术亮点】Redis Lua脚本原子化操作:高并发场景下的终极武器
java·redis·lua
swg3213211 小时前
Redis实现主从选举
java·前端·redis