秒杀服务

一、定时任务-Quartz

Cron表达式

执行定时任务需要给一个时间计划,这个时间计划可以用 Cron 表达式来编写

官方文档 Cron 表达式是一个字符串,是用空格分割的六到七个属性。

语法:秒 分 时 日 月 周 年(可忽略年,Spring 不支持年)

定时任务只能精确到秒

Seconds:0-59,举例:0 就是整秒执行,1 就是在第1秒的时候执行

Day of week:值可以写 1-7,也可以写 SUN-SAT,1 就是周日,7 就是周六

①特殊字符

,:枚举

  • (cron="7,9,23 * * * * ?"):代表任意时刻的7,9,23秒启动这个任务;

-:范围

  • (cron="7-20 * * * * ?"):任意时刻的 7-20 秒之间,每秒启动一次

*:任意

  • 指定位置的任意时刻都可以

/:步长

  • (cron="7/5 * * * * ?"):第 7 秒启动,每 5 秒一次;
  • (cron="*/5 * * * * ?"):任意时间启动之后,每 5 秒一次;
?`:(出现在日和周几的位置)为了防止日和周冲突,如果1个精确了,另一个就得写`?
  • (cron="* * * 1 * ?"):每月的 1 号,启动这个任务,如果两个都写精确值的话,可能会导致冲突,所以其中一个要使用?

L:(出现在日和周的位置)”,last:最后一个

  • (cron="* * * ? * 3L"):每月的最后一个周二

W: Work Day:工作日

  • (cron="* * * W * ?"):每个月的工作日触发
  • (cron="* * * LW * ?"):每个月的最后一个工作日触发

#: 第几个

  • (cron="* * * ? * 5#2"):5 代表周 4,#2 代表第 2 个,合起来就是每个月的第 2 个周 4

②示例

表达式 意义
0 0 12 ? 每天的12点执行一次
0 15 10 ? 每天的10:15执行
0 15 10 ? 每天的10:15执行
0 15 10 ? * 每天的10:15执行
0 15 10 ? 2005 2005年每天10:15执行都执行
0 14 * ? 每天14点启动,每隔1分钟执行一次
0 0/5 14 ? 每天14点启动,每隔5分钟执行一次
0 0/5 14,18 ? 每天的14点、18点启动,每隔5分钟执行一次
0 0-5 14 ? 14:00-14:05执行,每分钟执行一次
0 10,44 14 ? 3 WED 3月的每个星期三的14:10 、14:44启动这个任务,每分钟执行一次
0 15 10 ? * MON-FRI 每月的周一到周五,10:15执行
0 15 10 ? * 6L 每月的最后一个周五,10:15执行
0 15 10 ? * 6#3 每月的第3个周5,10:15执行

二、Spring Boot整合定时任务

1、与Quarts的区别

自动配置类参考 TaskSchedulingAutoConfiguration

@Slf4j
@Component
@EnableScheduling   // 开启定时功能
public class HelloSchedule {/***  一、与Quarts的区别*      1、Spring中的定时任务由6位组成,不支持第7位的年*      2、第6位的数字格式,1-7,代表周一到周日,当然也可以写成MON-SUN*      3、其它普遍与Quarts一致*/@Scheduled(cron = "*/5 * * ? * 1") // 开启定时任务public void hello(){log.info("hello");}
}

2、定时任务默认是阻塞的

        /*** 二、定时任务默认是阻塞的*      只要当前任务没执行完,下一个任务就执行不了* @throws InterruptedException*/@Scheduled(cron = "* * * ? * 1")public void block() throws InterruptedException {log.info("hello......");Thread.sleep(3000);}

3、解决定时任务阻塞的方法

  1. 可以使用异步任务的方式,CompletableFuture.runAsync(),自己提交到线程池

  2. 修改配置文件,spring.task.scheduling.pool.size=5

  3. 让定时任务异步执行

①异步任务

  1. 首先在类上面标注@EnableAsync,开启异步任务功能
  2. 然后在方法上标注@Async,执行异步任务
  3. 这个异步任务不是只能搭配定时任务,它可以替代CompletableFuture
  4. 自动配置类参考 TaskExecutionAutoConfiguration
  5. 它在配置文件中的线程池属性是:spring.task.execution.pool.xxx

②最终

使用异步+定时任务来实现定时任务不阻塞


三、定时上架秒杀商品

1、简介

每天凌晨3点,上架最近3天所需要秒杀的商品

因为这个时间段服务器压力较小,并且比较空闲,

上架最近3天的商品,可以给用户一个预告的功能,让用户提前知道哪个商品什么时间将要开启秒杀


2、随机码

为了防止有用户在得知秒杀请求时,发送大量请求对商品进行秒杀,我们采取了随机码的方式,即每个要参加秒杀的商品,都有一个随机码,只有通过正常提交请求的流程才可以获取,否则谁都无法得知随机码是多少,避免了恶意秒杀


3、商品的分布式信号量

信号量保存了当前秒杀商品的库存信息

我们的库存秒杀不应该是实时去数据库扣库存,因为几百万请求进来的时候,如果都去扣,那会直接把数据库压垮。

所以现在秒杀最大的问题就是,如何应对这些高并发的流量

首先,这么大的流量进到服务器的话,肯定有一些流量是无效的,比如秒杀不成功,假设我们现在就一百个商品要被秒杀,哪怕放进来一百万请求,最终也只有一百个请求,能成功的去数据库扣掉库存。

所以我们可以提前在 redis 里边设置一个信号量,这个信号量可以认为是一个自增量,假设这个信号量叫 count,它专门用来计数,它的初始值是 100,每进来一个请求,我们就让这个值减一,如果有用户想要秒杀这个商品,我们先去 redis 里边获取一个信号量,也就是给这一百的库存减一,然后这个值就变成九十九,如果能减成功了,那就把这个请求放行,然后再做后边的处理数据库。如果不能减,那就不用进行后续的操作了,我们只会阻塞很短的时间,就会释放这个请求,我们只有每一个请求都能很快的释放,能很快的做完,我们才能拥有处理大并发的能力。

这块有一个注意点,由于每一个请求进来减这个信号量的值,就是当前商品的库存信息,只有请求里携带了我们给秒杀商品设计的随机码,才可以来减信号量,如果不带随机码的话,直接减信号量的话,就会出现问题,可能秒杀还没开始,有一些恶意请求,就把信号量就减了了。

所以上面说的随机码是一种保护机制。


4、代码

  • 创建秒杀项目模块

  • 引入依赖

  • com.achang.achangmall.coupon.controller.SeckillSessionController

        /*** 查询最近三天需要参加秒杀商品的信息* @return*/@GetMapping(value = "/Lates3DaySession")public R getLates3DaySession() {List<SeckillSessionEntity> seckillSessionEntities = seckillSessionService.getLates3DaySession();return R.ok().setData(seckillSessionEntities);}
  • com.achang.achangmall.coupon.service.impl.SeckillSessionServiceImpl
@Service("seckillSessionService")
public class SeckillSessionServiceImpl extends ServiceImpl<SeckillSessionDao, SeckillSessionEntity> implements SeckillSessionService {@Autowiredprivate SeckillSkuRelationService seckillSkuRelationService;@Overridepublic List<SeckillSessionEntity> getLates3DaySession() {//计算最近三天//查出这三天参与秒杀活动的商品List<SeckillSessionEntity> list = this.baseMapper.selectList(new QueryWrapper<SeckillSessionEntity>().between("start_time", startTime(), endTime()));if (list != null && list.size() > 0) {List<SeckillSessionEntity> collect = list.stream().map(session -> {Long id = session.getId();//查出sms_seckill_sku_relation表中关联的skuIdList<SeckillSkuRelationEntity> relationSkus = seckillSkuRelationService.list(new QueryWrapper<SeckillSkuRelationEntity>().eq("promotion_session_id", id));session.setRelationSkus(relationSkus);return session;}).collect(Collectors.toList());return collect;}return null;}/*** 当前时间*/private String startTime() {LocalDate now = LocalDate.now();LocalTime min = LocalTime.MIN;LocalDateTime start = LocalDateTime.of(now, min);//格式化时间String startFormat = start.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));return startFormat;}/*** 结束时间* @return*/private String endTime() {LocalDate now = LocalDate.now();LocalDate plus = now.plusDays(2);LocalTime max = LocalTime.MAX;LocalDateTime end = LocalDateTime.of(plus, max);//格式化时间String endFormat = end.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));return endFormat;}
}
  • com.achang.achangmall.coupon.entity.SeckillSessionEntity
@TableField(exist = false)
private List<SeckillSkuRelationEntity> relationSkus;
  • com.achang.achangmall.seckill.vo.SeckillSkuVo
@Data
public class SeckillSkuVo {private Long id;/*** 活动id*/private Long promotionId;/*** 活动场次id*/private Long promotionSessionId;/*** 商品id*/private Long skuId;/*** 秒杀价格*/private BigDecimal seckillPrice;/*** 秒杀总量*/private Integer seckillCount;/*** 每人限购数量*/private Integer seckillLimit;/*** 排序*/private Integer seckillSort;}
  • com.achang.achangmall.seckill.config.ScheduledConfig
@Configuration
@EnableAsync
@EnableScheduling
public class ScheduledConfig {}
  • com.achang.achangmall.seckill.feign.CouponFeignService
@FeignClient("achangmall-coupon")
public interface CouponFeignService {/*** 查询最近三天需要参加秒杀商品的信息*/@GetMapping(value = "/coupon/seckillsession/Lates3DaySession")R getLates3DaySession();
}
  • com.achang.achangmall.seckill.to.SeckillSkuRedisTo
    @Datapublic class SeckillSkuRedisTo {/*** 活动id*/private Long promotionId;/*** 活动场次id*/private Long promotionSessionId;/*** 商品id*/private Long skuId;/*** 秒杀价格*/private BigDecimal seckillPrice;/*** 秒杀总量*/private Integer seckillCount;/*** 每人限购数量*/private Integer seckillLimit;/*** 排序*/private Integer seckillSort;//sku的详细信息private SkuInfoVo skuInfo;//当前商品秒杀的开始时间private Long startTime;//当前商品秒杀的结束时间private Long endTime;//当前商品秒杀的随机码private String randomCode;}
  • com.achang.achangmall.seckill.vo.SeckillSessionWithSkusVo
@Data
public class SeckillSessionWithSkusVo {private Long id;/*** 场次名称*/private String name;/*** 每日开始时间*/private Date startTime;/*** 每日结束时间*/private Date endTime;/*** 启用状态*/private Integer status;/*** 创建时间*/private Date createTime;private List<SeckillSkuVo> relationSkus;}
  • com.achang.achangmall.seckill.feign.ProductFeignService
@FeignClient("achangmall-product")
public interface ProductFeignService {@RequestMapping("/product/skuinfo/info/{skuId}")R getSkuInfo(@PathVariable("skuId") Long skuId);
}
  • 引入分布式锁redisson
<dependency><groupId>org.redisson</groupId><artifactId>redisson</artifactId><version>3.13.4</version>
</dependency>
  • com.achang.achangmall.seckill.config.MyRedissonConfig
@Configuration
public class MyRedissonConfig {/*** 所有对Redisson的使用都是通过RedissonClient*/@Bean(destroyMethod="shutdown")public RedissonClient redissonClient() throws IOException {//1、创建配置Config config = new Config();config.useSingleServer().setAddress("redis://192.168.109.101:6379");//2、根据Config创建出RedissonClient实例//Redis url should start with redis:// or rediss://RedissonClient redissonClient = Redisson.create(config);return redissonClient;}}
  • com.achang.achangmall.seckill.service.impl.SeckillServiceImpl
@Service
public class SeckillServiceImpl implements SeckillService {@Autowiredprivate ProductFeignService productFeignService;@Autowiredprivate CouponFeignService couponFeignService;private final String SESSION__CACHE_PREFIX = "seckill:sessions:";private final String SECKILL_CHARE_PREFIX = "seckill:skus";private final String SKU_STOCK_SEMAPHORE = "seckill:stock:";    //+商品随机码@Autowiredprivate RedissonClient redissonClient;@Autowiredprivate StringRedisTemplate redisTemplate;@Overridepublic void uploadSeckillSkuLatest3Days() {R lates3DaySession = couponFeignService.getLates3DaySession();if (lates3DaySession.getCode()==0){List<SeckillSessionWithSkusVo> vo = lates3DaySession.getData(new TypeReference<List<SeckillSessionWithSkusVo>>() {});//缓存到Redis//1、缓存活动信息saveSessionInfos(vo);//2、缓存活动的关联商品信息saveSessionSkuInfo(vo);}}/*** 缓存秒杀活动所关联的商品信息*/private void saveSessionSkuInfo(List<SeckillSessionWithSkusVo> sessions) {sessions.stream().forEach(session -> {//准备hash操作,绑定hashBoundHashOperations<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());}});});}/*** 缓存秒杀活动信息*/private void saveSessionInfos(List<SeckillSessionWithSkusVo> sessions) {sessions.stream().forEach(session -> {//获取当前活动的开始和结束时间的时间戳long startTime = session.getStartTime().getTime();long endTime = session.getEndTime().getTime();//存入到Redis中的keyString key = SESSION__CACHE_PREFIX + startTime + "_" + endTime;//获取到活动中所有商品的skuIdList<String> skuIds = session.getRelationSkus().stream().map(item -> item.getPromotionSessionId() + "-" + item.getSkuId().toString()).collect(Collectors.toList());redisTemplate.opsForList().leftPushAll(key,skuIds);});}
}

四、上架秒杀商品的幂等性保证

1、定时任务在分布式下的问题

分布式系统里边,定时任务会出现这个问题

比如我们这有三台机器:A1、A2、A3,它们代表我们同一个服务的三个副本,然后我们这三台机器,每台都有一个定时任务,因为它们都是同一段程序,定时任务的设置都一样,所以等时间一到,它们都会启动定时任务。一启动定时任务以后,因为将来它们要同时执行后面的上架代码,这就会导致同一个秒杀商品被重复上架3遍。

所以,我们要做的就是不应该让每个机器都执行这个定时任务,应该只让一台机器去执行

  • 解决

我们可以加一个分布式锁,获取到锁的机器才能执行,获取不到锁的机器,可以等获取到锁的机器执行失败,再次获取锁,这样获取到了还可以执行。

如果获取到锁的机器已经把这个任务执行完了,那其它机器也就不需要执行了。


2、幂等性保证

信号量这块,比如现在正在秒杀,由于另外一个机器,它的定时任务可能反应的比较慢,然后在秒杀期间启动了,一启动以后,本来我们这个秒杀商品已经被抢完了,结果由于那个机器的延时启动,又给它设置了一些库存,然后用户又能继续秒杀,这样就会给商家造成很大的损失。

所以在设置信号量以及那些商品数据之前,应该查询当前这个秒杀场次的商品的信息是否已经存在于缓存中,不存在才上架,已存在就不需要上架了,这也是我们锁说的幂等性保证


3、代码

  • com.achang.achangmall.seckill.scheduled.SeckillSkuScheduled
//秒杀商品定时上架任务
@Service
@Slf4j
public class SeckillSkuScheduled {@Autowiredprivate SeckillService seckillService;@Autowiredprivate RedissonClient redissonClient;//秒杀商品上架功能的分布式锁private final String upload_lock = "seckill:upload:lock";//上架最近三天的商品@Scheduled(cron="0 0 3 * * * ?")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();}}
}
  • com.achang.achangmall.seckill.service.impl.SeckillServiceImpl

/*** 缓存秒杀活动信息* @param sessions*/
private void saveSessionInfos(List<SeckillSessionWithSkusVo> sessions) {sessions.stream().forEach(session -> {//获取当前活动的开始和结束时间的时间戳long startTime = session.getStartTime().getTime();long endTime = session.getEndTime().getTime();//存入到Redis中的keyString key = SESSION__CACHE_PREFIX + startTime + "_" + endTime;//判断Redis中是否有该信息,如果没有才进行添加Boolean hasKey = redisTemplate.hasKey(key);//缓存活动信息if (!hasKey) {//获取到活动中所有商品的skuIdList<String> skuIds = session.getRelationSkus().stream().map(item -> item.getPromotionSessionId() + "-" + item.getSkuId().toString()).collect(Collectors.toList());redisTemplate.opsForList().leftPushAll(key,skuIds);}});}

String redisKey = seckillSkuVo.getPromotionSessionId().toString() + "-" + seckillSkuVo.getSkuId().toString();
if (!operations.hasKey(redisKey)) {}

五、首页查询秒杀商品

  • 配置网关

achangmall-gateway/src/main/resources/application.yml

        - id: seckill_routeuri: lb://achangmall-seckillpredicates:- Host=seckill.achangmall.com
  • 配置域名

C:\Windows\System32\drivers\etc\hosts

192.168.109.101 seckill.achangmall.com
  • com.achang.achangmall.seckill.controller.SeckillController
/**
* 当前时间可以参与秒杀的商品信息
*/
@GetMapping(value = "/getCurrentSeckillSkus")
@ResponseBody
public R getCurrentSeckillSkus() {//获取到当前可以参加秒杀商品的信息List<SeckillSkuRedisTo> vos = seckillService.getCurrentSeckillSkus();return R.ok().setData(vos);
}
  • achangmall-seckill/src/main/java/com/achang/achangmall/seckill/service/impl/SeckillServiceImpl.java
    /*** 获取到当前可以参加秒杀商品的信息*/
@Override
public List<SeckillSkuRedisTo> getCurrentSeckillSkus() {//1、确定当前属于哪个秒杀场次long currentTime = System.currentTimeMillis();//从Redis中查询到所有key以seckill:sessions开头的所有数据Set<String> keys = redisTemplate.keys(SESSION__CACHE_PREFIX + "*");for (String key : keys) {//seckill:sessions:1594396764000_1594453242000String replace = key.replace(SESSION__CACHE_PREFIX, "");String[] s = replace.split("_");//获取存入Redis商品的开始时间long startTime = Long.parseLong(s[0]);//获取存入Redis商品的结束时间long endTime = Long.parseLong(s[1]);//判断是否是当前秒杀场次if (currentTime >= startTime && currentTime <= endTime) {//2、获取这个秒杀场次需要的所有商品信息List<String> range = redisTemplate.opsForList().range(key, -100, 100);BoundHashOperations<String, String, String> hasOps = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);assert range != null;List<String> listValue = hasOps.multiGet(range);if (listValue != null && listValue.size() >= 0) {List<SeckillSkuRedisTo> collect = listValue.stream().map(item -> {String items = (String) item;SeckillSkuRedisTo redisTo = JSON.parseObject(items, SeckillSkuRedisTo.class);// redisTo.setRandomCode(null);当前秒杀开始需要随机码return redisTo;}).collect(Collectors.toList());return collect;}break;}}return null;
}
  • com.achang.achangmall.seckill.controller.SeckillController
    /*** 根据skuId查询商品是否参加秒杀活动*/@GetMapping(value = "/sku/seckill/{skuId}")@ResponseBodypublic R getSkuSeckilInfo(@PathVariable("skuId") Long skuId) {SeckillSkuRedisTo to = seckillService.getSkuSeckilInfo(skuId);return R.ok().setData(to);}
  • com.achang.achangmall.seckill.service.impl.SeckillServiceImpl
/*** 根据skuId查询商品是否参加秒杀活动*/
@Override
public SeckillSkuRedisTo getSkuSeckilInfo(Long skuId) {//1、找到所有需要秒杀的商品的key信息---seckill:skusBoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);//拿到所有的keySet<String> keys = hashOps.keys();if (keys != null && keys.size() > 0) {//4-45 正则表达式进行匹配String reg = "\\d-" + skuId;for (String key : keys) {//如果匹配上了if (Pattern.matches(reg,key)) {//从Redis中取出数据来String redisValue = hashOps.get(key);//进行序列化SeckillSkuRedisTo redisTo = JSON.parseObject(redisValue, SeckillSkuRedisTo.class);//随机码Long currentTime = System.currentTimeMillis();Long startTime = redisTo.getStartTime();Long endTime = redisTo.getEndTime();//如果当前时间大于等于秒杀活动开始时间并且要小于活动结束时间if (currentTime >= startTime && currentTime <= endTime) {return redisTo;}redisTo.setRandomCode(null);return redisTo;}}}return null;
}
  • com.achang.achangmall.product.feign.SeckillFeignService
@FeignClient(value = "achangmall-seckill",fallback = SeckillFeignServiceFallBack.class)
public interface SeckillFeignService {/*** 根据skuId查询商品是否参加秒杀活动*/@GetMapping(value = "/sku/seckill/{skuId}")R getSkuSeckilInfo(@PathVariable("skuId") Long skuId);}
  • com.achang.achangmall.product.fallback.SeckillFeignServiceFallBack
@Component
public class SeckillFeignServiceFallBack implements SeckillFeignService {@Overridepublic R getSkuSeckilInfo(Long skuId) {return R.error(BizCodeEnum.TO_MANY_REQUEST.getCode(),BizCodeEnum.TO_MANY_REQUEST.getMessage());}
}
  • com.achang.common.exception.BizCodeEnum
public enum BizCodeEnum {UNKNOW_EXCEPTION(10000,"系统未知异常"),VAILD_EXCEPTION(10001,"参数格式校验失败"),TO_MANY_REQUEST(10002,"请求流量过大,请稍后再试"),SMS_CODE_EXCEPTION(10002,"验证码获取频率太高,请稍后再试"),PRODUCT_UP_EXCEPTION(11000,"商品上架异常"),USER_EXIST_EXCEPTION(15001,"存在相同的用户"),PHONE_EXIST_EXCEPTION(15002,"存在相同的手机号"),NO_STOCK_EXCEPTION(21000,"商品库存不足"),LOGINACCT_PASSWORD_EXCEPTION(15003,"账号或密码错误"),;private Integer code;private String msg;BizCodeEnum(Integer code, String message) {this.code = code;this.msg = message;}public Integer getCode() {return code;}public String getMessage() {return msg;}
}

六、秒杀系统设计

1、需要注意的问题

  • 服务单一职责+独立部署

秒杀服务需要单独提取成一个微服务,这样即使它自己出问题,也不会影响别人。并且方便独立部署。

  • 秒杀链接加密

可以对链接进行 MD5 加密,也可以使用随机码机制,就是在真正开始秒杀的时候,用户才会得知随机码,其它时间都不会知道。

  • 库存预热+快速扣減少

如果秒杀要走正常的加入购物车流程,然后去来锁库存,最终去支付,这样呢整个流程太慢了,在高并发系统里边肯定会出现整个级联崩溃的情况。

我们应该先做到预热库存,比如现在要秒杀的商品,数量有400件,我们给 redis 里面存一个 400 的信号量,想要秒杀的人进来之后,必须要先拿到信号量,这一块我们会对 redis 的信号量进行快速扣减,直接扣减1个数,所以无论有多少请求进来,即使有百万请求,最终也只有 400个人能拿到这个信号量的值。然后我们会将这 400 个人放行给我们后台的集群系统,这些请求即使走正常的下单逻辑,系统也不会出现什么问题。

当然这一块最大问题就是,redis 扛不住

一台 redis 单机并发可能就在两万到三万左右。所以如果我们想让它扛得住,我们可以做 redis 集群,可以做上十几台的集群,让它能扛住百万的并发。

  • 动静分离

在这里插入图片描述

目前使用的是 nginx ,可以复制多个 nginx,组件 nginx 集群

上线以后更好的条件就是使用 CDN 来做压力承担。我们将现有的静态资源,全部分享给这个 CDN 网络,比如我们使用阿里云,我们让阿里云来保存这个静态资源,阿里云会将这些静态资源放进各个服务节点,比如有一个上海节点,还有北京节点,还有杭州节点。那接下来如果我们访问我们的静态资源,阿里云会就近选择一个最快的节点,给我们返回这个静态资源。

做好动静分离之后,放到后台的请求就很少了,以首页为例,5、60个请求,只有1个是动态请求,静态请求全过滤掉了,这样服务器的压力就小很多了。

  • 恶意请求拦截

第一种情况,恶意脚本

它会向服务器每秒发1000次秒杀请求,或者每秒发1000次请求商品数据的请求,但我们会发现,其实按照正用户正常的流量访问,刷的再快每秒可能也就五六次,所以每秒1000次请求肯定是有别的脚本在模拟这个访问。我们应该把这些恶意请求拦截过去,

还有一些伪造的请求

比如我们有很多请求需要带一些令牌,它不带令牌,直接发请求,我们也应该直接拦截下来,

一句话,只要一经过网关以后,放给我们后台的整个请求,应该是一个具有正常行为的请求

恶意请求的拦截,我们最好在网关层拦截,最终经过网关层拦截之后,放给后台集群的又是一些正常请求

  • 流量错峰

在我们的秒杀系统中,假设现在有100万个人来进行秒杀,同时点了立即抢购,那么瞬间流量就会达到一百万,

此时我们就需要处理这些流量,比如可以将流量分散到之后的几秒

可以用前端按钮1秒点击一次,或者结合图片验证码加入购物车

  • 限流&熔断&降级

我们的秒杀系统,可能要远程调用其它的服务,包括秒杀服务自己进来的流量也很大。

所以首先要做的第一个就是限流。限流有前端限流,有后端限流。

前端限流最典型的方式给按钮设置点击频率,比如点了第一次抢购之后,一秒以后才能点第二次、第三次,或者点一下就不能再点了。通过前端的限流,可以限制一部分的流量。

当然,如果用户知道点了这个按钮发什么请求,用恶意脚本去无限制的访问,前端也限不住。

所以前端限流先限制一些请求,请求来到后台时,后台来识别哪些请求是用户的正常行为,哪些是恶意行为,然后再给它进行过滤,包括请求一进来,即使是用户的正常行为,服务器也可以对其限流,比如1秒点了十几次,我们只放行一两次,

假设有100万请求,我们把不合理的去除掉,然后剩六十万,这六十万中,有些用户一秒点了十次,我们只给它放过一次,这样相当于十个请求我们只放过一个,请求就剩了六万。所以通过一步一步的操作,每到达一层,无论是前端还是后端,我们都给它来做一个限流的操作,把不合理的过滤掉,哪怕请求是合理的,点的次数太多了,也将其限制起来,最终后台集群里边收到的流量就会少很多。

我们还可以限制次数、限制总量

比如我们的秒杀服务现在部署了五个机器,峰值处理能力是十万,我们就可以在网关层做一个总限流,限制给秒杀服务的所有流量不能超过十万,超过十万了,就让用户等上两秒,等上两秒以后,再把请求传到秒杀服务。

还有熔断,也非常重要,

假设我们的秒杀系统中要调用其它业务,假设这个其它业务还有别的调用关系,比如 A 先调 B,B 再调 C, C 再调 D,如果这个 B 经常调用失败,我们就要给 B 做一个断路保护。比如我们知道 B 经常调用失败,那我们下一次就不尝试调 B 了,要不然就会一直在 B 那里阻塞等待。本来我们的请求按照正常调用0.1秒就得到释放了,可以处理下一个请求,但现在一直阻塞了3、5秒后才释放,告诉我们失败,这样就很不合理。

所以我们就加入熔断机制,只要调用链的任何一个服务出现问题,比如 D 出现问题,我们就给它返回快速失败。C 调用 D 快速的返回我们 R 对象,比如它的 code,改成 1,那就是失败。这样一失败以后,就能保证我们整个调用链是一个快速返回的,而不是阻塞的。

所以我们的熔断先保证快速失败,如果哪个服务出现问题,我们把它断路之后,相当于就把它隔离了,它也不会影响别的服务

再加上如果我们自己的服务出现问题,我们也可以让它降级运行,比如流量太大了,我们这个秒杀服务快被压垮了,我们可以将一部分的流量直接引导到一个降级页面,告知用户当前服务太忙,请稍后再访问。这也是种手段。

  • 队列消峰

比如要秒杀的商品有一百个库存,有一百万个人来抢这一百个库存,我们在 redis 中存储了信号量,所以这一百人去抢信号量,最终这一百万个请求只有一百个人能抢到信号量,信号量的扣减非常快,就是给一个数值减个一,所有用户都在统一的 redis 里边操作,也不可能出现信号量超扣的问题,因为它最多扣到零,这是一个原子操作。

接下来把拿到信号量的人,放行给我们的后台,后台可以直接将请求发给一个队列,然后订单服务就来监听这个秒杀队列,订单服务会针对队列里面的数据来创建订单等操作,即使花费5秒,甚至10秒都行,此时我们就可以告诉用户秒杀成功了,五分钟以后看我的订单去支付这个订单即可,所以我们可以引入队列。

特别是对于我们这个场景,如果是单品秒杀无所谓,比如有一个 iphone x,然后它有一百件可以秒杀,因为我们能放进来的请求其实就一百个,我们通过信号量控制以后,放进来一百个请求,这一百请求个走正常流程没问题。

但如果现在是淘宝的双十一,淘宝全网的所有商品,可能有几百万件商品,假设每一个商品都有一百个库存,然后现在用户都来秒杀这些商品,每一个商品放进来一百个请求,一百万个商品就会产生一亿的流量。所以这个时候队列的作用就特别明显,所有请求一进来,只要你能秒杀成功,那我就把这个请求的相关数据放到队列里边,整个后台的订单集群就来监听这个队列,队列里边做好整个集群化,存上几万亿的数据都没任何问题。放进队列里边的那些数据,则交给我们后边的集群慢慢按照自己的能力来进行消费。

反正无论怎么消费,一分钟以后肯定能见到结果。所以最终抢到商品的用户可能会出现订单延迟,但是最终都可以支付成功。

所以这就是我们的杀手锏,队列削峰

七、秒杀流程以及实现

  • com.achang.achangmall.seckill.controller.SeckillController
    /*** 商品进行秒杀(秒杀开始)* @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";}
  • com.achang.achangmall.seckill.service.impl.SeckillServiceImpl

 /*** 当前商品进行秒杀(秒杀开始)*/
@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、效验随机码和商品idString 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;
}
  • achangmall-seckill/src/main/resources/application.properties
spring.session.store-type=redis# RabbitMQ配置
spring.rabbitmq.host=192.168.109.101
spring.rabbitmq.port=5672
# 虚拟主机配置
spring.rabbitmq.virtual-host=/
## 开启发送端消息抵达Broker确认
#spring.rabbitmq.publisher-confirms=true
## 开启发送端消息抵达Queue确认
#spring.rabbitmq.publisher-returns=true
## 只要消息抵达Queue,就会异步发送优先回调returnfirm
#spring.rabbitmq.template.mandatory=true
## 手动ack消息,不使用默认的消费端确认
#spring.rabbitmq.listener.simple.acknowledge-mode=manual
spring.thymeleaf.cache=false#开启debug日志
logging.level.org.springframework.cloud.openfeign=debug
logging.level.org.springframework.cloud.sleuth=debug
  • achangmall-seckill/pom.xml
<!-- 整合springsession -->
<dependency><groupId>org.springframework.session</groupId><artifactId>spring-session-data-redis</artifactId>
</dependency>
<!-- 引入rabbitmq -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
  • com.achang.achangmall.seckill.interceptor.LoginUserInterceptor
@Component
public class LoginUserInterceptor implements HandlerInterceptor {public static ThreadLocal<MemberResponseVo> loginUser = new ThreadLocal<>();@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {String uri = request.getRequestURI();AntPathMatcher antPathMatcher = new AntPathMatcher();boolean match = antPathMatcher.match("/kill", uri);if (match) {HttpSession session = request.getSession();//获取登录的用户信息MemberResponseVo attribute = (MemberResponseVo) session.getAttribute(LOGIN_USER);if (attribute != null) {//把登录后用户的信息放在ThreadLocal里面进行保存loginUser.set(attribute);return true;} else {//未登录,返回登录页面response.setContentType("text/html;charset=UTF-8");PrintWriter out = response.getWriter();out.println("<script>alert('请先进行登录,再进行后续操作!');location.href='http://auth.achangmall.com/login.html'</script>");// session.setAttribute("msg", "请先进行登录");// response.sendRedirect("http://auth.achangmall.com/login.html");return false;}}return true;}}
  • com.achang.achangmall.order.conf.MQConfig

创建队列和绑定关系

/*** 商品秒杀队列*/
@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> argumentsBinding binding = new Binding("order.seckill.order.queue",Binding.DestinationType.QUEUE,"order-event-exchange","order.seckill.order",null);return binding;
}
  • com.achang.achangmall.order.listener.OrderSeckillListener

监听队列order.seckill.order.queue

@Slf4j
@Component
@RabbitListener(queues = "order.seckill.order.queue")
public class OrderSeckillListener {@Autowiredprivate OrderService orderService;@RabbitHandlerpublic void listener(SeckillOrderTo orderTo, Channel channel, Message message) throws IOException {log.info("准备创建秒杀单的详细信息...");try {orderService.createSeckillOrder(orderTo);channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);} catch (Exception e) {channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);}}}
  • com.achang.achangmall.order.service.impl.OrderServiceImpl
/*** 创建秒杀单*/
@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);
}

Day437438439.秒杀服务 -谷粒商城相关推荐

  1. Day400401402403404405406.商品服务 -谷粒商城

    商品服务 一.品牌管理 1.效果优化与快速显示开关 将逆向工程product得到的resources\src\views\modules\product文件拷贝到achangmall/renren-f ...

  2. Day429430431.订单服务 -谷粒商城

    订单服务 一.环境搭建 因为我们做的是动静分离,将静态资源上传到nginx上 配置本地域名:‪C:\Windows\System32\drivers\etc\hosts 配置服务网关:achangma ...

  3. Day425426.购物车服务 -谷粒商城

    购物车服务 一.环境搭建 springboot初始化 新增本地域名cart.achangmall.com 修改对应pom配置,与此次服务间版本对应 引入公共依赖 修改springboot版本 修改sp ...

  4. 谷粒商城-分布式高级篇【业务编写】

    谷粒商城-分布式基础篇[环境准备] 谷粒商城-分布式基础[业务编写] 谷粒商城-分布式高级篇[业务编写]持续更新 谷粒商城-分布式高级篇-ElasticSearch 谷粒商城-分布式高级篇-分布式锁与 ...

  5. 【谷粒商城 -秒杀服务】

    谷粒商城–秒杀服务–高级篇笔记十二 1.后台添加秒杀商品 未配置秒杀服务相关网关 1.1 配置网关 - id: coupon_routeuri: lb://gulimall-couponpredica ...

  6. 谷粒商城--秒杀服务--高级篇笔记十二

    谷粒商城–秒杀服务–高级篇笔记十二 1.后台添加秒杀商品 未配置秒杀服务相关网关 1.1 配置网关 - id: coupon_routeuri: lb://gulimall-couponpredica ...

  7. 谷粒商城-分布式高级篇[商城业务-秒杀服务]

    谷粒商城-分布式基础篇[环境准备] 谷粒商城-分布式基础[业务编写] 谷粒商城-分布式高级篇[业务编写]持续更新 谷粒商城-分布式高级篇-ElasticSearch 谷粒商城-分布式高级篇-分布式锁与 ...

  8. 尚硅谷2020微服务分布式电商项目《谷粒商城》-支付、秒杀

    学习更多的知识,整理不易,拒绝白嫖,记得三连哦 关注公众号:java星星 获取全套课件资料 1. 支付 订单搞定之后就是支付了,首先搭建支付工程. 1.1. 搭建环境 pom.xml <?xml ...

  9. 谷粒商城二十三秒杀服务

    秒杀是每一个电商系统中非常重要的模块,商家会不定期的发布一些低价商品,发布到秒杀系统中,秒杀系统的商品一般会放到首页展示,这样就可以引导用户购买商品. 秒杀的购买流程和普通的购买流程最大的特点就是瞬时 ...

最新文章

  1. python爬虫原理-python学习之python爬虫原理
  2. get,put,post,delete含义与区别
  3. 关于一些运算((与运算)、|(或运算)、^(异或运算)........)的本质理解【转】...
  4. 微软起诉Google阻止前高管跳槽
  5. Spring Boot中静态文件获得Thymeleaf支持(配置porm.xml)
  6. AI语音入门:认识词错率WER与字错率CER
  7. SilverLight行为小示例
  8. 非常有意思的35句话
  9. java string to bit_Java Convert String to Binary
  10. android t9键盘,T9/全键盘/侧滑 论手机键盘设计优缺点
  11. 苹果 macOS 12.4 RC 发布,带来全新 Studio Display 壁纸
  12. 如何在Nintendo Switch上管理和传输数据
  13. C3H5 3d立体魔方效果
  14. java 密码库_JCA-Java加密框架
  15. 如何将数据存入mysql_怎样将数据存入mysql数据库
  16. 万字长文:人脸识别综述(学习笔记)
  17. Maven的一个基础pom.xml文件结构
  18. 代价函数、目标函数、损失函数
  19. stm32 学习--Stm32F407 SPI1 全双工DMA 收发数据
  20. 四川嘉弘恒信:拼多多店铺广告主怎么开

热门文章

  1. FAT32和 NTFS功能上的差别
  2. (28)打鸡儿教你Vue.js
  3. Python开发植物大战僵尸游戏,详细教程
  4. Android Studio如何实现音乐播放器(简单易上手)
  5. DC2DC, 开关电源L,C 计算
  6. Maven私服Nexus的搭建及使用
  7. 韩国精神(2001.08)
  8. 最全常用User-Agent
  9. 奇幻RPG(物品锻造 与 Decorator模式)
  10. 修改winserver 3389为其它端口