最大特点就是瞬时高并发,针对这一特点必须要做到 限流+异步+缓存+独立部署。
可以在每天凌晨通过定时任务提前上架秒杀商品,然后讲上架的商品存到redis中,秒杀的时候就可以不用经过数据库了。设置分布式信号量作为扣减库存的依据,避免超卖现象发生。这里先简单描述下流程,涉及到的技术及具体实现方案下面会提到。
在SpringBoot中使用定时任务,可以使用Quartz框架,也可以使用SpringBoot自带的注解,我们使用注解的方式完成。
整合步骤
注意事项
案例演示
这种方式默认是阻塞的,如果当前任务执行时间过长,可能会影响下次任务的执行。不明白的可以看以下测试代码,模拟业务长时间执行的情况。
//每秒钟执行一次
@Scheduled(cron = "* * * * * ?")
public void test() throws InterruptedException {
log.info("hello");
Thread.sleep(3000);
}
由图可以看出,本应该间隔1秒执行的任务,却变成了间隔4秒。因此要解决这种问题就需要异步的去执行或者开启多线程,不能让业务影响任务的执行。由于多线程的方式在某些版本里支持的不友好,会不起作用,因此我们还是使用异步的方式来解决。
使用异步的话可以在方法内让业务以异步的方式执行,也可以让定时任务异步执行。
//每秒钟执行一次
@Async
@Scheduled(cron = "* * * * * ?")
public void test() throws InterruptedException {
log.info("hello");
Thread.sleep(3000);
}
加上异步任务后,可以看到方法不再阻塞。
异步任务默认的核心线程数是8,最大线程数是Integer.MAX_VALUE,最大线程数太大了,最好可以给个限制50或100什么的。
所有的设置都可以参考org.springframework.boot.autoconfigure.task.TaskExecutionProperties
保证秒杀业务即使扛不住高并发,宕机了,也不会影响别的业务。
防止恶意请求,如果别人提前知道了秒杀连接,通过工具大量请求的话,可能会给秒杀系统大量较大的压力,因此给每个商品加上随机码,只有活动开始的时候才能获取到随机码。
上架秒杀商品的时候最对每个商品设置一个随机码,可以存到redis中。只有秒杀活动开始的时候,前端才能获取到该随机码。秒杀的时候验证随机码是否正确,正确才可以执行秒杀流程。
秒杀活动读多写少,无需实时去数据库查询库存。秒杀商品定时上架的时候库存提前存到redis中,通过分布式信号量来控制秒杀请求,信号量的值就设置为商品的秒杀库存。
在网关层面,讲高频请求,参数没有随机码的请求等各种异常的请求都拦截掉
使用各种手段,尽量将请求的时间节点分散开来。比如走购物车,验证码等。
限流包括前端限流和后端限流,前端限流比如只能点击一次或者某段时间内只能点击一次。
后端限流、熔断、降级都可以通过Sentinel组件实现。
秒杀的时候通过信号量进行库存的控制,秒杀成功,直接给前端返回成功信息,然后给消息队列发送一条消息。这样就不需要操作数据库,可以支持更高的并发量。然后订单服务监听队列
慢慢进行下单、扣库存即可。
代码片段
@Override
public String kill(String killId, String key, Integer num) {
//获取当前登录用户
LoginUser loginUser = LoginUserInterceptor.threadLocal.get();
BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(RedisConstant.KEY_SKUKILL_SKUS);
String json = hashOps.get(killId);
if (StringUtils.isEmpty(json))
return null;
SecKillSkuRedisTo redis = JSON.parseObject(json, SecKillSkuRedisTo.class);
Long startTime = redis.getStartTime();
Long endTime = redis.getEndTime();
long current = new Date().getTime();
//活动剩余时间
long ttl = endTime - current;
//1、校验时间
if (current < startTime || current > endTime)
return null;
//2.幂等性验证,活动时间段内购买过就不能再买了
String randomCode = redis.getRandomCode();
//sessionId_skuId
String redisSkuKey = redis.getPromotionSessionId() + "_" + redis.getSkuId();
//3.校验随机码和商品Id
if (!randomCode.equals(key) || !redisSkuKey.equals(killId))
return null;
//4.校验限购数量
if (num > redis.getSeckillLimit())
return null;
//5.判断是否已经购买过,幂等性验证。秒杀的时候先占位。userId_sessionId_skuId,再次秒杀的时候就会占位失败
String redisKey = RedisConstant.PRE_KEY_SKUKILL_HISTORY + loginUser.getId() + "_" + redisSkuKey;
//ttl时间后,占位的key会自动删除。key是userId_sessionId_skuId,value是购买的数量
Boolean ifAbsent = redisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl, TimeUnit.MILLISECONDS);
//占位失败,当前时间段内已经购买过该商品了。
if (!ifAbsent) {
return null;
}
//占位成功,说明没有买过
RSemaphore semaphore = redissonClient.getSemaphore(RedisConstant.PRE_KEY_SKUKILL_SKU_STOCK_SEMAPHORE + randomCode);
//扣减信号量,不阻塞
boolean b;
try {
b = semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);
if (!b)
return null;
//所有校验都通过,正式生成订单
String orderSn = IdWorker.getTimeId();
SeckillOrderTo orderTo = new SeckillOrderTo();
orderTo.setOrderSn(orderSn);
orderTo.setMemberId(loginUser.getId());
orderTo.setNum(num);
orderTo.setSkuId(redis.getSkuInfo().getSkuId());
orderTo.setPromotionSessionId(redis.getPromotionSessionId());
orderTo.setSeckillPrice(redis.getSeckillPrice());
try {
rabbitTemplate.convertAndSend(MQConstant.ORDER_EVENT_EXCHANGE, MQConstant.ORDER_SECKILL_ORDER_KEY, orderTo);
} catch (AmqpException e) {
//消息发送失败,秒杀失败
return null;
}
return orderSn;
} catch (InterruptedException e) {
return null;
}
}