在高并发的电商或票务系统中,库存校验与扣减是关键业务逻辑之一,尤其在类似于 12306 这样的抢票场景中,如何在高并发条件下保证库存的正确性、避免超卖和确保数据一致性,是非常具有挑战性的。下面我将详细描述库存校验与扣减的逻辑,包括如何在高并发场景中避免数据不一致、如何通过分布式锁来控制并发、以及如何设计有效的系统架构。
1. 库存扣减的基本流程
库存的扣减一般包括两个步骤:库存校验 和 库存扣减,在高并发场景下,这两个步骤需要保持原子性,以防止出现超卖、库存不一致等问题。
流程图:
- 用户请求购买某种商品或车票。
- 系统检查该商品或车票的库存是否充足。
- 如果库存充足,系统扣减库存,并完成购买。
- 如果库存不足,返回失败。
但在高并发的情况下,直接使用数据库的 事务 可能会遇到性能瓶颈,因此通常会结合缓存、队列等机制来优化性能。
2. 高并发下的库存扣减问题
在高并发场景下,多个用户可能同时发起相同商品或车票的购买请求。若没有适当的并发控制措施,就可能出现以下几种问题:
- 超卖:多个请求在判断库存时,都认为库存足够,于是都成功扣减库存,导致超卖。
- 数据不一致:当多个请求并发操作库存时,可能导致库存信息的不一致或丢失。
- 性能瓶颈:高并发请求直接访问数据库可能会引发性能瓶颈,导致系统崩溃。
因此,我们需要设计合适的 库存校验 和 扣减 机制,确保在高并发情况下既能保证库存的正确性,又能保证系统的性能和可靠性。
3. 库存校验与扣减的解决方案
3.1 基于 Redis 实现库存的乐观锁
Redis 提供的 乐观锁(Optimistic Locking)机制适合高并发的库存扣减。使用 Redis 中的 watch 和 multi/exec 事务来实现。
乐观锁的核心思想是:在操作之前,不对库存进行加锁,而是进行校验并尝试扣减库存,如果发现库存已被修改,则操作失败。
实现步骤:
- 检查库存:首先检查 Redis 缓存中的库存信息。
- 乐观锁控制:使用
WATCH
监视 Redis 中的库存信息(比如车票的余票数)。如果库存信息没有发生变化,则执行扣减操作。 - 扣减库存:使用 MULTI 和 EXEC 命令将扣减库存操作提交给 Redis 执行。如果在执行时发现库存已被修改,操作会失败,必须重新进行库存校验和扣减。
代码示例(使用 Redis Watch):
// 获取 Redis 连接
Jedis jedis = jedisPool.getResource();
// 监视库存
jedis.watch("train:12306:train1234:tickets");
// 获取当前库存
int tickets = Integer.parseInt(jedis.get("train:12306:train1234:tickets"));
// 校验库存是否足够
if (tickets > 0) {
// 使用事务扣减库存
Transaction tx = jedis.multi();
tx.decrBy("train:12306:train1234:tickets", 1); // 扣减库存
List<Object> result = tx.exec(); // 提交事务
if (result == null) {
// 库存已被其他操作修改,重新进行抢票操作
System.out.println("库存不足,重试!");
} else {
System.out.println("抢票成功!");
}
} else {
System.out.println("库存不足!");
}
注意:这种方式的关键在于 WATCH 监视的过程。如果在操作过程中库存被其他用户修改了,当前的事务会返回 null
,需要重新尝试。乐观锁可以有效减少锁的使用,但需要在 exec 时进行重试机制。
3.2 基于 Redis 实现库存的悲观锁
悲观锁(Pessimistic Locking)是一种在操作前先对数据加锁的机制,适合高并发且要求严格一致性的场景。
在 Redis 中,SETNX(set if not exists)命令可以实现类似悲观锁的效果。利用这个命令可以确保同一时刻只有一个请求能对库存进行操作。
实现步骤:
- 获取锁:在操作库存之前,先尝试通过 Redis 的
SETNX
命令加锁,只有获取到锁的请求才能继续扣减库存。 - 扣减库存:如果获取到锁,扣减库存并处理业务逻辑。操作完成后释放锁。
- 释放锁:操作完成后,释放锁。
代码示例(使用 Redis SETNX 实现悲观锁):
String lockKey = "lock:train:12306:train1234";
String lockValue = UUID.randomUUID().toString();
// 尝试加锁
boolean locked = jedis.setnx(lockKey, lockValue) == 1;
if (locked) {
try {
// 扣减库存操作
int tickets = Integer.parseInt(jedis.get("train:12306:train1234:tickets"));
if (tickets > 0) {
jedis.decrBy("train:12306:train1234:tickets", 1); // 扣减库存
System.out.println("抢票成功!");
} else {
System.out.println("库存不足!");
}
} finally {
// 释放锁
if (jedis.get(lockKey).equals(lockValue)) {
jedis.del(lockKey); // 只有持有锁的线程才能删除锁
}
}
} else {
System.out.println("获取锁失败,稍后再试!");
}
注意:悲观锁的缺点是可能导致死锁,特别是在系统出现异常或线程未正常释放锁时。为防止死锁,可以设置锁的超时机制,确保锁在一定时间内自动释放。
3.3 队列化请求处理
对于极高并发的场景,队列化处理也是一种常见的解决方案。通过 Redis 或消息队列(如 Kafka、RabbitMQ)将用户请求进行排队,逐个处理请求。这样可以将多个并发请求转化为串行化的操作,避免直接在数据库或 Redis 上进行高并发操作。
实现步骤:
- 请求排队:将所有抢票请求按照到达顺序放入 Redis 的队列中(可以使用 List 或 Sorted Set)。
- 逐个处理:后端系统按顺序处理队列中的请求,每次从队列中取出一个请求,进行库存校验和扣减操作。
代码示例:
// 用户请求加入队列
redisTemplate.opsForList().leftPush("queue:train1234", userId);
// 从队列中逐个处理
String userId = redisTemplate.opsForList().rightPop("queue:train1234");
int tickets = Integer.parseInt(redisTemplate.opsForValue().get("train:12306:train1234:tickets"));
if (tickets > 0) {
redisTemplate.opsForValue().decrement("train:12306:train1234:tickets", 1);
System.out.println("抢票成功!");
} else {
System.out.println("库存不足!");
}
4. 最终一致性方案:异步处理
在一些场景下,可能不需要强一致性,而是可以容忍一些短暂的错误或延迟。此时,可以使用异步处理方式,来将库存扣减的操作放入消息队列中异步执行,最终通过定时任务来同步库存。
这种方式可以减轻系统的实时压力,但需要在后台处理完成后保证最终一致性。
总结:
在高并发的库存校验和扣减场景下,常见的解决方案包括:
- 乐观锁:利用 Redis 的
WATCH
和事务机制实现。适合高并发场景,可以减少锁的使用,但需要处理重试逻辑。 - 悲观锁:通过 Redis 的
SETNX
实现锁的机制,适合强一致性要求的场景,但可能会遇到死锁问题,需要设置超时机制。 - 请求排队:将并发请求排队处理,避免并发冲突,适合流量高峰时使用。
- 异步处理:通过消息队列等方式异步处理库存扣减,适合延迟一致性要求较低的场景。
5.lua脚本实现
使用 Lua 脚本 是解决 Redis 中高并发库存查询与扣减问题的另一个有效方案。通过 Lua 脚本的原子性,能够确保在执行查询和库存扣减操作时,不会被其他并发请求打断,从而避免了竞争条件和超卖的问题。
Lua 脚本的优势
- 原子性:Redis 中的 Lua 脚本是原子执行的,即脚本中的所有操作要么全部成功,要么全部失败,在执行过程中不会被其他命令中断。
- 性能:Lua 脚本执行是由 Redis 服务器直接处理的,不需要客户端和服务器之间进行多次交互,因此比单独执行多个 Redis 命令更高效。
- 减少网络延迟:通过将多个操作打包成一个脚本执行,减少了多次请求和响应的网络延迟。
实现思路
通过 Lua 脚本可以同时完成以下操作:
- 查询库存是否足够。
- 如果库存足够,扣减库存。
- 如果库存不足,返回失败信息。
Lua 脚本示例
下面是一个 Lua 脚本示例,该脚本会首先检查 Redis 中的库存,如果库存大于零,就扣减库存;如果库存不足,返回 false
或者 库存不足
的错误信息。
-- Lua 脚本:查询库存并扣减
-- KEYS[1]:库存的 Redis 键
-- ARGV[1]:扣减的数量
local stock = tonumber(redis.call('GET', KEYS[1])) -- 获取库存数量
local decrement = tonumber(ARGV[1]) -- 扣减数量
if stock >= decrement then
redis.call('DECRBY', KEYS[1], decrement) -- 扣减库存
return true -- 返回成功
else
return false -- 返回失败(库存不足)
end
解释
KEYS[1]
:代表库存数据在 Redis 中的键名(例如:"train:12306:train1234:tickets"
)。ARGV[1]
:代表扣减的数量(通常为 1,即每次扣减一个票)。redis.call('GET', KEYS[1])
:获取当前库存数量。redis.call('DECRBY', KEYS[1], decrement)
:扣减库存。if stock >= decrement
:检查库存是否足够,如果库存大于或等于扣减数量,则继续扣减,否则返回false
,表示库存不足。
如何在 Redis 客户端中调用这个 Lua 脚本
在客户端调用这个 Lua 脚本时,我们需要使用 Redis 提供的 EVAL
命令来执行脚本。
假设我们使用的是 Java 客户端(Jedis)来调用该脚本:
// 使用 Jedis 执行 Lua 脚本
Jedis jedis = jedisPool.getResource();
// 定义 Lua 脚本
String luaScript =
"local stock = tonumber(redis.call('GET', KEYS[1])) " +
"local decrement = tonumber(ARGV[1]) " +
"if stock >= decrement then " +
" redis.call('DECRBY', KEYS[1], decrement) " +
" return true " +
"else " +
" return false " +
"end";
// 库存键名
String stockKey = "train:12306:train1234:tickets";
// 扣减数量
int decrement = 1;
// 执行 Lua 脚本
Object result = jedis.eval(luaScript, 1, stockKey, String.valueOf(decrement));
// 根据脚本返回结果判断是否扣减成功
if (Boolean.TRUE.equals(result)) {
System.out.println("抢票成功!");
} else {
System.out.println("库存不足!");
}
解释
-
jedis.eval(luaScript, 1, stockKey, String.valueOf(decrement))
:执行 Lua 脚本,其中:luaScript
:包含 Lua 脚本的字符串。1
:表示 Lua 脚本中使用了 1 个键(KEYS[1]
)。stockKey
:库存的 Redis 键名。String.valueOf(decrement)
:扣减的数量(如 1)。
-
返回值:
- 如果脚本返回
true
,则表示扣减库存成功,打印"抢票成功!"
。 - 如果脚本返回
false
,则表示库存不足,打印"库存不足!"
。
- 如果脚本返回
多次调用 Lua 脚本的优化
如果你有多次并发请求需要处理,可以将扣减库存的操作封装到 Lua 脚本中,避免在客户端和 Redis 之间进行多次交互。例如,假设你需要同时扣减多个商品或多个车票的库存,可以在 Lua 脚本中循环处理多个商品或票的扣减。
Lua 脚本的更多扩展
-
增加库存操作: 如果你需要在库存不足时执行库存补充操作,可以在 Lua 脚本中增加对库存的
INCRBY
操作,动态调整库存。 -
过期时间控制: 可以在脚本中增加对库存键的过期时间控制,确保库存的键在一段时间后自动失效,避免长时间存在的过期数据。
-
分布式锁: 可以结合 Lua 脚本和分布式锁的策略,比如使用 Redis 的
SETNX
来保证只有一个请求能够进入库存操作,避免死锁。
总结
通过 Lua 脚本实现库存查询与扣减的方案,能够保证操作的原子性和高效性,尤其适用于高并发场景。借助 Redis 脚本,能够将查询库存和扣减库存等操作在单次网络请求中完成,大大减少了潜在的竞争条件,避免了超卖的风险。