首页 > Java > Java面试题 > 正文

理解乐观锁和悲观锁

畫卷琴夢
发布: 2025-11-24 12:15:05
原创
468人浏览过
悲观锁认为并发冲突常见,因此在操作前加锁以保证独占,如数据库行锁或synchronized;乐观锁假设冲突较少,允许并行操作,在提交时通过版本号或时间戳检查冲突,适用于读多写少场景。两者核心哲学不同:悲观锁追求安全性,牺牲性能;乐观锁追求高并发,容忍重试。选择取决于业务对一致性与性能的权衡。

理解乐观锁和悲观锁

理解乐观锁和悲观锁,核心在于它们处理并发冲突的哲学截然不同。悲观锁认为冲突是常态,所以它在操作前就直接“锁死”资源,确保独占;而乐观锁则相信冲突不常发生,它允许大家并行操作,只在提交时才检查是否有冲突,如果发现冲突,就让操作失败并重试。

解决方案

在我看来,理解乐观锁和悲观锁,首先要抓住它们对并发冲突的“态度”。

悲观锁(Pessimistic Locking) 这就像是你在图书馆看书,为了确保没人打扰,你直接把书拿到一个只有你才能进入的私人房间里看。它假定并发冲突是高频事件,所以在数据被修改之前,会先对数据进行加锁,阻止其他事务访问。这种锁通常是数据库层面的,比如行锁、表锁,或者是编程语言层面的互斥锁(如Java的synchronized关键字或ReentrantLock)。

  • 优点: 简单粗暴,能有效保证数据的一致性和完整性,避免了脏读、不可重复读和幻读等问题。操作一旦成功,数据状态就是确定的。
  • 缺点: 性能开销大,因为锁定的粒度可能较粗(比如锁住一整行甚至一张表),导致并发性能下降。如果锁持有时间过长,还可能引发死锁。在高并发场景下,这往往是个瓶颈。
  • 适用场景: 读少写多,或者对数据一致性要求极高,且并发冲突确实频繁的场景。

乐观锁(Optimistic Locking) 这更像是大家都在同一个图书馆里看书,每个人都可以拿走一份副本阅读修改,没人会阻止你。只有当你决定把修改后的内容放回原处时,系统才会检查你拿走的那份是不是最新版本。如果发现有人在你修改期间已经更新了原版,那你的修改就会被拒绝,你需要重新获取最新版本再修改。它假定并发冲突是低频事件,不阻塞其他事务对相同数据的读写,而是在更新数据时检测是否发生冲突。

  • 实现方式: 最常见的两种是版本号(Version)和时间戳(Timestamp)。
    • 版本号: 在数据表中增加一个version字段,每次数据更新时,将该字段值加1。更新操作时,会检查当前数据的version是否与你读取时的version一致。
      -- 伪代码示例:更新库存
      UPDATE products SET stock = stock - 1, version = version + 1
      WHERE id = ? AND version = ?;
      登录后复制

      如果受影响的行数为0,说明在你更新前,别人已经更新了这条数据,你的操作就需要重试。

    • 时间戳: 类似版本号,只是用时间戳来标记数据的最新状态。
  • 优点: 显著提升并发性能,尤其是在读多写少的场景下,因为没有了锁的开销。避免了死锁。
  • 缺点: 增加了业务逻辑的复杂性,需要应用程序自己处理冲突检测和重试机制。如果冲突频率很高,大量的重试反而可能导致性能下降。另外,存在ABA问题(如果值从A变B又变回A,版本号或时间戳能解决,但纯粹的CAS可能无法发现)。
  • 适用场景: 读多写少,并发冲突不频繁的场景,对性能要求较高。

为什么我们需要区分乐观锁和悲观锁?它们的核心设计哲学有何不同?

我们之所以需要区分这两种锁,说白了,就是因为它们代表了两种截然不同的资源管理和冲突解决策略,而这两种策略在不同的应用场景下,会带来天壤之别的系统表现。它们的核心设计哲学,在我看来,可以归结为:

悲观锁秉持的是一种“防御性编程”的哲学,它预设了最坏的情况——即并发冲突一定会发生,而且会频繁发生。所以,它的策略是“先发制人”,通过独占资源来彻底避免冲突。这就像是过马路,悲观锁会选择在红灯亮起时,彻底停下,等待绿灯亮起,确保绝对安全才通过。这种哲学优先考虑的是数据的一致性和安全性,哪怕牺牲一部分性能。它追求的是操作的确定性,一旦拿到锁,就意味着操作成功有保障。

而乐观锁则是一种“事后诸葛亮”的哲学,它预设了最好的情况——即并发冲突很少发生,或者即便发生,也能通过检测和重试来解决。它的策略是“放手一搏”,让所有操作并行进行,只在最后提交时才验证是否“踩雷”。这就像过马路,乐观锁会选择在人行横道上,相信大多数时候车辆会礼让,只在发现有车冲过来时才紧急避让或重新等待。这种哲学优先考虑的是系统的吞吐量和并发性,它相信大多数时候的并行操作都是无害的。它追求的是高效率,认为为少数可能发生的冲突而牺牲整体性能是不划算的。

选择哪种锁,其实也反映了我们对业务场景并发特性的判断和权衡。没有绝对的好坏,只有是否合适。

在实际开发中,乐观锁与悲观锁各自适用于哪些典型场景?请举例说明。

在实际开发中,这两种锁的选择,很大程度上取决于你对业务并发特性的判断,以及对性能和数据一致性权衡的优先级。

悲观锁的典型应用场景:

Vinteo AI
Vinteo AI

利用人工智能在逼真的室内环境中创建产品可视化。无需设计师和产品照片拍摄

Vinteo AI 83
查看详情 Vinteo AI
  1. 银行转账扣款: 想象一下,用户A要从账户里转账1000元。如果这时候不加锁,另一个操作同时从账户里取走500元,就可能出现账户余额计算错误。这种场景下,对账户余额的修改必须是强一致性的,任何并发修改都可能导致严重的资金问题。所以,通常会对账户记录加悲观锁(比如数据库行锁),确保只有一个事务能操作该账户,直到操作完成。
  2. 库存扣减(极端严格): 比如秒杀系统,对库存的扣减要求极高,一旦超卖就是巨大的损失。如果系统对并发扣减的冲突容忍度极低,宁愿牺牲一点性能也要确保不超卖,那么悲观锁就是首选。例如,在扣减库存前,直接锁定商品ID对应的库存记录,直到扣减完成并提交。
  3. 高并发且操作时间短的关键资源: 当某个共享资源被频繁访问,且每次访问都是短暂的修改操作时,悲观锁可以确保每次修改的原子性。例如,分布式系统中对某个全局唯一ID生成器的访问,为了确保不重复,可能会对生成逻辑加锁。

乐观锁的典型应用场景:

  1. 高并发的商品秒杀(库存扣减,但允许部分冲突重试): 虽然秒杀对库存要求高,但如果并发量实在太大,悲观锁可能直接导致系统崩溃。此时,乐观锁就成了优选。用户下单时,先读取当前库存和版本号,然后尝试更新库存并递增版本号。如果更新失败(版本号不匹配,说明有别人抢先一步),系统会提示用户重试或商品已售罄。这种策略牺牲了一点点用户体验(可能需要重试),但极大地提升了系统整体的并发吞吐量。
  2. 内容编辑或维基系统: 多个用户可能同时编辑同一篇文章。如果使用悲观锁,那么一个人编辑时,其他人就无法查看甚至无法打开文章。这显然不合理。乐观锁的方案是:每个人都可以拿到文章副本编辑,提交时系统检查文章版本号。如果发现有人在你编辑期间提交了新版本,系统会提示你“内容已更新,请合并或重新编辑”,避免了覆盖他人的修改。
  3. 缓存更新: 当多个线程尝试更新同一个缓存条目时,可以使用乐观锁。例如,通过CAS(Compare-And-Swap)操作来更新缓存值。如果当前值不是预期的旧值,说明其他线程已经更新了,当前操作就失败并重试。Java的AtomicIntegerAtomicLong等原子类就是基于CAS实现的乐观锁思想。

乐观锁实现中的常见挑战与应对策略有哪些?

乐观锁虽然在提升并发性能上效果显著,但它也并非万能药,实际实现中会遇到一些挑战,需要我们巧妙应对。

  1. ABA问题: 这是乐观锁特有的一个经典问题。假设一个变量V,初始值为A。线程1读取V为A。在线程1准备更新V之前,线程2将V从A修改为B,然后又从B修改回A。此时,线程1再执行更新操作时,会发现V的值仍然是A,认为没有发生过变化,从而成功执行更新。但实际上,V已经被修改了两次。

    • 应对策略: 引入版本号或者时间戳。Java的AtomicStampedReference就是为了解决ABA问题而设计的,它不仅比较值,还会比较一个“标记”(stamp),每次修改都增加标记值。这样,即使值变回去了,标记值也会不同,从而识别出中间的修改。
      // 伪代码:使用AtomicStampedReference解决ABA问题
      // AtomicStampedReference<Integer> asr = new AtomicStampedReference<>(100, 0);
      // int currentStamp = asr.getStamp();
      // boolean success = asr.compareAndSet(100, 120, currentStamp, currentStamp + 1);
      // currentStamp = asr.getStamp(); // 获取新的stamp
      // boolean success2 = asr.compareAndSet(120, 100, currentStamp, currentStamp + 1); // 即使值变回100,stamp也变了
      // currentStamp = asr.getStamp();
      // boolean finalSuccess = asr.compareAndSet(100, 90, oldStampFromThread1, oldStampFromThread1 + 1); // 线程1会失败
      登录后复制
  2. 活锁(Livelock): 当多个线程都尝试进行乐观更新,但由于冲突频繁,它们不断地重试,却又不断地失败,导致所有线程都处于忙碌状态,但没有一个能成功完成操作。这就像两个人在窄路上相遇,都想让路,结果你往左我往左,你往右我往右,谁也过不去。

    • 应对策略: 引入随机退避(Exponential Backoff)机制。当一次乐观更新失败后,不立即重试,而是等待一个随机的时间间隔,并且每次失败后,这个等待时间会逐渐增长。这样可以错开各个线程的重试时机,给其中一个线程创造成功的机会。
  3. 高并发冲突率下的性能下降: 乐观锁的优势在于冲突率低时性能高。但如果实际业务场景中冲突非常频繁,那么大量的重试操作反而会消耗大量的CPU资源和网络带宽,导致整体性能不升反降,甚至比悲观锁更差。

    • 应对策略: 监控系统冲突率。如果发现某个模块的乐观锁冲突率持续走高,可能需要重新评估设计,考虑是否需要调整为悲观锁,或者优化业务逻辑以减少冲突(比如拆分大事务,或者进行数据分片)。在某些极端情况下,甚至可以考虑结合两种锁的优点,比如先用乐观锁尝试,失败后退化为悲观锁。
  4. 业务逻辑复杂性增加: 乐观锁的实现通常需要应用层代码来处理版本号的比较、冲突的检测以及失败后的重试逻辑。这相比悲观锁的“加锁-操作-解锁”模式,无疑增加了开发和维护的复杂性。

    • 应对策略: 封装通用的乐观锁框架或工具类,将冲突检测和重试逻辑抽象出来,减少业务代码的侵入性。同时,在设计业务流程时,尽量使操作幂等化,这样即使重试也不会产生副作用。清晰的错误处理和日志记录也至关重要,以便快速定位和解决冲突问题。

总的来说,乐观锁不是银弹,它要求我们对业务场景有更深刻的理解和更精细的设计。

以上就是理解乐观锁和悲观锁的详细内容,更多请关注php中文网其它相关文章!

最佳 Windows 性能的顶级免费优化软件
最佳 Windows 性能的顶级免费优化软件

每个人都需要一台速度更快、更稳定的 PC。随着时间的推移,垃圾文件、旧注册表数据和不必要的后台进程会占用资源并降低性能。幸运的是,许多工具可以让 Windows 保持平稳运行。

下载
来源:php中文网
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
最新问题
开源免费商场系统广告
热门教程
更多>
最新下载
更多>
网站特效
网站源码
网站素材
前端模板
关于我们 免责申明 举报中心 意见反馈 讲师合作 广告合作 最新更新 English
php中文网:公益在线php培训,帮助PHP学习者快速成长!
关注服务号 技术交流群
PHP中文网订阅号
每天精选资源文章推送
PHP中文网APP
随时随地碎片化学习

Copyright 2014-2025 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号