【高并发秒杀系统】对分布式锁、缓存、消息队列、限流等的原理分析及代码实现

前言:在一些商城项目中,秒杀是不可或缺的。但是,如果将普通的购买、消费等业务流程用于秒杀系统,不做任何的处理,会导致请求阻塞严重、超买超卖等严重后果,服务器、数据库也可能因为瞬时的流量而奔溃。所以,设计一套高可用的秒杀系统,是尤为重要的。

一、技术选型阶段

在选型时,应尽可能用市面上主流、稳定的开发框架,以便于后续的维护与升级。本系统主要选用了以下框架来实现。

  • Springboot 用于基础架构
  • RedisRedission 用于分布式锁、缓存商品数据
  • RabbitMQ 用于异步处理订单,流量削峰
  • Guava 用于限流

二、业务详解

1. 业务流程图

整体的业务流程图如下,细节我将在下文说明。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Xfe38664-1646799142701)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(1)]-2ab191977b294f15a50fff7c45eb64d2.png)

2. 原理解析

2.1 接口层

接口层流程

1. 幂等性校验:

由于业务需求,每个用户只能购买一次商品,所以首先要进行幂等性校验,防止用户重复提交。
通常情况下,前端在用户点击购买后,会禁用购买按钮,用户无法提交第二次。但是,若用户在第一次提交之后发生了网络延迟,客户端错判认为请求未提交,这样就造成了两次相同的请求提交。其实,这种情况也会发生在表单提交等场景。

常见的解决思路有:

  • 在数据库中将用户id设置唯一索引,这样就无法插入重复的记录。

  • 利用分布式锁,用户请求后在Redis等缓存中设置锁,第二次请求到达时就无法获取到锁,直接返回失败。

  • 令牌机制(token),客户端在请求进入秒杀页面时,服务端生成一个token发送给客户端。客户端在请求抢购时将token携带在请求头或者cookie中发送到服务端。服务端判断缓存或者Session中是否包含此token。若有,则将此令牌移除,并执行业务操作;若没有,则返回下单失败。

    上述的三种解决方式,第一种是数据库级别的,后两种是业务层的。针对于本系统,第二种无论在代码的实现还是效率上都要更好。

2. Redis库存预热与预减

在秒杀时,如果商品数据存放在数据库中,短时间内可能有大量的请求访问数据库,造成数据库奔溃。

解决方案:

  • 在秒杀开始前,服务器提前主动将商品数据预热到Redis中,在接口层直接对Redis中的库存进行判断及预减。

  • Redis中关于本次秒杀的数据应设置较长的过期时间(至少要在本次秒杀结束之后过期),否则容易产生缓存击穿等问题。

    在进行缓存预减的操作时,分为两步,第一步是判断Redis中的库存是否充足,第二步是减库存。虽然在Redis中每一条指令都是原子性的,但是上述的两个步骤如果不做处理,那它们在Redis中并不是原子性的。
    即有可能发生以下这种情况:

库存超卖问题

可以看到,由于库存判断与减库存这两步操作不是一个原子性的整体,当线程A、B同时判断到库存充足时,双方都进行减库存,造成了多销的情况。

解决方案:

  • 使用Lua脚本,由于Lua脚本在Redis内部是原子性的,所以执行库存判断与减库存整体是一个原子操作,不会出现上述问题。
  • 使用Redis分布式锁,使用setnx命令可以设置一个分布式锁,线程每次进行库存判断前,获取锁,进行减库存后,释放锁。这样就能保证这两步操作的原子性。关于分布式锁,在下文会详解。
3. 分布式锁

上文提到,为了保证对Redis两步操作的原子性,可以采用分布式锁来实现。那何为分布式锁呢?首先,我们要知道传统的非分布式锁有什么缺陷。
想要在java中保证某一个代码段的原子性,可以使用sychronized关键字来实现。如果使用sychronized,在单机环境下一般不会有什么问题,但若是在集群部署的环境下,由于各个服务节点的进程内缓存是不会共享的,就造成了锁失效的问题。

解决方案:

  • 使用Redis实现分布式锁

  • 使用zookeeper实现分布式锁

    这里使用Redis来实现,具体的实现方案如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XDKuHs06-1646799142703)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(2)]-7b0551431dea406684d03faa51548d72.png)

这里有几个问题要解释:

  • 为什么要设置锁自动续期?
    设置锁的自动过期是为了防止一个线程获取了锁,但是运行过程中发生了故障,导致锁无法释放。
    针对上述问题,可以通过设置锁的时候设置过期时间解决,但是这存在两点不足的地方。
  1. 当获取锁的线程业务还没有执行完,锁过期时间到了,导致锁失效。

  2. 当获取锁的线程发生故障,其他线程只有等到锁到达过期时间了才能获取到锁,造成长时间阻塞。

    针对上述问题,就提出了锁的自动续期机制:

当线程A获取锁之后,先给锁设定一个初始的过期时间(30s),随后启动一个定时任务(watch dog),每隔一段时间来检测线程A是否还占有这把锁,如果还占有则不断刷新过期时间。
这样子,假设线程A在执行过程中发生异常,到达过期时间后就不会进行续期,锁就可以被其他线程获取到了。
通过这种方案,就解决了上述两点问题。

  • 为什么在解锁的时候要判断是不是自己的锁?
    我们可以设想一下这种情况:
    当线程A获取了锁,但是在解锁之前线程A因为某些不可抗拒因素阻塞住了。这个时候,线程B只能等待A主动释放锁或者锁到了过期时间,自动释放。假设B在锁到达过期时间后获取了这把锁,然后开始处理自己的业务了。但是这个时候,之前阻塞的线程A又恢复正常了,当他处理完自己的业务后,自然而然的把锁给解掉了。
    这样,在线程B的业务还没有处理完之前,A就将锁解掉了。那么这个时候,如果又有新的线程访问,就造成了业务的同时执行,也就是锁失效

    上述流程可以由下图来体现:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qy5RJcAf-1646799142703)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(3)]-57eb179124d541acadac995252b3f794.png)

针对上述问题,有如下解决方案:

首先,我们来看看原先redis分布式锁的结构:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sn1AkCFZ-1646799142704)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(5)]-3621a0454ce345a9bb6c68e473f6d357.png)
原先,我们在使用分布式锁的时候,只需要根据key的值来加锁,解锁就可以了。value可以设定为任意的值。这样子,其实对每一个线程来说,都是具有加锁与解锁的权利的。
为了不让别的线程解掉当前线程加的锁,我们可以在value中存储当前的线程ID。

就有了如下的锁结构。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ITYLHdiM-1646799142704)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(6)]-1727b3cc2de549c5bb1611ee5e61b46f.png)
于是,针对这种的所结构,每个线程只需要在加锁的时候将自己ID记录到value中,解锁的时候先进行判断,只有当锁中的ID与自己的ID一致的时,才能解锁。

范例如下图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GBAvPMSj-1646799142705)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(10)]-6f86a7f779e147aabcc3e9e4c40a7303.png)

到这里,就已经可以解决本系统的需求了。但是其实锁的结构还可以继续进行优化,这里做额外的补充。
因为与本系统业务无关,所以读者也可直接跳过此部分。

假设有这样一种情况,业务A需要获取锁,业务B也需要获取同一把锁。
如果在业务A中调用了业务B,会出现问题吗?
答案是肯定的,因为业务A在执行的时候,已经获取了锁,但是自己还没有释放。这个时候调用业务B,就又要尝试去获取锁。可是这把锁在业务A主动释放前,是获取不到的。这个时候,整个业务就会卡死在这一步,这就是死锁的情况。
那么,我们如何来解决这一问题呢?

基于上述问题,就有了如下的锁结构:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KOFFjmYD-1646799142705)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(4)]-53e52dedec5548eebc5326f7095c89b7.png)
将锁设置为hash的结构,hash中的field设置为线程ID,value设置为加锁的次数。我们将这种锁称为可重入锁。

具体的加锁逻辑如下图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UIfv9bov-1646799142705)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(8)]-b228dfc37cea42669a00930f09c9c862.png)

具体的解锁逻辑如下图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-srLVXlfa-1646799142706)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(9)]-262ee820dad64d10962a0c7e5d8eb8fe.png)

设置了可重入锁之后,业务A获取了锁,调用业务B时,B也能正常获取到锁。B处理完后解锁,这个时候锁的加锁次数为1,锁不会失效。A处理完业务后解锁,加锁次数为0,锁就可以正常释放了。

综上,我们就解决了分布式锁的一些问题。

2.2 业务层

业务层流程

1. 前置操作

在到达业务层后,首先要进行订单对象的封装、Redis中生成订单对象码的操作。

  • 为什么要主动生成订单号,不能用数据库自增吗?
    首先,如果我们使用数据库自增作为订单ID,那么就无法实现订单状态的追踪。我们在业务层就生成一个订单号,后续在Redis以订单号为key设置此订单的状态信息,例如:未处理、已处理、下单成功、下单失败等。这样,在随后的处理中,就可以获取这条订单的状态,基于它来实现防止重复消费、用户获取订单状态等业务。关于具体的实现,将在下文做解释。
    其次,使用数据库自增作为ID,由于数据库在做ID递增时会获取自增锁,这样就存在一个效率的问题。而且,在数据库集群分表的情况下,又要使用到分布式自增主键的技术,增加了业务实现的困难程度。

  • 为什么使用UUID作为主键?
    因为UUID是不会重复的,或者说概率极小。其实这里还可以使用其他的算法生成主键。

  • 为什么要在Redis中设置状态码?
    为了实现对订单状态的追踪,防止重复消费等问题。并且后续用户可以直接轮询Redis来查看秒杀结果。

    2. 投递消息到消息队列

    在进行完前置操作后,要将消息投放到消息队列中。

  • 为什么要将消息投递到消息队列中?
    倘若不使用MQ进行处理,只有当上一个订单处理完成之后,才能处理下一个订单。这样子,大量的用户请求就会阻塞,可能会造成订单的丢失。更严重的,是会造成服务器或数据库的崩溃。
    用户由于长时间的阻塞,不能及时接收到响应,就无法判断此次请求是否成功,用户的体验感也会下降。
    MQ是一种消息队列,使用MQ将同步处理消息转为异步处理。
    我们在将消息投递到MQ后,就完成对这条请求的响应,这样解决了请求阻塞的问题。当MQ监听者监听到消息之后,再完成入库等操作。这样,服务器就能以一种平滑的方式处理订单。 不会因为压力过大而出现问题。

  • 投递失败怎么办?
    在进行消息投递的时候,可能由于网络波动造成与MQ的连接异常,导致投递失败。所以,在这里设置了重试机制
    当投递失败后,会再次进行投递,当投递次数到达3次后,将订单对象存入到Redis中,交由后续处理。
    这样,就保证了消息的安全性。

    3. 返回订单号给用户

    将订单号返回给用户,用于后续轮询订单的结果。

2.3 MQ消费者

MQ消费者流程

1. 一些前置的判断操作

在消费者监听到新的消息后,会先进行一些前置操作,包括订单状态码判断、库存是否充足、订单是否已提交等操作。

  • 为什么要判断订单状态码?
    试想以下这种两种情况:
  1. 生产者尝试发送消息到消息队列,消息已经发送。但是MQ在返回发送成功响应时发生了网络错误,导致生产者误认为消息没发送成功,进行重复发送。

  2. 消费者监听到了新的消息,已进行完了业务的操作,但是在最后返回ack的时候发生了异常,导致MQ认为消息未被消费成功。这样,消息又会被重新消费。

    为了解决上述问题,在Redis中设置了订单状态码机制。

当业务层接受到订单后,设置此订单的状态为未处理;当消费者消费成功后,将次订单的状态设置为成功;消费失败后,将状态设置为失败
消费者监听到消息后,先对订单状态进行判断。如果是未处理,则继续进行后续判断;如果是成功失败,则不进行后续处理,直接返回ack。

综上,设置状态码在本阶段的主要作用是防止处理重复订单

  • 为什么要判断订单是否重复?
    试想这样一种情况:
    若消费者监听到消息,并已完成入库的操作,但是此时还没来得及设置Redis中的订单状态码,消费者发生了故障。MQ长时间没有得到ack,认为消费者消费失败,这条消息就会被重复消费。

    所以通过此次判断避免对同一订单重复进行入库操作。

2. 入库操作事务控制

在进行入库操作时,分为两个修改库存和添加订单两个环节。我们要保证这两个操作的原子性,就可以使用事务控制。如果其中有一个出现异常,就会回滚数据,并抛出异常。MQ也会对此消息进行再次的消费。

2.4 MQ失败重试机制

MQ失败重试机制

  • 为什么要设置失败重试?
    试想下面这种情况:
    MQ在进行订单入库的时候,与数据库的连接发生了异常。此时事务会回滚,消费是一个失败的状态。如果不进行失败重试,这条订单就会被“丢弃”。用户也将无法查询到订单的结果。
    显然这是一种严重的情况,为了避免这种情况,设置了失败重试的机制。

    具体的实现方案如下:

  • 若消费者消费成功,消息出队

  • 若消费者消费失败(出现异常),未响应ack。

  • MQ指定时间内未接受到ack,或者消费者抛出异常,尝试重新消费。

  • MQ尝试消费3次后,任然失败,就将消息转发到死信队列中。

  • 私信队列中的消息等待后续处理。

    2.5 用户查询订单结果
  • 用户下单后,接收到订单号,怎样知道自己有没有下单成功?
    有如下的方案:

  1. 前端轮询订单号,服务端从redis中获取订单情况返回给用户。若Redis中订单状态码为已处理,则继续去数据库中返回详细的信息。

  2. 服务器与客户端建立websocket连接,服务器在订单处理后主动通知客户端。

  3. 用户下单后,接口层设置轮询数据库,返回结果给用户。(不推荐)

    本系统采用第一种方案解决。
    用户查询订单结果

    2.6 接口限流方案

    在开放秒杀接口后,加入瞬时的请求超过了服务器能处理请求的最大值,服务器就容易造成问题,请求也将无法正常响应。
    所以,常见的解决方案就是使用接口限流。

    限流的实现方案有很多,这里提供3种解决方案。

1. 计数器限流
设定一个每秒请求次数的上限值,每次请求访问后,就将数加1。当到达次数之后,开启服务降级,不再处理请求。当下一秒开始后,计数器重新清0,重新开始计数。
2. 漏桶限流
创建一个大小恒定的漏桶存放请求,这个漏桶不限制请求进入的速率,请求以恒定的速率从桶底流出。若漏桶已经满了,后进入的请求将会溢出。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xl0zsSN8-1646799142707)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(11)]-0d1c3ef094e9438baa4c6db80da00f29.png)
3. 令牌桶限流
创建一个大小恒定的令牌桶,客户端维护这个令牌桶,以恒定的速率往桶中放令牌。每一次请求到达接口后,就从令牌桶中取走一个令牌。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-94jM7gRn-1646799142707)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(12)]-cfbc357b20a548f2b0db4b3881873d28.png)

若令牌桶中的令牌满了,则停止放入令牌。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-J60oeUvm-1646799142708)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(13)]-ec8de9992c894112ae816bc1bcfc6379.png)

若令牌桶中的令牌空了,则拒绝请求的访问。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZVlBNRQO-1646799142708)(https://www.chenpeman.top/upload/2022/03/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20(14)]-7045d6ed80054cbdbc4edf5f26775054.png)

本系统选用了令牌桶的方案来实现限流。

三、代码实现

3.1 自定义注解实现幂等性校验

/**
 * 接口幂等性锁注解
 * @author Chenpeman
 */
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface UniqueLock {
}
/**
 * 接口幂等性锁
 * 防止请求重复提交
 * @author Chenpeman
 */
@Slf4j
@Aspect
@Component
public class UniqueLockAop {
    @Autowired
    private RedisTemplate<String,Object> redisTemplate;

    /**
     * Around object.
     *
     * @param joinPoint the join point
     * @return the object
     * @throws Throwable the throwable
     */
    @Around("@annotation(com.example.seckill_demo.entity.UniqueLock)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable{
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        //获取请求参数
        Object[] args =joinPoint.getArgs();
        //生成锁ID
        String lockKey="seckill:"+ args[0] +":"+args[1];
        //设置锁
        Boolean b = redisTemplate.opsForValue().setIfAbsent(lockKey, 1);
        if(Boolean.TRUE.equals(b)){
            return joinPoint.proceed();
        }else {
            throw new GetUniqueLockException("获取锁失败");
        }
    }
}

3.2 分布式锁+缓存预减

/**
 * 秒杀接口
 * @author Chenpeman
 */
@RestController
@Slf4j
public class SeckillController {
    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private RedisTemplate<String,Object> redisTemplate;
    @Autowired
    private OrderService orderService;
    /**
     * 秒杀接口
     *
     * @param userId    用户id
     * @param productId 商品id
     * @param number    数量
     * @return 请求结果
     * @throws InterruptedException the interrupted exception
     */
    @PostMapping("/seckill")
    @Limit(key = "seckill", permitsPerSecond = 10000, timeout = 500, timeunit = TimeUnit.MILLISECONDS)
    @UniqueLock
    public Result<String> seckillProduct(Long userId,Long productId,Long number) throws InterruptedException {
        RLock lock = redissonClient.getLock("seckill:lock:product:" + productId);
        try{
            lock.lock();
            Integer remain = (Integer) redisTemplate.opsForValue().get("seckill:remain:product" + productId);
            if(remain==null || remain<number) return Result.makeRsp(RetCode.FAIL,RetMsg.FAIL,"当前商品已售空");
            redisTemplate.opsForValue().decrement("seckill:remain:product" + productId);
        }catch (Exception e){
            return Result.makeRsp(RetCode.INTERNAL_SERVER_ERROR,RetMsg.INTERNAL_ERROR,"服务器内部异常");
        }finally {
            lock.unlock();
        }
        SeckillOrder seckillOrder = new SeckillOrder();
        seckillOrder.setUserId(userId);
        seckillOrder.setProductId(productId);
        seckillOrder.setNumber(number);
        return Result.makeOKRsp(orderService.seckill(seckillOrder));
    }
}

3.3 业务层代码

package com.example.seckill_demo.service;

import com.example.seckill_demo.entity.SeckillOrder;

/**
 * 订单业务接口
 * @author Chenpeman
 */
public interface OrderService {
    /**
     * 秒杀商品
     *
     * @param seckillOrder 订单对象
     * @return 订单号
     * @throws InterruptedException the interrupted exception
     */
    public String seckill(SeckillOrder seckillOrder) throws InterruptedException;

    /**
     * 在redis中设置订单的状态码
     *
     * @param id     订单id
     * @param status 状态码
     */
    public void setRedisStatus(String id,int status);
/**
 * 订单(秒杀)业务
 * @author Chenpeman
 */
@Service
@Slf4j
public class OrderServiceImpl implements OrderService {
    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private ProductMapper productMapper;
    @Autowired
    private RabbitTemplate rabbitTemplate;
    @Autowired
    private RedisTemplate<String,Object> redisTemplate;
    @Override
    public String seckill(SeckillOrder seckillOrder) throws InterruptedException {
        //封装订单对象
        seckillOrder.setCreateTime(new Date());
        seckillOrder.setId(UUID.randomUUID().toString());
        //Redis中生成订单状态码
        //0:未处理 1:成功 -1:失败
        setRedisStatus(seckillOrder.getId(),0);
        //消息发送到MQ 尝试发送3次
        boolean sendResult=false;
        for(int i=0;i<3;i++){
            try{
                rabbitTemplate.convertAndSend("seckill.queue", seckillOrder);
                sendResult=true;
                log.info("新订单投递成功");
                break;
            }catch (Exception e){
                log.error("第[{}]次尝试投递新订单失败",i);
                Thread.sleep(200);
            }
        }
        //发送订单到MQ失败 失败订单存入Redis 后续统一处理
        if (!sendResult){
            redisTemplate.opsForHash().put("sendErrorOrders",seckillOrder.getId(), JSONObject.toJSONString(seckillOrder));
            log.error("订单[{}]发送到MQ失败",seckillOrder.getId());
            throw new SendToMQException("发送消息到MQ失败");
        }
        return seckillOrder.getId();
    }

    @Override
    public void setRedisStatus(String id,int status){
        redisTemplate.opsForValue().set("seckill:"+id,status);
    }
}

3.4 MQ消费者+入库事务

/**
 * MQ监听类
 * @author Chenpeman
 */
@Component
@Slf4j
public class RabbitListener {
    @Autowired
    private OrderService orderService;
    @Autowired
    private RedisTemplate<String,Object> redisTemplate;
    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private ProductMapper productMapper;

    /**
     * 处理订单消息
     *
     * @param seckillOrder 秒杀订单对象
     */
    @RabbitListener(queues = "seckill.queue")
    public void handleOrder(SeckillOrder seckillOrder){
        log.info("消费者接收到新订单消息[{}]",seckillOrder.getId());
        //判断此订单是否已被处理
        Integer i =(Integer) redisTemplate.opsForValue().get("seckill:" + seckillOrder.getId());
        if(i==null){
            //判断redis中没有状态码,判断为超时的消息
            throw new ConsumerException("发现超时未处理的订单");
        }else if(i==0){
            log.info("发现未处理的订单[{}]",seckillOrder.getId());
            //判断此订单是否已入库
            SeckillOrder so = orderMapper.selectById(seckillOrder.getId());
            if(so!=null){
                orderService.setRedisStatus(seckillOrder.getId(),1);
                log.error("[{}]订单已成功入库,请勿重复消费",seckillOrder.getId());
                return;
            }
            //判断此商品是否存在
            Product product = productMapper.selectById(seckillOrder.getProductId());
            if(product==null){
                orderService.setRedisStatus(seckillOrder.getId(),-1);
                log.error("不存在ID=[{}]的商品",seckillOrder.getProductId());
                return;
            }
            //判断商品库存是否充足
            if (product.getRemain()<seckillOrder.getNumber()){
                orderService.setRedisStatus(seckillOrder.getId(),-1);
                log.error("商品[{}]的库存不足",seckillOrder.getProductId());
                return;
            }
            //满足条件,进行订单入库
            //若发生错误,重新消费,累计三次重复消费后,消息被投送到死信队列
            orderService.insertOrder(seckillOrder);
        }
    }
}
@Transactional
    public void insertOrder(SeckillOrder seckillOrder) {
        //再确认一下判断商品库存是否充足
        Product product = productMapper.selectById(seckillOrder.getProductId());
        if (product.getRemain()<seckillOrder.getNumber()){
            setRedisStatus(seckillOrder.getId(),-1);
            log.error("商品[{}]的库存不足",seckillOrder.getProductId());
            throw new ProductNotEnoughException("商品库存不足");
        }
        //修改商品库存
        Long remain = product.getRemain();
        Long sales = product.getSales();
        remain=remain-1;
        sales=sales+1;
        product.setRemain(remain);
        product.setSales(sales);
        int i1 = productMapper.updateById(product);
        if (i1!=1){
            log.error("[{}]商品库存修改失败,回滚数据",seckillOrder.getProductId());
            throw new ProductUpdateException("商品库存修改失败");
        }
        //订单信息入库
        int i2 = orderMapper.insert(seckillOrder);
        if(i2!=1) {
            log.error("[{}]订单入库失败,回滚数据",seckillOrder.getId());
            throw new OrderInsertException("订单入库失败");
        }
        //修改Redis状态码
        setRedisStatus(seckillOrder.getId(),1);
    }

3.5 MQ消费重试+发送到死信队列

rabbitmq:
    host: 
    username: 
    password: 
    virtual-host: /
    listener:
      simple:
        retry:
          enabled: true # 开启消费者失败重试
          initial-interval: 1000ms # 初始的失败等待时长为1秒
          multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
          max-attempts: 3 # 最大重试次数
          stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
/**
 * Mq配置
 *
 * @author Chenpeman
 */
@Configuration
public class RabbitMqConfig {
    /**
     * 采用json传输数据
     *
     * @return the message converter
     */
    @Bean
    public MessageConverter jsonMessageConverter(){
        return new Jackson2JsonMessageConverter();
    }

    /**
     * 秒杀订单处理队列
     *
     * @return the queue
     */
    @Bean
    public Queue seckillQueue() {
        return new Queue("seckill.queue", true);
    }

    /**
     * 消费失败队列
     *
     * @return the queue
     */
    @Bean("error_queue")
    public Queue errorQueue(){
        return new Queue("error_queue",true);
    }

    /**
     * 处理错误消息的交换机
     *
     * @return the direct exchange
     */
    @Bean("error_direct")
    public DirectExchange errorMessageExchange(){
        return new DirectExchange("error_direct");
    }

    /**
     * 绑定错死信队列与交换机
     *
     * @param error_direct the error direct
     * @param error_queue  the error queue
     * @return the binding
     */
    @Bean
    public Binding bindingError(DirectExchange error_direct, Queue error_queue){
        return  BindingBuilder.bind(error_queue).to(error_direct).with("error");
    }

    /**
     * 配置失败自动投递到死信队列
     *
     * @param rabbitTemplate the rabbit template
     * @return the message recoverer
     */
    @Bean
    public MessageRecoverer messageRecoverer(RabbitTemplate rabbitTemplate){
        return new RepublishMessageRecoverer(rabbitTemplate,"error_direct","error");
    }

}

3.6 自定义注解实现接口限流

/**
 * 接口限流注解
 * @author Chenpeman
 */
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface Limit {
    /**
     * 资源的key,唯一
     * 作用:不同的接口,不同的流量控制
     *
     * @return the string
     */
    String key() default "";

    /**
     * 最多的访问限制次数
     *
     * @return the double
     */
    double permitsPerSecond () ;

    /**
     * 获取令牌最大等待时间
     *
     * @return the long
     */
    long timeout();

    /**
     * 获取令牌最大等待时间,单位(例:分钟/秒/毫秒) 默认:毫秒
     *
     * @return the time unit
     */
    TimeUnit timeunit() default TimeUnit.MILLISECONDS;

}
/**
 * 接口限流切面
 * @author Chenpeman
 */
@Slf4j
@Aspect
@Component
public class LimitAop {
    /**
     * 不同的接口,不同的流量控制
     * map的key为 Limiter.key
     */
    private final Map<String, RateLimiter> limitMap = Maps.newConcurrentMap();

    /**
     * 切面
     *
     * @param joinPoint the join point
     * @return the object
     * @throws Throwable the throwable
     */
    @Around("@annotation(com.example.seckill_demo.entity.Limit)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable{
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        //拿limit的注解
        Limit limit = method.getAnnotation(Limit.class);
        if (limit != null) {
            //key作用:不同的接口,不同的流量控制
            String key=limit.key();
            RateLimiter rateLimiter = null;
            //验证缓存是否有命中key
            if (!limitMap.containsKey(key)) {
                // 创建令牌桶
                rateLimiter = RateLimiter.create(limit.permitsPerSecond());
                limitMap.put(key, rateLimiter);
                log.info("新建了令牌桶={},容量={}",key,limit.permitsPerSecond());
            }
            rateLimiter = limitMap.get(key);
            // 拿令牌
            boolean acquire = rateLimiter.tryAcquire(limit.timeout(), limit.timeunit());
            // 拿不到命令,直接返回异常提示
            if (!acquire) {
                log.debug("令牌桶={},获取令牌失败",key);
                throw new ReachRequestLimitException("到达接口访问上限");
            }
        }
        return joinPoint.proceed();
    }

}

四、性能测试

我们使用Jmeter来做压力测试

  • 启动服务

Snipaste_20220309_103757.png

  • 配置两个商品,库存均为1000,订单表初始为空

Snipaste_20220309_103824.png
Snipaste_20220309_103841.png

  • 商品库存预热至Redis

Snipaste_20220309_104537.png
Snipaste_20220309_104543.png

  • 配置Jmeter,线程数4000,2s内执行完,循环1次(测试两遍)

Snipaste_20220309_103903.png

  • 请求数据csv,模拟一个人发送2次抢购A商品,2次抢购B商品。1000个人,4000个请求。

Snipaste_20220309_103915.png

  • 查看发送数据

Snipaste_20220309_104704.png
Snipaste_20220309_104656.png
Snipaste_20220309_104711.png

可以看到,请求均得到了及时的响应。让我们看看数据库的结果。

image.png

image.png

数据库中的数据正常售空,订单也没有任何的问题。

五、项目开源

https://gitee.com/chenpeman/seckilldemo

六、后记

尚有很多需要学习的地方,欢迎指出不足。
6799142708)]

  • 配置两个商品,库存均为1000,订单表初始为空

[外链图片转存中…(img-VarAtovj-1646799142709)]
[外链图片转存中…(img-16jDOojU-1646799142709)]

  • 商品库存预热至Redis

[外链图片转存中…(img-9Ka7P5gC-1646799142709)]
[外链图片转存中…(img-VVlKzdjz-1646799142710)]

  • 配置Jmeter,线程数4000,2s内执行完,循环1次(测试两遍)

[外链图片转存中…(img-GXbmaMqO-1646799142710)]

  • 请求数据csv,模拟一个人发送2次抢购A商品,2次抢购B商品。1000个人,4000个请求。

[外链图片转存中…(img-ev8cVy44-1646799142710)]

  • 查看发送数据

[外链图片转存中…(img-LWvwQqTC-1646799142710)]
[外链图片转存中…(img-t7GhTDTK-1646799142711)]
[外链图片转存中…(img-i4J3KCEK-1646799142711)]

可以看到,请求均得到了及时的响应。让我们看看数据库的结果。

[外链图片转存中…(img-3HRQdWSs-1646799142711)]

[外链图片转存中…(img-AkgKW9Je-1646799142711)]

数据库中的数据正常售空,订单也没有任何的问题。

五、项目开源

https://gitee.com/chenpeman/seckilldemo

六、后记

尚有很多需要学习的地方,欢迎指出不足。

  • 2
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Redis可以用来作为分布式缓存分布式消息队列的原因有以下几点。 首先,Redis具有高性能的内存数据库特性,在缓存场景下,能够实现快速的读写操作,提高数据的访问速度。通过将常用的数据存储在Redis中,可以避免频繁地查询数据库,减少响应时间,提高系统的吞吐量。 其次,Redis支持数据过期机制和LRU(Least Recently Used,最近最少使用)算法等缓存策略,可以根据需求定时清理过期数据或者根据缓存数据的使用频率进行淘汰,保证缓存的有效性和一定的容量。 此外,Redis还提供了常见的数据结构,例如字符串、列表、哈希、集合和有序集合等,这些数据结构的操作都是原子性的,可以支持多个客户端同时访问和修改数据,利于实现分布式的功能。通过Redis的SETNX(SET if Not eXists)指令可以实现简单的互斥机制,通过设定过期时间和唯一标识可以防止死的发生。 此外,通过Redis的订阅与发布机制,可以轻松地实现消息队列的功能。发布者可以发布消息,订阅者可以实时地接收到消息并进行相应处理。同时,Redis还支持发布与订阅模式的消息持久化,即使在消息发布者和订阅者之间存在断开连接的情况下,消息也不会丢失。 综上所述,Redis的高性能、灵活的缓存策略、原子性的数据操作和消息持久化机制,使其成为一个强大的工具,可以用来实现分布式缓存分布式消息队列的功能。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值