Redis秒杀功能设计与实现

前言

抢购问题不仅是电商类项目中一个重要的业务,也是许多开发人员在进阶过程中绕不开的问题,关于抢购,如果理清了前后的逻辑和里面涉及到的几个关键性的问题,问题就迎刃而解了

抢购中的几个常见问题

  • 如何设计抢购功能?(表结构,以及整体的抢购思路)
  • 不借助中间件如何实现抢购?(不借助redis)
  • 怎么利用redis解决抢购中的超卖问题
  • 怎么提升抢购的整体并发?

在这里插入图片描述

上图是抢购中的两个重要步骤,对于抢购用户,抽象来讲,服务端只需要完成对待抢购商品的锁定以及锁定后的下单操作即可

分开来说,商品名额锁定阶段,活动期间待抢购商品数量是有限的,参与抢购的用户数可能很大,因此必然存在高并发问题

既然存在高并发,为了提升整体的并发性能还能兼顾系统不至于崩溃,使用数据库作为秒杀抢购显然不合适,高并发场景下数据库IO将成为性能瓶颈,如此一来,参与抢购的活动涉及到的商品需借助redis来实现

抢购过程中,为保证公平,我们希望一个用户只能抢一单,但是在高并发场景下,用户数远大于商品数,如果逻辑控制的不合理,一定会出现超卖问题,即抢到商品的用户数大于实际参与抢购的商品数

来看一个简单的抢购案例

这里为简化业务,暂时先不操作数据库,下面的代码相信有过Java基础的同学都能看懂,模拟参与抢购的商品有10个,然后使用jemeter进行并发压测

SkillMapper模拟操作DB

public class SkillMapper {

    public static Integer count = 10;

    public Integer getCount() {
        return count;
    }

    public void updateCount(Integer count){
        SkillMapper.count = count;
    }
}

抢购业务service

@Service
public class SkillService {

    private SkillMapper skillMapper = new SkillMapper();

    public void skillProduct(){
        Integer count = skillMapper.getCount();
        if(count >0){
            System.out.println("恭喜你,抢到了");
            count = count-1;
            skillMapper.updateCount(count);
        }else {
            System.out.println("抱歉,商品抢完了");
        }
    }

}

提供一个接口

@RestController
public class SkillController {

    @Resource
    private SkillService skillService;

    //localhost:8088/skill
    @GetMapping("/skill")
    public String doSkill(){
        skillService.skillProduct();
        return "skill finish";
    }

}

启动工程,在浏览器反复调用:localhost:8088/skill
在这里插入图片描述
多调用几次后,最终会出现如下效果
在这里插入图片描述
在单进程下,这样是不会有问题的,下面在jemeter下,模拟使用100个用户进行调用
在这里插入图片描述
在这里插入图片描述
进行如上的配置后,重新启动工程,点击启动按钮,再次观察后台日志,异常现象产生了
在这里插入图片描述

如此,很多同学第一时间想到,加锁,搞定!想法没错,但注意这是抢购,在高并发环境下加锁相当占用整体的性能

如果不加锁呢?又期望同时兼顾性能问题,自然,redis成为首选,首先来看看如何使用redis配合完成抢购业务

在这里插入图片描述

整个抢购的业务和实现思路可以结合上面这张图理解,总结几点大概如下:

  • 将参与秒杀抢购的商品提前放入redis队列这里使用redis的list结构,为保证数据的可读取性,key的保存格式如: 活动ID:ProductId
  • 开始抢购时,每一个抢购成功的用户,从上面的队列中弹出一个数据
  • 同时,为保证后面下单能快速操作数据,将抢购成功的用户和商品保存至redis,使用redis的set结构,set集合可以确保数据不重复,从而可保证一人一单

从上面的流程图看,我们似乎并没有考虑多线程并发的问题,其实正想说的是,redis是单线程操作的,并发过来的请求到redis的list中取数据时,即便多个请求仍然要按照先后顺序在redis中单线程处理(结合自己的实际业务进行设计)

按照上面的思路和设计,下面开始编码过程(代码有需要可私信我)

1、创建一张业务抢购表

CREATE TABLE `t_promotion_seckill`  (
  `ps_id` bigint(20) NOT NULL AUTO_INCREMENT,
  `goods_id` int(11) NOT NULL,
  `ps_count` int(255) NOT NULL,
  `start_time` datetime(0) NULL DEFAULT NULL,
  `end_time` datetime(0) NULL DEFAULT NULL,
  `status` int(255) NULL DEFAULT NULL COMMENT '0-未开始 1-进行中  2-已结束',
  `current_price` float NOT NULL DEFAULT 0,
  PRIMARY KEY (`ps_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_unicode_ci ROW_FORMAT = Dynamic;

INSERT INTO `dataflow`.`t_promotion_seckill` (`ps_id`, `goods_id`, `ps_count`, `start_time`, `end_time`, `status`, `current_price`) VALUES ('1', '1101', '10', '2021-02-18 16:30:09', '2021-02-18 16:50:09', '0', '25');

2、搭建springboot工程,这里使用mybatis

整个项目结构如下:
在这里插入图片描述
由于代码较多,下面会对关键的部分做详细的说明,主要体现在如何结合redis完成抢购,即上面流程图中的右半边部分

首先我们在数据表中初始化一条数据:

简单说明下,将会对编号为1101的商品在16:30分到16:50分之间进行秒杀,秒杀数量为10个
在这里插入图片描述

为了将参与秒杀的商品提前放入redis的队列,这里使用一个简单的定时任务(也可以单独写个接口调用)

@Component
@EnableScheduling
public class SecKillTask {

    @Resource
    private PromotionSecKillMapper promotionSecKillMapper;

    @Resource
    private RedisTemplate redisTemplate;

    @Scheduled(cron = "0/5 * * * * ?")
    public void startSecKill(){
        List<PromotionSecKill> list  = promotionSecKillMapper.findUnstartSecKill();
        for(PromotionSecKill ps : list){
            System.out.println(ps.getPsId() + "秒杀活动启动");
            //删掉以前重复的活动任务缓存
            redisTemplate.delete("seckill:count:" + ps.getPsId());
            /**
             * 有多少库存商品,则初始化几个list对象
             * 实际业务中,可能是拿出部分商品参与秒杀活动,通过后台的界面进行设置
             */
            for(int i = 0 ; i < ps.getPsCount() ; i++){
                redisTemplate.opsForList().rightPush("seckill:count:" + ps.getPsId() , ps.getGoodsId());
            }
            ps.setStatus(1);
        }
    }

}

定时任务启动后,在redis中将会存在如下的初始化数据:
在这里插入图片描述
3、下面来看关键的抢购部分的逻辑

@Service
public class PromotionSecKillService {

    @Resource
    private PromotionSecKillMapper promotionSecKillMapper;

    @Resource
    private RedisTemplate redisTemplate;

    public void processSecKill(Long psId, String userid, Integer num) throws SecKillException {
        PromotionSecKill ps = promotionSecKillMapper.findById(psId);
        if (ps == null) {
            throw new SecKillException("秒杀活动不存在");
        }
        if (ps.getStatus() == 0) {
            throw new SecKillException("秒杀活动未开始");
        } else if (ps.getStatus() == 2) {
            throw new SecKillException("秒杀活动已结束");
        }
        Integer goodsId = (Integer) redisTemplate.opsForList().leftPop("seckill:count:" + ps.getPsId());
        if (goodsId != null) {
            //判断是否已经抢购过
            boolean isExisted = redisTemplate.opsForSet().isMember("seckill:users:" + ps.getPsId(), userid);
            if (!isExisted) {
                System.out.println("抢到商品啦,快去下单吧");
                redisTemplate.opsForSet().add("seckill:users:" + ps.getPsId(), userid);
            }else{
                redisTemplate.opsForList().rightPush("seckill:count:" + ps.getPsId(), ps.getGoodsId());
                throw new SecKillException("抱歉,您已经参加过此活动,请勿重复抢购!");
            }
        } else {
            throw new SecKillException("抱歉,该商品已被抢光,下次再来吧!");
        }
    }

}

在该段代码中,基本上是按照上述的业务流程图进行的,其中有一处值得注意,在高并发场景下,为了避免一个人抢到多个商品,在else逻辑中,我们使用了队列的补偿处理,这里也是恰好利用了redis的list特点

4、最后提供一个接口

	@GetMapping("/processKill")
    public Map processKill(Long psId , String userId) throws Exception{
        Map result = new HashMap();
        try {
            promotionSecKillService.processSecKill(psId , userId , 1);
            Map data = new HashMap();
            result.put("code", "200");
            result.put("message", "秒杀成功");
            result.put("data", data);
        } catch (SecKillException e) {
            result.put("code", "500");
            result.put("message", e.getMessage());
        }
        return result;
    }essKill(Long psid , String userid) throws Exception{
        Map result = new HashMap();
        try {
            promotionSecKillService.processSecKill(psid , userid , 1);
            Map data = new HashMap();
            result.put("code", "200");
            result.put("message", "秒杀成功");
            result.put("data", data);
        } catch (SecKillException e) {
            result.put("code", "500");
            result.put("message", e.getMessage());
        }
        return result;
    }

启动工程后,当看到redis中的数据初始化完毕后,接口调用:
localhost:8088/processKill?psId=1&userId=0001
在这里插入图片描述
当前0001的用户秒杀成功之后,将会从队列中删除一个商品
在这里插入图片描述
同时在set集合中保存了抢购用户的信息
在这里插入图片描述

5、测试在高并发环境下上面的功能是否好使

在jemeter中做如下的配置
在这里插入图片描述
为模拟100个用户抢购,这里使用一个外部的csv文件,文件内容如下:
在这里插入图片描述
在这里插入图片描述
配置完毕后,重新启动项目,并初始化相关的数据后,使用jemeter进行压测,观察后台打印日志

从后台日志看,redis队列中初始化的10个待秒杀的商品全部秒杀,并没有出现一个用户抢多单的现象
在这里插入图片描述
同时,redis的set集合中,也保存了对应抢到商品的10个用户
在这里插入图片描述

通过上面的案例演示,完成了使用redis进行秒杀抢购的功能,但是此案例我们省略了大量其他的业务,比如和优惠券结合,以及抢购后的下单操作,有兴趣的同学可以继续深入思考

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

猜你喜欢

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