document
API test

上架秒杀商品

POST

Description or Example

# 知识点 ## ~~为什么上架近三天的秒杀商品?~~ > ~~这主要和业务有关, 上架近三天可以做到预告的效果, 让用户熟知近几天的秒杀情况~~ ### 查询场次的要点 #### 老师查询场次有什么问题? > **老师查询场次的逻辑是, 场次的开始时间在这三天才能被查出来, 这有问题, 因为有可能有些场次在这这三天之前, 但是持续时间超过了这三天, 我们也需要查出来** > ***对于 `ge` 和 `le` 的理解, 我们必须带入字段思考, 不能之思考数值*** ## Redis存储逻辑 ### 场次映射关系的存储 #### 为什么以`开始时间_结束时间`作为`key`, `场次_商品id`作为`value` > **以场次的`开始时间_结束时间`作为`key`, 前提是这个`key`会有一个共用的前缀, 当我们想要将当天的秒杀商品查询出来的时候, 可以先获取前缀, 然后通过判断这两个时间戳来获取对应正确的场次, 从而获取到商品, 最终实现商品的展示** ### 存储的基本思路 > **先存储场次与sku的关联关系, 然后再存储sku与sku详情的关联关系, 最后通过着两层关联关系查询到对应的秒杀商品** ### 为什么关联关系对象封装需要随机码? > **如果不用随机码, 一旦秒杀的请求被不法分子得知, 随便蒙一个SkuId, 有可能会导致, 本来该商品10点秒杀, 他9点就买完了这种情况** ### 为什么关联关系对象封装开始和结束时间戳? > 有一个需求, 需要查询某个商品的秒杀信息, 如果这个商品存在秒杀信息, 有两种情况, 有可能中处于秒杀, 也可能不是, 这关乎着是否需要暴露随机码(避免被恶意攻击), 因此, 我们需要通过这个时间戳来判断是否在秒杀时间段内 ### 为什么存储信号量 > **信号量的作用主要是防止高并发的大招, 如果以极高的并发进来, 最终这些请求都要扣减库存, 假设100w请求进来, 最终秒杀商品有1w个, 那么只有1w的请求是可以成功扣减库存的, 其他99w都不可以, 即, 数据库莫名其妙多承受了99w并发, 数据库压力变大, 容易出现级联崩溃, 因此, 许哟啊信号量** ### 为什么随机码和信号量映射? > **因为秒杀需要用随机码来防止别人来恶意秒杀, 因此, 有了随机码才有了秒杀的门票, 因此, 我们需要通过随机码获取信号量, 避免别人绕过随机码获取信号量** > <font color="red">**即, 避免恶意分子没有随机码也能获取信号量秒杀**</font> ### 为什么信号量数量和秒杀数量一致? > 因为最坏或规定的情况下, 一个人买一件, 最多秒杀数个人去买, 因此, 秒杀数和信号量的大小一致 ### 信号量为什么不能存储到本地? > **如果信号量存储到本地, 信号量的个数和秒杀个数一样, 那么分布式场景下, 每个节点都有秒杀数的信号量, 那么, 真正的信号量是秒杀数的几倍, 会造成 超卖** ## 分布式下定时任务的问题 ### 重复上架问题 ![image.png](https://cos.easydoc.net/13568421/files/lmo80xty.png) > **在分布式场景下, 每一个微服务凌晨12点都会启动定时任务, 定时上架最新的秒杀商品, 每一个微服务都会上架秒杀商品, 这就会导致同一个秒杀商品被重复上架, 针对于`开始时间_结束时间`和hash的重复提交, 没什么关系**, *++**但是, 针对于随机码的信号量, 不同的节点随机码不同, 会导致Redis中随机码会冗余很多出来, 导致占用了大量的内存**++* ### 如何解决重复上架问题? > **我们可以用Redisson分布式锁来解决重复上架问题, 在上架秒杀商品的时候, 先获取分布式锁的锁资源, 然后判断是否存在对应的数据, 若不存在则上架, 存在则不上架** > <font color="red">**因为上架的操作只需要一次, 不需要考虑并发问题**</font> ### 分布式锁的注意事项 > **分布式锁最好指定锁的释放时间, 避免死锁** ### 多说要点 > ~~**其实这里没有幂等特性的就是随机码-信号量, 防止重复提交主要是避免信号量被存储多次, 其他都具有幂等性, 重复上架是没有问题的**~~ # 额外说明 ## 如果hash中直接用skuId作为key会发生什么? > *如果在hash直接用skuId作为key, 有一个非常严重的后果, 即如果一个商品在前后两个场次都出现了, 而且每个场次的秒杀数不一样, 由于防重机制, 最终只会存储一个sku对应的信息, 没有问题, 但是信号量随之也只存储一个, 这样的话两场共用一个信号量, 如果两场加起来为400+600的秒杀量, 最终可能只有400的秒杀量, 莫名其妙吞掉了600的秒杀量* > **因此, 场次_skuId这样才能区分出不同的场次的秒杀, 这样才不会吞** ## 为了避免同一个商品的信号量重复, 能不能判断随机码? > 肯定是不行的, 随机码不同的时间一定不同, 即不同的微服务的随机码一定不同, 如果判断随机码, 信号量一定会重复 > ***但是, 我们可以换一个角度, 随机码是Sku商品的属性, 因此, 如果SKu商品存在了, 随机码对应的信号量也存在了, 因此, 我们可以判断`场次_skuId`来判断是否需要信号量*** ## 没有锁行不行? > 虽然整个逻辑是幂等的, 但是, 如果没有锁, 可能出现一种情况, 即它们可能同时判断到没有对应的key, 然后同时添加, 这还是会有重复提交问题, 更严重的是, 它们可能会同时判断到hash里面没有对应的key, 导致随机码的信号量冗余 > **没有锁不行, 因为, 整个流程没有原子性, 容易被别人干扰** # Bug修复 ## 格式化`LocalDatTime`日志的注意事项 > **我们不能直接用`LocalDateTime`和数据库里面的日期比较, 因为两者的格式不一样, 因此, 我们需要格式化日期** ## 随机码和对象里面的随机码不一致问题 > **这里我们采取了批量保存策略, 没有加以判断, 虽然随机码加以判断了, 但是这里没有加以判断, 导致每一次添加都会获取到不同的随机码, 分布式场景下的多次提交就会产生随机码不一致问题** > **因此, 最好别用批量保存** > **注意: `yyyy-MM-dd hh:mm:ss`这种这种格式化策略是错误的, 因为hh只有12个小时, 我们应该使用HH, 即`yyyy-MM-dd HH:mm:ss`** # 上架流程图 ![image.png](https://cos.easydoc.net/13568421/files/lmoorwvs.png) # 核心代码 ps: 注意Redisson相关的依赖和配置 ```java /** * 上架秒杀商品 */ @Scheduled(cron = "30 0 0 * * ?") // 每天的凌晨00:00:30点都上架商品 @Async public void putSecKillSku() { secKillService.putSecKillSkus(); } ``` ```java @Service public class SecKillServiceImpl implements SecKillService { @Autowired private CouponService couponService; @Autowired private StringRedisTemplate redisTemplate; @Autowired private RedissonClient redissonClient; @Override public void putSecKillSkus() { // 获取秒杀的分布式锁 RLock rLock = redissonClient.getLock(SecKillConstant.SEC_KILL_REDISSON_LOCK); rLock.lock(10, TimeUnit.SECONDS); try { List<SeckillSessionTO> secKillSessionTOS = getSecKillSessionTOS(); if (secKillSessionTOS != null && !secKillSessionTOS.isEmpty()) { for (SeckillSessionTO secKillSessionTO : secKillSessionTOS) { List<SeckillSkuTO> secKillSkuTOS = saveSessionOnRedis(secKillSessionTO); if (secKillSkuTOS != null && !secKillSkuTOS.isEmpty()) { // 继续存储 商品详情信息 saveSessionSkuRelation(secKillSessionTO, secKillSkuTOS); } } } } finally { rLock.unlock(); } } /** * 存储信号量 及其 商品详情 * @param secKillSessionTO * @param secKillSkuTOS */ private void saveSessionSkuRelation(SeckillSessionTO secKillSessionTO, List<SeckillSkuTO> secKillSkuTOS) { // 获取哈希的操作 BoundHashOperations<String, String, String> operations = redisTemplate.boundHashOps(SecKillConstant.SEC_KILL_SKU_MAP_SKU_INFO); for (SeckillSkuTO secKillSkuTO : secKillSkuTOS) { String key = secKillSessionTO.getId() + "_" + secKillSkuTO.getSkuId(); // hash中的key if (Boolean.FALSE.equals(operations.hasKey(key))) { // 如果不存在key才存储随机码对应的信号量 // 给每个商品都存储上一个信号量 String randomToken = secKillSkuTO.getRandomToken(); // 随机码 RSemaphore semaphore = redissonClient.getSemaphore(SecKillConstant.SEC_KILL_RANDOM_CODE_SEMAPHORE + randomToken); // 信号量 semaphore.trySetPermits(secKillSkuTO.getSeckillCount().intValue()); // 设置信号量数量 // 设置信号量的过期时间 initExpire(SecKillConstant.SEC_KILL_RANDOM_CODE_SEMAPHORE + randomToken, secKillSessionTO.getEndTime().getTime(), secKillSessionTO.getStartTime().getTime()); operations.put(key, JSON.toJSONString(secKillSkuTO)); // 保存相关的关联关系 } } } /** * 存储场次 * @param secKillSessionTO * @return */ private List<SeckillSkuTO> saveSessionOnRedis(SeckillSessionTO secKillSessionTO) { long startTime = secKillSessionTO.getStartTime().getTime(); // 开始时间的时间戳 long endTime = secKillSessionTO.getEndTime().getTime(); // 结束时间的时间戳 String key = SecKillConstant.SEC_KILL_SESSION_PREFIX + startTime + "_" + endTime; List<SeckillSkuTO> secKillSkuTOS = secKillSessionTO.getSecKillSkuTOS(); // 获取所有的关联关系 if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) { if (secKillSkuTOS != null && !secKillSkuTOS.isEmpty()) { List<String> values = secKillSkuTOS.stream().map(secKillSkuTO -> secKillSessionTO.getId() + "_" + secKillSkuTO.getSkuId()).collect(Collectors.toList()); redisTemplate.opsForList().rightPushAll(key, values); // 关联关系存储完成 // 设置过期时间(关联关系) initExpire(key, endTime, startTime); } } return secKillSkuTOS; } /** * 获取场次信息 * @return */ private List<SeckillSessionTO> getSecKillSessionTOS() { R info = couponService.getCurrentSessionAndRelationSkuId(); // 最近三天的场次和对应的商品关联关系 return info.getData(new TypeReference<List<SeckillSessionTO>>() {}); } } ``` ```java @FeignClient("bitmall-coupon") public interface CouponService { @RequestMapping("/coupon/seckillsession/getCurrent/session/relation") R getCurrentSessionAndRelationSkuId(); } ``` ```java @RequestMapping("/getCurrent/session/relation") public R getCurrentSessionAndRelationSkuId() { // 场次可能有多个, 因此返回一个List集合 List<SeckillSessionTO> seckillSessionTO = seckillSessionService.getCurrentSessionAndRelationSkuId(); return R.ok().setData(seckillSessionTO); } ``` ```java @Override public List<SeckillSessionTO> getCurrentSessionAndRelationSkuId() { // 获取近三天的场次信息 List<SeckillSessionEntity> secKillSessionEntities = getSecKillSessionEntities(); // 存在场次信息的时候 if (secKillSessionEntities != null && !secKillSessionEntities.isEmpty()) { // 找到每个场次的关联关系 return secKillSessionEntities.stream().map(seckillSessionEntity -> { Long sessionId = seckillSessionEntity.getId(); // 场次的Id // 查询该场次对应的关联关系 List<SeckillSkuRelationEntity> secKillSkuRelationEntities = getSecKillSkuRelationEntities(sessionId); // 存在关联关系的时候 if (secKillSkuRelationEntities != null && !secKillSkuRelationEntities.isEmpty()) { SeckillSessionTO seckillSessionTO = new SeckillSessionTO(); // 目标场次对象(内含关联关系) BeanUtils.copyProperties(seckillSessionEntity, seckillSessionTO); // 拷贝对象 List<SeckillSkuTO> secKillSkuTOS = secKillSkuRelationEntities.stream().map(seckillSkuRelationEntity -> { Long skuId = seckillSkuRelationEntity.getSkuId(); // 通过SkuId查询商品的详情信息 SeckillSkuTO seckillSkuTO = new SeckillSkuTO(); // sku秒杀对象 BeanUtils.copyProperties(seckillSkuRelationEntity, seckillSkuTO); // 拷贝对象 SkuInfoTO skuInfoTO = getSkuInfoTO(skuId); // 获取sku基本信息 return seckillSkuTO .setSkuInfoTO(skuInfoTO) .setStartTime(seckillSessionEntity.getStartTime().getTime()) .setEndTime(seckillSessionEntity.getEndTime().getTime()) .setRandomToken(UUID.randomUUID().toString().replace("-","")); }).collect(Collectors.toList()); return seckillSessionTO.setSecKillSkuTOS(secKillSkuTOS); } return null; }).collect(Collectors.toList()); } return null; } /** * 获取sku基本信息 * @param skuId * @return */ private SkuInfoTO getSkuInfoTO(Long skuId) { R skuInfo = productService.getSkuInfo(skuId); SkuInfoTO skuInfo1 = skuInfo.getData("skuInfo", new TypeReference<SkuInfoTO>() { }); return skuInfo1; //todo: 逆天, 只有这样可以获取数据 } /** * 查询每一个场次对应的关联关系集合 * @param sessionId * @return */ private List<SeckillSkuRelationEntity> getSecKillSkuRelationEntities(Long sessionId) { LambdaQueryWrapper<SeckillSkuRelationEntity> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(SeckillSkuRelationEntity::getPromotionSessionId, sessionId); return seckillSkuRelationService.list(wrapper); } /** * 获取近三天的场次信息集合 * @return */ private List<SeckillSessionEntity> getSecKillSessionEntities() { String startTime = getStartTime(); String endTime = getEndTime(); LambdaQueryWrapper<SeckillSessionEntity> queryWrapper = new LambdaQueryWrapper<>(); // 这里相当于 当前时间在创建时间和结束时间的区间范围内 queryWrapper.le(SeckillSessionEntity::getStartTime, startTime) .ge(SeckillSessionEntity::getEndTime, endTime); return this.list(queryWrapper); } private String getEndTime() { LocalDateTime endTime = LocalDateTime.of(LocalDate.now().plusDays(2), LocalTime.MAX); // 两天后的23:59:59 // LocalDate.now().plusDays(2) 获取的是两天后, 因为两天加今天就是3天, 最后一天不能0点, 如果0点就变成两天了, 所以要最大时间23:59:59 return endTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); } private String getStartTime() { // 1. 构建时间时间区间条件 LocalDateTime startTime = LocalDateTime.of(LocalDate.now(), LocalTime.MIN); // 当前的0点 // LocalDate.now() 获取的是当前, LocalTime.MIN 获取的是最小时间, 当前的最小时间就是00:00:00 return startTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); } ```