高并发场景中,乐观锁和悲观锁哪个更适合?


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

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

  • 《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于 Spring AI + Spring Boot 3.x + JDK 21...查看介绍

  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...查看介绍;演示链接:http://116.62.199.48:7070/

  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/

  • 新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍

截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观

面试考察点

  1. 概念辨析:面试官不仅仅是想知道你知不知道这两种锁的名字,更是想确认你能不能讲清楚它们的底层思想差异——一个 "先锁后操作",一个 "先操作后验证"。

  2. 场景选型能力:核心中的核心。题干里的 "高并发" 是个伪命题,高并发读和高并发写完全是两码事,写冲突概率高和低又是两码事。能不能拆开讲,决定了这道题的分数档。

  3. 实践深度:是否熟悉 CAS、版本号、ABA、SELECT ... FOR UPDATEsynchronized 这些具体落地手段,有没有踩过坑。

  4. 工程化思维:生产环境很少单一锁方案解决一切,能不能给出 "悲观锁 + 拆分"、"Redis 预扣 + 数据库兜底" 这种混合方案,是区分高级和资深的分水岭。

核心答案

没有绝对谁更适合,关键看两个维度:写冲突概率 + 读写比例

场景特征 推荐策略 典型业务
读多写少,写冲突概率低 乐观锁 配置更新、订单状态流转
写多,冲突概率高 悲观锁 秒杀扣库存、抢红包
超高并发写,单行热点 悲观锁 + 拆分(分桶/分段) 爆款秒杀
读多写极少 无锁 + 缓存 商品详情页

记住一句话:写少冲撞低用乐观,写多冲撞高用悲观,热点写还得靠拆分

高并发乐观锁悲观锁选型
高并发乐观锁悲观锁选型

深度解析

一、两种锁的本质区别

先把思想讲清楚,不然后面的选型全是空中楼阁。

悲观锁:操作前先锁资源,独占到底。它假定 "一定会有人跟我抢",所以干脆把门关上,谁也别想进来。

乐观锁:操作时不加锁,提交的时候再验证 "这段期间有没有人动过我的数据"。它假定 "大家一般不会冲突",先干活,干完再说。

两者的流程对比:

悲观锁和乐观锁流程对比
悲观锁和乐观锁流程对比

上图把两种锁的执行路径并排放了。看左边的悲观锁,整个流程被一把锁从头包到尾,期间其他线程只能干等;右边的乐观锁不加锁,但在最后一步做版本校验,没冲突就提交,有冲突就重试或者直接返回失败。

关键差异就两点:

  • 悲观锁的代价是 "等待":其他线程被阻塞,上下文切换、锁竞争开销大
  • 乐观锁的代价是 "重试":冲突一多,CPU 全花在重试和自旋上

悲观锁等待与乐观锁重试
悲观锁等待与乐观锁重试

二、"高并发" 要先拆开看

很多人栽在这一步,觉得 "高并发" 就是一道题,其实它至少要拆成两个维度。

维度一:读写比例

  • 高并发读、低并发写:乐观锁完胜,因为读不阻塞、不加锁、不互相影响
  • 高并发写:要看维度二

维度二:写冲突概率

这是真正决定锁选型的核心。同样是 "高并发写":

  • 100 个请求同时改同一行库存 → 冲突概率接近 100%,乐观锁几乎每次都要重试,反而比悲观锁还慢
  • 100 个请求改 100 个不同的账户 → 冲突概率极低,乐观锁的代价就微乎其微

所以选型的本质是:冲突高 → 悲观锁(一次锁住,避免无效重试);冲突低 → 乐观锁(避免锁开销)

高并发读写比例和冲突概率
高并发读写比例和冲突概率

三、乐观锁的两种实现

方式一:CAS(Compare And Swap)

Java 里 AtomicIntegerLongAdder 这些原子类底层就是 CAS。核心是一条 unsafe.compareAndSwapInt 指令,CPU 层面的原子操作。

AtomicInteger stock = new AtomicInteger(100);

// CAS 扣减库存
public boolean deduct() {
    int current;
    int next;
    do {
        current = stock.get();
        if (current <= 0) {
            return false; // 库存不足
        }
        next = current - 1;
    } while (!stock.compareAndSet(current, next)); // 自旋重试
    return true;
}

这段代码的关键在 compareAndSet:它会比较当前值是不是还等于 current,是的话就更新为 next,不是就返回 false,然后外层 do-while 自旋重试。

方式二:版本号机制

数据库场景最常用,加一个 version 字段:

UPDATE product SET stock = stock - 1, version = version + 1
WHERE id = ? AND version = ?;

执行后看影响行数,是 1 就成功,是 0 说明版本号变了,有人抢先改过,业务层重试或返回失败。

四、悲观锁的几种实现

应用层synchronizedReentrantLock,适合单机场景。

数据库层SELECT ... FOR UPDATE,行级排他锁,事务结束才释放。

START TRANSACTION;
SELECT stock FROM product WHERE id = 1 FOR UPDATE;
-- 业务计算
UPDATE product SET stock = stock - 1 WHERE id = 1;
COMMIT;

FOR UPDATE 这一行特别关键,它会把查询的行加上排他锁,其他事务想改这行只能等当前事务提交。这里有个大坑:WHERE 条件必须走索引(主键、唯一索引、普通索引都行),一旦走不到索引,行锁会退化为表锁,整张表都被锁住,并发直接崩。而且不同隔离级别下,普通索引还可能加上间隙锁,进一步扩大锁范围。

五、秒杀扣库存的实战选型

这块我专门拎出来讲,因为面试官最爱追这个。

秒杀是典型的高并发写、冲突概率极高的场景。如果直接用乐观锁 CAS 扣数据库库存,会发生什么?1000 个请求同时抢 1 件商品,999 个会失败重试,数据库 CPU 瞬间飙满,这就是经典的 "库存热点" 问题。

正确做法是分层:

秒杀库存 Redis 预扣流程
秒杀库存 Redis 预扣流程

这个图说明的是:把高并发挡在数据库前面。Redis 用 Lua 脚本做原子预扣,瞬间拦截掉绝大部分请求;通过的消息进 MQ 异步落库,数据库的写压力就被削峰填谷了;最后数据库层面再用版本号乐观锁做最终一致性兜底,因为到了这一步冲突已经很少。

如果是更极端的爆款,光 Redis 还不够,还得做库存分桶:把 1000 件库存拆成 10 份,每份 100 件存到不同 key,请求按用户哈希路由到不同桶,冲突概率直接降一个数量级。

秒杀库存削峰和数据库兜底
秒杀库存削峰和数据库兜底

六、乐观锁的两个大坑

坑一:ABA 问题

线程 1 读到值 A,线程 2 把 A 改成 B 又改回 A,线程 1 的 CAS 还是会成功,因为它眼里值没变。但中间其实发生过修改。

解决办法:用版本号或者 AtomicStampedReference,每次修改都让版本号递增,哪怕值变回原样,版本号也对不上。

坑二:自旋开销

冲突高的时候,CAS 一直失败一直自旋,CPU 直接打满。所以 CAS 只适合冲突少的场景,这也是为什么秒杀这种热点场景不能用纯 CAS。

CAS ABA 问题和自旋开销
CAS ABA 问题和自旋开销

面试高频追问

  1. 追问一:CAS 是怎么保证原子性的?

底层是一条 CPU 指令,比如 x86 的 cmpxchg(多核要加 lock 前缀),硬件层面保证 "比较 + 交换" 是不可分割的。Java 通过 unsafe 包调用 native 方法触达这条指令。

  1. 追问二:synchronizedReentrantLock 该怎么选?

简单场景用 synchronized(JVM 维护、自动释放、JDK 6 之后性能优化得不错);需要可中断、可超时、公平锁、多条件变量等高级功能用 ReentrantLock

  1. 追问三:乐观锁失败了怎么办?

要么业务层重试(要限制重试次数,防止雪崩),要么直接返回失败让用户重新提交。秒杀这种场景一般是直接返回 "请重试",不让无限自旋。

  1. 追问四:高并发扣库存还有什么其他方案?

除了上面说的 Redis 预扣 + 库存分桶,还有:数据库层把单行库存拆成多行(分桶表)、用消息队列串行化、用 Lua + Redis Cluster 横向扩展。

常见面试变体

  • "CAS 的原理是什么?ABA 问题怎么解决?"
  • "数据库的乐观锁怎么实现?"
  • "SELECT ... FOR UPDATE 会锁表还是锁行?"
  • "秒杀系统怎么防止超卖?"

记忆口诀

选型三句话

  • 冲突低 → 乐观锁(CAS / 版本号)
  • 冲突高 → 悲观锁(synchronized / FOR UPDATE
  • 热点写 → 悲观 + 拆分(分桶 / 分段 / 缓存预扣)

总结

回到原题:高并发场景中乐观锁和悲观锁哪个更适合?答案是 看冲突概率。读多写少、冲突低,乐观锁;写多、冲突高,悲观锁;如果是秒杀这种超高并发热点写,单一锁策略救不了你,得配合 Redis 预扣、库存分桶、MQ 削峰这些手段一起上。能把这个权衡讲清楚,再举一个秒杀扣库存的实战例子,面试官基本会满意。