找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

604

积分

0

好友

76

主题
发表于 3 天前 | 查看: 12| 回复: 0

“昨天半夜又被报警短信吵醒了——线上商品库存居然出现了超卖。团队紧急回滚,定位代码时发现,明明在扣减库存的 update 语句前加了 synchronized,为什么在高并发下还是失效了?如果你也认为加个‘锁’就能解决一切并发问题,那你可能正站在一个危险的认知误区边缘。本文将带你穿透‘乐观锁’与‘悲观锁’的迷雾,从原理、实战到选型,让你不仅通过面试,更能真正写出健壮的高并发代码。”

在并发编程的世界里,“锁”是守护数据一致性的基石。但面对“乐观锁”与“悲观锁”这两个看似对立的概念,很多开发者容易陷入简单的字面理解:一个乐观,一个悲观,二选一罢了。然而,真正的选择绝非如此非黑即白。选错锁机制,轻则性能腰斩,重则数据错乱。今天,我们就来深入探讨这两种锁的本质与应用哲学。

一、本质探秘:两种截然不同的世界观

在深入技术细节前,我们必须从“世界观”层面理解它们。这绝非文字游戏,而是它们一切行为差异的源头。

悲观锁(Pessimistic Locking) 持有一种“总有刁民想害朕”的世界观。它悲观地认为,只要我访问的数据,就极有可能被别人修改。因此,它的策略是 “先下手为强” :在真正进行数据操作(增删改)之前,就抢先获取锁,将数据独占。在它持有锁的期间,其他所有尝试访问该数据的线程都必须挂起等待,直到锁被释放。这就像你进入图书馆的单人研究室,进去后立刻反锁门,无论你是否在写字,门外的人都只能等着。

乐观锁(Optimistic Locking) 则秉持一种“和谐社会,冲突少有”的乐观态度。它认为数据竞争是小概率事件,因此允许多个线程同时去读取和修改同一份数据。但乐观并非放纵,它有一个至关重要的 “事后校验” 环节:在提交更新时,会检查自上次读取后,数据是否已被其他线程修改过。如果没变,则提交成功;如果变了,则意味着本次更新基于过期的数据,必须放弃或重试。这就像多人协同编辑在线文档,大家都可以自由编辑,但在保存时,系统会检查自你打开后文档是否有更新,如果有,就会提示你“文档已变更,请合并更改”。

一张图看清两种锁的核心工作流程:

(此处应有一张对比流程图)
左列-悲观锁流程:开始 -> 获取锁 -> 执行业务逻辑&更新数据 -> 提交事务,释放锁 -> 结束。
右列-乐观锁流程:开始 -> 读取数据(含版本号V1)-> 执行业务逻辑(基于V1数据)-> 提交时检查当前版本号是否为V1 -> [是] 更新数据,版本号V1->V2 -> 提交成功;[否] 提交失败,回滚或重试 -> 结束。

二、实战对垒:代码里的刀光剑影

理论太抽象?让我们通过一个经典的 “电商库存扣减” 场景,看看它们如何落地。

场景:商品A库存100件,1万用户瞬间抢购。

1. 悲观锁实现(以数据库行锁为例)

// 使用 SELECT ... FOR UPDATE 在事务开始时即锁定记录
@Transactional
public boolean deductStockPessimistic(Long productId) {
    // Highlight: 关键就在这里!查询时直接锁定这条记录,阻止其他事务的读(某些隔离级别下)写。
    Product product = productMapper.selectForUpdate(productId);
    if (product.getStock() > 0) {
        product.setStock(product.getStock() - 1);
        productMapper.updateById(product);
        return true;
    }
    return false;
}

核心作用:这是一个典型的悲观锁Demo。 SELECT ... FOR UPDATE 语句是MySQL等数据库中实现悲观锁的常用手段,它在事务内对选中的行加上排他锁(X锁)。

点睛注释// Highlight: 关键就在这里!查询时直接锁定这条记录,阻止其他事务的读(某些隔离级别下)写。

2. 乐观锁实现(以版本号机制为例)

首先,商品表需要增加一个版本号字段 version

public class Product {
    private Long id;
    private String name;
    private Integer stock;
    private Integer version; // 新增的版本号字段
}

业务逻辑层:

public boolean deductStockOptimistic(Long productId) {
    int retryTimes = 3; // 乐观锁冲突常见处理:有限次重试
    for (int i = 0; i < retryTimes; i++) {
        Product product = productMapper.selectById(productId);
        if (product.getStock() <= 0) {
            return false;
        }
        // 基于读取到的数据计算新库存和版本号
        product.setStock(product.getStock() - 1);
        product.setVersion(product.getVersion() + 1);

        // Highlight: 更新时,将最初读取的版本号作为条件。如果匹配,说明期间无人修改,更新成功。
        int rows = productMapper.updateByIdWithVersion(product);
        if (rows == 1) {
            // 更新成功,影响行数为1
            return true;
        }
        // 更新失败(rows == 0),说明版本号对不上,循环重试
        // 在实际中,这里可以加一小段随机延迟,避免活锁
    }
    // 重试多次仍失败,可能并发冲突异常激烈
    throw new RuntimeException(“扣减库存失败,请重试”);
}

对应的MyBatis Update SQL大致如下:

UPDATE product
SET stock = #{stock}, version = #{version}
WHERE id = #{id} AND version = #{oldVersion} -- 核心的CAS操作

核心作用:这是乐观锁原理的完整示例,展示了“读取-计算-验证并更新(CAS)”的完整流程,以及失败重试的通用模式。

点睛注释// Highlight: 更新时,将最初读取的版本号作为条件。如果匹配,说明期间无人修改,更新成功。

生活化类比
想象你和同事共同维护一份共享的《项目进度表》。

  • 悲观锁:就像你把表格下载到本地,并立即在团队群里说:“我正在修改,大家别动!”。你修改的整个过程中,别人既看不到最新内容,也无法修改。等你改完上传,大家才能继续。
  • 乐观锁:就像你们直接用在线文档(如Google Docs)。你和同事可以同时打开编辑。你改完第10行点击保存,系统会悄无声息地保存成功。但如果你的同事在你读完后、保存前,已经修改并保存了第10行,那么当你点击保存时,系统会弹出一个友好的提示:“文档已更新,请刷新后合并你的更改”。这个“提示”就是乐观锁的冲突检测,而“刷新后合并”就是重试机制。

三、抉择时刻:我该用哪个?一张表格与一个故事

避坑指南:千万不要死记“乐观锁性能一定更好”!性能高低完全取决于冲突频率

特性维度 悲观锁 乐观锁
核心思想 防患于未然 事后校验,冲突重试
适用场景 写多读少,冲突概率。如:金融账户扣款、抢购核心资源。 读多写少,冲突概率。如:商品信息更新、文章点赞计数。
实现方式 数据库行锁( FOR UPDATE )、 Java synchronizedReentrantLock 等。 版本号、时间戳、CAS原子操作(如 AtomicInteger )。
优点 简单粗暴,保证强一致性,没有重试开销。 并发度高,无锁设计减少线程挂起,吞吐量往往更高。
缺点 性能开销大(加锁、释放锁、线程切换),可能引发死锁,降低并行度。 存在ABA问题(可通过增加版本号解决),冲突频繁时重试开销大,可能降低体验。
类比 独占会议室 协同编辑在线文档

一个踩坑案例
曾经在一个人力资源系统的“员工信息批量审核”功能中,我下意识地使用了乐观锁(版本号)。我认为审核动作不频繁,冲突低。上线后,在月度集中审核时,管理员经常反馈“提交失败,请重试”。一查日志,发现大量 Update rows=0 的记录。原来,管理员习惯长时间打开页面,然后快速连续审核多个员工。后提交的请求,其版本号早已被前一个请求更新,导致连续失败。
反思:这个场景看似“写少”,但操作是串行且连续的,对同一个数据的两次更新间隔极短,冲突概率接近100%。这里改用悲观锁(例如,在服务层对单个员工ID加 synchronized 锁),虽然损失了微不足道的并发度,却换来了管理员流畅的线性操作体验,是更合适的选择。这提醒我们,进行系统设计和选型时,必须深入理解真实业务场景的并发模式。

所以,选择的关键在于评估你的临界区(被保护的数据或代码段)发生竞争的真实概率。

四、超越数据库:广阔天地中的锁

锁的概念不仅限于数据库。在Java和分布式系统中也有广泛的应用。

  • Java层面的乐观锁java.util.concurrent.atomic 包下的 AtomicInteger 等类,其 incrementAndGet() 方法底层就是通过CAS(Compare-And-Swap) 这种CPU原子指令实现的乐观锁。它是 volatile 变量(保证可见性) + CAS(保证原子性)的经典组合。
  • 分布式锁是悲观锁:当我们使用Redis的 SETNX 命令或ZooKeeper的临时有序节点来实现分布式锁时,本质上实现的是一种分布式的悲观锁。因为它要求在访问共享资源前,必须先在所有服务实例间争夺一个全局唯一的锁令牌。
  • MVCC是乐观锁的延伸:数据库的多版本并发控制(MVCC) ,如MySQL的InnoDB在 REPEATABLE READ 隔离级别下的实现,可以看作是乐观锁思想的一种高级扩展。它通过维护数据的多个版本来实现非锁定读,极大提升了读并发性能。更多关于数据库的并发控制技术值得深入探索。

面试官追问:“你提到了CAS,那你知道CAS的ABA问题吗?如何解决?”
:ABA问题是指,一个变量值原来是A,被另一个线程改为B,然后又改回A。这时,使用CAS进行检查的线程会误以为它没有被修改过。解决方案是加入版本号或时间戳等“状态戳”,不比较值本身,而比较“值+状态戳”的复合信息。 AtomicStampedReference 就是JDK提供的解决方案。

五、实战总结

  1. 定义区分:悲观锁是“先锁后改”,乐观锁是“先改后验(冲突则弃/重试)”。
  2. 选择铁律高冲突用悲观,低冲突用乐观。冲突概率是动态的,需结合业务场景评估。
  3. 实现方式:悲观锁可用数据库 FOR UPDATE 、Java内置锁;乐观锁常用版本号、CAS原子类。
  4. 性能认知:在低冲突下,乐观锁无阻塞,性能远超悲观锁;在高冲突下,乐观锁的重试成本可能使其性能反劣于悲观锁。
  5. 实践要点:乐观锁必须配合重试机制,并设置合理的重试次数上限;悲观锁需注意锁粒度,并小心死锁。

希望这篇文章能帮助你构建起关于锁选择的清晰认知。技术的道路需要不断学习和交流,欢迎来 云栈社区 与其他开发者共同探讨更多高并发与系统架构的实战话题。




上一篇:Java SpringBoot项目调用外部HTTP接口的三种实战方案:HttpClient、RestTemplate与Feign
下一篇:HBF高带宽闪存技术解析:为何它能成为AI推理的存储新贵?
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-24 01:45 , Processed in 0.380317 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表