秒杀库存扣减方案
# 前言
目前市面上秒杀课题常见的解决方案都是资格判断和订单落库解耦的结构,本文讨论主题主要聚焦在用户下单资格判断。
假定需求包含以下部分:
- 存库限制
- 一人一单
基于这俩点需求我做了一些思考和尝试。
# 分布式锁
首先是分布式锁的方案,这个也蛮常见的,就是通过加锁来进行判断资格和库存扣减,代码示例如下:
@GetMapping("/seckill/{id}")
public Object secKill(@PathVariable Long id) {
Long userId = getUserId();
// 加锁
RLock lock = redisService.getLock("seckill:lock:" + id);
if (lock.tryLock()) {
try {
// 查库存
Integer stock = redisService.get("seckill:stock:" + id);
if (stock != null && stock > 0) {
// 查看当前用户是否下单
boolean havOrder = redisService.hasSetElement("seckill:order:" + id, userId);
if (havOrder) {
return "不能重复下单";
}
// 减库存
redisService.set("seckill:stock:" + id, --stock);
// 添加用户下单记录
redisService.getSet("seckill:order:" + id).add(userId);
// TODO 加入消息队列异步落库
return "下单成功";
} else {
return "库存不足";
}
} catch (Exception e) {
return "下单异常";
} finally {
lock.unlock();
}
}
return "系统繁忙,请稍后重试";
}
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
这里redisService是一个基于redisson二次开发的工具类,锁是分布式锁。
# 测试情况
配置:100库存,1000个线程,携带不同token。
断言:“系统繁忙,请稍后重试”为异常响应。
库存扣减情况

下单记录
可以看到1k人同时下单,只有29人下单成功。
jmeter报告
乍一看好像数据表现不错,吞吐量是很高,但是异常率也很高。可见这个方案存在几个问题:
- 接口有效响应率低:有效响应指的是“系统繁忙,请稍后重试”以外的响应,用户下单,每次都是这个提示,看的人想痛骂产品经理,用户体验不太好。
- 存在原子性问题:减库存和添加用户下单记录是两次redis会话,spring的事务默认是不支持redis的,所以极端情况下是存在原子性问题的。
# Lua脚本
针对上一个方案存在的问题,lua脚本可以很好的解决。
这里安利一个IDEA的Lua脚本插件叫
EmmyLua
语法参考:https://www.runoob.com/lua/lua-tutorial.html (opens new window)
脚本编写
-- 判断是否有库存 get stockKey
local stock = redis.call('get', KEYS[1])
-- 如果为key空
if stock == false then
return 1
end
-- 如果没库存
if tonumber(stock) <= 0 then
return 1
end
-- 校验是否已经下单 SISMEMBER orderKey userId
if (redis.call('sismember', KEYS[2], ARGV[1]) == 1) then
return 2
end
-- 扣减库存 INCRBY stockKey -1
redis.call('incrby', KEYS[1], -1)
-- 增加下单记录 SADD orderKey userId
redis.call('sadd', KEYS[2], ARGV[1])
return 0
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
稍微解释一下:
Redis执行Lua脚本的基本语法是EVAL script numkeys [key [key ...]] [arg [arg ...]]
比如我想执行SET name 张三这条指令,通过脚本执行就是:
EVAL "redis.call('set', KEYS[1], ARGV[1])" 1 name 张三
KEYS和ARGV是Redis这边规定的特殊占位符- script是脚本内容
- numkeys是
KEYS的数量,上面的1就是只有一个参数是Key - 后面跟的就是入参了,redis会通过key的数量判断前面多少个是KEYS,后面的就是ARGV了
程序编写
private static String seckillLuaScript;
static {
// 加载脚本文件
try {
InputStream inputStream = new ClassPathResource("lua/seckill.lua").getInputStream();
seckillLuaScript = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@GetMapping("/seckill/{id}")
public Object secKill(@PathVariable Long id) {
Long userId = getUserId();
// 拼接Lua脚本入参
String stockKey = "seckill:stock:" + id;
String orderKey = "seckill:order:" + id;
// 执行Lua脚本
RScript luaScript = redisService.getLuaScript();
Long result = luaScript.eval(RScript.Mode.READ_WRITE,
seckillLuaScript, RScript.ReturnType.VALUE,
List.of(stockKey, orderKey), userId);
if (eval == 1) {
return "库存不足";
}
if (eval == 2) {
return "不能重复下单";
}
if (eval == 0) {
// TODO 加入消息队列异步落库
return "下单成功";
}
return "系统繁忙,请稍后重试";
}
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
34
35
这里用的是Redisson来执行Lua脚本。
# 测试情况
配置:100库存,1000个线程,携带不同token。
断言:“系统繁忙,请稍后重试”为异常响应。
库存扣减情况

下单记录
可以看到1k人同时下单,库存完美清空。
jmeter报告

在保证吞吐量的情况下还做到了0异常,完美。
# 关于落库
落库的方案也有很多,其实就是MQ的一个选型,然后如何保证消息幂等性和可靠性,可能会存在一些极端情况导致落库失败,或者说是一个实时性的问题。这个结果无非就是库存扣减,用户下单成功但没产生订单。要么是后续与下单情况做一个比对,然后检测自动补偿,要么是等用户打客服反馈然后手动干预补偿,后者可能就有点内啥了哈哈哈,总之详细的一个实现或者思路等我哪天有想法了再补上吧。