秒杀是每一个电商系统中非常重要的模块,商家会不定期的发布一些低价商品,发布到秒杀系统中,秒杀系统的商品一般会放到首页展示,这样就可以引导用户购买商品。
秒杀的购买流程和普通的购买流程最大的特点就是瞬时流量特别大
,
如果是普通的购买,由于时间段比较分散,任何时间都可以购买,留给某一段时间段的流量可能比较均匀。
秒杀在一个时间段会涌入大量流量,非常考验系统对峰值流量的应对能力
。
秒杀具有瞬间高并发的特点,针对这一特点,必须要做限流 + 异步 + 缓存(页面静态化)+ 独立部署。
限流方式:
独立部署
如果秒杀服务和其他服务(例如商品服务)混合起来,如果秒杀时间一到,瞬时流量全涌进商品系统,正常的购买商品流程可能就会瘫痪,系统就没有更多的能力去处理正常的功能了。
这块保存秒杀商品到数据库的时候是通过后台管理系统的,我把前面的前端知识都忘的差不多了,所以就直接操作数据库了
秒杀的这些商品经常是高并发访问,不可能每一次都去访问数据库,这样太慢了,而且会把数据库压垮,
我们可以在秒杀的商品将要秒杀之前提前上架,可以放到缓存中,商品的全部数据全部放到缓存
中去拿,这样数据库压力就不大,
包括秒杀商品的库存处理
,每次也不应该扣减数据库,也可以将秒杀商品要用的库存也上架到缓存里面,每次扣库存从缓存中扣库存就可以了。
每天晚上十一点将第二天将要秒杀的商品全部扫描放到缓存中,这样就减轻了第二天商品秒杀的时候数据库的压力。
语法:秒 分 时 日 月 周 年(年spring不支持)
定时任务颗粒度最细只能精确到秒
,:枚举;
(cron=“7,9,23 * * * * ?”):任意时刻的 7,9,23 秒启动这个任务;
-:范围:
(cron=“7-20 * * * * ?”):任意时刻的 7-20 秒之间,每秒启动一次
*
:任意;
指定位置的任意时刻都可以
/:步长;
(cron=“7/5 * * * * ?”):第 7 秒启动,每 5 秒一次;
(cron=“*/5 * * * * ?”):任意秒启动,每 5 秒一次;
?:(出现在日和周几的位置):为了防止日和周冲突,在周和日上如果要写通配符使用?
如果一个精确/*
,另一个就需要写?
(cron=“* * * 1 * ?”):每月的 1 号,启动这个任务;
L:(出现在日和周的位置)”,last:最后一个
(cron=“* * * ? * 3L”):每月的最后一个周二
W:Work Day:工作日
(cron=“* * * W * ?”):每个月的工作日触发
(cron=“* * * LW * ?”):每个月的最后一个工作日触发
#:第几个
(cron=“* * * ? * 5#2”):每个月的第 2 个周 4
package com.atlinxi.gulimall.seckill.scheduled;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
*
* 定时任务
* spring的定时任务是自己的,不是整合quartz的,但基本使用是一模一样的
*
* 1. @EnableScheduling 开启定时任务
* 2. @Scheduled 开启一个定时任务
* 3. 自动配置类 TaskSchedulingAutoConfiguration
*
*
* 异步任务(也可以用到其他逻辑上,不止是定时任务上),底层实际上就是线程池,节省了我们自己创建线程池的方法
* 1. @EnableAsync 开启异步任务功能
* 2. @Async 给希望异步执行的方法上标注
* 3. 自动配置类 TaskExecutionAutoConfiguration,属性绑定 TaskExecutionProperties
*
*
* 解决:使用异步+定时任务来完成定时任务不阻塞的功能
*
*
*/
@Slf4j
@Component
@EnableAsync
@EnableScheduling
public class HelloSchedule {
/**
* 1. spring中6位组成,不允许第7位的年
* 2. 在周几的位置,1-7代表周一到周日;也可以写:MON-SUN
* 3. 定时任务不应该阻塞。默认是阻塞的
* 1. 可以让业务(service)运行以异步(CompletableFuture)的方式,自己提交到线程池
* 就是把异步任务直接写在定时任务中
* 2. springboot支持定时任务线程池
* 设置spring.task.scheduling.pool.size,改变线程池的线程数,没用。。。。。。
* 3. 让定时任务异步执行
* 异步任务
*
*
*
* "* * * * * ?" 表示的是在程序启动的那一秒开始执行
*
* 阻塞的话,虽然定时任务是每秒执行,但是睡3s就变成了每隔4s执行
* 非阻塞,也就是异步的话,就还是每s执行
*
*/
@Async
@Scheduled(cron = "* * * * * ?")
public void hello() throws InterruptedException {
log.info("hello...");
Thread.sleep(3000);
}
}
// 配置文件配置线程池参数
spring.task.execution.pool.core-size=5
spring.task.execution.pool.max-size=50
这应该是同一个服务部署了集群,所以此时三个服务器上都有这个定时任务,并且是一模一样的代码,时间一到,就同时启动了三个定时任务,然而我们只需要一个就好,加一个分布式锁即可。
package com.atlinxi.gulimall.coupon.service.impl;
import com.atlinxi.gulimall.coupon.entity.SeckillSkuRelationEntity;
import com.atlinxi.gulimall.coupon.service.SeckillSkuRelationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.atlinxi.common.utils.PageUtils;
import com.atlinxi.common.utils.Query;
import com.atlinxi.gulimall.coupon.dao.SeckillSessionDao;
import com.atlinxi.gulimall.coupon.entity.SeckillSessionEntity;
import com.atlinxi.gulimall.coupon.service.SeckillSessionService;
@Service("seckillSessionService")
public class SeckillSessionServiceImpl extends ServiceImpl<SeckillSessionDao, SeckillSessionEntity> implements SeckillSessionService {
@Autowired
SeckillSkuRelationService seckillSkuRelationService;
@Override
public PageUtils queryPage(Map<String, Object> params) {
IPage<SeckillSessionEntity> page = this.page(
new Query<SeckillSessionEntity>().getPage(params),
new QueryWrapper<SeckillSessionEntity>()
);
return new PageUtils(page);
}
@Override
public List<SeckillSessionEntity> getLatest3DaySession() {
// 计算最近三天
// 我们需要 2023-04-25 00:00:00,2023-04-27 23:59:59
List<SeckillSessionEntity> list = this.list(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();
List<SeckillSkuRelationEntity> relationEntities = seckillSkuRelationService.list(new QueryWrapper<SeckillSkuRelationEntity>()
.eq("promotion_session_id", id));
session.setRelationSkus(relationEntities);
return session;
}).collect(Collectors.toList());
return collect;
}
return null;
}
private String startTime(){
// 2023-04-25
LocalDate now = LocalDate.now();
// 00:00
LocalTime min = LocalTime.MIN;
// 2023-04-25 00:00
LocalDateTime start = LocalDateTime.of(now, min);
String format = start.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return format;
}
private String endTime(){
// 2023-04-25
LocalDate now = LocalDate.now();
// 2023-04-27
LocalDate plusDays = now.plusDays(2);
// 23:59:59
LocalTime max = LocalTime.MAX;
// 2023-04-27 23:59:59
LocalDateTime end = LocalDateTime.of(plusDays, max);
String format = end.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return format;
}
}
package com.atlinxi.gulimall.seckill.scheduled;
import com.atlinxi.gulimall.seckill.service.SeckillService;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
/**
*
* 秒杀商品的定时上架
* 每天晚上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 final String upload_lock = "seckill:upload:lock";
// todo 幂等性处理,上架之后的秒杀商品在下一次定时任务就不需要上架了
@Scheduled(cron = "0 * * * * ?")
public void uploadSeckillSkuLatest3Days(){
log.info("上架秒杀的商品信息");
// 1. 重复上架,无需处理
// 就是说当天3点扫描0点的,肯定是错过了,然而当天的肯定昨天也扫描过了
/**
*
* 在集群状态下,到规定状态下,所有机器都会执行这个定时任务
* 我们只需要有一个服务器执行定时任务就可以了
*
* 分布式锁
* 获取到锁的人先会执行,它释放锁,下边的人才会执行
* 锁的业务执行完成,状态已经更新完成。释放锁以后,其他人获取到就会拿到锁最新的状态
* 这儿最新的状态应该指的就是业务层我们做的幂等性判断,即使其他服务器拿到锁,进来判断发现全部存在了,就不会再次保存数据了
*
*
* 我们在业务层面已经设置成幂等,那么不加锁行不行呢
* 答案是不行的,就是怕在幂等判断(实际上就是判断key是否存在)的时候,两个定时任务同时进去,那肯定就出问题了
业务层面的幂等实际上就是在保存各种信息的时候先判断该key是否存在
*
*/
RLock lock = redissonClient.getLock(upload_lock);
// 10s预计业务就执行完了
lock.lock(10, TimeUnit.SECONDS);
try {
seckillService.uploadSeckillSkuLatest3Days();
} finally {
lock.unlock();
}
}
}
package com.atlinxi.gulimall.seckill.service.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.TypeReference;
import com.atlinxi.common.to.mq.SeckillOrderTo;
import com.atlinxi.common.utils.R;
import com.atlinxi.common.vo.MemberRespVo;
import com.atlinxi.gulimall.seckill.feign.CouponFeignService;
import com.atlinxi.gulimall.seckill.feign.ProductFeignService;
import com.atlinxi.gulimall.seckill.interceptor.LoginUserInterceptor;
import com.atlinxi.gulimall.seckill.service.SeckillService;
import com.atlinxi.gulimall.seckill.to.SeckillSKuRedisTo;
import com.atlinxi.gulimall.seckill.vo.SeckillSessionsWithSkus;
import com.atlinxi.gulimall.seckill.vo.SeckillSkuVo;
import com.atlinxi.gulimall.seckill.vo.SkuInfoVo;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RSemaphore;
import org.redisson.api.RedissonClient;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundHashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Slf4j
@Service
public class SeckillServiceImpl implements SeckillService {
@Autowired
CouponFeignService couponFeignService;
@Autowired
StringRedisTemplate stringRedisTemplate;
@Autowired
ProductFeignService productFeignService;
@Autowired
RedissonClient redissonClient;
@Autowired
RabbitTemplate rabbitTemplate;
private final String SESSIONS_CACHE_PREFIX = "seckill:sessions:";
private final String SKUKILL_CACHE_PREFIX = "seckill:skus";
private final String SKU_STOCK_SEMAPHORE = "seckill:stock:"; // + 商品随机码
/**
* 上架秒杀商品
*/
@Override
public void uploadSeckillSkuLatest3Days() {
// 1. 扫描最近三天需要参与秒杀的活动
R session = couponFeignService.getLatest3DaySession();
if (session.getCode() == 0){
// 上架商品
List<SeckillSessionsWithSkus> sessionData = session.getData(new TypeReference<List<SeckillSessionsWithSkus>>() {
});
// 缓存到redis中
// 1. 缓存活动信息
saveSessionInfos(sessionData);
// 2. 缓存活动的关联商品信息
saveSessionSkuInfos(sessionData);
}
}
private void saveSessionInfos(List<SeckillSessionsWithSkus> sessions){
sessions.stream().forEach(session->{
Long startTime = session.getStartTime().getTime();
Long endTime = session.getEndTime().getTime();
String key = SESSIONS_CACHE_PREFIX + startTime + "_" + endTime;
Boolean hasKey = stringRedisTemplate.hasKey(key);
// 缓存活动信息
if (!hasKey){
List<String> collect = session.getRelationSkus().stream().map(item -> item.getPromotionSessionId() + "_" + item.getSkuId().toString()).collect(Collectors.toList());
stringRedisTemplate.opsForList().leftPushAll(key,collect);
}
});
}
private void saveSessionSkuInfos(List<SeckillSessionsWithSkus> sessions){
sessions.stream().forEach(session -> {
// 准备hash操作
BoundHashOperations<String, Object, Object> ops = stringRedisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
session.getRelationSkus().stream().forEach(seckillSkuVo -> {
String token = UUID.randomUUID().toString().replace("-", "");
if (!ops.hasKey(seckillSkuVo.getPromotionSessionId().toString() + "_" + seckillSkuVo.getSkuId().toString())){
// 缓存商品
SeckillSKuRedisTo redisTo = new SeckillSKuRedisTo();
// 2.sku的秒杀信息
BeanUtils.copyProperties(seckillSkuVo,redisTo);
// 1. sku的基本数据
R skuInfo = productFeignService.getSKuInfo(seckillSkuVo.getSkuId());
if (skuInfo.getCode() == 0){
SkuInfoVo info = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() {
});
redisTo.setSkuInfoVo(info);
}
// 3.设置当前商品的秒杀时间信息
redisTo.setStartTime(session.getStartTime().getTime());
redisTo.setEndTime(session.getEndTime().getTime());
/**
*
* 4. 随机码? seckill?skuId=1&key=afsdgdnfkjgd
* 秒杀的接口,seckill?skuId=1,
*
* 这个接口容易引起攻击,假如我们写一个工具,无限重试来发这个请求
* 只要秒杀一开放,立马请求发出去,肯定第一个抢到
* 引入随机码的目的就是,想要秒杀但是不知道随机码,发请求也没用
* 随机码只有秒杀开始的那一刻才暴露出来
*
*/
redisTo.setRandomCode(token);
/**
*
* 5.扣库存
*
* 秒杀不应该是实时去数据库扣库存,如果几百万的请求到数据库都去扣库存,看谁能扣成功,是不可能的事情
*
* 现在最大的问题,应对这些高并发流量的进来,肯定就是有一些流量不需要做事儿的,比如秒杀不成功
* 现在就100个商品,哪怕100W请求放进来,最终只有100个人才能成功的去数据库执行我们减库存的方法
*
* 所以我们可以提前在redis中设置一个信号量(自增量),
* 所以最终我们每一个商品都有了信号量信息,那你想要秒杀这个商品,先去redis中获取一个信号量,
* 也就是给库存减一个,如果减库存成功就放行,去数据库中减库存
* 如果信号量获取失败,都不用执行下面的逻辑,不用进行后续的操作,就会阻塞很短的时间,整个请求就会得到很快的释放
* 我们只有每一个请求都很快的释放,才可以拥有处理大并发的能力
*
*
* 信号量的值就是该sku的库存数量,
*
*
*
*
*
*/
String s = JSON.toJSONString(redisTo);
ops.put(seckillSkuVo.getPromotionSessionId().toString() + "_" + seckillSkuVo.getSkuId().toString(),s);
// 如果当前场次的商品的库存信息已经上架就不需要上架了
// 5. 使用库存作为分布式的信号量 (信号量的一大作用就是限流)
// 秒杀请求如果进来没获取到信号量,就不用执行接下来的逻辑了
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
// 商品可以秒杀的数量作为信号量
semaphore.trySetPermits(seckillSkuVo.getSeckillCount());
}
});
});
}
}
查询当前时间的秒杀商品
预告/展示
该秒杀商品(从redis中查所有商品,真实业务中会把秒杀的商品设置ttl,所以redis中的秒杀商品不会很多很多的)
加入购物车
按钮改成开始抢购
/**
* 返回当前时间可以参与的秒杀商品信息
* @return
*/
@Override
public List<SeckillSKuRedisTo> getCurrentSeckillSkus() {
// 1. 确定当前时间属于哪个秒杀场次
long time = new Date().getTime();
Set<String> keys = stringRedisTemplate.keys(SESSIONS_CACHE_PREFIX + "*");
for (String key : keys) {
// seckill:sessions:1682784000000_1682791200000
String replace = key.replace(SESSIONS_CACHE_PREFIX, "");
String[] s = replace.split("_");
long start = Long.parseLong(s[0]);
long end = Long.parseLong(s[1]);
if (time>=start && time<=end){
// 2. 获取这个秒杀场次需要的所有商品信息
// -100到100,代表的就是长度,我们秒杀的商品肯定没有这么多,所以肯定能取全
List<String> range = stringRedisTemplate.opsForList().range(key, -100, 100);
BoundHashOperations<String, String, String> hashOps = stringRedisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
List<String> list = hashOps.multiGet(range);
if (list!=null){
List<SeckillSKuRedisTo> collect = list.stream().map(item -> {
SeckillSKuRedisTo redis = JSON.parseObject(item.toString(), SeckillSKuRedisTo.class);
return redis;
}).collect(Collectors.toList());
return collect;
}
break;
}
}
return null;
}
$.get("http://seckill.gulimall.com/currentSeckillSkus",function (resp) {
if (resp.data.length > 0){
resp.data.forEach(function (item) {
$(" + item.skuId + ")'> ")
.append($(""))
.append(""
+ item.skuInfoVo.skuTitle + "")
.append($("" + item.seckillPrice + ""))
.append($("" + item.skuInfoVo.price + ""))
.appendTo("#seckillContent")
})
/**
* 将该标签放到哪个标签中
* .appendTo()
*
*
花王 (Merries) 妙而舒 纸尿裤 大号 L54片 尿不湿(9-14千克) (日本官方直采) 花王 (Merries) 妙而舒 纸尿裤 大号 L54片 尿不湿(9-14千
¥83.9¥99.9
*/
}
})
秒杀也是添加商品到购物车,不同的是商品的价格是秒杀的价格。
秒杀业务最大的特点就是高并发
(秒杀业务也是高并发系统的代表),所以我们考虑的不是秒杀业务该怎么进行,而应该考虑的是如何设计一个高并发的系统,能承载住大量峰值流量。
以秒杀为例,在高并发系统中,为了防止大量流量请求瞬间全部涌进来,把整个系统压垮,应该考虑以下问题
秒杀服务是一个高并发的服务,最好不要把代码和其他混写,只做秒杀,就可以抽取成一个单独的微服务,
独立部署是为了即使扛不住压力,也不要影响其他的服务
假设别人都知道商品秒杀的请求地址,那么就可以写一个脚本,每秒发一千个请求过去进行秒杀,这样就会出现问题。
可以将链接直接加密,比如整个链接生成一个md5或者uuid,就代表整个链接的值。
秒杀的时候参数只有skuid是不够的,每个商品还有一个随机码,随机码只有秒杀活动开始的时候,页面才会返回,这样就不能提前写脚本来进行秒杀了(我们采用的是这种)
如果我们走正常的加入购物车流程,去扣库存,最终来支付,整个流程太慢了,在高并发系统中,肯定会出现级联崩溃的问题。
所以我们要做预热库存,例如我们秒杀的商品数量有400件,利用定时任务,提前扫描进redis中,使用信号量来控制库存,无论有多少请求,最后只会有400个人拿到信号量的值,所以我们最终放行到后台的请求也就只有这400个,这个时候走我们正常的加入购物车逻辑,也不会出现很多的大问题。
快速扣减,在redis中使用信号量来增减一个数就行了。
这块儿容易出现的问题就是redis扛不住,一台redis单机并发在2w-3w左右,如果有更大的并发,我们可以来做集群,做十几台redis的集群,就可以抗住百万的并发。
包括redis最终做成一个分片、高可用,后面会做。
无论是秒杀系统还是电商系统中的其他业务,都得做动静分离,所有的静态请求直接是由nginx直接返回的,静态资源全部存在nginx中,所以我们可以把nginx复制多份,随便访问哪个nginx,静态资源都由nginx返回。
如果是动态请求,除了所有的静态资源,动态请求nginx才会路由到后台的网关,网关就会交给我们的微服务。
我们的商品详情页,有63个请求,真正核心请求(动态请求)只有一个,剩下的全部是静态资源。
如果我们上线以后有更好的条件就是使用cdn来做我们的压力承担。我们现在的所有静态资源可以分享给cdn网络,使用阿里云,让阿里云来保存这些静态资源,阿里云会放进各个服务节点,假设有北京节点、上海节点、浙江节点,如果访问静态资源,阿里云会就近来选择一个最快的节点给我们返回静态资源。
做好了动静分离以后,把静态请求全过滤了,压给我们后台的请求就不多了。
对于高并发系统,恶意请求也有很多种
恶意脚本,1000/s发送秒杀请求,
然而按照用户正常的流量访问,在页面中刷的再快,每秒可能也就五六次,所以每秒一千次肯定不是人为的
我们只要一经过网关以后,放给我们后台的整个请求应该是一个具有正常行为的,所以恶意请求的拦截是在网关层
。
100w人同时点击立即抢购,那么瞬间流量就是100w,就得想办法处理。
可以通过一些技术让它整个瞬间的100w流量分散到几秒之间。
点击抢购之后输入验证码
加入购物车
点击抢购的时候是直接加入购物车的,到时候提交订单,支付,大家的操作快慢都不一样,还是能把瞬时流量分散开
在高并发系统中这个一定要考虑。
秒杀的流量很大,所以我们首先要做的第一个就是限流。
前端限流
点击抢购之后跳转页面,或者只能点击一下,或者间隔一秒点击一下
后端限流
在页面拿到商品的秒杀地址之后,继续拿恶意脚本无限次的访问,这个在前端无法限制
后台可以识别哪些是用户的正常行为,哪些是恶意行为,再来给它进行过滤,有时候即使是用户的正常行为,但是点击了十几次秒杀,那么也只给它放行一次。
最终给后台集群里面放的流量就很少。
限制次数,限制总量
比如秒杀部署了5台服务器,峰值处理能力就是10w,就可以在网关层做一个总限流,发给秒杀服务的流量不能超过10w,超过10w需要等待几秒。
秒杀需要调用其他微服务,调用链的任何微服务出现了问题,我们就做一个断路保护,知道哪个服务调用失败了,下一次就不尝试调用该微服务了,我们就加入熔断机制,快速返回失败,这样就不会造成阻塞。
流量如果太大,秒杀服务快要被压垮了,一部分的流量可以直接引导到一个降级页面,提示当前服务太忙,请稍后再访问。
无论多大的流量,哪怕有1000w,只要我收得到请求,我只要一收进来,原来的逻辑是下订单、扣库存、支付等,正常执行可能需要3-5s。
比如现在有100个库存,后台收到了100w个请求,100w只有100个请求可以抢到信号量,信号量的扣减很快,就是给一个数值减1,大家都在统一的redis中操作,也不可能出现信号量超扣的问题,最多只能扣到0,这是个原子操作。
只要能拿到信号量就放行给后台,后台可以直接将请求发给队列,订单服务就来监听秒杀队列,只要秒杀的请求能被放进队列中的,订单服务就在这儿慢慢创建订单,创建5s、10s的都行。告知用户秒杀成功,5min之后看控制台
如果是单体秒杀就无所谓,一个商品100件秒杀,通过信号量控制100个,能放进来的秒杀就只有100个,这100个走正常流程就没问题。
但现在假设是淘宝的双十一,凌晨淘宝全网的所有商品,可能几百万件商品,每件商品都假设有100件库存,用户都来秒杀这些商品,每件商品放进来100个,一百万件商品就是一亿的流量,这个时候队列的作用就特别的明显。
只要能秒杀成功,就把商品放到队列里面,整个订单的后台集群就来监听这个队列,慢慢按照自己的能力来进行消费,反正无论怎么消费,1min之后肯定见到结果了。所以最终你的订单可能会出现延迟,但是你最终都能支付成功。
像京东、小米一样,秒杀只是商品的优惠显示信息,整个购物流程还是加入购物车,跳到订单确认页,再支付
点击立即抢购以后,请求发给秒杀系统
秒杀系统做一系列的判断,登录判断、合法性校验,合法性校验包括是否在秒杀事件,与skuId对应的随机码,该用户是否已秒杀该商品(幂等性)
获取信号量,获取不到则秒杀失败。
获取到信号量之后我们不走之前的流程,创建订单、给数据库保存详细信息、锁库存等,秒杀作为一个独立的流程,库存只要定时任务能上架,它底层的数据库库存已经锁定了这些库存,
用户只要能获取到信号量,我们就在秒杀服务中快速生成订单(包括用户信息、订单号、商品信息)发给mq,相当于只发送了一个消息,并没有操作数据库,后台的订单服务监听消息慢慢的创建订单。
消息队列在这一块儿做的是流量削峰,请求一进来,我们不用立即去调用订单服务,而是放到mq中,订单服务再慢慢去消费,就不至于把我们订单服务打垮。要打垮也只能打垮秒杀服务。
此时,立即给前端的用户返回,秒杀成功,正在为您准备订单
,在订单准备页经过几秒后跳转到支付页,支付之前也可以确认一下收货地址
京东、小米秒杀流程
秒杀是一个优惠信息,整个系统,我们点加入购物车,走这一套流程,流量就分散开了
秒杀的流程与加入购物车的业务流程是统一的,普通商品的数据信息和秒杀商品的信息相差很小。
应对超高并发流量的秒杀流程
优点
从请求进来,到controller、service一大堆判断处理截止我们给前端用户返回页面,我们没有操作一次数据库,没有做过任何一次远程调用,这个流程就非常快了。
只需要校验好所有的合法性就行了,我们所有的数据都在缓存里面放着,一切正常之后我们快速给用户指定一个单号,告诉前端秒杀成功,后端的订单服务再慢慢消费。
缺点
极限情况下,订单服务都崩了,已经告诉用户秒杀成功了,mq发出去了,然而却没有人消费这个订单,订单的详细信息不能准备好,就不能支付成功。
后续处理与我们加入购物车的整套业务不一样,就得另外编写一套逻辑。
// 订单服务监听消息实际上就是保存订单,这儿就不记录了
@GetMapping("/kill")
public String secKill(@RequestParam("killId") String killId,
@RequestParam("key") String key,
@RequestParam("num") Integer num,
Model model){
String orderSn = seckillService.kill(killId,key,num);
model.addAttribute("orderSn",orderSn);
return "success";
}
/**
*
* 我们这一块儿的方法会超级快,所有的操作都是在缓存中操作,
* 一系列对比可能都不会超过10ms,获取信号量等待100ms,发送消息到mq可能用时10ms,共用时120ms
*
* 如果获取信号量顺利,那么我们可能20ms就能执行完这个方法,
* 在单机状态下,这个方法只占用了20ms的时间,一个线程从请求到响应只花费50ms,1s单线程就能容纳20的并发
* 如果我们现在用的tomcat设置为500并发的请求,1s的吞吐量就能达到1w
*
* 如果是我们以前就会很慢,一个线程可能都得执行3s,就算tomcat 500个线程来接请求,1s只能有大概160个请求被处理
*
*
*
*
* 如果获取信号量不等待的话,我本地的电脑测试这个kill()流程是12ms,1s1个线程相当于可以处理100个请求
* tomcat假设同时有500个处理请求的线程,就一个秒杀服务,单机部署情况下,我们就能处理5w的并发
* 如果有100w,建立20个单机的集群,就可以做到
* 同时也不害怕把订单服务压垮,虽然秒杀有这么多的请求,但是库存肯定没有这么多,不是每个请求都能进来获取到信号量的
* 而且就算这些也是拿消息队列削峰处理的,然后订单就会处理订单逻辑
* 页面就返回秒杀成功,给一个确认订单的按钮
*
*
*
* 库存在存进redis后,数据库中应该锁定库存数量,等秒杀完毕后,如果还有剩余商品,再给数据库中加回去
*
* @param killId
* @param key
* @param num
* @return
*/
// todo 上架商品的时候,每一个数据都有过期时间
// todo 秒杀后续的流程,简化了收货地址等信息
@Override
public String kill(String killId, String key, Integer num) {
long s1 = System.currentTimeMillis();
MemberRespVo respVo = LoginUserInterceptor.loginUser.get();
// 1. 获取当前秒杀商品的详细信息
BoundHashOperations<String, String, String> hashOps = stringRedisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
// 7_1
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 time = new Date().getTime();
long ttl = endTime - time;
// 1. 校验时间的合法性
// 定时任务保存的秒杀商品实际上在保存的时候就可以给一个过期时间
// 这里我们是为了测试方便,就没有设置
if (time>=startTime && time<=endTime){
// 2. 校验随机码和商品id
String randomCode = redis.getRandomCode();
String skuId = redis.getPromotionSessionId() + "_" + redis.getSkuId();
if (randomCode.equals(key) && killId.equals(skuId)){
// 3. 验证购物数量是否合理
if (num<=redis.getSeckillLimit()){
// 4. 验证这个人是否已经购买过。幂等性;只要秒杀成功,就去占位。 userId_sessionId_skuId
// SETNX 不存在的时候才占位
String redisKey = respVo.getId() + "_" + skuId;
// 自动过期
/**
setIfAbsent()
如果键不存在则新增,存在则不改变已经有的值。
如果为空就set值,并返回1
如果存在(不为空)不进行操作,并返回0
*/
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl, TimeUnit.MILLISECONDS);
if (aBoolean){
// 占位成功说明从来没有买过
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + randomCode);
// 从信号量中减一个
// 这个方法是阻塞的
// semaphore.acquire(num);
// 获取信号量的等待时间为100ms
//
boolean b = semaphore.tryAcquire(num);
if (b){
// 秒杀成功
// 快速下单,发送mq消息
String timeId = IdWorker.getTimeId();
SeckillOrderTo orderTo = new SeckillOrderTo();
orderTo.setOrderSn(timeId);
orderTo.setMemberId(respVo.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);
long s2 = System.currentTimeMillis();
log.info("耗时。。。{}",(s2-s1));
// 订单号
return timeId;
}
return null;
}else {
// 说明已经买过了
return null;
}
}
}else {
return null;
}
}else {
return null;
}
}
return null;
}
最初,小逸听说爸爸妈妈要去找老师和副校长,他担心告状之后,自己可能被打得更厉害。苏迎澜告诉小逸,没有一件事情是靠逃避解决掉的,去直面恐惧,才可以快点成长,保护自己。“你愿意站出来陪妈妈去打这一仗吗?”“我愿意”。
https://baijiahao.baidu.com/s?id=1760481532554271247
一个妈妈的反校园暴力“战斗”