上架服务

流程图

秒杀服务 - 图1

Spring提供的定时任务

spring异步任务会有阻塞的问题:

秒杀服务 - 图2

定时任务和异步任务(spring提供的异步任务注解)

秒杀服务 - 图3

定时任务—分布式下的问题

秒杀服务 - 图4

定时上架

config配置类:

  1. @Configuration
  2. @EnableAsync // 开启异步任务
  3. @EnableScheduling // 开启定时任务
  4. public class ScheduledConfig {
  5. }

定时执行任务:

这里加入了分布式锁,防止多个服务一起上架。

  1. /**
  2. * 秒杀商品的定时上架;
  3. * 每天晚上3点;上架最近三天需要秒杀的商品。
  4. * 当天00:0e;00 - 23:59:59
  5. * 明天00:00:00 - 23:59:59
  6. * 后天00:00;00 - 23:59:59
  7. */
  8. @Slf4j
  9. @Service
  10. public class SeckillScheduled {
  11. @Autowired
  12. private SeckillService seckillService;
  13. @Autowired
  14. private RedissonClient redissonClient;
  15. // 每分钟一次
  16. @Scheduled(cron = "0 * * * * ?")
  17. public void uploadSeckillSkuLatest3Days() {
  18. log.info("正在执行上架任务...");
  19. // 加入分布式锁,反正多个服务器一起上架
  20. RLock lock = redissonClient.getLock(SeckillConstant.UPLOAD_LOCK);
  21. lock.lock(10, TimeUnit.SECONDS);
  22. try {
  23. seckillService.uploadSeckillSkuLatest3Day();
  24. } catch (Exception exception) {
  25. lock.unlock();
  26. }
  27. }
  28. }

接口保存上架商品在redis中

  1. @Override
  2. public void uploadSeckillSkuLatest3Day() {
  3. R r = couponFeignService.getLates3DaySession();
  4. List<SeckillSessionWithSkusTo> seckillSessionWithSkusToList = r.getData(new TypeReference<List<SeckillSessionWithSkusTo>>() {
  5. });
  6. // 保存秒杀场的信息
  7. saveSessionInfos(seckillSessionWithSkusToList);
  8. // 保存每个秒杀场次的商品信息
  9. saveSessionSkuInfos(seckillSessionWithSkusToList);
  10. }

1.提前将要秒杀的商品上架到redis中(减少db压力)
从redis获取秒杀商品
实现:
使用定时任务,扫描第二天要秒杀的商品上架到redis中
2.秒杀商品的库存也上传到redis
从redis扣除库存(信号量的方式)

根据流程图保存数据:

保存场次信息:

  1. private void saveSessionInfos(List<SeckillSessionWithSkusTo> list) {
  2. // 存储在redis中的List数据类型
  3. // key:start_end value:skuList
  4. list.stream().forEach(session -> {
  5. long startTime = session.getStartTime().getTime();
  6. long endTime = session.getEndTime().getTime();
  7. String key = SeckillConstant.SESSION_CACHE_PREFIX + startTime + "_" + endTime;// 场次的key
  8. // 没有才放入,保证幂等性
  9. if (!redisTemplate.hasKey(key)) {
  10. List<String> collect = session.getRelationSkus().stream().map(item -> item.getPromotionSessionId().toString() + "_" + item.getSkuId().toString()).collect(Collectors.toList());
  11. redisTemplate.opsForList().leftPushAll(key, collect);
  12. }
  13. });
  14. }

保存sku信息:

注意还需要加上信号量,代表库存

  1. private void saveSessionSkuInfos(List<SeckillSessionWithSkusTo> list) {
  2. list.stream().forEach(session -> {
  3. // 准备hash操作
  4. BoundHashOperations<String, Object, Object> boundHashOps = redisTemplate.boundHashOps(SeckillConstant.SECKILL_CHARE_KEY);
  5. session.getRelationSkus().stream().forEach(item -> {
  6. // 生成随机码作为标识,防止别人恶意扣库存
  7. String token = UUID.randomUUID().toString().replace("-", "");
  8. // redis中没有才保存,保证幂等性
  9. if (!boundHashOps.hasKey(item.getPromotionSessionId().toString() + "_" + item.getSkuId().toString())) {
  10. // 保存在redis中的对象
  11. SeckillSkuRedisTo seckillSkuRedisTo = new SeckillSkuRedisTo();
  12. // 远程调用,查询sku的信息
  13. R r = productFeignService.info(item.getSkuId());
  14. if (r.getCode() == 0) {
  15. SkuInfoTo skuInfo = r.getData("skuInfo", new TypeReference<SkuInfoTo>() {
  16. });
  17. seckillSkuRedisTo.setSkuInfo(skuInfo);
  18. }
  19. // 拷贝属性
  20. BeanUtils.copyProperties(item, seckillSkuRedisTo);
  21. // 设置限购数量
  22. seckillSkuRedisTo.setSeckillLimit(item.getSeckillLimit().intValue());
  23. // 设置商品的开始和结束时间
  24. seckillSkuRedisTo.setStartTime(session.getStartTime().getTime());
  25. seckillSkuRedisTo.setEndTime(session.getEndTime().getTime());
  26. // 设置随机码
  27. seckillSkuRedisTo.setRandomCode(token);
  28. // 存入redis
  29. String jsonString = JSON.toJSONString(seckillSkuRedisTo);
  30. boundHashOps.put(item.getPromotionSessionId().toString() + "_" + item.getSkuId().toString(), jsonString);
  31. // 使用库存作为信号量,信号量用完,就不能在扣库存,起到了限流的功能
  32. // 使用redisson作为信号量框架
  33. // redis中没有key,才加入信号量,保证幂等性
  34. RSemaphore semaphore = redissonClient.getSemaphore(SeckillConstant.SKU_STOCK_SEMAPHORE + token);
  35. semaphore.trySetPermits(item.getSeckillCount().intValue());
  36. }
  37. });
  38. });
  39. }

幂等性保护

幂等性保护也就是一个服务上架之后,其他服务就不能上架,否则存在幂等性问题。

在上架时进行判断,例如:

  1. if (!boundHashOps.hasKey(item.getPromotionSessionId().toString() + "_" + item.getSkuId().toString()))

之后再进行商品的上架和信号量的加入。

保存场景信息的时候,也保证了幂等性:

  1. // 没有才放入,保证幂等性
  2. if (!redisTemplate.hasKey(key)) {
  3. }

判断商品是否存在库存

秒杀服务 - 图5

controller:

  1. @ResponseBody
  2. @GetMapping("/getSkuInfo/{skuId}")
  3. public R getSkuInfoBySkuId(@PathVariable("skuId") Long skuId) {
  4. SeckillSkuRedisTo seckillSkuRedisTo = seckillService.getSkuInfoBySkuId(skuId);
  5. return R.ok().setData(seckillSkuRedisTo);
  6. }

接口实现类:

流程:使用正则表达式匹配所有商品参加的场次,返回数据的时候注意根据是否开始再返回随机码。

  1. @Override
  2. public SeckillSkuRedisTo getSkuInfoBySkuId(Long skuId) {
  3. // 找到所有参与的商品
  4. BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SeckillConstant.SECKILL_CHARE_KEY);
  5. Set<String> keys = hashOps.keys();
  6. if (keys != null && keys.size() > 0) {
  7. // 正则表达式是为了找到这个商品所有参加的场次
  8. String regx = "\\d_" + skuId;
  9. for (String key : keys) {
  10. // 如果和正则表达式匹配
  11. if (Pattern.matches(regx, key)) {
  12. String redisStr = hashOps.get(key);
  13. SeckillSkuRedisTo seckillSkuRedisTo = JSON.parseObject((String) redisStr, SeckillSkuRedisTo.class);
  14. // 获取当前时间进行判断,如果秒杀已经开始需要知道随机码,如果未开始,不需要携带随机码
  15. long time = new Date().getTime();
  16. if (time < seckillSkuRedisTo.getStartTime() || time > seckillSkuRedisTo.getEndTime()) {
  17. seckillSkuRedisTo.setRandomCode(null);
  18. }
  19. return seckillSkuRedisTo;
  20. }
  21. }
  22. }
  23. return null;
  24. }

高并发秒杀系统的设计

关注的问题

秒杀服务 - 图6

秒杀服务 - 图7

  • 1.单一职责
  • 2.秒杀链接加密
    • 随机码,秒杀开始才暴露
  • 3.库存预热+快速扣减(redis存储库存信号量,最终正常进入购物车的流量最多是库存数)
    • 按照库存信号量原子扣减
  • 4.动静分离
    • nginx/CDN
  • 5.恶意请求拦截
    • 网关层按照访问次数拦截脚本请求【异常请求】
  • 6.流量错峰
    • 【最重要是体现在秒杀开始的那一刻的错峰】判断登录状态、输入验证码、加入购物车、提交订单
  • 7.限流&熔断&降级
    • 前端限流:间隔1秒允许点击
    • 后端限流:
      • 限制次数:同一个用户10次放行2次
      • 限制总量:秒杀服务峰值处理能力10万,网关层放行不得超过10万,超过的等待两秒放行
    • 熔断:A->B->C,链路中B总是失败,则下次调用时直接返回错误不调用B
    • 降级:流量太大,秒杀模块将流量引导到降级页面,服务繁忙页【正常请求】
  • 8.队列削峰(杀手锏)
    • 扣减库存信号量成功的秒杀信息存入队列,订单系统监听队列创建订单(按照自己的处理能力消费)

秒杀流程图

两种方案:
方案一:
加入购物车(仍然走购物车流程,但价格按照秒杀价格计算),创建订单、锁定库存
优点:只需要做好适配,无大改动
缺点:将秒杀的流量带给了其他模块
方案二:【采用方案二,队列削峰】

  1. 直接发送MQ消息,订单根据消息创建订单(不需要锁定库存,库存预热了【信号量】),订单关闭增加信号量<br /> 优点:没有将秒杀的压力分担给其他模块,只有校验合法性没有远程调用、db操作<br /> 缺点:订单等模块需要提供监听消费信息创建订单,如果订单崩了,会导致支付失败<br /> <br />假设一个请求50ms,一个线程1s能处理20个请求<br />Tomcat开启500个线程,1s能处理10000个请求

以下使用方案二

从秒杀开始到结束没有请求一次数据库和远程调用一次,所以响应很快。

秒杀服务 - 图8

流程

秒杀的controller

  1. @GetMapping("/kill")
  2. public String seckill(@RequestParam("killId") String killId,
  3. @RequestParam("key") String key,
  4. @RequestParam("num") Integer num,
  5. Model model) throws InterruptedException {
  6. String orderSn = seckillService.killSku(killId, key, num);
  7. model.addAttribute("orderSn", orderSn);
  8. return "success";
  9. }

登录判断在前端

实现类:主要是校验

秒杀服务 - 图9

校验:秒杀时间校验,随机码校验,幂等性的校验(防止用户多次秒杀,所以使用用户的id进行redis中占坑,就是使用setnx命令)

信号量扣减,每次校验完成后,进行信号量的扣减,每次扣库存,到0后拒接藐视业务,进而限制了流量。

最终发送订单给消息队列。

TODO信号量:

  1. 1.接收创建秒杀订单的队列也应该做成延时队列,超时未支付,消息进入死信队列释放订单
  2. 2.监听释放订单的消费者,释放订单后,发送一条释放信号量的信息到释放信号量的死信队列
  3. 3.监听释放信号量的死信队列,逻辑跟释放库存一样(释放订单产生一条释放库存的消息,延时队列产生一条释放库存的消息)

TODO释放库存:

  1. 场次超时后,将信号量归还到库存
  1. // TODO 上架秒杀商品的时候,每一个数据都有过期时间
  2. // TODO 秒杀后续的流程,简化了收货地址等信息。
  3. @Override
  4. public String killSku(String killId, String key, Integer num) {
  5. MemberRespVo memberRespVo = LoginUserInterceptor.threadLocal.get();
  6. // 绑定hash操作
  7. BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SeckillConstant.SECKILL_CHARE_KEY);
  8. String redisStr = hashOps.get(killId);
  9. if (!StringUtils.isEmpty(redisStr)) {
  10. SeckillSkuRedisTo seckillSkuRedisTo = JSON.parseObject((String) redisStr, SeckillSkuRedisTo.class);
  11. // 开始进行时间合法性校验
  12. long time = new Date().getTime();
  13. Long startTime = seckillSkuRedisTo.getStartTime();
  14. Long endTime = seckillSkuRedisTo.getEndTime();
  15. if (time >= startTime && time <= endTime) {
  16. // 开始进行随机码的校验,防止别人恶意刷商品
  17. // 将skuId的验证一起校验
  18. String randomCode = seckillSkuRedisTo.getRandomCode();
  19. // 拼接起来,和redis做判断
  20. String skuId = seckillSkuRedisTo.getPromotionSessionId().toString() + "_" + seckillSkuRedisTo.getSkuId();
  21. if (randomCode.equals(key) && killId.equals(skuId)) {
  22. // 如果都匹配,校验数量是否合理
  23. Integer seckillLimit = seckillSkuRedisTo.getSeckillLimit();
  24. if (num <= seckillLimit) {
  25. // 数量合理,此时需要检验用户是否已经买过,防止用户多次购买
  26. // 这里也是保证了幂等性,买一次就需要一个幂等性字段
  27. // 解决:如果秒杀成功,那么使用该用户的id和商品id在redis中占一个坑
  28. String redisKey = memberRespVo.getId() + "_" + skuId;
  29. // 占坑,使用setnx操作(类似于加锁),并设置过期时间
  30. // 生成过期时间
  31. long ttl = endTime - time;
  32. Boolean ifAbsent = redisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl, TimeUnit.MILLISECONDS);
  33. if (ifAbsent) {
  34. // 占坑成功,说明从来没有买过,可以进行购买
  35. // 此时判断是否能获取信号量(也就是是否还有库存)
  36. RSemaphore semaphore = redissonClient.getSemaphore(SeckillConstant.SKU_STOCK_SEMAPHORE + randomCode);
  37. // 这里使用tryAcquire,而不是acquire的元婴是,后者如果没有信号量会一致阻塞获取,而不释放
  38. // 注意扣取的信号量
  39. try {
  40. boolean acquire = semaphore.tryAcquire(num);
  41. if (acquire) {
  42. // 成功之后,发送到消息队列,订单慢慢处理
  43. // 使用rabbitMQ消峰
  44. SeckillOrderTo order = new SeckillOrderTo();
  45. String orderSn = IdWorker.getTimeId();// 订单号
  46. order.setOrderSn(orderSn);// 订单号
  47. order.setMemberId(memberRespVo.getId());// 用户ID
  48. order.setNum(num);// 商品上来给你
  49. order.setPromotionSessionId(seckillSkuRedisTo.getPromotionSessionId());// 场次id
  50. order.setSkuId(seckillSkuRedisTo.getSkuId());// 商品id
  51. order.setSeckillPrice(seckillSkuRedisTo.getSeckillPrice());// 秒杀价格
  52. // TODO 需要保证可靠消息,发送者确认+消费者确认(本地事务的形式)
  53. rabbitTemplate.convertAndSend("order-event-exchange", "order.seckill.order", order);
  54. long time1 = new Date().getTime();
  55. log.info("秒杀总共耗时:{}", time1 - time);
  56. return orderSn;
  57. }
  58. } catch (Exception e) {
  59. return null;
  60. }
  61. } else {
  62. // 占坑失败
  63. return null;
  64. }
  65. } else {
  66. return null;
  67. }
  68. } else {
  69. return null;
  70. }
  71. } else {
  72. return null;
  73. }
  74. }
  75. return null;
  76. }

成功之后发送订单到消息队列

做流量的削峰

秒杀服务 - 图10

秒杀服务的rabbitMQ服务略,在订单服务中创建队列和绑定关系,发送给订单队列。

  1. @Bean
  2. public Queue orderSeckillOrderQueue() {
  3. return new Queue("order.seckill.order.queue", true, false, false);
  4. }
  5. @Bean
  6. public Binding orderSeckillOrderQueueBinding() {
  7. return new Binding("order.seckill.order.queue",
  8. Binding.DestinationType.QUEUE,
  9. "order-event-exchange",
  10. "order.seckill.order",
  11. null);
  12. }

监听器,注意一定要注入容器

  1. @RabbitListener(queues = {"order.seckill.order.queue"})
  2. @Service
  3. @Slf4j
  4. public class SeckillOrderListener {
  5. @Autowired
  6. private OrderService orderService;
  7. @RabbitHandler
  8. public void closeOrder(SeckillOrderTo seckillOrderTo, Message message, Channel channel) throws IOException {
  9. log.info("准备创建秒杀订单...");
  10. try {
  11. orderService.createSeckillOrder(seckillOrderTo);
  12. channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
  13. } catch (Exception exception) {
  14. channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);
  15. }
  16. }
  17. }

订单成功监听,创建订单代码

  1. @Override
  2. public void createSeckillOrder(SeckillOrderTo order) {
  3. // 1.创建订单
  4. OrderEntity orderEntity = new OrderEntity();
  5. orderEntity.setOrderSn(order.getOrderSn());
  6. orderEntity.setMemberId(order.getMemberId());
  7. orderEntity.setCreateTime(new Date());
  8. BigDecimal totalPrice = order.getSeckillPrice().multiply(BigDecimal.valueOf(order.getNum()));// 应付总额
  9. orderEntity.setTotalAmount(totalPrice);// 订单总额
  10. orderEntity.setPayAmount(totalPrice);// 应付总额
  11. orderEntity.setStatus(OrderConstant.OrderStatusEnum.CREATE_NEW.getCode());
  12. // 保存订单
  13. this.save(orderEntity);
  14. // 2.创建订单项信息
  15. OrderItemEntity orderItem = new OrderItemEntity();
  16. orderItem.setOrderSn(order.getOrderSn());
  17. orderItem.setRealAmount(totalPrice);
  18. orderItem.setSkuQuantity(order.getNum());
  19. // 保存商品的spu信息
  20. R r = productServiceClient.getSpuBySkuId(order.getSkuId());
  21. SpuInfoTo spuInfo = r.getData(new TypeReference<SpuInfoTo>() {
  22. });
  23. orderItem.setSpuId(spuInfo.getId());
  24. orderItem.setSpuName(spuInfo.getSpuName());
  25. orderItem.setSpuBrand(spuInfo.getBrandName());
  26. orderItem.setCategoryId(spuInfo.getCatalogId());
  27. // 保存订单项数据
  28. orderItemService.save(orderItem);
  29. }