一、限购
对于像秒杀这种大流量、高并发的业务场景,不适合直接将全部流量打到库存系统,所以这个时候就需要有个系统能够承接大流量,并且只放和商品库存相匹配的请求量到库存系统,而限购就承担这样的角色。限购之于库存,就像秒杀之于下单,前者都是后者的过滤网和保护伞。
1.定义
限购的主要功能就是做商品的限制性购买。
因为参加秒杀活动的商品都是爆品、稀缺品,所以为了让更多的用户参与进来,并让有限的投放量惠及到更多的人,所以往往会对商品的售卖做限制,一般限制的维度主要包括两方面:
- 商品维度限制
- 个人维度限制
商品维度限制:最基本的限制就是商品活动库存的限制,即每次参加秒杀活动的商品投放量。如果再细分,还可以支持针对不同地区做投放的场景,比如我只想在北京、上海、广州、深圳这些一线城市投放,那么就只有收货地址是这些城市的用户才能参与抢购,而且各地区库存量是隔离的,互不影响。
个人维度限制:以个人维度来做限制,这里不单单指同一用户 ID,还会从同一手机号、同一收货地址、同一设备 IP 等维度来做限制。比如限制同一手机号每天只能下 1 单,每单只能购买 1 件,并且一个月内只能购买 2 件等。个人维度的限购,体现了秒杀的公平性。
2.流程
首先在限购系统中配置活动库存以及各种个人维度的限购策略;然后在用户提单时,走下限购系统,通过限购的请求,再去做真实库存的扣减,这个时候到库存系统的量已经是非常小了。
二、库存扣减方案
库存的扣减主要涉及到两个核心操作:
- 一个是查询商品库存。
- 一个是在活动库存充足的情况下,做对应数量的扣减。
库存超卖的问题主要是由两个原因引起:
- 一个是查询和扣减不是原子操作
- 一个是并发引起的请求无序
解决:库存扣减的原子性和有序性
1.数据库的行锁机制
这种方式的优点是简单安全,但是其性能比较差,无法适用于秒杀业务场景,在请求量比较小的业务场景下,可以考虑。
2.分布式锁
通过 Redis 或者 ZooKeeper 来实现一个分布式锁,以商品维度来加锁,在获取到锁的线程中,按顺序去执行商品库存的查询和扣减,这样就同时实现了顺序性和原子性。
不管通过哪种方式实现的分布式锁,都是有弊端的。
以 Redis 的实现来说,仅仅在设置锁的有效期问题上,如果时间太短,那么业务程序还没有执行完,锁就自动释放了,这就失去了锁的作用;而如果时间偏长,一旦在释放锁的过程中出现异常,没能及时地释放,那么所有的业务线程都得阻塞等待直到锁自动失效,这与要实现高性能的秒杀系统是相悖的。所以通过分布式锁的方式可以实现,但不建议使用。
3.Redis Lua 脚本
Redis的Lua 脚本可以保证脚本中的所有逻辑会在一次执行中按顺序完成。在 Lua 脚本中,可以调用 Redis 的原生 API,这样就能同时满足顺序性和原子性的要求了。
因为 Lua 脚本并不会自动完成回滚操作,所以如果脚本逻辑中包含两步写操作,需要自己去做回滚。库存扣减的逻辑针对 Redis 的命令就两种,一个读一个写,并且写命令在最后,这样就不存在需要回滚的问题了。
三、使用LUA脚本实现库存扣减
实现 Redis 执行 Lua 脚本的命令有两个:
- 一个是 EVAL
- 一个是 EVALSHA
原生 EVAL 方法的使用语法:EVAL script numkeys key [key ...] arg [arg ...]
- EVAL:命令
- script:Lua 脚本的字符串形式
- numkeys:要传入的参数数量
- key:入参,可以传入多个
- arg:额外的入参
但这种方式需要每次都传入 Lua 脚本字符串,不仅浪费网络开销,同时 Redis 需要每次重新编译 Lua 脚本,对于追求性能极限的系统来说,不是很完美。
EVALSHA语法:EVALSHA sha1 numkeys key [key ...] arg [arg ...]
与 EVAL 类似,不同的是这里传入的不是脚本字符串,而是一个加密串 sha1。sha1通过另一个命令 SCRIPT LOAD 返回的,该命令是预加载脚本用的,语法为:SCRIPT LOAD script
通过预加载命令,将 Lua 脚本先存储在 Redis 中,并返回一个 sha1,下次要执行对应脚本时,只需要传入 sha1 即可执行对应的脚本。这完美地解决了 EVAL 命令存在的弊端。
1.脚本编写
查询库存并判断库存是否充足,如果充足,则做相应的扣减操作:
-- 调用Redis的get指令,查询活动库存,其中KEYS[1]为传入的参数1,即库存key
local c_s = redis.call('get', KEYS[1])
-- 判断活动库存是否充足,其中KEYS[2]为传入的参数2,即当前抢购数量
if not c_s or tonumber(c_s) < tonumber(KEYS[2]) then
return 0
end
-- 如果活动库存充足,则进行扣减操作。其中KEYS[2]为传入的参数2,即当前抢购数量
redis.call('decrby',KEYS[1], KEYS[2])
2.添加脚本预加载机制
预加载方式:
- 方式一:外部预加载好,生成了 sha1 然后配置到配置中心,这样 Java 代码从配置中心拉取最新 sha1 即可
- 方式二:在服务启动时,来完成脚本的预加载,并生成单机全局变量 sha1。
①将 Lua 脚本转成字符串形式,并通过 @PostConstruct 完成脚本的预加载:
@Component
public class RedisTools {
@Autowired
JedisPool jedisPool;
Logger Logger = LogManager.getLogger(RedisTools.class);
/**
* Luo逻辑:首先判断活动库存是否存在,以及库存余量是否够本次购买数量,如果不够,则返回0,如果够则完成扣减并返回1
* 两个入参,
* KEYS[1] : 活动库存的key
* KEYS[2] : 活动库存的扣减数量
*/
private String STORE_DEDUCTION_SCRIPT_LUA =
"local c_s = redis.call('get', KEYS[1])\n" +"if not c_s or tonumber(c_s) < tonumber(KEYS[2]) then\n" + "return 0\n" +
"end\n"+
"redis.call('decrby',KEYS[1],KEYS[2])\n"+"return 1";
//在系统启动时,将脚本预加载到Redis中,并返回一个加密的字符串,下次只要传该加密窜,即可执行对应脚本,减少了Redis的预编译
private String STORE_DEDUCTION_SCRIPT_SHA1 = "";
@PostConstruct
public void init(){
try (Jedis jedis = jedisPool.getResource()) {
String sha1 = jedis.scriptLoad(STORE_DEDUCTION_SCRIPT_LUA);
logger.error("生成的shal:" + sha1);
STORE_DEDUCTION_SCRIPT_SHA1 = sha1;
}
}
}
②新增 EVALSHA 方法
/**
* 调用Lua脚本,不需要每次都传入Lua脚本,只需要传入预编译返回的sha1即可
* @param key
* @param buyNum
* @return
*/
public Long evalsha(String key,String buyNum) {
try (Jedis jedis = jedisPool.getResource()) {
Object obj = jedis.evalsha(STORE_DEDUCTION_SCRIPT_SHA1, 2, key, buyNum);
//脚本中返回的结果是0或1,表示失败或者成功
return (Long)obj;
}
}
③方法入参为活动商品库存 key 以及单次抢购数量,并在内部调用 Lua 脚本执行库存扣减操作:
@Autowired
RedisTools redisTools;
@Override
public String submitOrder(SettlementOrderDTO orderDT0) {
//1.校验商品标识
//2.限购
Long count = redisTools.evalsha("store_"+orderDT0.getProductId(),String.value0f(orderDT0.getBuyNum()));
logger.error(orderDTo.getUserId()+"限购结果:"+count);
if(count==null || count<=0){
return null;
}
//3.下单-初始化
......
}