如何快速的完成一个秒杀的功能?
瞬间高并发。
限流 + 异步 + 缓存(页面静态化)+ 独立部署
提前一天,每天晚上3点上架最近三天要秒杀的商品。
通过定时任务触发事件,要秒杀的商品放入redis中。
Qartz : 实现定时任务。
Spring Schedule: 实现定时任务。
SeckillService
// 1. 扫描最近三天所有需要参与秒杀的活动
// 2. 查询出秒杀活动关联的所有商品。
// 3. 将商品数据缓存到Redis
// 3.1 缓存活动信息
前缀: seckill_sessions_
key: 开始时间long_结束时间long
value: [场次id_商品的id]数组
// 3.2 缓存活动关联的商品信息
前缀:seckill_skus hash结构保存
field: 场次id_商品的id(防止商品id重复)
value: 商品详情数据(秒杀数据、商品数据、随机码)
// 4. 设置随机码
随机码: 防止一直循环访问秒杀请求,通过UUID生成
// 5. 获取redisson 信号量(限流),使用库存作为分布式
seckill:stock:+商品随机码
商品可以秒杀的数量作为信号量
redissonClient.getSemaphore() // 获取信号量
semaphore.trySetPermits(商品的库存量)
TODO
1. 幂等性问题(分布式)
场次上架完了,下次再执行定时任务,不能重复上架了。
增加分布式锁。 seckill:upload:lock
代码层面,判断是否存在对应的活动的key,如果存在则不用执行了,不存在才需要执行。
活动会话 活动商品都要 进行判断。
代码说明:
定时任务
@Scheduled(cron = "0 0 0-8 * * ?")
public void uploadSeckillSkuLatest3Day() {
log.info("\n上架秒杀商品的信息");
// 1.重复上架无需处理
// 加上分布式锁 状态已经更新 释放锁以后其他人才获取到最新状态
RLock lock = redissonClient.getLock(upload_lock);
lock.lock(10, TimeUnit.SECONDS);
try {
//此处调用:远程调用 最近三天 最近三天 秒杀商品信息
seckillService.uploadSeckillSkuLatest3Day();
} finally {
lock.unlock();
}
}
service主体代码块:
@Slf4j
@Service
public class SeckillServiceImpl implements SeckillService {
@Autowired
private CouponFeignService couponFeignService;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private ProductFeignService productFeignService;
@Autowired
private RedissonClient redissonClient;
@Autowired
private RabbitTemplate rabbitTemplate;
private final String SESSION_CACHE_PREFIX = "seckill:sessions:";
private final String SKUKILL_CACHE_PREFIX = "seckill:skus:";
// +商品随机码
private final String SKUSTOCK_SEMAPHONE = "seckill:stock:";
@Override
public void uploadSeckillSkuLatest3Day() {
// 远程调用:
// 1.扫描最近三天要参加秒杀的商品
R r = couponFeignService.getLate3DaySession();
if(r.getCode() == 0){
List<SeckillSessionsWithSkus> sessions = r.getData(new TypeReference<List<SeckillSessionsWithSkus>>() {});
// 2.缓存活动信息
saveSessionInfo(sessions);
// 3.缓存活动的关联的商品信息
saveSessionSkuInfo(sessions);
}
}
/**
* 2.缓存活动信息
*/
private void saveSessionInfo(List<SeckillSessionsWithSkus> sessions){
if(sessions != null){
sessions.stream().forEach(session -> {
long startTime = session.getStartTime().getTime();
long endTime = session.getEndTime().getTime();
String key = SESSION_CACHE_PREFIX + startTime + "_" + endTime;
Boolean hasKey = stringRedisTemplate.hasKey(key);
if(!hasKey){
// 获取所有商品id
List<String> collect = session.getRelationSkus().stream().map(item -> item.getPromotionSessionId() + "-" + item.getSkuId()).collect(Collectors.toList());
// 缓存活动信息
stringRedisTemplate.opsForList().leftPushAll(key, collect);
}
});
}
}
private void saveSessionSkuInfo(List<SeckillSessionsWithSkus> sessions){
if(sessions != null){
sessions.stream().forEach(session -> {
BoundHashOperations<String, Object, Object> ops = stringRedisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
session.getRelationSkus().stream().forEach(seckillSkuVo -> {
// 1.商品的随机码
String randomCode = UUID.randomUUID().toString().replace("-", "");
if(!ops.hasKey(seckillSkuVo.getPromotionSessionId() + "-" + seckillSkuVo.getSkuId())){
// 2.缓存商品
SeckillSkuRedisTo redisTo = new SeckillSkuRedisTo();
BeanUtils.copyProperties(seckillSkuVo, redisTo);
// 3.sku的基本数据 sku的秒杀信息
R info = productFeignService.skuInfo(seckillSkuVo.getSkuId());
if(info.getCode() == 0){
SkuInfoVo skuInfo = info.getData("skuInfo", new TypeReference<SkuInfoVo>() {});
redisTo.setSkuInfoVo(skuInfo);
}
// 4.设置当前商品的秒杀信息
redisTo.setStartTime(session.getStartTime().getTime());
redisTo.setEndTime(session.getEndTime().getTime());
redisTo.setRandomCode(randomCode);
ops.put(seckillSkuVo.getPromotionSessionId() + "-" + seckillSkuVo.getSkuId(), JSON.toJSONString(redisTo));
// 如果当前这个场次的商品库存已经上架就不需要上架了
// 5.使用库存作为分布式信号量 限流
RSemaphore semaphore = redissonClient.getSemaphore(SKUSTOCK_SEMAPHONE + randomCode);
semaphore.trySetPermits(seckillSkuVo.getSeckillCount().intValue());
}
});
});
}
}
}
获取秒杀商品数据
(1)取当前时间,根据keys *
找到Redis存储的最近三天所有秒杀场次,找到当前时间所匹配时间段的场次。
(2)根据场次查询所有的秒杀商品。(秒杀当时需要返回它的随机码, 如果是活动预告的,则不需要返回随机码)
@Override
public List<SeckillSkuRedisTo> getCurrentSeckillSkus() {
// 1.确定当前时间属于那个秒杀场次
long time = new Date().getTime();
// 定义一段受保护的资源
try (Entry entry = SphU.entry("seckillSkus")) {
Set<String> keys = stringRedisTemplate.keys(SESSION_CACHE_PREFIX + "*");
for (String key : keys) {
// seckill:sessions:1593993600000_1593995400000
String replace = key.replace("seckill:sessions:", "");
String[] split = replace.split("_");
long start = Long.parseLong(split[0]);
long end = Long.parseLong(split[1]);
if (time >= start && time <= end) {
// 2.获取这个秒杀场次的所有商品信息
List<String> range = stringRedisTemplate.opsForList().range(key, 0, 100);
BoundHashOperations<String, String, String> hashOps = stringRedisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
List<String> list = hashOps.multiGet(range);
if (list != null) {
return list.stream().map(item -> {
SeckillSkuRedisTo redisTo = JSON.parseObject(item, SeckillSkuRedisTo.class);
// redisTo.setRandomCode(null);
return redisTo;
}).collect(Collectors.toList());
}
break;
}
}
} catch (BlockException e) {
log.warn("资源被限流:" + e.getMessage());
}
return null;
}
服务单一职责,独立部署 秒杀服务即使自己扛不住,挂掉,也不要影响别人。
秒杀链接加密(随机码实现) 防止恶意攻击,模拟秒杀请求,1000次/s攻击。 防止链接暴露,自己工作人员,提前秒杀商品。
库存预热+快速扣减库存信息通过定时任务放到redis,并且库存作为信号量 秒杀读多写少,无需每次实时校验库存,我们库存预热,放到redis中,信号量控制进来秒杀的请求。
动静分离
nginx做好动静分离,保证秒杀和商品详情页的动态请求才打到后端的服务集群,使用CDN网络,分担本集群压力。
恶意请求拦截 识别非法攻击请求并进行拦截,脚本发送请求
流量错峰 使用各种手段,将流量分担到更大宽度的时间点,比如验证码,加入购物车。
限流、熔断、降级 前端限流+后端限流(前端:秒杀按钮隔1s才能再次点击,后端:sentinel) 限制次数,限制总量,快速失败降级运行,熔断隔离防止雪崩。
队列削峰
一万个商品,每个1000件秒杀,双11所有秒杀成功的请求,进入队列。慢慢创建订单,扣减库存即可。
秒杀请求格式
xxxx.com/kill?killId=session_id&code=xxxx&num=1
killId = 秒杀场次ID + 商品SKUID
code = 商品的随机码
num = 秒杀的数量
秒杀操作流程细节:
前端限流:
1. 秒杀按钮,点击前需要进行是否登录判断。
后端限流:
2. 结合SpringSession + 拦截器,判断是否登录,如果没有登录,先跳转到登录页
秒杀Service方法
3. 获取秒杀商品的详细信息
3.1 如果查询不到,直接结束。
3.2 查询到就开始校验。 秒杀时间范围是否正确。
3.3 校验 随机码 和 商品ID。 缓存中 和 传输过来的随机码要一样
3.4 验证购物数量是否合理,秒杀购买数量限制, 是否大于 Num
3.5 判断是否购买过该商品。幂等性处理。只要秒杀成功,就去占位。setNX 占位。
key : userId_sessionId_skuId
value: num
超时时间:活动结束时间 - 当前时间。 (自动过期)
3.6 通过分布式信号量来减库存。
semaphore.tryAcquire(num, 100, TimeUnit.millseconds),如果信号量可以获取成功,就秒杀成功。
没有获取到就秒杀失败。
3.7 快速下单,发送MQ消息 10ms
IdWorker.getTimeId() // 返回订单号
代码说明:
@Autowired
private SeckillService seckillService;
@GetMapping("/kill")
public String secKill(@RequestParam("killId") String killId, // session_skuID
@RequestParam("key") String key,
@RequestParam("num") Integer num, Model model){
String orderSn = seckillService.kill(killId,key,num);
// 1.判断是否登录
model.addAttribute("orderSn", orderSn);
return "success";
}
@Override
public String kill(String killId, String key, Integer num) {
MemberRespVo memberRsepVo = LoginUserInterceptor.loginUser.get();
// 1.获取当前秒杀商品的详细信息
BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREDIX);
String json = hashOps.get(killId);
System.out.println(killId);
System.out.println(json);
if(StringUtils.isEmpty(json)){
return null;
}else{
SecKillSkuRedisTo redisTo = JSON.parseObject(json, SecKillSkuRedisTo.class);
// 校验合法性
long time = new Date().getTime();
if(time >= redisTo.getStartTime() && time <= redisTo.getEndTime()){
// 1.校验随机码跟商品id是否匹配
String randomCode = redisTo.getRandomCode();
String skuId = redisTo.getPromotionSessionId() + "_" + redisTo.getSkuId();
if(randomCode.equals(key) && killId.equals(skuId)){
// 2.说明数据合法
Integer limit = redisTo.getSeckillLimit();
if(num <= limit){
// 3.验证这个人是否已经购买过了
String redisKey = memberRsepVo.getId() + "_" + skuId;
// 让数据自动过期
long ttl = redisTo.getEndTime() - redisTo.getStartTime();
Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl<0?0:ttl, TimeUnit.MILLISECONDS);
if(aBoolean){
// 占位成功 说明从来没买过
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMPHORE + randomCode);
boolean acquire = semaphore.tryAcquire(num);
if(acquire){
// 秒杀成功
// 快速下单 发送MQ
String orderSn = IdWorker.getTimeId() + UUID.randomUUID().toString().replace("-","").substring(7,8);
SecKillOrderTo orderTo = new SecKillOrderTo();
orderTo.setOrderSn(orderSn);
orderTo.setMemberId(memberRsepVo.getId());
orderTo.setNum(num);
orderTo.setSkuId(redisTo.getSkuId());
orderTo.setSeckillPrice(redisTo.getSeckillPrice());
orderTo.setPromotionSessionId(redisTo.getPromotionSessionId());
rabbitTemplate.convertAndSend("order-event-exchange","order.seckill.order", orderTo);
return orderSn;
}
}else {
return null;
}
}
}else{
return null;
}
}else{
return null;
}
}
return null;
}
结合MQ下单功能
引入RabbitMQ,开启发送端确认、和消费者ACK机制。
通过rabbitTemplate.convertandSend 发送消息到队列
快速订单对象:
订单号
活动场次ID
商品ID
会员ID
购买数量
订单服务
@Bean 创建 Queue 和 QueueBinding
订单服务对 秒杀队列进行监听 @RabbitListener
进行创建秒杀单。
/**
* @author WGR
* @create 2020/8/19 -- 22:54
*/
@RabbitListener(queues = "order.seckill.order.queue")
@Component
public class OrderSecKillListener {
@Autowired
private OrderService orderService;
@RabbitHandler
public void listener(SecKillOrderTo secKillOrderTo, Channel channel, Message message) throws IOException {
try {
// 创建秒杀单的信息
orderService.createSecKillOrder(secKillOrderTo);
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
} catch (Exception e) {
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
}
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。