1、秒杀活动
1.1、定时任务
/**
* 定时任务
* 1、@EnableScheduling 开启定时任务
* 2、@Scheduled开启一个定时任务
*
* 异步任务
* 1、@EnableAsync:开启异步任务
* 2、@Async:给希望异步执行的方法标注
*/
@Slf4j
@Component
@EnableAsync
@EnableScheduling
public class HelloScheduled {
/**
* 1、在Spring中表达式是6位组成,不允许第七位的年份
* 2、在周几的的位置,1-7代表周一到周日
* 3、定时任务不该阻塞。默认是阻塞的
* 1)、可以让业务以异步的方式,自己提交到线程池
* CompletableFuture.runAsync(() -> {
* },execute);
*
* 2)、支持定时任务线程池;设置 TaskSchedulingProperties
* spring.task.scheduling.pool.size: 5
*
* 3)、让定时任务异步执行
* 异步任务
*
* 解决:使用异步任务 + 定时任务来完成定时任务不阻塞的功能
*
*/
@Async
@Scheduled(cron = "*/5 * * ? * 4")
public void hello() {
log.info("hello...");
try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); }
}
}
1.2、秒杀商品定时上架
1.2.1、业务流程图
1.2.2、代码实现
/**
* 秒杀商品定时上架
* 每天晚上3点,上架最近三天需要三天秒杀的商品
* 当天00:00: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;
//秒杀商品上架功能的锁
private final String upload_lock = "seckill:upload:lock";
//TODO 保证幂等性问题
// @Scheduled(cron = "*/5 * * * * ? ")
@Scheduled(cron = "0 0 1/1 * * ? ")
public void uploadSeckillSkuLatest3Days() {
//1、重复上架无需处理
log.info("上架秒杀的商品...");
//分布式锁
RLock lock = redissonClient.getLock(upload_lock);
try {
//加锁
lock.lock(10, TimeUnit.SECONDS);
seckillService.uploadSeckillSkuLatest3Days();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
uploadSeckillSkuLatest3Days
private final String SESSION__CACHE_PREFIX = "seckill:sessions:";
private final String SECKILL_CHARE_PREFIX = "seckill:skus";
private final String SKU_STOCK_SEMAPHORE = "seckill:stock:"; //+商品随机码
@Override
public void uploadSeckillSkuLatest3Days() {
//1、扫描最近三天的商品需要参加秒杀的活动
R lates3DaySession = couponFeignService.getLates3DaySession();
if (lates3DaySession.getCode() == 0) {
//上架商品
List<SeckillSessionWithSkusVo> sessionData = lates3DaySession.getData("data", new TypeReference<List<SeckillSessionWithSkusVo>>() {
});
//缓存到Redis
//1、缓存活动信息
saveSessionInfos(sessionData);
//2、缓存活动的关联商品信息
saveSessionSkuInfo(sessionData);
}
}
缓存活动信息 saveSessionInfos
/**
* 缓存秒杀活动信息
* @param sessions
*/
private void saveSessionInfos(List<SeckillSessionWithSkusVo> sessions) {
sessions.stream().forEach(session -> {
//获取当前活动的开始和结束时间的时间戳
long startTime = session.getStartTime().getTime();
long endTime = session.getEndTime().getTime();
//存入到Redis中的key
String key = SESSION__CACHE_PREFIX + startTime + "_" + endTime;
//判断Redis中是否有该信息,如果没有才进行添加
Boolean hasKey = redisTemplate.hasKey(key);
//缓存活动信息
if (!hasKey) {
//获取到活动中所有商品的skuId
List<String> skuIds = session.getRelationSkus().stream()
.map(item -> item.getPromotionSessionId() + "-" + item.getSkuId().toString()).collect(Collectors.toList());
redisTemplate.opsForList().leftPushAll(key,skuIds);
}
});
}
缓存活动的关联商品信息 saveSessionSkuInfo
/**
* 缓存秒杀活动所关联的商品信息
* @param sessions
*/
private void saveSessionSkuInfo(List<SeckillSessionWithSkusVo> sessions) {
sessions.stream().forEach(session -> {
//准备hash操作,绑定hash
BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
session.getRelationSkus().stream().forEach(seckillSkuVo -> {
//生成随机码
String token = UUID.randomUUID().toString().replace("-", "");
String redisKey = seckillSkuVo.getPromotionSessionId().toString() + "-" + seckillSkuVo.getSkuId().toString();
if (!operations.hasKey(redisKey)) {
//缓存我们商品信息
SeckillSkuRedisTo redisTo = new SeckillSkuRedisTo();
Long skuId = seckillSkuVo.getSkuId();
//1、先查询sku的基本信息,调用远程服务
R info = productFeignService.getSkuInfo(skuId);
if (info.getCode() == 0) {
SkuInfoVo skuInfo = info.getData("skuInfo",new TypeReference<SkuInfoVo>(){});
redisTo.setSkuInfo(skuInfo);
}
//2、sku的秒杀信息
BeanUtils.copyProperties(seckillSkuVo,redisTo);
//3、设置当前商品的秒杀时间信息
redisTo.setStartTime(session.getStartTime().getTime());
redisTo.setEndTime(session.getEndTime().getTime());
//4、设置商品的随机码(防止恶意攻击)
redisTo.setRandomCode(token);
//序列化json格式存入Redis中
String seckillValue = JSON.toJSONString(redisTo);
operations.put(seckillSkuVo.getPromotionSessionId().toString() + "-" + seckillSkuVo.getSkuId().toString(),seckillValue);
//如果当前这个场次的商品库存信息已经上架就不需要上架
//5、使用库存作为分布式Redisson信号量(限流)
// 使用库存作为分布式信号量
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
// 商品可以秒杀的数量作为信号量
semaphore.trySetPermits(seckillSkuVo.getSeckillCount());
}
});
});
}
1.3、秒杀抢购活动
1.3.1、设计一个高并发的秒杀系统要关注的问题
- 服务单一职责+独立部署:秒杀服务即使自己扛不住压力,挂掉之后,不要影响别的服务使用。
- 秒杀链接加密:
- 防止恶意攻击,模拟秒杀请求,1000次/s攻击。
- 防止链接暴露,只有秒杀时间到了,才会一个秒杀商品的一个随机码,防止自己工作人员知道秒杀链接,提前秒杀商品。
- 库存预热+快速扣减,秒杀读多写少。无需每次实时校验库存,我们库存预热,放到redis中,用信号量控制进来秒杀的请求
- 动静分离,nginx做好动静分离。保证秒杀和商品详情页的动态请求才打到后端的服务集群。使用CDN网络,分担本集群压力。
- 恶意请求拦截:识别非法攻击请求并进行拦截,网关层
- 流量错峰:使用各种手段,将流量分担到更大宽度的时间点。比如验证码,加入购物车
- 限流&熔断&降级:前端限流+后端限流,限制次数,限制总量,快速失败降级运行,熔断隔离防止雪崩
- 队列削峰:1万个商品,每个1000件秒杀。双11所有秒杀成功的请求,进入队列,慢慢创建,订单,扣减库存即可。
1.3.1.1、购物车流程
和普通购买流程一致,优点是可以整体分担秒杀的并发量,但是整体的并发压力较高,例如:京东,小米的秒杀活动。
1.3.1.2、立即抢购,下订单
秒杀作为一个独立的流程,不操作数据库和调用其他的服务,速度比较快,也不会影响其他的服务,缺点就是需要对秒杀进行单独的业务处理。
- 商品服务定时上架、缓存秒杀活动、缓存秒杀商品,具体代码参考1.2
- 分布式信号量的配置、消息配置、秒杀队列、yml
分布式信号量的配置
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.56.102:6379");
RedissonClient redisson = Redisson.create(config);
return redisson;
}
}
pom
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<!-- 消息队列-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
消息配置
@Configuration
public class MyAmqpConfig {
@Bean
public MessageConverter messageConverter() {
//在容器中导入Json的消息转换器
return new Jackson2JsonMessageConverter();
}
}
yml
redis:
host: xxxx
port: 6379
database: 0
rabbitmq:
host: /
username: xx
password: xx
listener:
simple:
acknowledge-mode: manual
秒杀队列
@Configuration
public class MyRabbitMQConfig {
/**
* 商品秒杀队列
* @return
*/
@Bean
public Queue orderSecKillOrrderQueue() {
Queue queue = new Queue("order.seckill.order.queue", true, false, false);
return queue;
}
@Bean
public Binding orderSecKillOrrderQueueBinding() {
//String destination, DestinationType destinationType, String exchange, String routingKey,
// Map<String, Object> arguments
Binding binding = new Binding(
"order.seckill.order.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.seckill.order",
null);
return binding;
}
}
- 商品秒杀
/**
* 商品进行秒杀(秒杀开始)
* @param killId
* @param key
* @param num
* @return
*/
@GetMapping(value = "/kill")
public String seckill(@RequestParam("killId") String killId,
@RequestParam("key") String key,
@RequestParam("num") Integer num,
Model model) {
String orderSn = null;
try {
//1、判断是否登录
orderSn = seckillService.kill(killId,key,num);
model.addAttribute("orderSn",orderSn);
} catch (Exception e) {
e.printStackTrace();
}
return "success";
}
kill(String killId, String key, Integer num)
/**
* 当前商品进行秒杀(秒杀开始)
* @param killId
* @param key
* @param num
* @return
*/
@Override
public String kill(String killId, String key, Integer num) throws InterruptedException {
long s1 = System.currentTimeMillis();
//获取当前用户的信息
MemberResponseVo user = LoginUserInterceptor.loginUser.get();
//1、获取当前秒杀商品的详细信息从Redis中获取
BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
String skuInfoValue = hashOps.get(killId);
if (StringUtils.isEmpty(skuInfoValue)) {
return null;
}
//(合法性效验)
SeckillSkuRedisTo redisTo = JSON.parseObject(skuInfoValue, SeckillSkuRedisTo.class);
Long startTime = redisTo.getStartTime();
Long endTime = redisTo.getEndTime();
long currentTime = System.currentTimeMillis();
//判断当前这个秒杀请求是否在活动时间区间内(效验时间的合法性)
if (currentTime >= startTime && currentTime <= endTime) {
//2、效验随机码和商品id
String randomCode = redisTo.getRandomCode();
String skuId = redisTo.getPromotionSessionId() + "-" +redisTo.getSkuId();
if (randomCode.equals(key) && killId.equals(skuId)) {
//3、验证购物数量是否合理和库存量是否充足
Integer seckillLimit = redisTo.getSeckillLimit();
//获取信号量
String seckillCount = redisTemplate.opsForValue().get(SKU_STOCK_SEMAPHORE + randomCode);
Integer count = Integer.valueOf(seckillCount);
//判断信号量是否大于0,并且买的数量不能超过库存
if (count > 0 && num <= seckillLimit && count > num ) {
//4、验证这个人是否已经买过了(幂等性处理),如果秒杀成功,就去占位。userId-sessionId-skuId
//SETNX 原子性处理
String redisKey = user.getId() + "-" + skuId;
//设置自动过期(活动结束时间-当前时间)
Long ttl = endTime - currentTime;
Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl, TimeUnit.MILLISECONDS);
if (aBoolean) {
//占位成功说明从来没有买过,分布式锁(获取信号量-1)
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + randomCode);
//TODO 秒杀成功,快速下单
boolean semaphoreCount = semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);
//保证Redis中还有商品库存
if (semaphoreCount) {
//创建订单号和订单信息发送给MQ
// 秒杀成功 快速下单 发送消息到 MQ 整个操作时间在 10ms 左右
String timeId = IdWorker.getTimeId();
SeckillOrderTo orderTo = new SeckillOrderTo();
orderTo.setOrderSn(timeId);
orderTo.setMemberId(user.getId());
orderTo.setNum(num);
orderTo.setPromotionSessionId(redisTo.getPromotionSessionId());
orderTo.setSkuId(redisTo.getSkuId());
orderTo.setSeckillPrice(redisTo.getSeckillPrice());
rabbitTemplate.convertAndSend("order-event-exchange","order.seckill.order",orderTo);
long s2 = System.currentTimeMillis();
log.info("耗时..." + (s2 - s1));
return timeId;
}
}
}
}
}
long s3 = System.currentTimeMillis();
log.info("耗时..." + (s3 - s1));
return null;
}
秒杀队列监听
package com.song.gulimall.order.listener;
import com.rabbitmq.client.Channel;
import com.song.common.to.mq.SeckillOrderTo;
import com.song.gulimall.order.service.OrderService;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
@RabbitListener(queues = "order.seckill.order.queue")
public class SeckillOrderListener {
@Autowired
private OrderService orderService;
@RabbitHandler
public void createOrder(SeckillOrderTo orderTo, Message message, Channel channel) throws IOException {
System.out.println("***********接收到秒杀消息");
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
orderService.createSeckillOrder(orderTo);
channel.basicAck(deliveryTag, false);
} catch (Exception e) {
channel.basicReject(deliveryTag,true);
}
}
}
创建秒杀订单
/**
* 创建秒杀单
* @param orderTo
*/
@Override
public void createSeckillOrder(SeckillOrderTo orderTo) {
//TODO 保存订单信息
OrderEntity orderEntity = new OrderEntity();
orderEntity.setOrderSn(orderTo.getOrderSn());
orderEntity.setMemberId(orderTo.getMemberId());
orderEntity.setCreateTime(new Date());
BigDecimal totalPrice = orderTo.getSeckillPrice().multiply(BigDecimal.valueOf(orderTo.getNum()));
orderEntity.setPayAmount(totalPrice);
orderEntity.setStatus(OrderStatusEnum.CREATE_NEW.getCode());
//保存订单
this.save(orderEntity);
//保存订单项信息
OrderItemEntity orderItem = new OrderItemEntity();
orderItem.setOrderSn(orderTo.getOrderSn());
orderItem.setRealAmount(totalPrice);
orderItem.setSkuQuantity(orderTo.getNum());
//保存商品的spu信息
R spuInfo = productFeignService.getSpuInfoBySkuId(orderTo.getSkuId());
SpuInfoVo spuInfoData = spuInfo.getData("data", new TypeReference<SpuInfoVo>() {
});
orderItem.setSpuId(spuInfoData.getId());
orderItem.setSpuName(spuInfoData.getSpuName());
orderItem.setSpuBrand(spuInfoData.getBrandName());
orderItem.setCategoryId(spuInfoData.getCatalogId());
//保存订单项数据
orderItemService.save(orderItem);
}
//K: SESSION_CACHE_PREFIX + startTime + "_" + endTime
//V: sessionId+"-"+skuId的List
private final String SESSION_CACHE_PREFIX = "seckill:sessions:";
//K: 固定值SECKILL_CHARE_PREFIX
//V: hash,k为sessionId+"-"+skuId,v为对应的商品信息SeckillSkuRedisTo
private final String SECKILL_CHARE_PREFIX = "seckill:skus";
//K: SKU_STOCK_SEMAPHORE+商品随机码
//V: 秒杀的库存件数
private final String SKU_STOCK_SEMAPHORE = "seckill:stock:"; //+商品随机码
// 上架最近3天的秒杀活动
public void uploadSeckillSkuLatest3Days() {
R r = couponFeignService.getSeckillSessionsIn3Days();
if (r.getCode() == 0) {
List<SeckillSessionWithSkusVo> sessions = r.getData(new TypeReference<List<SeckillSessionWithSkusVo>>() {
});
//在redis中分别保存秒杀场次信息和场次对应的秒杀商品信息
saveSecKillSession(sessions);
saveSecKillSku(sessions);
}
}
private void saveSecKillSession(List<SeckillSessionWithSkusVo> sessions) {
sessions.stream().forEach(session->{
String key = SESSION_CACHE_PREFIX + session.getStartTime().getTime() + "_" + session.getEndTime().getTime();
//当前活动信息未保存过
if (!redisTemplate.hasKey(key)){
List<String> values = session.getRelations().stream()
.map(sku -> sku.getPromotionSessionId() +"-"+ sku.getSkuId())
.collect(Collectors.toList());
redisTemplate.opsForList().leftPushAll(key,values);
}
});
}
private void saveSecKillSku(List<SeckillSessionWithSkusVo> sessions) {
BoundHashOperations<String, Object, Object> ops = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
sessions.stream().forEach(session->{
session.getRelations().stream().forEach(sku->{
String key = sku.getPromotionSessionId() +"-"+ sku.getSkuId();
if (!ops.hasKey(key)){
SeckillSkuRedisTo redisTo = new SeckillSkuRedisTo();
//1. 保存SeckillSkuVo信息
BeanUtils.copyProperties(sku,redisTo);
//2. 保存开始结束时间
redisTo.setStartTime(session.getStartTime().getTime());
redisTo.setEndTime(session.getEndTime().getTime());
//3. 远程查询sku信息并保存
R r = productFeignService.info(sku.getSkuId());
if (r.getCode() == 0) {
SkuInfoVo skuInfo = r.getData("skuInfo", new TypeReference<SkuInfoVo>() {
});
redisTo.setSkuInfoVo(skuInfo);
}
//4. 生成商品随机码,防止恶意攻击
String token = UUID.randomUUID().toString().replace("-", "");
redisTo.setRandomCode(token);
//5. 序列化为json并保存
String jsonString = JSON.toJSONString(redisTo);
ops.put(key,jsonString);
//5. 使用库存作为Redisson信号量限制库存
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
semaphore.trySetPermits(sku.getSeckillCount());
}
});
});
}
秒杀
@Override
public String kill(String killId, String key, Integer num) throws InterruptedException {
BoundHashOperations<String, String, String> ops = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
String json = ops.get(killId);
String orderSn = null;
if (!StringUtils.isEmpty(json)){
SeckillSkuRedisTo redisTo = JSON.parseObject(json, SeckillSkuRedisTo.class);
//1. 验证时效
long current = System.currentTimeMillis();
if (current >= redisTo.getStartTime() && current <= redisTo.getEndTime()) {
//2. 验证商品和商品随机码是否对应
String redisKey = redisTo.getPromotionSessionId() + "-" + redisTo.getSkuId();
if (redisKey.equals(killId) && redisTo.getRandomCode().equals(key)) {
//3. 验证当前用户是否购买过
MemberResponseVo memberResponseVo = LoginInterceptor.loginUser.get();
long ttl = redisTo.getEndTime() - System.currentTimeMillis();
//3.1 通过在redis中使用 用户id-skuId 来占位看是否买过
Boolean occupy = redisTemplate.opsForValue().setIfAbsent(memberResponseVo.getId()+"-"+redisTo.getSkuId(), num.toString(), ttl, TimeUnit.MILLISECONDS);
//3.2 占位成功,说明该用户未秒杀过该商品,则继续
if (occupy){
//4. 校验库存和购买量是否符合要求
if (num <= redisTo.getSeckillLimit()) {
//4.1 尝试获取库存信号量
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + redisTo.getRandomCode());
boolean acquire = semaphore.tryAcquire(num,100,TimeUnit.MILLISECONDS);
//4.2 获取库存成功
if (acquire) {
//5. 发送消息创建订单
//5.1 创建订单号
orderSn = IdWorker.getTimeId();
//5.2 创建秒杀订单to
SeckillOrderTo orderTo = new SeckillOrderTo();
orderTo.setMemberId(memberResponseVo.getId());
orderTo.setNum(num);
orderTo.setOrderSn(orderSn);
orderTo.setPromotionSessionId(redisTo.getPromotionSessionId());
orderTo.setSeckillPrice(redisTo.getSeckillPrice());
orderTo.setSkuId(redisTo.getSkuId());
//5.3 发送创建订单的消息
rabbitTemplate.convertAndSend("order-event-exchange", "order.seckill.order", orderTo);
}
}
}
}
}
return orderSn;
}
return null;
}