字节跳动上亿级别秒杀系统优化,用这个方案可行吗?

正式加入字节跳动!电商实战之优惠卷实现 在电商项目中,为了鼓励客户多购买商品,经常发放优惠卷,为了给客户心里带来紧迫感,优惠卷也是有时间限制的,超过限制这张优惠卷将自动作废。 1.1. 表结构设计 为了实现这个功能,数据库一般有一张优惠卷的表,这表里面有使用时间,过期时间、优惠卷金额等字段,一般的表结构如下 CREATE TABLE enjoy_coupon ( id int(11) NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL, money int(255) DEFAUL 阅读详情

秒杀相信大家都不陌生,商家会发布一些价格低廉、数量很少的商品,吸引用户抢购,例如每年双十一活动就属于典型的秒杀活动。还有类似春节12306抢票、小米手机限量发售等都可以理解为“秒杀”。

秒杀特点是持续时间短,抢购人数多,参与人数大大高于商品数量。抢购开始前后大量用户请求涌入,极易给服务造成巨大压力。如果系统设计不当,还容易造成超卖、数据丢失等问题。

本文我们主要讨论在秒杀的高并发场景下,传统订单架构存在的性能瓶颈,如何利用redis、MQ等中间件对系统做优化,解决缓存加速、防止重复提交、排队下单、超卖、少卖、削峰、异步下单等核心问题。

秒杀业务流程简介

秒杀总体业务流程可以简述为

  1. 商户创建秒杀活动,设定秒杀时间段,选择本次活动的商品,设置折扣、库存等;
  2. 用户APP端在活动即将开始时会看到秒杀活动列表,点击活动可以看到商品列表,点击商品可以查看秒杀商品详情;
  3. 商品详情页用户点击立即抢购;
  4. 如果库存充足,则创建订单成功;否则秒杀失败
  5. 提交订单后超时未支付,系统会自动关闭订单,回滚库存。

秒杀页面主要分为:

(1)首页秒杀活动列表

(2)商品详情页

普通订单系统

我们来看看普通订单系统是如何处理订单请求的.

订单下单流程图

ä¸äº¿çº§å«ç§æç³»ç»ä¼åï¼ç¨è¿ä¸ªæ¹æ¡å¯è¡åï¼ä¸äº¿çº§å«ç§æç³»ç»ä¼åï¼ç¨è¿ä¸ªæ¹æ¡å¯è¡åï¼

 

流程分析

在springcloud环境下,普通订单下单流程可以总结为:

1.用户确认订单、提交订单,发送下单请求至订单微服务;

2.订单服务会调用用户服务做一系列业务校验,如账号是否异常等;

还会调用商品服务,校验商品信息;

商品服务又会调用活动服务,校验优惠券、计算优惠等;

3.各服务从MySql获取业务数据,进行业务计算、业务校验;

4.生成订单,最后将订单数据入库。

瓶颈分析

普通订单系统分析

​ 以上是传统微服务架构订单业务的经典流程,在用户量不多、并发不高的正常业务场景下,支撑起正常的业务需求是没问题的。可以通过部署集群、数据库分库分表和读写分离、sql调优、硬件升级等方式,进一步提高系统稳定性和抗并发能力。

但是对于秒杀业务场景,由于秒杀活动特点是商品库存少,参与人数多,在秒杀开始前后,系统的瞬间请求流量飙升,对后端服务尤其是数据库造成很大压力,如果不能进行有效削峰、限流,所有请求一次性到某一台服务器或数据库上,服务很有可能出现卡顿、不可用甚至宕机的可能,给用户造成不良体验。

普通订单系统处理秒杀业务的瓶颈

数据库负担过重

从上图可以看出,仅一个下单请求,所有服务的查询、修改等都是直接操作mysql,没有用到缓存,秒杀开始,系统瞬间承受平时数十倍甚至上百倍的流量,导致mysql cpu占用升高,压力过重,直接拖慢所有系统服务。

频繁的跨服务调用

由上图可以看出,秒杀相关接口在查询业务数据时,由于下单业务复杂,需要校验的业务项非常多,后端不得不频繁跨服务调用,订单服务会调用商品、用户等服务、活动等服务,活动服务可能还会调用其它服务,

调用链过多、过长,可能某一环节响应时间过长而拖慢系统整体速度,同时微服务之间的互相调用也会占用系统CPU、内存资源,造成服务器性能下降。

容易产生大量无效下单请求

秒杀商品只有10个,却有1000个下单请求,这1000个请求到后端会全部走一遍下单逻辑,而实际上真正成功的订单只有10个,其它秒杀失败的请求没有过滤掉。

没有排队处理请求

所有请求一窝蜂涌入,容易造成请求积压,造成OOM。

串行处理

在高并发情况下,为保证不出现超卖问题,所有涉及库存操作都会加锁处理,串行执行,增加请求处理耗时。即使系统能容忍很高的并发,也很可能出现请求堆积、超时等情况。

链接暴露

秒杀url很容易通过抓包工具获取,竞争对手或黄牛党可以通过脚本或刷单工具发送下单请求,轻则活动还没开始商品便卖光,严重的服务器宕机,活动失败,GG。

秒杀常见优化方案

关于秒杀系统,可优化的点非常多,这里列出如下几点:

前端层面

前端优化(前端按钮点击频率限制、限制用户维度访问频率、限制商品维度访问频率、验证码机制等)

页面数据的静态化+多级缓存(CDN加速+Nginx+Redis)

服务层面

web服务器优化(tomcat、undertow)

nginx限流

负载均衡

服务器硬件升级

削峰处理

服务降级、熔断

jvm性能调优

业务层面

数据库分库分表、读写分离

sql调优

代码调优

.................

本次优化关键点

​ 实际上,受限于经费、时间、团队技术水平等条件,实际优化中我们可能无法对以上几点逐条优化,一是耗时耗力,二是可能没必要,具体优化时还是要以实际业务并发量为准。 在资源、时间有限的情况下,我们需要一个高效、最能够显著提升效果的优化方案。

本文主要介绍在服务层面,如何针对瞬时的高并发请求做削峰处理;业务层面,如何利用缓存减轻mysql数据库访问压力、如何排队处理、如何防止重复提交、防止超卖问题等。

利用缓存

秒杀的业务特点是读多写少,一个秒杀商品只有10个,可能有10w个人来抢,最终只有10个用户会产生写操作,其它请求都是查询库存,非常适合利用缓存优化。

缓存这块我们选用redis,redis基于内存,内存的读写速度非常快;同时redis内部是单线程操作,省去了很多上下文切换线程的时间。redis采用多路复用技术,非阻塞式IO,可以抗住高达百万级的并发量。

排队下单

利用redis进行排队抢单,记录排队数据。秒杀请求到后端后,不立即走创建订单逻辑,先通过redis校验排队、库存信息,校验通过后将秒杀请求缓存到redis。

削峰处理

通过RabbitMQ消息队列削峰:

秒杀请求不直接生成订单,先存入MQ消息队列,可以写一个消息监听器,平缓消费秒杀请求数据,减轻数据库并发量。

优化方案设计

数据缓存

通过以上分析我们知道秒杀的最大瓶颈便是mysql,所以我们要将mysql的压力转移给缓存。

在活动开始前,我们可以配置循环定时任务,将秒杀活动、秒杀商品相关信息全部缓存到redis中,可以根据活动信息,设置缓存的失效时间。

前端秒杀活动、商品详情等数据的获取,全部走redis。

ä¸äº¿çº§å«ç§æç³»ç»ä¼åï¼ç¨è¿ä¸ªæ¹æ¡å¯è¡åï¼

 

确认订单

用户发送确认订单请求时,首先校验该用户是否是否已经排队,排队信息从redis中获取,如果已经排队下单,直接返回; 否则继续走确认订单的业务逻辑。

 

ä¸äº¿çº§å«ç§æç³»ç»ä¼åï¼ç¨è¿ä¸ªæ¹æ¡å¯è¡åï¼

下单流程优化

1.用户提交秒杀订单,后端先获取判断是会否是本次活动黑名单用户,如果是,则直接返回

2.获取该用户的排队信息(避免用户重复提交),如果已经排队,则直接返回;

3.则从redis中获取库存信息,判断库存是否充足,如果库存不足,直接返回;

4.库存充足,则redis中记录用户下单排队信息,包括活动id、商品id、用户id等,同时将商品库存减1,这里涉及到超卖问题;

同时给前端返回一个code,表示排队中;

5.前端接收到数据后,显示排队中,并根据商品id轮询请求服务器,获取下单状态;

6.此时大部分请求都已经被过滤,只有少量请求最终会走到这一步,此时将它们发送到RabbitMQ下单队列中;

7.消息监听器监听下单消息,执行真正的下单逻辑。

(1)下单失败:这里涉及到黑名单问题,如果一个用户多次下单都失败,可能是恶意请求,也可能是该用户不符合本场秒杀条件(业务校验不通过),可以考虑将该用户加入本场秒杀活动黑名单中。

同时要回滚库存、删除用户排队缓存信息、更新redis秒杀状态信息。

(2)下单成功,更新redis秒杀状态信息,状态更新微订单已创建。发送延迟消息给rabbitMq,超时未支付自动关闭订单,回滚库存等。

ä¸äº¿çº§å«ç§æç³»ç»ä¼åï¼ç¨è¿ä¸ªæ¹æ¡å¯è¡åï¼

 

定时任务缓存预热

(1)定时任务将 状态为已发布且未开始的秒杀活动、秒杀商品写入redis缓存;

(2)扫描已过期的秒杀活动,移除缓存。

扫描秒杀数据

@Scheduled("*************************")
public void pushSpikeInfoIntoRedis(){
       //查询未开始的秒杀活动信息
        List<RedisSpike> spikeList = spikeMapper.selectSpikeOfNotStart();
      
        //查询秒杀商品信息
        List<RedisSpikeSku> spikeSkuList = spikeSkuMapper.selectSkuList();
        //根据活动分组
       Map<Long, List<RedisSpikeSku>> groupSpikeSkuList=
       spikeSkuList.stream().collect(Collectors.groupingBy(RedisSpikeSku::getSpikeId))

        //秒杀活动存储到redis
        for (RedisSpike spike : spikeList) {
            redisTemplate.opsForHash()
           .put("hosjoy-spike-test:spike-info", spikeId,spike );
           
         }
        //秒杀商品存储到redis
       for(Map.Entry<Long, List<RedisSpikeSku>>entry: groupSpikeSkuList.entrySet()){
           Long spikeId= entry.getKey;
           List<RedisSpikeSku >skuList = entry.getValue();
           for(RedisSpikeSku sku: skuList){
            redisTemplate.opsForHash()
           .put("hosjoy-spike-test:spike-sku:"+spikeId, sku.getId, sku );                 
           }      
        }       
    
}

库存入队

sku库存单独放到一个队列里面。 例如库存为5个,就在队列里push 5个元素。

        //库存信息
      for(RedisSpikeSku sku: spikeSkuList){
          Integer inventory = sku.getInventory();
          List<Long> skuIds = new ArrayList<>(inventory);
          for(int i=0; i<inventory; i++){
              skuIds.add(sku.getId());
          }          
          redisTemplate.opsForList().leftPushAll("hosjoy-spike-test:spike-sku-inventory:" + spikeId + spikeSkuId, skuIds);
      }

定时扫描过期数据

扫描已经结束的秒杀活动和秒杀商品缓存,清理无用数据。

    //删除已过期活动
    List<Long> expireSpikes = new ArrayList<>();
    List<RedisSpike> spikes = redisTemplate.opsForHash().values("hosjoy-spike-test:spike-info");
    LocalDateTime now = LocalDateTime.now();
    spikes.forEach(s->{
        LocalDateTime endTime = s.getEndTime();
        if(now.after(endTime)){
            expireSpikes.add(s.getId());
            redisTemplate.opsForHash().delete("hosjoy-spike-test:spike-info",s.getId());
        }
     });

    //删除已过期sku
    expireSpikes.forEach(spikeId->{
        redisTemplate.opsForHash().delete("hosjoy-spike-test:spike-sku:"+spikeId);
     }); 

秒杀活动列表

public SpikeListResponse spikeList(Long userId) {    
    //获取所有秒杀活动
    List<RedisSpike> spikes = redisTemplate.opsForHash().values("hosjoy-spike-test:spike-     info");
    //该用户可见的活动
    List<RedisSpike> visibleSpikes= new ArrayList<>();
    
    //业务处理,过滤当前用户不可见的活动
    ////根据具体业务自由实现
    removeSomeSpike(spikes, userId, visibleSpikes);
    
    //距离当前时间最近的活动
    RedisSpikeSku currentSpike = getCurrentSpike(visibleSpikes);
    
    //获取该活动商品列表
    List<RedisSpikeSku> spikeSkuList = redisTemplate.opsForHash()
           .values("hosjoy-spike-test:spike-sku:"+currentSpike.getId() );
    
    //业务校验、处理(例如去除当前用户不可见的商品)
    //根据具体业务自由实现
    removeSomeSku(spikeSkuList, userId);
    
    //数据封装
    SpikeListResponse spikeList =new SpikeListResponse();
    spikeList.setSpikes(visibleSpikes); //活动列表
    spikeList.setCurrentSpike(currentSpike); //当前活动
    spikeList.setCurrentSpikeSkuList(spikeSkuList); //商品列表
    return spikeList;
}

秒杀活动商品列表

public SpikeSkuListResponse spikeSkuListResponse(Long spikeId, Long userId) {    
    //获取活动信息
    RedisSpike spike = redisTemplate.opsForHash()
           .get("hosjoy-spike-test:spike-info", spikeId);
    
    //获取该活动商品列表
    List<RedisSpikeSku> spikeSkuList = redisTemplate.opsForHash()
           .values("hosjoy-spike-test:spike-sku:"+spikeId );
    
     //业务校验、处理(例如去除当前用户不可见的商品)
    //根据具体业务自由实现
    removeSomeSku(spikeSkuList, userId);
    
    //数据封装
    SpikeSkuListResponse response =new SpikeSkuListResponse();
    response.spikeInfo(spike); //活动信息
    response.setSpikeSkuList(spikeSkuList); //商品列表
    return spikeList;
}

秒杀商品详情页

直接从redis中获取商品、活动信息:

public SpikeSkuResponse spikeSkuDetail(Long spikeId, Long skuId) {
   //获取商品信息
   RedisSpikeSku sku = redisTemplate.opsForHash().get("hosjoy-spike-test:spike-           sku:"+spikeId, skuId);
    
    //获取活动信息
    RedisSpike spike = redisTemplate.opsForHash()
           .get("hosjoy-spike-test:spike-info", spikeId);
    
    //其它业务处理
    .............................
    .............................
    
    //数据封装
    SpikeSkuResponse response =new SpikeSkuResponse();
    response.set...........
    response.set...........
    return response;
}

确认订单

确认订单前先校验用户是否已经有秒杀订单或提交过秒杀请求。如果已经提交过直接响应确认订单失败,这样又可以拦截大量的提交订单请求。

防止重复提交

获取用户提交订单次数,如果次数大于1说明已经提交过秒杀请求。

spike-user-queue-count这个缓存在下文会讲到。

//判断是否已排队
//防止脚本刷单、重复提交等,
Long currentUserQueue = redisTemplate.opsForHash().get("hosjoy-spike-test:spike-user-queue-count:"+ spikeId+spikeSkuId, userId);
if(currentUserQueue > 1){
    throw new ClientException("您已提交了订单,请误重复提交");
}

提交订单

黑名单校验

用户如果多次提交订单失败,有可能是恶意刷单,也可能是该用户不符合购买条件导致提交订单失败(实际业务校验不通过),可以在提交订单失败后将该用户加入黑名单,在提交订单前校验该用户是否为黑名单用户。

Long failCount= redisTemplate.opsForHash().get("hosjoy-spike-test:spike-black-user:"+         spikeId+spikeSkuId, userId);

if(failCount > SpikeConstants.MAX_FAIL_TIMES){
    throw new ClientException("您不符合购买条件");
}

防止重复提交

利用redis的increment操作,记录用户请求提交次数,如果是第一次秒杀请求,increment后的值肯定为1,则允许排队;如果值大于1,说明重复提交。

这里不需要加锁判断,不用担心并发问题,多线程环境下,各个线程获取到的currentUserQueue肯定是准确的(redis单线程操作特性)。

//当前用户提交次数
Long currentUserQueue = redisTemplate.opsForHash().increment("hosjoy-spike-test:spike-user-queue-count:"+ spikeId+spikeSkuId, userId, 1);
if(currentUserQueue > 1){
    throw new ClientException("您已提交了订单,请勿重复提交");
}

超卖问题

超卖问题原因分析

引起超卖的原因很可能是代码逻辑进行 取库存-----判断库存是否充足-------业务操作…..扣减库存-----库存入库

类似的操作,并发情况下,多线程同时取库存,假设2个线程,库存只有1个,两个线程取出的库存都为1,

库存校验均通过,然后进行减库存、入库等操作,然后都下单成功了,造成超卖现象,1个商品卖给了2个人。

超卖常用解决方案

事务

我们可以使用使用redis的 watch + multi 指令,去监听秒杀商品库存,如果库存数发生改变,则后续无法进行修改库存操作。

缺点:(1)由于watch采用乐观锁机制,没有对其它线程修改操作作限制,因此事务有可能频繁失败;需要用while循环去重复尝试;

(2)增加服务器压力

分布式锁

利用分布式锁,保证同一时刻只有一个线程进行读库存---修改库存操作。

缺点:同一个商品多用户同时下单的时候,会基于分布式锁串行化处理,导致没法同时处理同一个商品的大量下单的请求,并发处理能力较弱。

redis 队列

将库存缓存到redis队列,队列里面放sku_id,例如库存为5个,就放5个id。

通过rightPop操作取出商品,预扣减库存,如果pop出来的元素为空,说明售罄 。

这里利用了redis单线程操作特性,队列取id即扣减库存,相当于原子操作,高并发场景下不需要开事务,也不用加锁同步,性能、数据一致性均好于以上两种方案。

乐观锁

利用CAS原理,在操作数据库更新库存的时候,更新条件带上之前查询到的库存数量,如果更新结果数为0,说明过程中其它线程修改了库存。

select inventory from sku;
update sku set inventory =#{inventory} where id=?  and inventory= ?

代码实现

我们采用redis队列+乐观锁的方式控制超卖问题,后者需在生成订单数据入库的逻辑实现,redis队列实现的代码样例如下:

//预扣减库存
String  skuId = redisTemplate..opsForList().rightPop("hosjoy-spike-test:spike-sku-inventory:" + spikeId + spikeSkuId);
//pop出来空,说明已售罄
//此处不需要加锁判断,不会产生并发问题
if(skuId == null){
    //删除排队信息
   redisTemplate.opsForHash().delete("hosjoy-spike-test:spike-user-queue-count:"+        spikeId+spikeSkuId, userId);   
    throw new ClientException("商品已售空");
}

少卖问题

少卖问题原因分析

少卖可能出现的原因有

(1)redis预扣减库存成功,但是执行真正的下单逻辑失败了,且库存没有回滚;

(2)用户订单提交成功了,但是超时没有支付,且超时后活动已结束或者超时后没有回滚库存;

(3)用户排队成功了,但是排队下单请求消息发送到MQ失败了,或者MQ消息丢了,或者消费者弄丢了数据。

解决方案

(1)异步下单失败后,要即时回滚redis中的sku库存;

(2)缩短支付时间,或者修改秒杀流程:先支付再确认订单;超时未支付后即时回滚redis中的sku库存;

(3)解决MQ消息丢失问题(下文会提到)。

排队下单

经过上述黑名单校验、重复提交校验、库存校验后,只有少量的请求最终会加入到下单队列中了。

此时系统只是预扣减库存,用户只是抢到了一个机会,加入到排队下单队列,是否能够真正购买成功取决于最终的订单创建逻辑的执行结果。

(1)将下单状态存入到redis中,设置状态为排队中,不生成订单,直接返回给用户排队中。

(2)同时更新商品信息,预扣减库存数量;

(3)同将排队信息发送到RabbitMq, 排队信息包括userId、skuId,spikeId等;立即响应客户端:下单排队中请稍候.....

//下单状态存入到Redis中 状态为排队中
OrderQueueState orderQueueState = new OrderQueueState(......下单状态封装........);
redisTemplate.opsForHash().put("hosjoy-spike-test:spike-order-user-queue-state:"+ spikeId+spikeSkuId, userId, orderQueueState);

//更新秒杀商品库存数量
 redisTemplate.opsForHash()
           .increment("hosjoy-spike-test:spike-sku:"+spikeId, skuId, -1 );

//发送排队信息到RabbitMq
sendRabbit(queueState);

订单创建

监听RabbitMq下单队列消息,异步生成订单;

同时更新redis中的下单状态缓存,设置订单id, 前端可通过轮询方式获取到生成的订单id

@Transactional
@StreamListener("USER-SPIKE-ORDER-QUEUE")
public void userSpikeOrderQueueInput(Message message) {
        
    //调用订单创建逻辑,入库,生成订单id
    Long orderId = appOrderService.submit();
    //订单创建成功,更新下单状态为订单提交成功
    if(orderId != null){        
        OrderQueueState orderQueueState = new OrderQueueState();
        orderQueueState.setState("订单创建成功")
        orderQueueState.setOrderId(orderId);
        
        redisTemplate.opsForHash().put("hosjoy-spike-test:spike-order-user-queue-state:"+         spikeId+spikeSkuId, userId, orderQueueState);
        //发送延迟消息,超时未支付自动关闭订单
        sendMQDelayMessage();
    }
    //可能因为各种业务校验不通过而导致提交订单失败
    else{
        //回滚库存队列,否则会出现少卖问题
        redisTemplate.opsForList.leftPush("hosjoy-spike-test:spike-sku-inventory:" + spikeId + spikeSkuId, spikeSkuId);
        //商品库存数量+1
        redisTemplate.opsForHash()
           .increment("hosjoy-spike-test:spike-sku:"+spikeId, spikeSkuId, 1 );
       
        //下单状态更新为订单提交失败
        OrderQueueState orderQueueState = new OrderQueueState();
        orderQueueState.setState("订单创建失败")
        redisTemplate.opsForHash().put("hosjoy-spike-test:spike-order-user-queue-state:"+         spikeId+spikeSkuId, userId, orderQueueState); 
        //删除排队信息
        redisTemplate.opsForHash().delete("hosjoy-spike-test:spike-user-queue-count:"+             spikeId+spikeSkuId, userId);
        //用户多次提交订单失败,有可能是恶意刷单,也可能是该用户不符合购买条件导致提交订单失败(实际业务校验          不通过)。 
        //可以将该用户加入黑名单。
        redisTemplate.opsForHash().increment("hosjoy-spike-test:spike-black-user:"+         spikeId+spikeSkuId, userId, 1)
    }    
}

超时未支付关闭订单

订单生成后我们通过sendMQDelayMessage发送了延迟消息,有些人下完单可能并不会付款,超过这个时间后消费者接收到MQ延迟消息,这时需做如下处理:

(1)回滚redis库存、数据库库存,否则会出现少卖问题;

(2)删除排队信息;

(3)删除下单状态缓存

@Transactional
@StreamListener(OrderSink.UNPAID_AUTO_CLOSE_INPUT)
public void unpaidAutoCloseInput(Long orderId) {
    log.info("[RABBITMQ][ORDER] 收到订单订单自动关闭消息, 订单id为: {}", orderId);
    this.closeOrder();
    
    //看看是否确实没有支付
      OrderQueueState orderQueueState = redisTemplate.opsForHash().get("hosjoy-spike-test:spike-order-user-queue-state:"+spikeId+spikeSkuId, userId);
    //仍然有下单排队状态缓存,说明没有完成支付
    if(orderQueueState!=null){
     //回滚库存
    redisTemplate.opsForList.leftPush("hosjoy-spike-test:spike-sku-inventory:" + spikeId +     spikeSkuId, spikeSkuId);
     //删除排队信息
    redisTemplate.opsForHas h().delete("hosjoy-spike-test:spike-user-queue-count:"+            spikeId+spikeSkuId, userId); 
      //删除下单排队状态
       redisTemplate.opsForHash().delete("hosjoy-spike-test:spike-order-user-queue-              state:"+ spikeId+spikeSkuId, userId);   
    }
    
}

支付完成回调

支付完成后,需要删除用户排队数据、删除排队状态数据。

//删除排队信息
 redisTemplate.opsForHash().delete("hosjoy-spike-test:spike-user-queue-count:"+        spikeId+spikeSkuId); 
//删除下单排队状态缓存
//如果不删除,在收到超时未支付消息时,会认为该订单未支付
 redisTemplate.opsForHash().delete("hosjoy-spike-test:spike-order-user-queue-state:"+        spikeId+spikeSkuId, userId); 

秒杀状态查询

订单是异步生成的,所以需要后端提供秒杀状态查询接口。

前端可轮询获取秒杀状态。

@GetMapping(value = "/status")
public Integer queryStatus(){
       
    OrderQueueState orderQueueState=
        redisTemplate.opsForHash().get("hosjoy-spike-test:spike-order-user-queue-state:"+         spikeId+spikeSkuId, userId);
    
    if(orderQueueState!=null){
        orderQueueState.getState();
    }
    //查询不到下单状态缓存
    //可能原因:1.未下单 2:创建订单业务逻辑出错 3: 超时未支付 4:已支付
    throw new ClientException("ERROR");
}

补充问题

重复下单问题

MQ在整个秒杀流程中扮演了很重要的角色,因为下单数据全部暂存在MQ中,一旦消费者重复消费,就有可能出现一个用户秒杀到两个商品的重复下单情况。

所以代码有必要进行重复消息判断。

解决方案:

利用redis:发送消息时指定消息的全局唯一id;收到消息后查询redis是否有该id,有则说明是重复消息。然后立即将id存入redis中。

业务校验:生成订单时校验用户是否已经秒杀过该商品。

下单消息丢失

如果下单消息丢失了,用户秒的这个商品就可能永远卖不出去了,造成少卖问题。

以rabbit为例:

生产者丢失消息

(1)使用事务;

(2)使用confirm模式;

(3)异步监听确认模式

MQ丢失消息

开启消息持久化

消费者丢失消息

消费者消费完成后回执确认,如果一段时间后MQ没有收到消费者的回执确认,MQ就认为消息没有被成功消费,会将消息重新发送给其他消费者。

库存一致性问题

redis中记录的库存主要用于即时判断库存是否充足,作用是过滤大部分秒杀请求,只接收库存数量的请求放入请求队列。并不需要与mysql中的库存保持强一致性。

所以本方案不需要保持两者数据的一致性。

redis挂了怎么办

如果redis都扛不住了,说明并发量很高了.........

这个其实是个高可用的问题,首先要做好数据持久化工作,防止数据丢失;

其次可以采用市面上比较成熟的分布式高可用redis解决方案,如codis。

如何处理恶意下单请求

如何防止脚本刷单和脚本攻击?

1.首先保证刷单者最多也只能刷到一件商品:真正的下单逻辑校验一个用户只能购买一件商品,可以把用户id和商品id作为联合主键索引存储到数据库中,重复购买会自动报错,;

2.重复提交校验(前面讲过了);

3.验证码机制;

4.IP限流:限制同一IP访问速率

数据链路层:交换机设备本身便具有限制同一IP访问速率的功能(这其实属于网管应该干的事情)

网关层限流:nginx配置limit_conn_zone限制同一ip的访问速率:

limit_conn_zone $binary_remote_address zone=addr:10m;

web服务器限流:MQ、限流算法(如RateLimiter)等,代码实现。

5.url加密防暴露:利用可变url,根据skuId生成md5加密参数,下单时校验md5码是否正确。

我们要做到:(1)秒杀开始前,谁都不知道秒杀接口url到底是什么;

(2)秒杀接口url是可变的,每次请求的url都不一样。

实现思路:

首先获取秒杀下单url可变参数,根据skuId进行md5加密。校验是否已经到秒杀时间,防止秒杀还没开始就要有人通过脚本刷单。

//用于加密和解密的密钥
private final String cipher="hosjoy-spike-md5-cipher&73@(**$d--=,./;~·2··%##4";

//返回url可变部分
public String  getPathVirableMd5(long skuId) {
    if(LocalDateTime.now().before(spikeStartTime) ){
        throw new ClientException("还没到秒杀时间")
    }
    String base=skuId+"/"+cipher;
    return DigestUtils.md5DigestAsHex(base.getBytes());
}

(2)下单请求

@RequestMapping(value = "/submit/{skuId}/{md5}")
public void  submit(@PathVariable("skuId") Long skuId, @PathVariable("md5") String md5){
    return spikeService.submitSpikeOrder(skuId, md5);  
}

public void submitSpikeOrder(long skuId,  String md5){
      if(md5==null||!md5.equals(getPathVirableMd5(skuId))){
         //md5校验错误
         throw new RuntimeException("非法请求");
      }
      //校验通过,执行下单
      this. doSubmitSpikeOrder(skuId);
    }
}

结语

​ 一个秒杀系统,可以设计的很复杂,因为具体业务的并发量本身就特别高,不得不逐点优化;也可以设计的很简单,因为业务可能本身不复杂,用户量也没达到那种量级。具体需要优化到什么程度,还是需要以我们的实际业务为依据。

​ 本文主要从缓存、削峰处理等维度介绍优化效果和性价比较高的一种方案, 实际上可优化的点太多了,如果你的业务流量达到千万上亿级别,仅用上述方案是远远不够的,但是如果你们现有的系统采用传统订单架构来应对秒杀业务,相信这篇文章能够给你一定启发和帮助。

Redis 如何实现库存扣减操作和防止被超卖? 电商当项目经验已经非常普遍了,不管你是包装的还是真实的,起码要能讲清楚电商中常见的问题,比如库存的操作怎么防止商品被超卖基于redis实现扣减库存的具体实现在日常开发中有很多地方都有类似扣减库存的操作,比如电商系统中的商品库存,抽奖系统中的奖品库存等。在上面的第一种和第二种方式都是基于数据来扣减库存。 阅读详情

相关推荐

js实现商城秒杀倒计时功能(动态设置秒杀时间)

疯狂编程学习效果可视化写博客阅读优秀代码心态调整。

m0_61943758的博客 1281

实战!如何从零搭建10万级 QPS 大流量、高并发优惠券系统

需求背景 十万级的用户券系统需求,负责券建立、发放、核销、查询。 需求分析 首先对需求进行拆解,大致包含两部分: 券的配置,即全批次(券模板)的创建,券模板的有效期和券的库存信息; 券的发放,即券记录的创建和管理(过期,状态流转); 技术选型 券模板、券记录都要持久化,同时还需要支持条件查询,因此选用通用的MySQL作为存储DB; 另外由于需要支持高并发的读写,所以引入Redis做缓存; 券模板、券记录都有过期时间、过期状态等状态流转,有必要引入延迟消息队列做相应的业务处理,Roc.

jyhhhhhhh的专栏 1284

逆向选择启示录:一个失败高频策略如何意外跑赢市场

本文探讨了高频交易中的逆向选择现象,揭示了一个失败的高频策略如何通过简化意外跑赢市场。通过加密货币市场的实证分析和蒙特卡洛模拟,研究发现低预测性的简单策略反而更具鲁棒性,有效规避了逆向选择风险。文章还提供了小币种做市策略的优化框架和实践建议,为量化交易者提供了宝贵洞见。

weixin_29191669的博客 401

从零开始开发亿级流量电商优惠券项目实战

2. **优惠券生成模块**:这个模块应该能够创建新的优惠券,并设定相应的参数,比如优惠券的有效期、可用商品或类目、优惠幅度(比如是打折还是直接减价)、优惠券数量限制等。6. **报表和分析模块**:分析优惠券的使用情况,包括哪些优惠券被使用的最多,哪些用户最常使用优惠券等。5. **优惠券核销模块**:当用户想要使用优惠券时,系统需要验证优惠券的有效性,包括是否在有效期内、是否符合使用条件等。1. **用户管理模块**:存储和管理用户的信息,包括用户的优惠券使用历史和当前可用的优惠券。

iNiBuBian的博客 566

告别WSL!实测AMD ROCm7预览版在Windows下跑ComfyUI,速度提升与避坑全记录

本文详细记录了在Windows原生环境下部署AMD ROCm7+PyTorch+ComfyUI的全流程,相比WSL环境性能显著提升。涵盖硬件兼容性确认、开发环境配置、Python环境搭建、ComfyUI部署优化及性能对比测试,为AI绘图爱好者提供实用指南。

weixin_42528090的博客 671

【eNSP 华为模拟器】小型校园网模拟环境的设计与配置(附eNSP源文件)

【eNSP 华为模拟器】小型校园网模拟环境的设计与配置 一、实验概述 二、交换机的配置 2.1 二层交换机的配置 2.2 三层交换机的配置 三、路由器的配置 四、实验结果 五、实验总结 一、实验概述 按照以下的拓扑连接,自行设计VLAN和IP地址等网络参数,实现PC1到PC2的IP通信。其中,R1,R2为路由器;LSW1,LSW4为3层交换机;LSW2,LSW3 ,LSW6, LSW7为2层交换机;CLIENT1-CLIENT12为PC机。 拓扑图 课程设计要求:(1)IP地址自行规划设计,应该清晰地

qq_43757282的博客 4万+

Redis解决库存超卖问题

商品和订单服务间使用MQ 商品服务的库存变化时,通过 MQ 通知订单服务库存变化。 原始的同步流程 查询商品信息 (调用商品服务) 计算总价(生成订单详情) 商品服务扣库存(调用商品服务) 订单入库( 生成订单) 在订单生成时直接扣库存,这是最原始的扣库存方案,比较简单,但存在一系列问题: 可能导致很多订单把产品库存扣除而未支付,这就需要有一个后台脚本,将一段时间内没有支付的订单的库存释放,把订单取消 即时扣库存,并发差 1、3步商品服务,操作商品服务的 db,2、4步订单服务,操作订单服务的 db

JavaEdge全是干货的技术号 4686

微信小程序如何转云开发

小程序转云开发

zhangxiaobai___的博客 3914

国内最难入职的IT公司排行:深度拆解与求职攻略

摘要: 中国IT行业最难入职的公司榜单显示,拼多多和字节跳动位居前列,分别以算法与工程实践的双重考验、深度与广度的技术碾压著称。拼多多注重高并发架构与优化能力,字节跳动则侧重系统设计与底层原理。百度偏好原理追问,京东重视工程实践,快手平衡技术与业务。求职成功公式为“计算机基础+算法+项目/实习/比赛/论文”,学历劣势者需通过算法、硬核项目或实习弥补。建议分阶段准备(基础期、强化期、实战期、冲刺期),掌握面试技巧与心态调整,难但非不可突破。

IT枫斗者的博客 512

薛定谔 | 先导化合物优化与相互作用可视化

当获得针对某个靶点的先导分子后,想要在此基础上进行化学结构修饰。为了提升化学改造的成功率可以采用本教程方法进行结构虚拟改造/优化。每用薛定谔处理一个新任务,第一步必须创建新的project以方便日后管理。选项卡中修改工作路径(可以方便地找到输出文件)

药研猿的博客 2144

IAR for ARM 搭建STM32F103C8T6工程模板(实战指南)

本文详细介绍了如何在IAR for ARM开发环境中为STM32F103C8T6微控制器搭建完整的工程模板。从环境准备、固件库获取、目录结构设计到工程配置与编译调试,提供了实战指南和常见问题解决方案,帮助开发者快速建立稳定的STM32开发环境。

fun88的博客 988

数据湖仓一体架构在电商平台用户行为分析与个性化推荐中的应用

数据湖仓一体架构通过存储计算融合、实时批流一体、安全智能协同三大创新,解决了电商平台用户行为分析的关键痛点。据IDC预测,2025年全球电商数据湖仓市场规模将达85亿美元,年复合增长率达28%。建议企业建立数据治理委员会,制定三年演进路线图。重点投入领域包括:1)实时计算引擎升级(预算占比30%);2)安全合规体系完善(25%);3)AI能力嵌入(20%);4)边缘计算部署(15%);5)人才储备建设(10%)。未来研究方向应聚焦于:1)千亿级模型的高效服务化;2)联邦学习与隐私计算的深度融合;

chwrw的博客 981

【机器人学习】abb工业机器人运动学分析与圆弧轨迹规划(三维模型+matlab代码)

下载链接 solidworks模型 D-H建模 圆仿真过程 视频过程:

yjw0911的博客 2532

实现高并发秒杀的几种方式

如果要完美解决高并发场景,需要 从缓存、限流、消息队列、数据库扩展多方面考虑,而不仅仅是一两个技术就可以。还需要注意系统的稳定性、扩展性,要足够灵活与健壮。如果大家对相关文章感兴趣,可以关注公众号"架构殿堂",会持续更新AIGC,java基础面试题, netty, spring boot,spring cloud等系列文章,一系列干货随时送达!

Venus's Blog 2万+

最简解决 Unity Error Registry configuration is invalid Unable to connect https packages.unity.cn

前言 在学习其他大佬项目的时候,大佬说项目能直接运行,然后我拉下来就报如下错误 Unity报错:[Package Manager]Error Registry configuration is invalid:Unable to connect “https://packages.unity.cn” 翻了很多博发现是 unity3d 中国增强版的原因,中国增强版屏蔽了国外自定义的插件仓库。 安装 非中国增强版后可以成功打开。 然后大家给出的解决方案都比较激进,大抵就是更换Unity版本就OK了。 当然我是觉

Gipsyz的博客 6842

WINCC7.3破解文件

WINCC7.3硬件够破解文件,有需要的下载!WINCC7.3硬件够破解文件,有需要的下载!

库存扣减超卖和少卖解决方案(数据库机制/redis自减)

所谓“超卖”指的就是商品卖多了,一般我们在商品扣减库存的时候,都会先判断库存够不够,如果够在进行扣减,不够则直接返回下单失败。但是,如果在高并发场景中,可能存在以下情况:当有两个并发线程,同时查询库存,这时数据库中库存剩余1,所以两个线程都得到1的库存,然后经过库存校验之后分别开始进行库存扣减,最终导致库存被扣减成负数。之所以会发生以上问题,主要是因为并发导致的,所以,解决超卖的问题本质上是解决并发问题。以上问题,最终就是要实现库存扣减过程中的原子性和有序性。

qq_44598725的博客 1231

jdk-8u144-linux-x64.tar.gz.

该资源为搭建Linux系统Java环境用,资源的后缀为tar.gz格式的压缩包

前端秒杀商品详情显示实现

【代码】前端秒杀商品详情显示实现。

m0_68935893的博客 515

老版QQ原生表情gif动态

这是老版的QQ表情,不过全是动态的的,Windows照片查看器是不会动的,可以右击用浏览器打开查看,再说一遍是老版的,只是分享给宁愿用老版也要动态的人。

visio安装包

Microsoft Office Visio 是一款专业的制图软件。利用经过更新的形状、协作工具和数据链接图表,创建专业级图表以简化复杂的信息。

秒杀系统设计(前端设计,网关的作用,解决Redis的并发竞争,数据的同步方式,MQ的作用)

背景 我之前写过一个秒杀系统的文章不过有些许瑕疵,所以我准备在之前的基础上进行二次创作,不过让我决心二创秒杀系统的原因是我最近面试了很多读者,动不动就是秒杀系统把我整蒙蔽了,我懵的主要是秒杀系统的细节大家都不知道,甚至不知道电商公司一个秒杀系统的组成部分。 我之前在某电商公司就是做电商活动的,所以这样的场景和很多解决方案我是比较清楚的,那我就从我自身去带着大家看看一个秒杀的设计细节以及中间各种解决方案的利弊,以下就是我设计的秒杀系统,几乎涵盖了市面上所有秒杀的实现细节: 正文 首先设计一个系统之

Penge_的博客 939

redis+lua实现高并发库存扣减和回流,超卖和少卖场景使用。

怎么保证执行的原子性? Redis使用同一个Lua解释器来执行所有命令,同时,Redis保证以一种原子性的方式来执行脚本:当lua脚本在执行的时候,不会有其他脚本和命令同时执行,这种语义类似于 MULTI/EXEC。从别的客户端的视角来看,一个lua脚本要么不可见,要么已经执行完。 然而这也意味着,执行一个较慢的lua脚本是不建议的,由于脚本的开销非常低,构造一个快速执行的脚本并非难事。但是你要注意到,当你正在执行一个比较慢的脚本时,所以其他的客户端都无法执行命令。 redis+lua实现高并发库存扣

lujiawei00的专栏 2984

秒杀系统前端如何优化?

今天被问到如何优化一个秒杀系统? 其实这是一个老生长谈的问题,但这种问题一般都问服务端,我还特意问了这个需要前端来做优化吗?人家强调怎么在前端做会更好? 我当时的回答是限流,让按钮置灰,然后加入动画优化,让用户有更好的体验。人家追问还有吗? 我就不太了解了,回来之后我在网上查了一下,下面把看到的总结一下,如果还有更多的方案,欢迎补充,我想到的话也会回来补充的, 在秒杀系统中,前端能进行的优化点: 限流,点击提交按钮后按钮置灰,显示为正在排队中,能处理结束或若干秒后,才允许用户点击 页面静态化,将页面做成静

张木期的博客 1307

防住了超卖,却输给了“少卖”?

电商系统库存与订单管理的并发控制要点: 库存扣减策略:采用Redis预扣(下单减库存)+数据库实扣(支付减库存)的折中方案,平衡用户体验与库存安全。 防超卖:通过Redis Lua脚本实现原子性库存扣减,确保高并发下的数据一致性。 防少卖:异步订单落库时可能因消息丢失导致Redis库存已扣但订单未生成,需引入库存回补或对账机制。 支付与关单并发控制:使用状态机规范订单状态流转(如PENDING→PAID/CLOSED),结合乐观锁(CAS更新)避免支付回调与定时任务的冲突,确保状态变更的原子性。

程序员天天困的博客 2885

2024年前端最全如何设计一个合格的高并发秒杀系统_java高并发系统设计,【面试宝典】

大前端和全栈是以后前端的一个趋势,懂后端的前端,懂各端的前端更加具有竞争力,以后可以往这个方向靠拢。这边整理了一个对标“阿里 50W”年薪企业高级前端工程师成长路线,由于图片太大仅展示一小部分。

2401_84617080的博客 1216

2024年关于电商秒杀系统中防超卖、以及高性能下单的处理方案简述,2024年最新2024前端面试心得

由于篇幅限制,pdf文档的详解资料太全面,细节内容实在太多啦,所以只把部分知识点截图出来粗略的介绍,每个小节点里面都有更细化的内容!开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】最全面试题新鲜出炉:70+算法题、近30种大厂面试笔试常考知识点秒杀抢购系统的成功平稳运行,有一些需要注意的知识点。1 高并发,以及刷接口等黑客请求对服务端的负载冲击2 高并发时带来的超卖,即商品数量的控制3 高负载下,下单的速度和成功率的保证4 其他以秒杀单品为例,如抢小米手机。

2301_82244279的博客 641

Redis实现预减库存

当前方法实现InitializingBean接口,并重写afterPropertiesSet方法。

weixin_44778973的博客 1174

一篇文章轻松搞定SpringBoot + redis解决商品秒杀库存超卖

推荐阅读:前阿里P7架构师,分享工作心得及面试经验,助力圆梦大厂 阿里P8架构师谈:工作1-5年的Java工程师,怎样提高核心竞争力 问题描述 在众多抢购活动中,在有限的商品数量的限制下如何保证抢购到商品的用户数不能大于商品数量,也就是不能出现超卖的问题;还有就是抢购时会出现大量用户的访问,如何提高用户体验效果也是一个问题,也就是要解决秒杀系统的性能问题。 本文主要介绍基于redis 实现商品秒杀功能。先来跟大家讲下大概思路。总体思路就是要减少对数据库的访问,尽可...

文章中资料均可获取,有需要请添加yunduoa2019即可 608

k8s部署服务,设置tomcat和undertow的线程数

在Kubernetes中,docker服务部署可调整的线程数设置,tomcat&undertow

qq_34870707的博客 3155
上一篇: 头条二面:你们公司怎么处理MySQL的 Binlog 日志?
下一篇: 七进七出,终获阿里32k*16offer,这就是我悲惨的面试经历~
Java斗帝之路
博客等级 码龄7年 2392粉丝 687原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值