顶部横幅广告
  • 微信
您当前的位置:首页 > 资讯

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

作者:三青 时间:2023-06-10 阅读数:人阅读

 

好好学习,天天向上

本文已收录至我的Github仓库DayDayUPhttp://github.com/RobodLee/DayDayUP

,欢迎Star

畅购商城(一):环境搭建畅购商城(二):分布式文件系统FastDFS畅购商城(三):商品管理畅购商城(四):Lua、OpenResty、Canal实现广告缓存与同步畅购商城(五):Elasticsearch实现商品搜索畅购商城(六):商品搜索畅购商城(七):Thymeleaf实现静态页畅购商城(八):微服务网关和JWT令牌畅购商城(九):Spring Security Oauth2畅购商城(十):购物车畅购商城(十一):订单畅购商城(十二):接入微信支付畅购商城(十三):秒杀系统「上」

流程分析

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

秒杀是一个并发量很大的系统,数据吞吐量都很大,MySQL的数据是保存在硬盘中的,数据吞吐的能力满足不了整个秒杀系统的需求。为了提高系统的访问速度,我们定时将秒杀商品从MySQL加载进Redis,因为Redis的数据是保存在内存中的,速度非常快,可以满足很高的吞吐量。

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

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

理论上这时候还要对用户进行一些合法性检测,比如账号是否异常等,但是这太耗时了,为了减少系统响应的时间,用户检测这一步先省略。直接让用户进行排队,排队就是将用户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/changgougithub.com/RobodLee/changgou

本站所有文章、数据、图片均来自互联网,一切版权均归源网站或源作者所有。

如果侵犯了你的权益请来信告知我们删除。邮箱:dacesmiling@qq.com

标签:
微信

三青

当你还撑不起你的梦想时,就要去奋斗。如果缘分安排我们相遇,请不要让她擦肩而过。我们一起奋斗!

微信
阿里云