在黑马点评项目中,优惠券秒杀的部分提到了乐观锁和悲观锁,本文来简单了解一下什么是乐观锁和悲观锁。
在电商大促、秒杀活动中,库存扣减是核心场景之一。但你是否遇到过这样的情况:明明库存只剩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内存锁 | 控制多线程对内存资源的访问 | synchronized 、ReentrantLock |
分布式锁 | 控制分布式系统中跨服务的资源访问 | 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),核心是平衡一致性、性能和复杂度。
下次遇到库存扣减问题,记得避开“代码判断”的陷阱,优先用数据库原子更新或锁机制保证原子性!