上架服务
流程图
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
@Service
public class SeckillScheduled {
@Autowired
private SeckillService seckillService;
@Autowired
private 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中
@Override
public 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:skuList
list.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);
// 存入redis
String 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);
}
接口实现类:
流程:使用正则表达式匹配所有商品参加的场次,返回数据的时候注意根据是否开始再返回随机码。
@Override
public 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 秒杀后续的流程,简化了收货地址等信息。
@Override
public 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());// 用户ID
order.setNum(num);// 商品上来给你
order.setPromotionSessionId(seckillSkuRedisTo.getPromotionSessionId());// 场次id
order.setSkuId(seckillSkuRedisTo.getSkuId());// 商品id
order.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服务略,在订单服务中创建队列和绑定关系,发送给订单队列。
@Bean
public Queue orderSeckillOrderQueue() {
return new Queue("order.seckill.order.queue", true, false, false);
}
@Bean
public 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
@Slf4j
public class SeckillOrderListener {
@Autowired
private OrderService orderService;
@RabbitHandler
public 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);
}
}
}
订单成功监听,创建订单代码
@Override
public 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);
}