乐观锁和悲观锁

在黑马点评项目中,优惠券秒杀的部分提到了乐观锁和悲观锁,本文来简单了解一下什么是乐观锁和悲观锁。

在电商大促、秒杀活动中,库存扣减是核心场景之一。但你是否遇到过这样的情况:明明库存只剩1件,却同时卖出2单,导致超卖?这背后正是并发场景下的线程安全问题——多个请求同时读取库存、计算扣减,最终导致数据不一致。

今天我们就从​​乐观锁​​和​​悲观锁​​出发,结合库存扣减场景,深入理解并发安全的底层逻辑,并聊聊其他常见锁类型。


一、乐观锁与悲观锁:两种并发控制思路

1. 悲观锁:提前加锁,强制串行

核心思想

假设“并发冲突一定会发生”,因此​​提前加锁​​阻止其他操作,确保当前操作独占资源。就像去餐厅吃饭,担心座位被抢,直接占座后再点餐。

实现方式

依赖数据库或编程语言的显式锁机制。例如数据库的行锁(SELECT ... FOR UPDATE)、Java的synchronized关键字。

典型场景

高并发下冲突概率大的场景(如秒杀库存、账户转账)。

作用

通过强制串行化访问,彻底避免并发冲突,保证数据一致性。

示例:库存扣减中的悲观锁

以MySQL为例,使用SELECT ... FOR UPDATE对库存行加锁:

BEGIN; -- 开启事务
-- 步骤1:查询库存并加行锁(其他事务需等待锁释放)
SELECT stock FROM product WHERE id = 123 FOR UPDATE;
-- 步骤2:扣减库存(此时其他事务无法修改该行)
UPDATE product SET stock = stock - 1 WHERE id = 123;
COMMIT; -- 提交事务,释放锁
  • ​执行流程​​:第一个事务查询并锁定库存行后,其他事务执行SELECT ... FOR UPDATE会被阻塞,直到锁释放(事务提交/回滚)。
  • ​效果​​:确保同一时间只有一个事务能修改库存,彻底避免超卖。

2. 乐观锁:先操作后检测,冲突则重试

核心思想

假设“并发冲突很少发生”,因此​​先操作后检测​​,若发现冲突则回滚重试。就像图书馆借书,先拿走书看完,还书时检查是否被标记(若有则重新借)。

实现方式

通过版本号(Version)或时间戳实现,无需显式加锁。更新时校验版本号是否与查询时一致,一致则更新并递增版本,否则重试。

典型场景

读多写少、冲突概率低的场景(如评论点赞、配置更新)。

作用

避免锁的开销(如线程阻塞、上下文切换),提升并发性能。

示例:库存扣减中的乐观锁

在商品表中增加version字段(初始为0),通过CAS(Compare-And-Swap)实现无锁更新:

BEGIN; -- 开启事务
-- 步骤1:查询当前库存和版本号(无锁)
SELECT stock, version FROM product WHERE id = 123;
-- 假设查询到 stock=100,version=1
-- 步骤2:尝试扣减(仅当版本号未变时更新)
UPDATE product 
SET stock = stock - 1, version = version + 1 
WHERE id = 123 
  AND stock > 0  -- 校验库存是否足够
  AND version = 1; -- 校验是否被其他事务修改过
-- 步骤3:检查更新是否成功(影响行数是否为1)
IF (ROW_COUNT() = 1) THEN
    COMMIT; -- 成功,提交事务
ELSE
    ROLLBACK; -- 失败,重试或报错
END IF;
  • ​执行流程​​:若两个线程同时读取到version=1,第一个线程成功更新后,第二个线程的UPDATE会因version不匹配(已变为2)而失败,需重试。
  • ​优势​​:无锁等待,适合高并发但冲突少的场景。

二、为什么库存扣减常用这两种锁?

库存扣减是典型的​​原子性操作​​,需保证“查询-计算-更新”三步不被其他操作打断。否则可能出现超卖(如库存剩1时,两个请求同时读取到1,均扣减为0,导致库存为-1)。

悲观锁的优势

通过数据库行锁强制串行化,直接解决并发冲突。适合高并发且冲突多的场景(如秒杀),但可能因锁等待降低性能。

乐观锁的优势

通过版本号无锁检测,减少锁竞争。适合冲突较少但QPS较高的场景(如普通商品库存扣减),但需处理重试逻辑。


三、其他常见锁类型:按场景选对锁

锁的分类可从多个维度(作用范围、功能特性),以下是常见类型:

1. 按作用范围划分

类型说明示例
数据库锁控制数据库资源的访问行锁(InnoDB)、表锁(MyISAM)、间隙锁
Java内存锁控制多线程对内存资源的访问synchronizedReentrantLock
分布式锁控制分布式系统中跨服务的资源访问Redis(SETNX/RedLock)、ZooKeeper

2. 按功能特性划分

类型说明示例
读写锁允许多个读锁共存,写锁独占(读多写少场景)Java的ReentrantReadWriteLock
自旋锁竞争锁失败时循环检查锁状态(减少线程切换,但可能空转CPU)内核态代码(如操作系统调度)
信号量(Semaphore)控制同时访问资源的线程数量(计数器,非互斥)限制数据库连接池最大连接数(Semaphore(10)
条件变量(Condition)配合锁使用,实现线程间协调(如生产者-消费者模型中的通知机制)Java的Condition.await()/signal()

3. 其他特殊锁

  • ​无锁(Lock-Free)​​:通过CAS等原子操作实现,无需显式加锁(如Java的AtomicInteger)。
  • ​公平锁 vs 非公平锁​​:公平锁按等待队列顺序分配锁(如ReentrantLock(true)),非公平锁允许插队(默认策略,性能更好但可能导致饥饿)。

四、代码判断库存的陷阱:为什么“if (stock > 0)”不够?

很多新手会直接在代码中写:

if (product.getStock() > 0) {
    product.setStock(product.getStock() - 1);
}

但这​​无法保证线程安全​​!问题出在“检查-扣减”的非原子性:

1. 问题复现:超卖是怎么发生的?

假设库存初始为1,两个线程同时执行:

时间点线程A操作线程B操作
T1读取库存stock=1(判断>0)
T2读取库存stock=1(判断>0)
T3扣减库存→stock=0
T4扣减库存→stock=-1(超卖!)

核心漏洞:“检查库存”和“扣减库存”是两步独立操作,中间可能被其他线程打断,导致重复扣减。

2. 正确方案:保证“检查+扣减”的原子性

要彻底避免超卖,必须保证两步操作的原子性。以下是三种主流方案:

方案1:数据库原子更新(隐式锁)

直接通过UPDATE语句完成“检查+扣减”,数据库自动保证原子性:

-- 仅当库存>0时扣减1(WHERE条件在更新时重新校验)
UPDATE product 
SET stock = stock - 1 
WHERE id = 123 AND stock > 0;
  • ​原理​​:数据库执行UPDATE时会加行锁,且WHERE stock > 0会在更新时重新检查库存(即使其他事务已修改)。
  • ​效果​​:库存为1时,只有一个线程能成功执行(影响行数=1),另一个线程失败(影响行数=0)。
方案2:乐观锁(CAS机制)

通过版本号实现无锁原子操作(见前文示例),适合读多写少场景。

方案3:悲观锁(显式行锁)

通过SELECT ... FOR UPDATE显式锁定库存行(见前文示例),适合高并发冲突场景。


总结

  • ​悲观锁​​:强一致性,适合高冲突场景(如秒杀),但可能牺牲性能。
  • ​乐观锁​​:高并发友好,适合低冲突场景(如普通库存),但需处理重试逻辑。
  • ​其他锁​​:根据场景选择(读多写少用读写锁,分布式用Redis锁,无锁用CAS),核心是平衡一致性、性能和复杂度。

下次遇到库存扣减问题,记得避开“代码判断”的陷阱,优先用数据库原子更新或锁机制保证原子性!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值