库存是许多业务场景中都会涉及的概念,比如购物场景中的商品库存,以及营销场景中的优惠券库存。不同的业务场景有各自的交互流程,后端设计也会随之不同。
购物场景:用户必须完成支付才能真正拥有商品。因此,库存的扣减需要与支付流程紧密结合,并与其他系统交互协调,确保在支付成功后才正式扣减库存。
营销场景:用户通常只需点击领取按钮即可获得优惠券,而不需要支付。这种情况下,库存的扣减操作可以通过一个简单的接口完成,流程相对简单(支付购买券包则归为购物场景)。
这两个典型场景在后端设计上有明显的差异,营销场景的发券流程更直接,而购物场景则需要处理更多的系统交互来确保库存的准确性。
不过在设计时均要考虑对库存操作的安全与高效。
项目完整代码,点击 github-SafeInventory查看
1 库存的安全操作
对库存的安全操作可以从以下2个角度理解
- 不超卖/超发
- 不少发
1.1 不超发-并发
在并发扣减库存时,如果设计不当, 就会出现商品超卖、库存超发问题。
确保库存不超卖、不超发在系统设计中是一个常见且难处理的问题。
“不超”的本质是要保证同一时刻只有一个线程在处理库存扣减操作,从而避免多线程或多进程并发操作导致的超发情况。那么常见的解决方案就是加锁, 锁可以是乐观锁、悲观锁、分布式锁
1.2 不少发-库存回滚
“不少发”指的是,当业务操作未成功时,需要将扣减的库存加回去,避免库存凭空消失,导致实际发出的库存量小于库存表记录的已经发出的库存量,造成数据不一致。
为了防止这种情况,必须确保库存扣减操作和业务数据操作的原子性,即如果业务操作失败,已扣减的库存必须回滚。
在库存回滚时也要考虑并发问题,防止库存回滚加多了。
本文将从发券扣库存、购买商品扣库存2个业务场景, 分别讨论 在本地事务、 分布式事务下如何安全操作库存, 其中分布式事务又分为2种情况,单服务多数据库分布式事务, 多服务多数据库分布式事务
关于事务的分类及具体介绍, 可以点击阅读Intro to 事务
下面以发券场景为例,一段典型错误的库存操作代码出发, 分析在本地事务下如何安全操作库存。
本地事务指的是, 券库存表 和 用户券表在同一个数据库中。
2. 典型错误代码
1 | CREATE TABLE inventory ( |
基于此表, 代码按照以下流程进行处理
1 | public class NotSafeInventoryService { |
1 | SELECT id, |
2.1 并发操作下,快照读引发的库存超卖/超发
通过快照读读取足够的库存, 在修改库存时,库存可能已经被其他事务修改, 不是最初查询到的那个库存了, 在并发情况下有可能将库存扣减至小于0, 导致超卖超发。
2.2 库存扣减与业务操作不具有原子性-少发
reduceInventory 中如果业务代码执行失败, 没有库存回滚逻辑,会导致少发。
3 用本地事务保证 库存回滚,避免少发
以上这段代码,因为没有任何代码保证库存表与业务表操作的原子性, 如果业务操作失败,已经扣减的库存是没有回滚 -把库存加回去的逻辑
如果库存表和业务表在同一个数据库中, 可以直接使用数据库的事务机制保证操作的原子性。
Spring 中提供的事务注解@Transactional ,是基于底层数据库本身的事务处理机制工作提供本地事务功能。
注意:@Transactional 无法保证 数据库操作和其他中间件操作如kafka操作具有原子性。
如果需要保证,可以点击阅读TrustMessage-基于2PC+MySQL+泛化调用实现的可靠消息中心
所以如果上述代码加上@Transactional , 即可以保证业务操作失败时,库存正确回滚。
因此本文以下内容重点讨论在本地事务下已经保证库存回滚不少发的前提下,如何保证不超卖/超发。
保证并发时库存不被扣减至小于0 有2种思路
- 库存条件约束
- 在实际扣减时,只要保证库存扣减后不小于 0 即可。如果当前可用库存大于等于需要扣减的数量,则允许扣减,否则拒绝操作。
- 库存扣减基于查询时的库存一致性:
- 实际扣减时必须保证库存还是查询时的那个库存, 如果不是,则不能执行库存扣减操作,避免超卖。
- 这种思路对应的就是加锁, 数据库乐观锁、数据库悲观锁、分布式锁
4 库存条件约束
此方法只需要在修改库存检查库存是否大于扣减数量即可 , 以此保证当前有足够的库存完成此次扣减操作。1
UPDATE inventory SET available_stock = available_stock - #{quantity} WHERE product_id = #{productId} AND available_stock >= #{quantity}
- MySQL InnoDB 引擎,在可重复读隔离级别下, update 会加行锁,保证针对同一行数据并发写的串行执行, 确保任意时刻只有一个请求修改库存
- 通过where 条件中添加available_stock >= #{quantity} 这个库存约束条件,可以保证库存不会扣减至小于0 导致超卖超发
- available_stock >= #{quantity} 也可以是 available_stock - #{quantity} >=0
虽然通过库存约束条件可以保证不超发超卖,但是这种方案并不适合复杂业务场景,一致性差。
所以接下来重点讨论“实际扣减时必须保证库存还是查询时的那个库存, 如果不是,即使库存够也不能执行库存扣减操作”的解决方案。
常见的方案就是 加锁, 数据库乐观锁、数据库悲观锁、分布式锁
3. 乐观锁,版本号
为库存记录增加一个 version
字段,每次扣减库存时,都要检查 version
字段是否与期望值一致。 并在成功操作后更新版本号。
3.1 表设计
1 | version INT NOT NULL DEFAULT 0 COMMENT '数据版本号,用作乐观锁控制', |
每次扣减库存时,都要检查 version
字段是否与期望值一致。 并在成功操作后更新版本号。1
2UPDATE inventory SET available_stock = available_stock - #{quantity},version = version + 1
WHERE product_id = #{productId} AND version = #{version}
3.2 处理流程
整理处理流程不变如下, 只是在扣减库存时的sql 语句上发生了变化
1 | public class InventoryWithVersionService { |
以上,不论是库存条件约束,还是版本号乐观锁,都是适合并发不高的情况, 否则在修改库存时会出现频繁失败的情况, 影响用户体验和性能。
4. 悲观锁, select… for update
数据库悲观锁 SELECT ... FOR UPDATE
是一种更好的并发控制手段。
当执行 SELECT ... FOR UPDATE
时,数据库会在查询的行上加上一个排他锁(Exclusive Lock,简称 X 锁),这会锁定该行,以防止其他事务对它进行修改或删除操作。
- 同一事务内:在持有行锁的情况下,不会影响同一事务内对该行数据的修改
- 其他事务:对于其他试图修改该行的事务,会被锁阻塞,直到持锁的事务完成并释放锁后,才可以对该行进行修改。
4.1 表设计
1 | SELECT id, |
4.2 处理流程
在查询数据时,使用SELECT ... FOR UPDATE
查询语句
1 | InventoryModel inventory = inventoryMapper.selectByProductIdForUpdate(productId); |
4.3 性能瓶颈表现
SELECT ... FOR UPDATE
的使用会受限于数据库的性能瓶颈,在高并发情况下的主要体现在以下两个方面:
4.3.1 对同一个库存的高并发请求
- 大量事务等待:如果多个事务同时请求对同一个库存项进行扣减(即对同一行数据加锁),
SELECT ... FOR UPDATE
会在第一个事务获取行锁后,使其他事务处于等待状态,直到该事务完成并释放锁。这会导致后续请求大量等待,增加系统的响应时间。 - 锁等待队列:在这种情况下,所有对同一行的请求都会排队等待,直到持有锁的事务完成操作。锁的竞争会使得系统在高并发情况下变得缓慢,出现性能瓶颈。如果事务运行时间较长(如涉及复杂计算或外部请求),等待时间会进一步延长,影响系统吞吐量。
因此,同一个库存项上的并发请求会引发SELECT ... FOR UPDATE
的性能问题,因为所有请求都依赖于锁的释放。
4.3.2 对不同库存项的高并发请求
- 锁管理开销:如果数据库表中对多个库存项并发请求频繁,即便这些请求锁定的行不同,数据库仍需处理大量的锁操作、管理和维护。对于大表,数据库会创建多个行锁,增加了系统的锁管理开销。
- 锁升级与锁膨胀:数据库在高并发的情况下,可能会将行锁升级为表级锁,特别是某些数据库为了提高性能或减少锁争用而引入的锁优化策略,这种升级会阻塞其他事务对整张表的操作,从而影响性能。
因此,虽然对不同库存项的并发请求不一定直接引发锁等待问题,但大量的行锁会带来锁管理的性能开销。锁膨胀和锁升级在数据库负载较高时也可能导致性能下降。此外,由于表级锁的升级机制,其他对该表的查询和更新操作可能受到影响,导致整体性能下降。
总结
- 同一行的高并发请求:这种情况直接导致锁等待问题,影响更为显著。对于
SELECT ... FOR UPDATE
而言,这通常是最大的瓶颈来源。 - 同一张表不同行的高并发请求:这种情况下,锁操作的管理开销增加。如果频繁的锁定和释放操作超出数据库的负载能力,数据库的整体性能也会受到影响,导致吞吐量下降。
4.4 SELECT ... FOR UPDATE
也是分布式锁
虽然说在高并发情况下,SELECT … FOR UPDATE 有性能问题,需要使用分布式锁来提升性能,但其实本质上,SELECT ... FOR UPDATE
在一些具体的使用场景下也可以当成分布锁使用,只不过它依赖数据库,容易遇到瓶颈问题
5. Redis 分布式锁
在高并发时,可以使用更高效的Redis 分布式锁替代 SELECT ... FOR UPDATE
的行锁
5.1 处理流程
在用户请求发券时,通过一个分布式锁来确保每次只允许一个服务实例去处理该优惠券的库存扣减。具体流程为:
- 通过请求的唯一标识,请求获取分布式锁。
- 如果获取成功,则该实例可以继续执行库存扣减操作;如果未获取到锁,则表示有其他实例正在处理当前的请求,当前请求需要等待或者直接返回失败(视具体业务需求而定)。
1 | public class InventoryWithRedisService { |
5.2 在Spring事务管理下,注意正确加锁
如果你了解Spring 事务的工作原理的话,应该能发现 上面这段代码其实是有问题的。
它的实际工作效果时 开启事务-加锁-业务逻辑-释放锁-提交事务。
在事务提交前,Redis 锁就会释放, 这会导致并发问题。具体原因可以阅读 在Spring事务管理下,使用Synchronized 为什么会出现并发问题
一般我们可以采用将锁移出事务管理范围的解决方案, 修改后的代码如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34public class InventoryWithRedisService {
private static final Logger logger = LoggerFactory.getLogger(InventoryWithRedisService.class);
RedisDistributedLock redisDistributedLock;
InventoryWithVersionService inventoryWithVersionService;
private static final int EXPIRE_TIME = 5 * 60;
private static final String LOCK_KEY_PREFIX = "product_lock:";
public boolean reduceInventory(Integer productId, Integer quantity, String requestId) {
String lockKey = LOCK_KEY_PREFIX + productId;
// 模拟获取redis 分布式锁逻辑
boolean lockAcquired = redisDistributedLock.acquireLock(lockKey, requestId, EXPIRE_TIME);
if (!lockAcquired) {
logger.info("未获取到锁 productId: {}, quantity: {}", productId, quantity);
// 获取锁失败,返回或重试
return false;
}
try {
return inventoryWithVersionService.reduceInventory(productId, quantity);
} finally {
// 如果释放失败则重试或者等待过期
if (lockAcquired) {
redisDistributedLock.releaseLock(lockKey, requestId);
}
}
}
}
5.3 Redis 分布式锁是如何提升性能的
根据以上设计可以看出,使用Redis 分布式锁后,依然要先查询库存库存,但是使用的是快照读,而不必加锁,这样可以显著提升性能。
因为快照读不会造成数据库层面的锁等待和阻塞。以避免传统行锁导致的锁争用问题,从而显著提升性能。因此,在高并发和分布式场景中,Redis 锁和快照读是非常高效的组合方式。这种方式既保证了数据一致性,又减少了数据库的负载,能够有效提升系统的响应速度。
5.4 性能提升了,但也更复杂了
5.4.1 未获取到锁该怎么处理
在高并发系统中,使用 Redis 锁虽然性能更高,但未获取锁的请求管理需要更多的业务代码支持。
具体来讲就是,
在使用 SELECT ... FOR UPDATE
时,数据库的行锁等待机制能够自动阻塞请求,直到锁可用或超时。这种机制由数据库内核管理,开发人员无需额外处理等待逻辑。
但是 Redis 不提供内置的等待机制,因此在未能获取锁时,业务代码需要自行决定处理未获取到锁的请求,这为业务代码带来了额外的复杂性,因为需要显式地管理这些请求,例如重试、返回错误、或采用其他处理逻辑。
Redis 锁未获取到时的常见处理方式
5.4.1.1 重试机制
在未获取到锁时稍微休眠一段时间(如 100 毫秒),然后再次尝试获取锁,重复该操作直至成功或达到最大重试次数。
适用于对实时性要求较高,但可以容忍短暂延迟的场景,例如库存扣减、秒杀等业务。
- 优点:可以在短时间内提高锁获取成功率,减少请求失败次数。
- 缺点:如果并发量过大或锁占用时间较长,重试可能导致系统频繁轮询,增加服务器负担。
1 | int retryCount = 0; |
5.4.1.2 返回错误提示用户稍后重试
在未获取到锁的情况下,立即返回“系统繁忙,请稍后重试”的提示给用户。
适用于实时性要求高、无法容忍用户等待的场景,例如访问量巨大的电商秒杀、抢购场景。
- 优点:实现简单,不需要额外处理等待逻辑;避免了因频繁轮询带来的系统开销。
- 缺点:用户体验可能不佳,尤其在并发量较大时,大量请求直接被拒绝。
5.4.1.3 排队机制
在业务逻辑中模拟等待机制,例如将未获取到锁的请求存入队列,并在锁可用时通过队列逐个处理。这样可以模拟类似于数据库行锁的等待机制。
总体来讲,实现成本高
一般来说,重试机制和立即返回错误更为常用、合理,可以根据具体的业务需求灵活选择适合的处理方式。这两种方式可以满足大多数应用的并发控制需求,既实现了 Redis 分布式锁的性能优势,又能在大多数场景下提供良好的用户体验。
5.4.2 请求唯一标识
在使用分布式锁,最好可以有一个请求唯一标识,例如orderId, 这有助于做重复请求的幂等判断 以及锁的安全释放(防止锁被其他线程释放)
5.4.3 锁的高可用
如果 Redis 崩溃,锁信息可能丢失。在关键业务场景中,需要搭配使用高可用 Redis 集群。
5.4.4 锁的过期时间
避免因服务器崩溃或请求超时而引发的死锁, 需要锁设置一个过期时间。
这样即使在某些请求未正常释放锁的情况下,锁也会在过期后自动释放,确保不会出现“死锁”现象,系统始终能够继续处理其他请求。
使用 Redis 分布式锁 可以有效解决并发情况下的超卖问题,但存在一个潜在风险:如果锁的过期时间设置不合理,在业务操作未完成时,锁就自动过期并释放,其他并发请求可能会错误地获得锁并操作库存,最终导致超卖问题。
5.5 分布式锁+版本号
在高并发场景中,仅使用 Redis 分布式锁可能不足以完全保证库存的一致性,尤其在遇到锁过期、网络故障等问题时,都可能出现并发导致库存超卖。
在使用 Redis 锁的基础上,引入版本号控制,可以更好地防止超卖、并发冲突和过期锁带来的数据不一致问题。
Redis 锁与版本号的组合方案 能够为系统带来更高的可靠性和一致性保障,防止超卖
5.5.1 表设计
与单独使用版本号乐观锁保持一致
6 方案比较
库存条件控制和版本号机制:适用于并发量不高的情况。通过检查库存是否满足扣减条件,或者使用版本号控制更新时的一致性。然而,在高并发场景下,这些方法可能会频繁失败,导致用户体验和系统性能下降。
SELECT ... FOR UPDATE
:通过数据库行锁确保在任意时刻只有一个线程能够修改库存。这种方式能有效避免并发问题,但在高并发场景下可能导致性能瓶颈,因为大量线程会等待锁释放。Redis 分布式锁:通过 Redis 实现分布式锁,避免数据库行锁,确保同一时刻只有一个线程可以修改库存。这种方式能提高并发处理能力,但引入了第三方中间件,增加了系统的复杂性,需要设计更多的细节来确保锁的有效性和健壮性。