1.如何做好幂等性校验

2.如何确保消息稳定消费

3.如何做好消息防堆积

4.本地事务与消息发送之间存在时序问题

1.如何做好幂等性校验

1.1 生产环境遇到的问题

我们在学习mq的时候,很多教程都说要进行幂等性校验,但是生产情况中,什么场景需要校验、应该使用哪些方式来校验,却缺乏详尽说明,今天,我们就来系统解析幂等性的问题,帮助你真正理解并应用到实际项目中。
幂等性的定义:幂等性是指无论一个操作执行多少次,其结果都保持一致。

1.2 幂等性校验的场景和方式
1.21 使用redis(一次性 token)

我们可以将消息的id、请求的requestId等(唯一性比较强的字段,也可以自己拼接),当成一个唯一键。

  • 如果 Redis 中存在该 key,则不再处理;
  • 如果 Redis 中不存在该 key,则执行业务逻辑,并记录该 key。

    @Slf4j
    @Component
    public class DemoQueueConsumer {
      @Autowired
      private RedissonClient redissonClient;
      @Autowired
      private StringRedisTemplate stringRedisTemplate;
    
      @RabbitListener(queues = "InquiryAutoReceiveQueue")
      @Transactional(rollbackFor = Exception.class)
      public void receive(Channel channel, Message message, @Headers Map<String, Object> map) throws IOException, InterruptedException {
          String uuid = new String(message.getBody());
          
          Boolean success = stringRedisTemplate.opsForValue().setIfAbsent("yourKey", "yourValue", Duration.ofMinutes(5));
          if (Boolean.TRUE.equals(success)) {
              // 设置成功,说明 key 原本不存在 进行业务处理
              
          } else {
              // 设置失败,key 已存在
              
          }
          channel.basicAck((Long) map.get(AmqpHeaders.DELIVERY_TAG), false);
      }
    
    }

    上述代码问题:
    1.要注意生产者一定时间后重复发送消息,可能会导致重复消费
    2.如果消费业务中抛出异常,没有恰当的补偿机制,容易造成消息消费失败。
    3.如果业务处理中出现异常容易消息堆积,还要进行异常处理。

1.22 使用分布式锁

和一次性token类似,不过这里采用分布式锁,引入了redission,增加了一定的可靠性。

@Slf4j
@Component
public class DemoQueueConsumer {
    @Autowired
    private RedissonClient redissonClient;

    @RabbitListener(queues = "InquiryAutoReceiveQueue")
    @Transactional(rollbackFor = Exception.class)
    public void receive(Channel channel, Message message, @Headers Map<String, Object> map) throws IOException, InterruptedException {
        String uuid = new String(message.getBody());
        RLock lock = redissonClient.getLock(uuid);

        if (lock.tryLock(3L, 6L, TimeUnit.SECONDS)) {
            try {
                // 处理业务逻辑
            } finally {
                lock.unlock();
            }
        }

        channel.basicAck((Long) map.get(AmqpHeaders.DELIVERY_TAG), false);
    }
}

上述代码问题:
1.与方式 1 的问题类似;
2.要确保 unlock() 的调用,避免死锁。

1.23 使用状态机判断

可以在消费前,对消费主体进行检查查看是否为预期的状态,比如下单后推送支付成功消息,要先确认订单已经支付再进行推送(根据具体业务来),最为保险。

@Slf4j
@Component
public class DemoQueueConsumer {
    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @RabbitListener(queues = "InquiryAutoReceiveQueue")
    @Transactional(rollbackFor = Exception.class)
    public void receive(Channel channel, Message message, @Headers Map<String, Object> map) throws IOException, InterruptedException {
        String uuid = new String(message.getBody());

        //现根据uuid查询订单数据,判断订单状态,如果状态满足进一步处理的前置条件,再进行下一步处理,否则就不处理

        channel.basicAck((Long) map.get(AmqpHeaders.DELIVERY_TAG), false);
    }

}

问题:
1.建议结合 Redis 锁/唯一 token 使用,单靠业务状态判断存在并发处理风险;
2.如果多个消费者并发处理同一条数据,可能因状态未更新导致重复操作。

校验方式实现难度幂等保障程度推荐程度说明
Redis 一次性 token⭐️⭐️⭐️⭐️⭐️适合简单场景,易于实现
分布式锁⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️更稳妥,但需注意锁释放
状态机判断⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️依赖业务状态,健壮但复杂度较高
多方式组合⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️最稳妥,适合核心流程

最终建议的代码

@Slf4j
@Component
public class DemoQueueConsumer {
    @Autowired
    private RedissonClient redissonClient;

    @RabbitListener(queues = "InquiryAutoReceiveQueue")
    @Transactional(rollbackFor = Exception.class)
    public void receive(Channel channel, Message message, @Headers Map<String, Object> map) throws IOException, InterruptedException {
        String uuid = new String(message.getBody());
        RLock lock = redissonClient.getLock(uuid);
                //先加锁
        if (lock.tryLock(3L, 6L, TimeUnit.SECONDS)) {
                // 再使用数据库进行前置状态校验
            try {
                // 处理业务逻辑
                // 正常消费
                channel.basicAck((Long) map.get(AmqpHeaders.DELIVERY_TAG), false);
            } finally {
                lock.unlock();
            }
        }
    }
}

2.如何确保消息稳定消费

上述提到了校验幂等性后,可能出现的异常,其中有一个就是消息的异常处理

  • 但凡代码出现一点异常,这条消息就不会手动进行ack,那么这条消息就会被阻塞,rabbitmq如果一个消费者阻塞消息到达了阈值(默认250),就不会对后续消息进行消费,那么所有业务逻辑就停滞了。

图片.png

此时我们就要做好消息的的异常处理,所以建议加入以下代码:

@Slf4j
@Component
public class DemoQueueConsumer {
    @Autowired
    private RedissonClient redissonClient;

    @RabbitListener(queues = "InquiryAutoReceiveQueue")
    @Transactional(rollbackFor = Exception.class)
    public void receive(Channel channel, Message message, @Headers Map<String, Object> map) throws IOException, InterruptedException {
        String uuid = new String(message.getBody());
        RLock lock = redissonClient.getLock(uuid);

        if (lock.tryLock(3L, 6L, TimeUnit.SECONDS)) {
            // 再使用数据库进行前置状态校验
            try {
                // 处理业务逻辑
                // 正常消费
                channel.basicAck((Long) map.get(AmqpHeaders.DELIVERY_TAG), false);
            } catch (Exception e) {
                // 针对异常进行处理
                // 这里采取扔回队列,重新消费,也可以通过定时任务仍回队列
                channel.basicReject((Long) map.get(AmqpHeaders.DELIVERY_TAG), true);
            } finally {
                lock.unlock();
            }
        }
    }
}

上述代码问题:
1.如果出现异常丢回队列,可能会造成死循环,导致cpu飚高。

所以我们也可以把消息消费掉,然后用定时任务里从数据库中读出待处理的订单,再丢回队列。

3.如何做好消息防堆积

我们做好异常处理后,可以保证这条消息被消费,也可以一定程度上做到防止堆积,但是无法保证这条消息一直有异常的情况下,造成的死循环,也可能会造成一定程度的堆积,所以我们需要在消息好几次出现异常的时候,进行消息推送,进行人工干预,因为这条消息可能因为代码逻辑问题一直没办法处理了。

@Slf4j
@Component
public class DemoQueueConsumer {
    @Autowired
    private RedissonClient redissonClient;

    @RabbitListener(queues = "InquiryAutoReceiveQueue")
    @Transactional(rollbackFor = Exception.class)
    public void receive(Channel channel, Message message, @Headers Map<String, Object> map) throws IOException, InterruptedException {
        String uuid = new String(message.getBody());
        RLock lock = redissonClient.getLock(uuid);

        if (lock.tryLock(3L, 6L, TimeUnit.SECONDS)) {
            // 再使用数据库进行前置状态校验
            try {
                // 处理业务逻辑
                // 正常消费
                channel.basicAck((Long) map.get(AmqpHeaders.DELIVERY_TAG), false);
            } catch (Exception e) {
                // 针对异常进行处理,如果失败到达了一定程度,进行报警等等
                // 这里采取扔回队列,重新消费,也可以通过定时任务仍回队列
                channel.basicReject((Long) map.get(AmqpHeaders.DELIVERY_TAG), true);
            } finally {
                lock.unlock();
            }
        }
    }
}

最大重试机制(可配合 Redis 或数据库记录)

Long retryCount = redisTemplate.opsForValue().increment("retry_" + uuid);
if (retryCount > 3) {
    // 推送报警、持久化错误信息,或者发送到死信队列
}

4.本地事务与消息发送之间存在时序问题

问题描述:
假设现在有这样一个常见的业务逻辑:

  1. 下单(写入订单数据库)
  2. 发送 uuid 到 MQ 队列
  3. 消费者拿到 uuid,再查订单表,处理逻辑

如果 消息发送成功了,数据库事务还没提交,消费者就拿到 uuid 并去查库,就会发现查不到订单,因为生产者事务还没提交。

解决方案:消费者做重试 + 幂等性 + 延迟消费

可以在消费者查询数据库的时候,先进行判空,如果是空的,再把消息丢回队列,等待下次再进行消费。

sequenceDiagram
    participant User as 用户
    participant Service as 应用服务
    participant DB as 数据库
    participant MQ as 消息队列
    participant Consumer as 消费者服务

    User->>Service: 发起下单请求
    Service->>DB: 开启数据库事务
    Service->>DB: 插入订单数据
    Service->>DB: 插入本地消息记录(待发送)
    Service-->>DB: 提交事务
    Service->>MQ: 发送订单UUID到队列
    Service->>DB: 更新消息记录为“已发送”
    MQ->>Consumer: 投递订单UUID消息
    Consumer->>DB: 根据UUID查询订单信息
    alt 订单存在并有效
        Consumer->>Consumer: 执行业务逻辑
    else 订单不存在
        Consumer->>Consumer: 丢弃或重试
    end
@Slf4j
@Component
public class DemoQueueConsumer {
    @Autowired
    private RedissonClient redissonClient;

    @RabbitListener(queues = "InquiryAutoReceiveQueue")
    @Transactional(rollbackFor = Exception.class)
    public void receive(Channel channel, Message message, @Headers Map<String, Object> map) throws IOException, InterruptedException {
        String uuid = new String(message.getBody());
        RLock lock = redissonClient.getLock(uuid);
        //先查询一下该uuid对应的类是否为空
        if (lock.tryLock(3L, 6L, TimeUnit.SECONDS)) {
            // 再使用数据库进行前置状态校验
            try {
                // 处理业务逻辑
                // 正常消费
                channel.basicAck((Long) map.get(AmqpHeaders.DELIVERY_TAG), false);
            } catch (Exception e) {
                // 针对异常进行处理,如果失败到达了一定程度,进行报警等等
                // 这里采取扔回队列,重新消费,也可以通过定时任务仍回队列
                channel.basicReject((Long) map.get(AmqpHeaders.DELIVERY_TAG), true);
            } finally {
                lock.unlock();
            }
        }
    }
}

苏凌峰
73 声望40 粉丝

你的迷惑在于想得太多而书读的太少。