【高并发实战】Redis + Lua 脚本解决秒杀超卖问题(从第一版到第二版的演进)
·
前言
在电商秒杀、抢红包等高并发场景中,“超卖”是最经典也是最棘手的问题之一。今天我们就通过一个真实的秒杀案例,看看如何从有问题的第一版迭代到原子操作的 Lua 脚本第二版,彻底解决超卖难题。
1. 场景描述
我们要实现一个简单的秒杀功能:
- 商品库存存储在 Redis 中(如
stock:product:1001 = 10) - 用户请求秒杀接口
- 如果库存充足,扣减库存并返回成功
- 如果库存不足,返回已售罄
2. 第一版实现:分步操作(存在超卖风险)
2.1 代码实现
@PostMapping("/{productId}")
public String seckill(@PathVariable String productId,
@RequestParam String userId) {
String stockKey = "stock:product:" + productId;
// 第一步:查询库存
String stockStr = redisTemplate.opsForValue().get(stockKey);
// 第二步:判断库存
if (stockStr == null || Integer.parseInt(stockStr) <= 0) {
return "已售罄";
}
// 第三步:扣减库存
Long remaining = redisTemplate.opsForValue().decrement(stockKey);
if (remaining < 0) {
// 回滚
redisTemplate.opsForValue().increment(stockKey);
return "已售罄";
}
return "用户 " + userId + " 秒杀成功!";
}
2.2 问题分析
这段代码在低并发下运行正常,但在高并发场景下会出现超卖。
时间线演示:
假设库存只有 1 件,两个请求同时到达:
| 时间点 | 请求A | 请求B | 库存值 |
|---|---|---|---|
| T1 | get → 得到 1 | - | 1 |
| T2 | - | get → 得到 1 | 1 |
| T3 | decrement → 变成 0 | - | 0 |
| T4 | - | decrement → 变成 -1 | -1 |
结果: 两个请求都成功了,但库存变成了 -1,这就是典型的超卖!
核心原因:
“查库存” 和 “扣库存” 是两个独立的操作,中间有时间窗口,不具备原子性。
3. 第二版实现:Lua 脚本(原子操作,解决超卖)
3.1 Lua 脚本详解
我们将"查库存"和"扣库存"打包成一个 Lua 脚本:
local stock = tonumber(redis.call('get', KEYS[1]))
if stock == nil or stock <= 0 then
return 0
end
redis.call('decr', KEYS[1])
return 1
逐行解析:
-
查询库存
local stock = tonumber(redis.call('get', KEYS[1]))KEYS[1]:传入的库存 keyredis.call('get', ...):执行 Redis GET 命令tonumber():将字符串转为数字
-
判断库存是否充足
if stock == nil or stock <= 0 then return 0 end- 如果库存为
nil(不存在)或<= 0,返回0表示失败
- 如果库存为
-
扣减库存
redis.call('decr', KEYS[1])- 执行 Redis DECR 命令,库存减 1
-
返回成功
return 1- 返回
1表示秒杀成功
- 返回
3.2 Java 代码实现
@RestController
@RequestMapping("/seckill")
public class SeckillController {
private final StringRedisTemplate redisTemplate;
// Lua脚本:查库存 + 扣库存,原子执行
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setResultType(Long.class);
SECKILL_SCRIPT.setScriptText(
"local stock = tonumber(redis.call('get', KEYS[1])) " +
"if stock == nil or stock <= 0 then " +
" return 0 " +
"end " +
"redis.call('decr', KEYS[1]) " +
"return 1"
);
}
public SeckillController(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 秒杀接口 —— Lua脚本原子操作
*/
@PostMapping("/{productId}")
public String seckill(@PathVariable String productId,
@RequestParam String userId) {
String stockKey = "stock:product:" + productId;
Long result = redisTemplate.execute(
SECKILL_SCRIPT,
Collections.singletonList(stockKey)
);
if (result == null || result == 0) {
return "已售罄";
}
return "用户 " + userId + " 秒杀成功!";
}
}
3.3 为什么 Lua 脚本能解决超卖?
核心原理:原子性执行
Redis 网络 I/O 可以多线程,但命令执行队列是单线程串行的,它会把整个 Lua 脚本当作一个不可分割的整体来执行。在执行期间,不会插入其他客户端的命令。
时间线演示(使用 Lua 脚本后):
| 时间点 | 请求A | 请求B | 库存值 |
|---|---|---|---|
| T1 | 开始执行 Lua 脚本(获得锁) | - | 1 |
| T2 | get → 1 | (等待) | 1 |
| T3 | decr → 0 | (等待) | 0 |
| T4 | 返回 1,脚本结束 | - | 0 |
| T5 | - | 开始执行 Lua 脚本 | 0 |
| T6 | - | get → 0 | 0 |
| T7 | - | 判断 <= 0,返回 0 | 0 |
结果: 只有请求 A 成功,请求 B 被正确拦截,库存保持为 0,没有超卖!
4. 知识点总结
4.1 Redis 常用操作
// 设置库存
redisTemplate.opsForValue().set("stock:product:1001", "10");
// 查询库存
String stock = redisTemplate.opsForValue().get("stock:product:1001");
// 自减(等价于 Lua 中的 decr)
redisTemplate.opsForValue().decrement("stock:product:1001");
4.2 Lua 脚本的优势
- 原子性:整个脚本执行期间不会被中断
- 减少网络开销:一次请求完成多个操作
- 代码复用:脚本可以缓存和重复使用
4.3 适用场景
- 秒杀/抢购
- 库存扣减
- 计数器限流
- 分布式锁
5. 思考题
如果现在要求每个用户只能秒杀一次,这个 Lua 脚本应该如何修改?
提示: 可以使用 Redis 的 Set 结构记录已秒杀的用户 ID。
欢迎在评论区留下你的答案!👇
结语
通过从第一版到第二版的演进,我们看到了如何利用 Redis + Lua 脚本优雅地解决高并发下的超卖问题。在实际生产中,还可以结合消息队列、数据库乐观锁等方案进一步提升系统的可靠性。
如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、转发!有任何问题也欢迎在评论区交流~
标签: #Redis #Lua #高并发 #秒杀 #超卖 #SpringBoot #后端开发
更多推荐




所有评论(0)