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),就不会对后续消息进行消费,那么所有业务逻辑就停滞了。
此时我们就要做好消息的的异常处理,所以建议加入以下代码:
@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.本地事务与消息发送之间存在时序问题
问题描述:
假设现在有这样一个常见的业务逻辑:
- 下单(写入订单数据库)
- 发送 uuid 到 MQ 队列
- 消费者拿到 uuid,再查订单表,处理逻辑
如果 消息发送成功了,数据库事务还没提交,消费者就拿到 uuid 并去查库,就会发现查不到订单,因为生产者事务还没提交。
解决方案:消费者做重试 + 幂等性 + 延迟消费
可以在消费者查询数据库的时候,先进行判空,如果是空的,再把消息丢回队列,等待下次再进行消费。
@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();
}
}
}
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。