上架服务
流程图

Spring提供的定时任务
spring异步任务会有阻塞的问题:

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

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

定时上架
config配置类:
@Configuration@EnableAsync // 开启异步任务@EnableScheduling // 开启定时任务public class ScheduledConfig {}
定时执行任务:
这里加入了分布式锁,防止多个服务一起上架。
/*** 秒杀商品的定时上架;* 每天晚上3点;上架最近三天需要秒杀的商品。* 当天00:0e;00 - 23:59:59* 明天00:00:00 - 23:59:59* 后天00:00;00 - 23:59:59*/@Slf4j@Servicepublic class SeckillScheduled {@Autowiredprivate SeckillService seckillService;@Autowiredprivate RedissonClient redissonClient;// 每分钟一次@Scheduled(cron = "0 * * * * ?")public void uploadSeckillSkuLatest3Days() {log.info("正在执行上架任务...");// 加入分布式锁,反正多个服务器一起上架RLock lock = redissonClient.getLock(SeckillConstant.UPLOAD_LOCK);lock.lock(10, TimeUnit.SECONDS);try {seckillService.uploadSeckillSkuLatest3Day();} catch (Exception exception) {lock.unlock();}}}
接口保存上架商品在redis中
@Overridepublic void uploadSeckillSkuLatest3Day() {R r = couponFeignService.getLates3DaySession();List<SeckillSessionWithSkusTo> seckillSessionWithSkusToList = r.getData(new TypeReference<List<SeckillSessionWithSkusTo>>() {});// 保存秒杀场的信息saveSessionInfos(seckillSessionWithSkusToList);// 保存每个秒杀场次的商品信息saveSessionSkuInfos(seckillSessionWithSkusToList);}
1.提前将要秒杀的商品上架到redis中(减少db压力)
从redis获取秒杀商品
实现:
使用定时任务,扫描第二天要秒杀的商品上架到redis中
2.秒杀商品的库存也上传到redis
从redis扣除库存(信号量的方式)
根据流程图保存数据:
保存场次信息:
private void saveSessionInfos(List<SeckillSessionWithSkusTo> list) {// 存储在redis中的List数据类型// key:start_end value:skuListlist.stream().forEach(session -> {long startTime = session.getStartTime().getTime();long endTime = session.getEndTime().getTime();String key = SeckillConstant.SESSION_CACHE_PREFIX + startTime + "_" + endTime;// 场次的key// 没有才放入,保证幂等性if (!redisTemplate.hasKey(key)) {List<String> collect = session.getRelationSkus().stream().map(item -> item.getPromotionSessionId().toString() + "_" + item.getSkuId().toString()).collect(Collectors.toList());redisTemplate.opsForList().leftPushAll(key, collect);}});}
保存sku信息:
注意还需要加上信号量,代表库存
private void saveSessionSkuInfos(List<SeckillSessionWithSkusTo> list) {list.stream().forEach(session -> {// 准备hash操作BoundHashOperations<String, Object, Object> boundHashOps = redisTemplate.boundHashOps(SeckillConstant.SECKILL_CHARE_KEY);session.getRelationSkus().stream().forEach(item -> {// 生成随机码作为标识,防止别人恶意扣库存String token = UUID.randomUUID().toString().replace("-", "");// redis中没有才保存,保证幂等性if (!boundHashOps.hasKey(item.getPromotionSessionId().toString() + "_" + item.getSkuId().toString())) {// 保存在redis中的对象SeckillSkuRedisTo seckillSkuRedisTo = new SeckillSkuRedisTo();// 远程调用,查询sku的信息R r = productFeignService.info(item.getSkuId());if (r.getCode() == 0) {SkuInfoTo skuInfo = r.getData("skuInfo", new TypeReference<SkuInfoTo>() {});seckillSkuRedisTo.setSkuInfo(skuInfo);}// 拷贝属性BeanUtils.copyProperties(item, seckillSkuRedisTo);// 设置限购数量seckillSkuRedisTo.setSeckillLimit(item.getSeckillLimit().intValue());// 设置商品的开始和结束时间seckillSkuRedisTo.setStartTime(session.getStartTime().getTime());seckillSkuRedisTo.setEndTime(session.getEndTime().getTime());// 设置随机码seckillSkuRedisTo.setRandomCode(token);// 存入redisString jsonString = JSON.toJSONString(seckillSkuRedisTo);boundHashOps.put(item.getPromotionSessionId().toString() + "_" + item.getSkuId().toString(), jsonString);// 使用库存作为信号量,信号量用完,就不能在扣库存,起到了限流的功能// 使用redisson作为信号量框架// redis中没有key,才加入信号量,保证幂等性RSemaphore semaphore = redissonClient.getSemaphore(SeckillConstant.SKU_STOCK_SEMAPHORE + token);semaphore.trySetPermits(item.getSeckillCount().intValue());}});});}
幂等性保护
幂等性保护也就是一个服务上架之后,其他服务就不能上架,否则存在幂等性问题。
在上架时进行判断,例如:
if (!boundHashOps.hasKey(item.getPromotionSessionId().toString() + "_" + item.getSkuId().toString()))
之后再进行商品的上架和信号量的加入。
保存场景信息的时候,也保证了幂等性:
// 没有才放入,保证幂等性if (!redisTemplate.hasKey(key)) {}
判断商品是否存在库存

controller:
@ResponseBody@GetMapping("/getSkuInfo/{skuId}")public R getSkuInfoBySkuId(@PathVariable("skuId") Long skuId) {SeckillSkuRedisTo seckillSkuRedisTo = seckillService.getSkuInfoBySkuId(skuId);return R.ok().setData(seckillSkuRedisTo);}
接口实现类:
流程:使用正则表达式匹配所有商品参加的场次,返回数据的时候注意根据是否开始再返回随机码。
@Overridepublic SeckillSkuRedisTo getSkuInfoBySkuId(Long skuId) {// 找到所有参与的商品BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SeckillConstant.SECKILL_CHARE_KEY);Set<String> keys = hashOps.keys();if (keys != null && keys.size() > 0) {// 正则表达式是为了找到这个商品所有参加的场次String regx = "\\d_" + skuId;for (String key : keys) {// 如果和正则表达式匹配if (Pattern.matches(regx, key)) {String redisStr = hashOps.get(key);SeckillSkuRedisTo seckillSkuRedisTo = JSON.parseObject((String) redisStr, SeckillSkuRedisTo.class);// 获取当前时间进行判断,如果秒杀已经开始需要知道随机码,如果未开始,不需要携带随机码long time = new Date().getTime();if (time < seckillSkuRedisTo.getStartTime() || time > seckillSkuRedisTo.getEndTime()) {seckillSkuRedisTo.setRandomCode(null);}return seckillSkuRedisTo;}}}return null;}
高并发秒杀系统的设计
关注的问题


- 1.单一职责
- 2.秒杀链接加密
- 随机码,秒杀开始才暴露
- 3.库存预热+快速扣减(redis存储库存信号量,最终正常进入购物车的流量最多是库存数)
- 按照库存信号量原子扣减
- 4.动静分离
- nginx/CDN
- 5.恶意请求拦截
- 网关层按照访问次数拦截脚本请求【异常请求】
- 6.流量错峰
- 【最重要是体现在秒杀开始的那一刻的错峰】判断登录状态、输入验证码、加入购物车、提交订单
- 7.限流&熔断&降级
- 前端限流:间隔1秒允许点击
- 后端限流:
- 限制次数:同一个用户10次放行2次
- 限制总量:秒杀服务峰值处理能力10万,网关层放行不得超过10万,超过的等待两秒放行
- 熔断:A->B->C,链路中B总是失败,则下次调用时直接返回错误不调用B
- 降级:流量太大,秒杀模块将流量引导到降级页面,服务繁忙页【正常请求】
- 8.队列削峰(杀手锏)
- 扣减库存信号量成功的秒杀信息存入队列,订单系统监听队列创建订单(按照自己的处理能力消费)
秒杀流程图
两种方案:
方案一:
加入购物车(仍然走购物车流程,但价格按照秒杀价格计算),创建订单、锁定库存
优点:只需要做好适配,无大改动
缺点:将秒杀的流量带给了其他模块
方案二:【采用方案二,队列削峰】
直接发送MQ消息,订单根据消息创建订单(不需要锁定库存,库存预热了【信号量】),订单关闭增加信号量<br /> 优点:没有将秒杀的压力分担给其他模块,只有校验合法性没有远程调用、db操作<br /> 缺点:订单等模块需要提供监听消费信息创建订单,如果订单崩了,会导致支付失败<br /> <br />假设一个请求50ms,一个线程1s能处理20个请求<br />Tomcat开启500个线程,1s能处理10000个请求
以下使用方案二
从秒杀开始到结束没有请求一次数据库和远程调用一次,所以响应很快。

流程
秒杀的controller
@GetMapping("/kill")public String seckill(@RequestParam("killId") String killId,@RequestParam("key") String key,@RequestParam("num") Integer num,Model model) throws InterruptedException {String orderSn = seckillService.killSku(killId, key, num);model.addAttribute("orderSn", orderSn);return "success";}
登录判断在前端
实现类:主要是校验

校验:秒杀时间校验,随机码校验,幂等性的校验(防止用户多次秒杀,所以使用用户的id进行redis中占坑,就是使用setnx命令)
信号量扣减,每次校验完成后,进行信号量的扣减,每次扣库存,到0后拒接藐视业务,进而限制了流量。
最终发送订单给消息队列。
TODO信号量:
1.接收创建秒杀订单的队列也应该做成延时队列,超时未支付,消息进入死信队列释放订单2.监听释放订单的消费者,释放订单后,发送一条释放信号量的信息到释放信号量的死信队列3.监听释放信号量的死信队列,逻辑跟释放库存一样(释放订单产生一条释放库存的消息,延时队列产生一条释放库存的消息)
TODO释放库存:
场次超时后,将信号量归还到库存
// TODO 上架秒杀商品的时候,每一个数据都有过期时间// TODO 秒杀后续的流程,简化了收货地址等信息。@Overridepublic String killSku(String killId, String key, Integer num) {MemberRespVo memberRespVo = LoginUserInterceptor.threadLocal.get();// 绑定hash操作BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SeckillConstant.SECKILL_CHARE_KEY);String redisStr = hashOps.get(killId);if (!StringUtils.isEmpty(redisStr)) {SeckillSkuRedisTo seckillSkuRedisTo = JSON.parseObject((String) redisStr, SeckillSkuRedisTo.class);// 开始进行时间合法性校验long time = new Date().getTime();Long startTime = seckillSkuRedisTo.getStartTime();Long endTime = seckillSkuRedisTo.getEndTime();if (time >= startTime && time <= endTime) {// 开始进行随机码的校验,防止别人恶意刷商品// 将skuId的验证一起校验String randomCode = seckillSkuRedisTo.getRandomCode();// 拼接起来,和redis做判断String skuId = seckillSkuRedisTo.getPromotionSessionId().toString() + "_" + seckillSkuRedisTo.getSkuId();if (randomCode.equals(key) && killId.equals(skuId)) {// 如果都匹配,校验数量是否合理Integer seckillLimit = seckillSkuRedisTo.getSeckillLimit();if (num <= seckillLimit) {// 数量合理,此时需要检验用户是否已经买过,防止用户多次购买// 这里也是保证了幂等性,买一次就需要一个幂等性字段// 解决:如果秒杀成功,那么使用该用户的id和商品id在redis中占一个坑String redisKey = memberRespVo.getId() + "_" + skuId;// 占坑,使用setnx操作(类似于加锁),并设置过期时间// 生成过期时间long ttl = endTime - time;Boolean ifAbsent = redisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl, TimeUnit.MILLISECONDS);if (ifAbsent) {// 占坑成功,说明从来没有买过,可以进行购买// 此时判断是否能获取信号量(也就是是否还有库存)RSemaphore semaphore = redissonClient.getSemaphore(SeckillConstant.SKU_STOCK_SEMAPHORE + randomCode);// 这里使用tryAcquire,而不是acquire的元婴是,后者如果没有信号量会一致阻塞获取,而不释放// 注意扣取的信号量try {boolean acquire = semaphore.tryAcquire(num);if (acquire) {// 成功之后,发送到消息队列,订单慢慢处理// 使用rabbitMQ消峰SeckillOrderTo order = new SeckillOrderTo();String orderSn = IdWorker.getTimeId();// 订单号order.setOrderSn(orderSn);// 订单号order.setMemberId(memberRespVo.getId());// 用户IDorder.setNum(num);// 商品上来给你order.setPromotionSessionId(seckillSkuRedisTo.getPromotionSessionId());// 场次idorder.setSkuId(seckillSkuRedisTo.getSkuId());// 商品idorder.setSeckillPrice(seckillSkuRedisTo.getSeckillPrice());// 秒杀价格// TODO 需要保证可靠消息,发送者确认+消费者确认(本地事务的形式)rabbitTemplate.convertAndSend("order-event-exchange", "order.seckill.order", order);long time1 = new Date().getTime();log.info("秒杀总共耗时:{}", time1 - time);return orderSn;}} catch (Exception e) {return null;}} else {// 占坑失败return null;}} else {return null;}} else {return null;}} else {return null;}}return null;}
成功之后发送订单到消息队列
做流量的削峰

秒杀服务的rabbitMQ服务略,在订单服务中创建队列和绑定关系,发送给订单队列。
@Beanpublic Queue orderSeckillOrderQueue() {return new Queue("order.seckill.order.queue", true, false, false);}@Beanpublic Binding orderSeckillOrderQueueBinding() {return new Binding("order.seckill.order.queue",Binding.DestinationType.QUEUE,"order-event-exchange","order.seckill.order",null);}
监听器,注意一定要注入容器
@RabbitListener(queues = {"order.seckill.order.queue"})@Service@Slf4jpublic class SeckillOrderListener {@Autowiredprivate OrderService orderService;@RabbitHandlerpublic void closeOrder(SeckillOrderTo seckillOrderTo, Message message, Channel channel) throws IOException {log.info("准备创建秒杀订单...");try {orderService.createSeckillOrder(seckillOrderTo);channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);} catch (Exception exception) {channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);}}}
订单成功监听,创建订单代码
@Overridepublic void createSeckillOrder(SeckillOrderTo order) {// 1.创建订单OrderEntity orderEntity = new OrderEntity();orderEntity.setOrderSn(order.getOrderSn());orderEntity.setMemberId(order.getMemberId());orderEntity.setCreateTime(new Date());BigDecimal totalPrice = order.getSeckillPrice().multiply(BigDecimal.valueOf(order.getNum()));// 应付总额orderEntity.setTotalAmount(totalPrice);// 订单总额orderEntity.setPayAmount(totalPrice);// 应付总额orderEntity.setStatus(OrderConstant.OrderStatusEnum.CREATE_NEW.getCode());// 保存订单this.save(orderEntity);// 2.创建订单项信息OrderItemEntity orderItem = new OrderItemEntity();orderItem.setOrderSn(order.getOrderSn());orderItem.setRealAmount(totalPrice);orderItem.setSkuQuantity(order.getNum());// 保存商品的spu信息R r = productServiceClient.getSpuBySkuId(order.getSkuId());SpuInfoTo spuInfo = r.getData(new TypeReference<SpuInfoTo>() {});orderItem.setSpuId(spuInfo.getId());orderItem.setSpuName(spuInfo.getSpuName());orderItem.setSpuBrand(spuInfo.getBrandName());orderItem.setCategoryId(spuInfo.getCatalogId());// 保存订单项数据orderItemService.save(orderItem);}
