持续学习&持续更新中…
守破离
【雷丰阳-谷粒商城 】【分布式高级篇-微服务架构篇】【28】秒杀
- 秒杀
- 高并发(秒杀)系统关注的问题
- 高并发有三宝
- 秒杀商品定时上架
- 秒杀核心流程
- Java8的日期API简单使用
- 参考
秒杀
秒杀具有瞬间高并发的特点,针对这一特点,必须要做限流 + 异步 + 缓存 + 动静分离 + 独立部署。
限流方式:
- 前端限流,一些高并发的网站直接在前端页面开始限流,例如:小米的验证码设计
- nginx 限流,直接负载部分请求到错误的静态页面:令牌算法/漏斗算法
- 网关限流,限流的过滤器
- 代码中使用分布式信号量
- rabbitmq 限流(能者多劳:chanel.basicQos(1)),保证发挥所有服务器的性能。
高并发(秒杀)系统关注的问题
高并发有三宝
- 缓存
- 异步
- 对拍好
秒杀商品定时上架
思路:
-
每天晚上3点上架最近三天的秒杀商品。【这个时间段服务器压力较小,并且比较空闲】,而且上架最近3天的商品,可以预告给用户最近三天哪个商品什么时间将要开启秒杀
-
缓存到redis
-
商品秒杀随机码:
- 没有随机码的话,秒杀接口假如是这样:
seckill?skuId=1
,这样不安全容易引起脚本攻击秒杀 - 有了随机码:
seckill?skuId=1&key=sfuhgregsfds2fdsf4
,商品开始了秒杀这个随机码才会暴露出来;就算你知道哪个商品要秒杀没有随机码你也秒杀不了 - 随机码还可以用于当作该商品的分布式信号量,来限流
- 没有随机码的话,秒杀接口假如是这样:
-
redis中数据保存结构设计:
-
秒杀场次:
- key:
"seckill:sessions:" + startTime + "_" + endTime
- value:
[getPromotionSessionId() + "_" + getSkuId(), ...]
:是一个List
- key:
-
秒杀商品信息:
- key:
seckill:skus
- value: HashMap
- HashMap里面存着:
- key:
getPromotionSessionId() + "_" + getSkuId()
- value: 商品的信息Json字符串
- key:
-
分布式信号量RSemaphore:使用商品的数量作为分布式的信号量【限流】。只有请求里携带了秒杀商品的随机码,才可以减信号量,如果不带随机码直接减信号量的话,可能秒杀还没开始,有一些恶意请求,就把信号量就减了。
- key:
"seckill:stock:" + 商品随机码
- value:
getSeckillCount()
- key:
-
-
redis中的数据要有过期时间
-
使用
分布式锁+业务判断
保证幂等性:锁的业务执行完成,状态已经更新完成。释放锁以后,其他人获取到锁去执行业务,会拿到最新的状态去判断,做好判断就不会重复执行- 分布式锁:Redisson【集群环境下,让一个服务实例去执行上架即可】,加上分布式锁后,在业务方法里做好判断,即可保证幂等性
- 业务判断:
- 如果当前这个场次已经上架就不需要上架
- 如果当前这个场次的这个商品的库存信息已经上架就不需要上架
-
注意:
- 上架成功后,锁定对应数量的库存,一切都去Redis操作
- 秒杀结束后,从Redis中根据销售情况,将没卖完的库存加回去
代码实现:
/**
* 秒杀商品的定时上架;
* 每天晚上3点;上架最近三天需要秒杀的商品。
* 当天00:00:00 - 23:59:59
* 明天00:00:00 - 23:59:59
* 后天00:00:00 - 23:59:59
*/
@Slf4j
@Service
public class SeckillSkuScheduled {
@Autowired
SeckillService seckillService;
@Autowired
RedissonClient redissonClient;
private static final String SECKILL_UPLOAD_LOCK = "seckill:upload:lock";
@Async
@Scheduled(cron = "*/3 * * * * ?")
// @Scheduled(cron = "0 * * * * ?") //每分钟执行一次吧,上线后调整为每天晚上3点执行
// @Scheduled(cron = "0 0 3 * * ?") //线上模式
public void uploadSeckillSkuLatest3Days(){
log.info("上架秒杀的商品信息...");
// seckillService.uploadSeckillSkuLatest3Days();
//TODO 写博客
// 分布式锁【集群环境下,让一个服务实例去执行上架即可】
// 加上分布式锁后,在业务方法里做好判断,即可保证幂等性:
// 锁的业务执行完成,状态已经更新完成。释放锁以后。其他人获取到锁去执行业务,会拿到最新的状态去判断,不会重复执行
RLock lock = redissonClient.getLock(SECKILL_UPLOAD_LOCK);
lock.lock(10, TimeUnit.SECONDS);
try {
seckillService.uploadSeckillSkuLatest3Days();
} finally {
lock.unlock();
}
}
}
private static final String SESSIONS_CACHE_PREFIX = "seckill:sessions:";
private static final String SKUKILL_CACHE_PREFIX = "seckill:skus";
private static final String SKU_STOCK_SEMAPHORE = "seckill:stock:";//+商品随机码
/**
* TODO 上架成功后,锁定对应数量的库存,一切都去Redis操作
* 秒杀结束后,从Redis中根据销售情况,将没卖完的库存加回去
*/
@Override
public void uploadSeckillSkuLatest3Days() {
//扫描最近三天需要参与秒杀的活动
R session = couponFeignService.getLates3DaySession();
if (session.getCode() == 0) {
//上架商品
List<SeckillSesssionsWithSkus> sessionData = session.getData(new TypeReference<List<SeckillSesssionsWithSkus>>() {
});
//缓存到redis
//1、缓存活动信息
saveSessionInfos(sessionData);
//2、缓存活动的关联商品信息
saveSessionSkuInfos(sessionData);
}
}
private void saveSessionInfos(List<SeckillSesssionsWithSkus> sesssions) {
if (sesssions != null) sesssions.stream().forEach(session -> {
Long startTime = session.getStartTime().getTime();
Long endTime = session.getEndTime().getTime();
// 如果当前这个场次已经上架就不需要上架
String key = SESSIONS_CACHE_PREFIX + startTime + "_" + endTime;
if (!redisTemplate.hasKey(key)) {
List<String> collect = session.getRelationSkus().stream().map(item -> item.getPromotionSessionId() + "_" + item.getSkuId()).collect(Collectors.toList());
//缓存活动信息
redisTemplate.opsForList().leftPushAll(key, collect);
//过期时间
redisTemplate.expireAt(key, new Date(endTime));
}
});
}
private void saveSessionSkuInfos(List<SeckillSesssionsWithSkus> sesssions) {
if (sesssions != null) sesssions.stream().forEach(sesssion -> {
//准备hash操作
List<SeckillSkuVo> relationSkus = sesssion.getRelationSkus();
if (relationSkus != null && relationSkus.size() > 0) {
BoundHashOperations<String, Object, Object> ops = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
relationSkus.stream().forEach(seckillSkuVo -> {
//4、随机码? seckill?skuId=1&=dadlajldj
final String token = UUID.randomUUID().toString().replace("-", "");
// 如果当前这个场次的这个商品的库存信息已经上架就不需要上架
final String cacheKey = seckillSkuVo.getPromotionSessionId() + "_" + seckillSkuVo.getSkuId();
if (!ops.hasKey(cacheKey)) {
//缓存商品
SecKillSkuRedisTo redisTo = new SecKillSkuRedisTo();
//1、sku的基本数据
R skuInfo = productFeignService.getSkuInfo(seckillSkuVo.getSkuId());
if (skuInfo.getCode() == 0) {
SkuInfoVo info = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() {
});
redisTo.setSkuInfo(info);
}
//2、sku的秒杀信息
BeanUtils.copyProperties(seckillSkuVo, redisTo);
//3、设置上当前商品的秒杀时间信息
redisTo.setStartTime(sesssion.getStartTime().getTime());
redisTo.setEndTime(sesssion.getEndTime().getTime());
redisTo.setRandomCode(token);
String jsonString = JSON.toJSONString(redisTo);
//每个商品的过期时间不一样。所以,我们在获取当前商品秒杀信息的时候,做主动删除,代码在 getSkuSeckillInfo 方法里面
ops.put(cacheKey, jsonString);
//TODO 写博客
// 使用商品的数量作为分布式的信号量【限流】
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
//商品可以秒杀的数量作为信号量
semaphore.trySetPermits(seckillSkuVo.getSeckillCount());
//设置过期时间。
semaphore.expireAt(sesssion.getEndTime());
}
});
}
});
}
redis保存结果:
场次信息:
商品信息:
分布式信号量:
秒杀核心流程
流程1:(购物车流程)
流程2:【使用】
秒杀中消息队列的使用:
/**
* 秒杀
*
* @param killId 商品id: 2_1 【场次_skuId】
* @param key 随机码
* @param num 秒杀的数量
*/
// TODO 上架秒杀商品的时候,每一个数据都有过期时间。√
// TODO 秒杀后续的流程
public String kill(String killId, String key, Integer num) {
long s1 = System.currentTimeMillis();
//获取当前秒杀商品的详细信息
BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
String json = hashOps.get(killId);
if (StringUtils.isEmpty(json)) {
return null;
} else {
SecKillSkuRedisTo redis = JSON.parseObject(json, SecKillSkuRedisTo.class);
//校验合法性
Long startTime = redis.getStartTime();
Long endTime = redis.getEndTime();
long currentTime = new Date().getTime();
//1、校验时间的合法性
if (currentTime >= startTime && currentTime <= endTime) {
//2、校验随机码和商品id
String randomCode = redis.getRandomCode();
String promotionSessionId_skuId = redis.getPromotionSessionId() + "_" + redis.getSkuId();
if (randomCode.equals(key) && killId.equals(promotionSessionId_skuId)) {
//3、验证购物数量是否合理
if (num <= redis.getSeckillLimit()) {
//4、验证这个人是否已经购买过。幂等性; 如果只要秒杀成功,就去占位。 userId_promotionSessionId_skuId
//TODO 占位也可以放到一个Redis的Hash里面
MemberRespVo userInfo = LoginUserInterceptor.loginUser.get();
String redisKey = userInfo.getId() + "_" + promotionSessionId_skuId;
//占位得自动过期
long ttl = endTime - currentTime;
//SETNX
Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl, TimeUnit.MILLISECONDS);
if (aBoolean) {
//占位成功说明从来没有买过
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + randomCode);
boolean b = semaphore.tryAcquire(num);
// boolean b = semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);
if (b) {
//秒杀成功;
//快速下单。发送MQ消息
String timeId = IdWorker.getTimeId(); //订单号
SeckillOrderTo orderTo = new SeckillOrderTo();
orderTo.setOrderSn(timeId);
orderTo.setMemberId(userInfo.getId());
orderTo.setNum(num);
orderTo.setPromotionSessionId(redis.getPromotionSessionId());
orderTo.setSkuId(redis.getSkuId());
orderTo.setSeckillPrice(redis.getSeckillPrice());
rabbitTemplate.convertAndSend("order-event-exchange", "order.seckill.order", orderTo);
//TODO MQ监听消息处理
// 用户秒杀成功,信号量不用恢复【商品已经卖出去了】
// 用户如果超时未支付或者取消订单,还得去恢复信号量,让其他人能参与秒杀
long s2 = System.currentTimeMillis();
log.info("耗时...{}", (s2 - s1));
return timeId;
}
return null;
} else {
//说明已经买过了
return null;
}
}
} else {
return null;
}
} else {
return null;
}
}
return null;
}
Java8的日期API简单使用
LocalDate now = LocalDate.now();
LocalDate dayAfter2 = now.plusDays(2);
System.out.println("now : " + now);
System.out.println("两天后 : " + dayAfter2);
LocalTime min = LocalTime.MIN;
LocalTime max = LocalTime.MAX;
System.out.println("最小时间:" + min);
System.out.println("最大时间:" + max);
// 最近三天开始的活动:2024-07-17 00:00:00 到 2024-07-19 23:59:59
LocalDateTime start = LocalDateTime.of(now, min);
LocalDateTime end = LocalDateTime.of(dayAfter2, max);
System.out.println("开始时间:" + start);
System.out.println("结束时间:" + end);
System.out.println("结束时间:" + start.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
System.out.println("结束时间:" + end.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
参考
雷丰阳: Java项目《谷粒商城》Java架构师 | 微服务 | 大型电商项目.
本文完,感谢您的关注支持!