畅购商城(十三):秒杀系统「上」

好好学习,每天向上前端

本文已收录至个人Github仓库DayDayUP:github.com/RobodLee/DayDayUP,欢迎Starjava

流程分析

上面这张图是整个秒杀系统的流程。简单介绍一下:git

秒杀是一个并发量很大的系统,数据吞吐量都很大,MySQL的数据是保存在硬盘中的,数据吞吐的能力知足不了整个秒杀系统的需求。为了提升系统的访问速度,咱们定时将秒杀商品从MySQL加载进Redis,由于Redis的数据是保存在内存中的,速度很是快,能够知足很高的吞吐量。github

用户访问秒杀系统,请求到了OpenResty,OpenResty从Redis中加载秒杀商品,而后用户来到了秒杀列表页。当用户点击某个秒杀商品时,OpenResty再从Redis中加载秒杀商品详情信息,接着用户就来到了秒杀商品详情页。redis

当进入到商品详情页以后用户就能够点击下单了,点击下单的时候,OpenResty会检查商品是否还有库存,没有库存就下单失败。有库存的话还须要检查一下用户是否登陆,没有登陆的话再到OAuth2.0认证服务那边去登陆,登陆成功后再进入到秒杀微服务中,开始正式的下单流程。sql

理论上这时候还要对用户进行一些合法性检测,好比帐号是否异常等,可是这太耗时了,为了减小系统响应的时间,用户检测这一步先省略。直接让用户进行排队,排队就是将用户id和商品id存入Redis队列,成功排队后给用户返回一个 “正在排队”的信息。数据库

当排队成功后就开启多线程抢单,为每一个排队的用户分配一个线程。在排队用户本身的线程中开始检测帐号的状态是否正常,而后从Redis中检测库存时候足够,当全部条件都知足的时候,下单成功,将订单信息存入Redis。并将Redis中的排队信息从“排队中”改成“待支付”,这样前端在查询状态的时候就知道能够开始支付了,而后跳转到支付页面进行支付。当用户支付成功后,将抢单信息从Redis中删除,并同步到MySQL中。后端

最后一个问题,有的用户成功抢单后并不去付款,因此咱们须要定时去处理未支付的订单。方案和上一篇文章中提到的同样,使用RabbitMQ死信队列。在抢单成功后将订单id、用户id和商品id存到RabbitMQ的队列1,设置半个小时后过时,过时后将信息发送给队列2,咱们去监听队列2。当监听到队列2中的消息的时候,说明半个小时已经到了,这时候咱们再去Redis中查询订单的状态,若是已经支付了就不去管它;若是没有支付就向微信服务器发送请求关闭支付,而后回滚库存,并将Redis中的抢单信息删除。服务器

这样整个秒杀流程就结束了。微信

定时任务

怎么搭建秒杀微服务就不记录了,没什么好说的,秒杀微服务名为changgou-service-seckill。定时任务我也是第一次接触,因此在这里记录一下。

首先在启动类上添加一个注解@EnableScheduling去开始对定时任务的支持。而后建立一个类SeckillGoodsPushTask,在这个类上添加@Component注解,将其注入Spring容器。而后再添加一个方法,加上@Scheduled注解,声明这个方法是一个定时任务。

/**
 * SeckillGoodsPushTask
 * 定时将秒杀商品加载到redis中
 */
@Scheduled(cron = "0/5 * * * * ?")
public void loadGoodsPushRedis() {
    List<Date> dateMenu = DateUtil.getDateMenus();
    for (Date date : dateMenu) {
        date.setYear(2019-1900);    //2019-6-1 为了方便测试
        date.setMonth(6-1);
        date.setDate(1);
        String dateString = SystemConstants.SEC_KILL_GOODS_PREFIX +DateUtil.data2str(date,"yyyyMMddHH");
        BoundHashOperations boundHashOperations = redisTemplate.boundHashOps(dateString);
        Set<Long> keys = boundHashOperations.keys();	//获取Redis中已有的商品的id集合
        List<SeckillGoods> seckillGoods;
        //将秒杀商品的信息从数据库中加载出来
        if (keys!=null && keys.size()>0) {
            seckillGoods = mapper.findSeckillGoodsNotIn(date,keys);	
        } else {
             seckillGoods = mapper.findSeckillGoods(date);
        }
        //遍历秒杀商品集合,将商品依次放入Redis中
        for (SeckillGoods seckillGood : seckillGoods) {
            boundHashOperations.put(seckillGood.getId(),seckillGood);
        }
    }
}
----------------------------------------------------------------------------------------------------------------
@Repository("seckillGoodsMapper")
public interface SeckillGoodsMapper extends Mapper<SeckillGoods> {

    //查找符合条件的秒杀商品
    @Select("SELECT" +
            " * " +
            " FROM " +
            " tb_seckill_goods " +
            " WHERE " +
            " status = 1 " +
            " AND stock_count > 0 " +
            " AND start_time >= #{date} " +
            " AND end_time < DATE_ADD(#{date},INTERVAL 2 HOUR)")
    List<SeckillGoods> findSeckillGoods(@Param("date") Date date);

    //查询出符合条件的秒杀商品,排除以前已存入的
    @SelectProvider(type = SeckillGoodsMapper.SeckillProvider.class, method = "findSeckillGoodsNotIn")
    List<SeckillGoods> findSeckillGoodsNotIn(@Param("date") Date date, @Param("keys") Set<Long> keys);

    class SeckillProvider {
        public String findSeckillGoodsNotIn(@Param("date") Date date, @Param("keys") Set<Long> keys) {
            StringBuilder sql = new StringBuilder("SELECT" +
                    " * " +
                    " FROM " +
                    " tb_seckill_goods " +
                    " WHERE " +
                    " status = 1 " +
                    " AND stock_count > 0 " +
                    " AND start_time >=  ");
            sql.append("'").append(date.toLocaleString()).append("'")
                    .append(" AND end_time < DATE_ADD(")
                    .append("'").append(date.toLocaleString()).append("'")
                    .append(" ,INTERVAL 2 HOUR) ")
                    .append(" AND id NOT IN (");
            for (Long key : keys) {
                sql.append(key).append(",");
            }
            sql.deleteCharAt(sql.length() - 1).append(")");
            System.out.println(sql.toString());
            return sql.toString();
        }
    }

}

(cron = "0/5 * * * * ?")中几个参数分别表明秒-分-时-日-月-周-年。年能够省略,因此是6个。*表示全部值,好比 “分” 是*就表明每分钟都执行。?表示不须要关心这个值是多少。/表示递增触发,0/5表示从0秒开始每5秒触发一次。因此这段代码配置的就是每5秒执行一次定时任务。

上面这段代码的意思是:将MySQL中的秒杀商品放入Redis,为了不添加剧复的商品,先获取Redis中已有商品的id集合,而后在查询数据库的时候将已有的排除掉。redis中存入商品的键为秒杀开始的时间,例如 "2020100110"表示2020年10月1日10点,获取时间菜单用的是资料提供的一个工具类DateUtil。DateUtil的代码不难,我就不介绍了,开调试模式跟着走一遍就能看懂。为了方便测试,我将日期定在了2019年6月1日,实际开发中应该用当前日期。

秒杀频道页

将商品加载到Redis中后就能够开始下单流程了,首先须要有个秒杀频道页,就是将对应时间段的秒杀商品加载到页面上展现出来。前端将当前时间的字符串(yyyyMMddHH)传到后端,后端从Redis中查询出对应的商品返回到前端,前端进行展现。

//   SeckillGoodsController
//根据时间段(2019090516) 查询该时间段的全部的秒杀的商品
@GetMapping("/list")
public Result<List<SeckillGoods>> list(@RequestParam("time") String time){
    List<SeckillGoods> list = seckillGoodsService.list(time);
    return new Result<>(true,StatusCode.OK,"查询成功",list);
}
-----------------------------------------------------------------------------------
//  SeckillGoodsServiceImpl
@Override
public List<SeckillGoods> list(String time) {
    return redisTemplate.boundHashOps(SystemConstants.SEC_KILL_GOODS_PREFIX+time).values();
}

代码很简单,就是根据键将商品从Redis中查询出来。

秒杀商品详情页

当用户点击秒杀频道页的商品后,就会进入到秒杀商品详情页。前端将当前时间段和商品的id传到后端,后端从Redis中将商品信息查询出来,而后返回给前端进行展现。

//   SeckillGoodsController
//根据时间段  和秒杀商品的ID 获取商品的数据
@GetMapping("/one")
public Result<SeckillGoods> one(String time,Long id){
    SeckillGoods seckillGoods = seckillGoodsService.one(time, id);
    return new Result<>(true,StatusCode.OK,"查询商品数据成功",seckillGoods);
}
------------------------------------------------------------------------------------------
//  SeckillGoodsServiceImpl
@GetMapping("/one")
public Result<SeckillGoods> one(String time,Long id){
    SeckillGoods seckillGoods = seckillGoodsService.one(time, id);
    return new Result<>(true,StatusCode.OK,"查询商品数据成功",seckillGoods);
}

多线程抢单

上面两个小节内容都很少,如今正式进入下单的流程。由于在秒杀环境中,并发量都很大,若是只开一个线程的话,用户不知道要等到猴年马月,因此为每一个下单的用户分配一个线程去进行处理是比较稳当的。

要在SpringBoot中开启多线程,首先在启动类上添加一个注解@EnableAsync去开启对异步任务的支持。

//SeckillOrderController
//下单
@RequestMapping("/add")
public Result<Boolean> add(String time,Long id){
    //1.获取当前登陆的用户的名称
    String username ="robod";//测试用写死
    boolean flag = seckillOrderService.add(id, time, username);
    return new Result(true,StatusCode.OK,"排队中。。。",flag);
}

前端将时间段和商品的id传进来,用户名暂时写死,方便测试。

//  SeckillOrderServiceImpl
@Override
public boolean add(Long id, String time, String username) {
    SeckillStatus seckillStatus = new SeckillStatus(username,LocalDateTime.now(),1,id,time);
    //将seckillStatus存入redis队列
    redisTemplate.boundListOps(SystemConstants.SEC_KILL_USER_QUEUE_KEY).leftPush(seckillStatus);
    redisTemplate.boundHashOps(SystemConstants.SEC_KILL_USER_STATUS_KEY).put(username,seckillStatus);
    multiThreadingCreateOrder.createOrder();
    return true;
}

在这段代码中,先根据已有的信息建立了一个SeckillStatus对象,这个类中存放了秒杀的一些状态信息。而后将seckillStatus放入redis队列中,若是及时地处理订单系统响应速度就会变慢,因此先建立一个SeckillStatus放入redis,而后调用multiThreadingCreateOrder.createOrder()去开启一个线程处理订单。

@Component
public class MultiThreadingCreateOrder {
	…………
    //异步抢单
    @Async  //声明该方法是个异步任务,另开一个线程去运行
    public void createOrder() {
        //从redis队列中取出seckillStatus
        SeckillStatus seckillStatus = (SeckillStatus) 
            redisTemplate.boundListOps(SystemConstants.SEC_KILL_USER_QUEUE_KEY).rightPop();

        BoundHashOperations seckillGoodsBoundHashOps = 
            redisTemplate.boundHashOps(SystemConstants.SEC_KILL_GOODS_PREFIX + seckillStatus.getTime());
        //从redis中查询出秒杀商品
        SeckillGoods seckillGoods = (SeckillGoods)seckillGoodsBoundHashOps.get(seckillStatus.getGoodsId());   
        if (seckillGoods == null || seckillGoods.getStockCount() <=0 ) {
            throw new RuntimeException("已售罄");
        }
        //建立秒杀订单
        SeckillOrder seckillOrder = new SeckillOrder();
        seckillOrder.setSeckillId(seckillGoods.getId());
        seckillOrder.setMoney(seckillGoods.getCostPrice());
        seckillOrder.setUserId(seckillStatus.getUsername());
        seckillOrder.setCreateTime(LocalDateTime.now());
        seckillOrder.setStatus("0");
        //将秒杀订单存入redis,键为用户名,确保一个用户只有一个秒杀订单
        redisTemplate.boundHashOps(SystemConstants.SEC_KILL_ORDER_KEY)
            .put(seckillStatus.getUsername(),seckillOrder);

        //减库存,若是库存没了就从redis中删除,并将库存数据写到MySQL中
        seckillGoods.setStockCount(seckillGoods.getStockCount()-1);
        if (seckillGoods.getStockCount() <= 0) {
            seckillGoodsBoundHashOps.delete(seckillStatus.getGoodsId());
            seckillGoodsMapper.updateByPrimaryKeySelective(seckillGoods);
        } else {
            seckillGoodsBoundHashOps.put(seckillStatus.getGoodsId(),seckillGoods);
        }
        //下单成功,更改seckillstatus的状态,再存入redis中
        seckillStatus.setOrderId(seckillOrder.getId());
        seckillStatus.setMoney(Float.valueOf(seckillGoods.getCostPrice()));
        seckillStatus.setStatus(2);		//等待支付
        redisTemplate.boundHashOps(SystemConstants.SEC_KILL_USER_STATUS_KEY)
            .put(seckillStatus.getUsername(),seckillStatus);
    }

}

在这个方法上添加了一个@Async注解,说明该方法是个异步任务,每次执行该方法的时候都会另开一个线程去运行。以前不是将订单存入redis队列中了吗,如今从redis队列中取出。而后根据商品id查询出商品信息。接着进行库存判断,若是没有商品或者库存没了说明已经卖完了,抛出已售罄的异常。若是有库存的话,就建立一个秒杀订单,将status置为0表示未支付。 而后将订单存入redis中,这样订单就算建立完成了。成功建立订单后就应该减去相应的库存。若是减完库存后发现库存没了,说明最后一件商品已经卖完了,这时候就能够将redis中的该商品删除,并更新到MySQL中。

最后修改seckillstatus的内容,并更新到redis中。以前没说把seckillstatus存入redis的做用,其实它的做用就是供前端查询订单状态。

既然是查询订单状态,得提供一个接口吧👇

// SeckillOrderController
//查询当前登陆的用户的抢单信息(状态)
@GetMapping("/query")
public Result<SeckillStatus> queryStatus(String username) {
    SeckillStatus seckillStatus = seckillOrderService.queryStatus(username);
    if (seckillStatus == null) {
        return new Result<>(false,StatusCode.NOT_FOUND_ERROR,"未查询到订单信息");
    }
    return new Result<>(true,StatusCode.OK,"订单查询成功",seckillStatus);
}
-------------------------------------------------------------------------------------------
//SeckillOrderServiceImpl
@Override
public SeckillStatus queryStatus(String username) {
  return (SeckillStatus) redisTemplate.boundHashOps(SystemConstants.SEC_KILL_USER_STATUS_KEY).get(username);
}

前端将用户名传入进来,而后查询订单状态,若是查询出来的状态是待支付的话,就能够进入支付流程了。

总结

好了,这篇文章到这里就结束了,主要介绍了一下秒杀的流程,而后实现了定时任务,秒杀频道页,秒杀商品详情页和多线程抢单的功能。这个秒杀系统尚未结束,还存在不少问题,在下一篇文章中,将会修改现有的问题并继续完善秒杀的流程。让咱们下期再见!

码字不易,能够的话,给我来个点赞收藏关注

代码:https://github.com/RobodLee/changgou

相关文章
相关标签/搜索