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"));
}
```