当前位置: 首页>后端>正文

秒杀的库存与限购设计

一、限购

对于像秒杀这种大流量、高并发的业务场景,不适合直接将全部流量打到库存系统,所以这个时候就需要有个系统能够承接大流量,并且只放和商品库存相匹配的请求量到库存系统,而限购就承担这样的角色。限购之于库存,就像秒杀之于下单,前者都是后者的过滤网和保护伞。

1.定义

限购的主要功能就是做商品的限制性购买。
因为参加秒杀活动的商品都是爆品、稀缺品,所以为了让更多的用户参与进来,并让有限的投放量惠及到更多的人,所以往往会对商品的售卖做限制,一般限制的维度主要包括两方面:

  • 商品维度限制
  • 个人维度限制

商品维度限制:最基本的限制就是商品活动库存的限制,即每次参加秒杀活动的商品投放量。如果再细分,还可以支持针对不同地区做投放的场景,比如我只想在北京、上海、广州、深圳这些一线城市投放,那么就只有收货地址是这些城市的用户才能参与抢购,而且各地区库存量是隔离的,互不影响。

个人维度限制:以个人维度来做限制,这里不单单指同一用户 ID,还会从同一手机号、同一收货地址、同一设备 IP 等维度来做限制。比如限制同一手机号每天只能下 1 单,每单只能购买 1 件,并且一个月内只能购买 2 件等。个人维度的限购,体现了秒杀的公平性。

2.流程

首先在限购系统中配置活动库存以及各种个人维度的限购策略;然后在用户提单时,走下限购系统,通过限购的请求,再去做真实库存的扣减,这个时候到库存系统的量已经是非常小了。

秒杀的库存与限购设计,第1张

二、库存扣减方案

库存的扣减主要涉及到两个核心操作:

  • 一个是查询商品库存。
  • 一个是在活动库存充足的情况下,做对应数量的扣减。

库存超卖的问题主要是由两个原因引起:

  • 一个是查询和扣减不是原子操作
  • 一个是并发引起的请求无序

解决:库存扣减的原子性和有序性

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.下单-初始化
        ......
    }

https://www.xamrdz.com/backend/3xn1940441.html

相关文章: