Redis解决秒杀中的超卖问题

前言

在上一篇,我们通过一个简单的案例,分享了怎么利用redis设计并实现一个秒杀抢购的功能,关于秒杀功能中,需要注意的比较关键的有两个问题

  • 高并发场景下,怎么确保不会超卖
  • 高并发场景下,如何确保一人一单

具体在设计的时候,需要结合实际的项目和业务场景,比如1000个人抢购50件商品的时候,抢购是一方面,抢购完毕之后还要下单,这个下单的业务是和抢购放在一起呢还是单独处理,需结合具体场景以及系统的架构进行分析

下面用一个更通俗的业务场景来比较全面的

业务场景描述

某些商家为了吸引客户进店消费,推出线上发放优惠券活动,抢到优惠券的用户消费时可以抵扣一定金额,

某商家在周五下午5点到6点之间发放50张优惠券,优惠券的使用有效期为一个月,而参与抢购的用户多达1000人

在支付宝或美团等平台上,商家可以提前将优惠券的信息通过后台系统进行录入(有的商家有自己的点餐APP,可以通过后退系统设置)
在这里插入图片描述

上面的这个业务,相信不少在支付宝推出的不定期抢购优惠券活动时都参与过,为方便演示,我们还是梳理下整体的业务流程方便理解

在这里插入图片描述

通过流程图,大致需要这样两步:

  • 添加活动信息,这个实际中由商家通过后台页面操作,本例提供一个接口实现
  • 在第一步操作完成后,抢购的时间,代金券的有效期,甚至代金券可以抵扣的金额等信息就确定了,就开始抢购的过程
  • 抢到代金券的用户锁定了名额,同时会增加一条抢购的活动订单

1、创建基本的表结构

上述流程中涉及到的表,活动代金券表,抢购订单表,用户表

活动代金券表

CREATE TABLE `skill_vochers` (
  `id` int(12) NOT NULL AUTO_INCREMENT,
  `vocher_id` int(12) DEFAULT NULL,
  `amount` int(12) DEFAULT NULL,
  `start_time` datetime DEFAULT NULL,
  `end_time` datetime DEFAULT NULL,
  `is_valid` int(11) DEFAULT NULL,
  `create_date` datetime DEFAULT NULL,
  `update_date` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;

抢购订单表

CREATE TABLE `vochers_orders` (
  `id` int(12) NOT NULL AUTO_INCREMENT,
  `order_no` varchar(255) DEFAULT NULL,
  `vocher_id` int(12) DEFAULT NULL,
  `user_id` int(12) DEFAULT NULL,
  `status` int(12) DEFAULT NULL,
  `skill_order_id` int(12) DEFAULT NULL,
  `order_type` int(12) DEFAULT NULL,
  `is_valid` int(11) DEFAULT NULL,
  `create_date` datetime DEFAULT NULL,
  `update_date` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=207 DEFAULT CHARSET=utf8;

用户表

CREATE TABLE `t_user` (
  `id` int(12) NOT NULL,
  `username` varchar(255) DEFAULT NULL,
  `password` varchar(255) DEFAULT NULL,
  `nickname` varchar(255) DEFAULT NULL,
  `phone` varchar(64) DEFAULT NULL,
  `email` varchar(64) DEFAULT NULL,
  `avatar_url` varchar(255) DEFAULT NULL,
  `create_date` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
  `update_date` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2、搭建一个springboot的demo工程

做好前置的配置工作(maven依赖,实体映射,yml配置文件等)后,这里直接使用上一篇的工程,继续添加内容即可

按照流程图上面所说,首先,需要一个添加抢购活动的接口

	@Resource
    private SkillVocherService skillVocherService;

	@PostMapping("/addSkillVocher")
    public String addSkillVocher(@RequestBody SkillVocher skillVocher){
        skillVocherService.add(skillVocher);
        return "add success";
    }
@Service
public class SkillVocherService {

    @Resource
    private SkillVocherMapper skillVocherMapper;

    @Resource
    private VocherOrderMapper vocherOrderMapper;

    private static AtomicInteger atomicInteger1 = new AtomicInteger(1);
    private static AtomicInteger atomicInteger2 = new AtomicInteger(1);

    public void add(SkillVocher skillVocher){
        SkillVocher skillVocher1 = skillVocherMapper.selectVocher(skillVocher.getVocherId());
        if(skillVocher1 != null){
            throw new RuntimeException("该券已经拥有了抢购活动");
        }
        skillVocher.setId(atomicInteger1.incrementAndGet());
        skillVocherMapper.save(skillVocher);
        System.out.println("添加成功");
    }
}

然后通过postman等接口调用工具,初始化一条数据到skill_vochers表,
在这里插入图片描述
在这里插入图片描述

3、秒杀功能实现关键代码

实际项目中,秒杀时需要考虑的因素是很多的,对于某个参与抢购的用户来讲,秒杀的逻辑中一定会有许多前置的校验,在流程图中也做了一些说明,只有充分考虑到多方面的校验,才不至于出现一些“意外”,毕竟最后都是跟钱挂钩的

下面贴出秒杀的关键代码,省略了部分的校验,

	@GetMapping("/doSkill")
    public String doSkill(int voucherId,int userId){
        return skillVocherService.doSkill(voucherId,userId);
    }
	public String doSkill(int vocherId,int userId){
        //1、判断代金券是否加入活动了
        SkillVocher skillVocher = skillVocherMapper.selectVocher(vocherId);
        if(skillVocher==null){
            throw new RuntimeException("该券未加入抢购活动");
        }
        //2、判断当前用户是否已经抢过
        VochersOrders defineOrder = vocherOrderMapper.findDefineOrder(userId, skillVocher.getId());
        if(defineOrder != null){
            throw new RuntimeException("该用户已经抢到过代金券,无需再抢了");
        }
        //3、扣减库存
        int count = skillVocherMapper.stockDecrease(skillVocher.getId());
        if(count ==0){
            throw new RuntimeException("该券已经卖完了");
        }
        //4、下单
        VochersOrders vochersOrders = new VochersOrders();
        vochersOrders.setId(atomicInteger2.incrementAndGet());
        vochersOrders.setUserId(userId);
        vochersOrders.setSkillOrderId(skillVocher.getId());
        vochersOrders.setVocherId(skillVocher.getVocherId());
        String orderNo = UUID.randomUUID().toString().replaceAll("-","").substring(4,9);
        vochersOrders.setOrderNo(orderNo);
        vochersOrders.setOrderType(1);
        vochersOrders.setStatus(0);
        int saveCount = vocherOrderMapper.save(vochersOrders);
        if(saveCount !=0){
            return "skill success";
        }else {
            return "skill fail";
        }
    }

这段代码,没有使用redis,放在单机环境下,如果部署高并发的环境,理论上也是没问题的,我们不妨测试下吧,将工程运行起来

浏览器调一下接口:localhost:8088/doSkill?voucherId=1&userId=1111

在这里插入图片描述
有个1111的用户抢到了一张券,券的数量减少1个
在这里插入图片描述
订单表多了一条记录
在这里插入图片描述
但是放在高并发环境下,将会出现什么问题呢?还记得在本文开篇提出的2个问题吗?超卖和一人抢多单的问题,下面通过jemeter模拟高并发环境下的情况

测试1:超卖问题复现

准备一个csv文件,保存了100个用户,将活动表的数量手动调整为50个,模拟100用户抢50张券

csv用户
在这里插入图片描述
jemeter相关配置

5000个线程并发抢购
在这里插入图片描述
设置请求参数
在这里插入图片描述
为模拟100个用户,这里的userId使用变量,数据读取从csv中读取
在这里插入图片描述
点击开始按钮,进行压测,观察数据表结果,

活动表的50张代金券变成-149张
在这里插入图片描述
订单表产生了199条订单
在这里插入图片描述
这个很明显发生了超卖,不用说,在高并发环境下,如果没有任何的控制,超卖问题必然会出现

测试2:一人抢多单

jemeter做简单的配置即可
在这里插入图片描述
在这里插入图片描述
点击开始压测,清空数据表再测试,

抢购活动表的优惠券数量只减少了1个,这个是正确的
在这里插入图片描述
订单表同一个用户却产生了31条订单,即一人多单的现象发生了
在这里插入图片描述
以上,通过压测复现了一下,在秒杀活动中,如果不对代码做任何的控制下,会产生的两个严重的问题,下面来探讨如何解决呢?

1、解决超卖问题

借助redis,和上一篇一样,为提升整体的性能,我们提前将待抢购的代金券信息放到redis中,因此改造添加活动代金券逻辑,主要将插入mysql表的操作放到插入到redis中

	public void add(SkillVocher skillVocher){
        String key = RedisKeyConstants.skill_vochers.getKey() + skillVocher.getVocherId();
        if(redisTemplate.hasKey(key)){
            redisTemplate.delete(key);
        }
        Map<String,Object> map = redisTemplate.opsForHash().entries(key);
        if(!map.isEmpty() && (int)map.get("amount") > 0){
            throw new RuntimeException("该券已经拥有了抢购活动");
        }
        Date now = new Date();
        skillVocher.setIsValid(1);
        skillVocher.setCreateDate(now);
        skillVocher.setUpdateDate(now);
        redisTemplate.opsForHash().putAll(key,BeanUtil.beanToMap(skillVocher));
    }

按照上面同样的方式调用一下接口,
在这里插入图片描述

同时改造秒杀抢购的逻辑,将需要查代金券库存的地方,修改为从redis中获取,下单的逻辑不变,

public String doSkill(int vocherId,int userId){
        //1、判断代金券是否加入活动了
        String key = RedisKeyConstants.skill_vochers.getKey() + vocherId;
        Map<String,Object> entries = redisTemplate.opsForHash().entries(key);
        SkillVocher skillVocher = BeanUtil.mapToBean(entries,SkillVocher.class,true);
        if(skillVocher==null){
            throw new RuntimeException("该券未加入抢购活动");
        }
        //2、判断当前用户是否已经抢过
        VochersOrders defineOrder = vocherOrderMapper.findDefineOrder(userId, skillVocher.getId());
        if(defineOrder != null){
            throw new RuntimeException("该用户已经抢到过代金券,无需再抢了");
        }
        //3、扣减库存
        long count = redisTemplate.opsForHash().increment(key, "amount", -1);
        if(count < 0){
            throw new RuntimeException("该券已经卖完了");
        }
        //4、下单
        VochersOrders vochersOrders = new VochersOrders();
        vochersOrders.setId(atomicInteger2.incrementAndGet());
        vochersOrders.setUserId(userId);
        vochersOrders.setSkillOrderId(skillVocher.getId());
        vochersOrders.setVocherId(skillVocher.getVocherId());
        String orderNo = UUID.randomUUID().toString().replaceAll("-","").substring(4,9);
        vochersOrders.setOrderNo(orderNo);
        vochersOrders.setOrderType(1);
        vochersOrders.setStatus(0);
        int saveCount = vocherOrderMapper.save(vochersOrders);
        if(saveCount !=0){
            return "skill success";
        }else {
            return "skill fail";
        }
    }

改造完毕之后,清空数据表,按照多人抢购50张券的jemeter配置,开始压测,观察数据表的变化,

订单表产生了50个订单,
在这里插入图片描述
活动表的代金券数量却出现了负数,即出现了超卖问题
在这里插入图片描述

究其原因,在抢购逻辑的关键处,对于redis来说是分成了2步,即先查库存,然后再扣减库存,高并发场景下,这可能是在不同的线程中执行的,即可能是非原子操作的,如果将它们合在一个线程中执行呢?按照这个猜想,在redis中,提供了lua脚本,可以利用lua脚本来解决这个问题(关于lua,有兴趣的同学可以网上自行学习下,语法和shell比较相似)
在这里插入图片描述
提供简单的lua脚本:

if (redis.call('hexists',KEYS[1],KEYS[2]) == 1) then
    local stock = tonumber(redis.call('hget',KEYS[1],KEYS[2]));
    if (stock > 0) then
        redis.call('hincrby',KEYS[1],KEYS[2],-1);
        return stock;
    end;
    return 0;
end;

简单解释下,这段脚本的意思是:

如果传入的两个参数中,同时包含了业务key,以及"amount"这个字段,首先根据这两个key查找优惠券的数量是否大于0,大于0的情况下,才进行数量的减一

下面改造秒杀的代码,主要将扣减库存的逻辑修改为使用lua脚本执行

redis配置类添加lua脚本的bean

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);

        //设置value的序列化方式为JSOn
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        //设置key的序列化方式为String
        redisTemplate.setKeySerializer(new StringRedisSerializer());

        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.afterPropertiesSet();

        return redisTemplate;
    }

    @Bean
    public DefaultRedisScript<Long> stockScript(){
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setLocation(new ClassPathResource("stock.lua"));
        redisScript.setResultType(Long.class);
        return redisScript;
    }

}
	public String doSkill(int vocherId,int userId){
        //1、判断代金券是否加入活动了
        String key = RedisKeyConstants.skill_vochers.getKey() + vocherId;
        Map<String,Object> entries = redisTemplate.opsForHash().entries(key);
        SkillVocher skillVocher = BeanUtil.mapToBean(entries,SkillVocher.class,true);
        if(skillVocher==null){
            throw new RuntimeException("该券未加入抢购活动");
        }
        //2、判断当前用户是否已经抢过
        VochersOrders defineOrder = vocherOrderMapper.findDefineOrder(userId, skillVocher.getId());
        if(defineOrder != null){
            throw new RuntimeException("该用户已经抢到过代金券,无需再抢了");
        }
        //3、扣减库存
        /*long count = redisTemplate.opsForHash().increment(key, "amount", -1);
        if(count < 0){
            throw new RuntimeException("该券已经卖完了");
        }*/
        //3、扣减库存采用 redis + lua
        List<String> keys = new ArrayList<>();
        keys.add(key);
        keys.add("amount");
        Long amount = (Long)redisTemplate.execute(defaultRedisScript, keys);
        if(amount == null || amount <1) {
            throw new RuntimeException("该券已经卖完了");
        }
        //4、下单
        VochersOrders vochersOrders = new VochersOrders();
        vochersOrders.setId(atomicInteger2.incrementAndGet());
        vochersOrders.setUserId(userId);
        vochersOrders.setSkillOrderId(skillVocher.getId());
        vochersOrders.setVocherId(skillVocher.getVocherId());
        String orderNo = UUID.randomUUID().toString().replaceAll("-","").substring(4,9);
        vochersOrders.setOrderNo(orderNo);
        vochersOrders.setOrderType(1);
        vochersOrders.setStatus(0);
        int saveCount = vocherOrderMapper.save(vochersOrders);
        if(saveCount !=0){
            return "skill success";
        }else {
            return "skill fail";
        }
    }

修改完毕后,我们使用jemeter相同的方式进行压测,观察redis中的数据变化和订单表的变化,

redis中 amount的数量变为0
在这里插入图片描述
订单表产生了50条数据
在这里插入图片描述
说到这里,可能还是有同学有点疑问,为什么使用了这种方式之后,就可以确保amount的数据和订单数据的正确呢?我们设想下,由于改进后的代码扣库存可以确保amount的数据没问题,就算是高并发多个线程同时走到扣库存这一段代码,由于redis执行命令是单线程的,总有一个线程进来的时候是抢完了的,一旦抢完了,就会进入到“该券已经卖完了”的异常中,从而下单的逻辑就进不去了
在这里插入图片描述
本篇通过代码结合压测的方式演示了高并发下秒杀存在的超卖问题,以及利用redis+lua解决超卖问题,篇幅比较长,有兴趣的同学可以深入研究,本篇到此结束,最后感谢观看!

需要源码的同学可前往下载
https://download.csdn.net/download/zhangcongyi420/15400165

猜你喜欢

转载自blog.csdn.net/zhangcongyi420/article/details/113872024