1.如何防止重复消费(以rbmq为例其它原理都一样)
- 消费者拿到消息 → 开始处理
- 处理太慢,超时了
- RabbitMQ 自动认为消费失败 → 隐式 NACK
- 队列把这条消息重新放回队列,再发一次
- 这时候,另一个消费者(或同一个消费者的另一个线程)又收到这条消息,开始执行第二遍
- 关键来了:原来那个慢的消费者,根本不知道超时了,还在继续跑!
- 最后:
- 老线程执行完了
- 新线程也执行完了→ 同一个业务逻辑执行了两遍 → 重复消费
- 把消息给消费者,放到消费者内存里
- 但消息本体还在 MQ 队列里,一直都在
- 只是状态变成
unacked(被拿走待确认) - 等待结果:
- ACK → 队列里这条消息删掉
- NACK / 超时 → 状态变回
ready,再发一遍
下面来看解决方法:
分布式锁 + 状态幂等 + 手动 ACK/NACK + 重试 3 次进死信 +乐观锁+ 防重复消费
下面来看代码:
java
spring:
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
listener:
simple:
# 必须手动ACK,超时才生效
acknowledge-mode: manual
# 消费超时时间:毫秒
consumer-timeout: 60000 # 60秒
消费者完整代码
java
import com.rabbitmq.client.Channel;
import lombok.RequiredArgsConstructor;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
@Component
@RequiredArgsConstructor
public class OrderConsumer {
private final StringRedisTemplate redisTemplate;
private final OrderMapper orderMapper;
private final RabbitTemplate rabbitTemplate;
// 监听业务队列
@RabbitListener(queues = "order.queue")
public void handleOrderMessage(String orderId, Message message, Channel channel) throws IOException {
// 消息唯一标识,必须用这个ACK/NACK
long deliveryTag = message.getMessageProperties().getDeliveryTag();
// 分布式锁key:用订单id做唯一标识
String lockKey = "lock:order:" + orderId;
// 从消息头获取已重试次数,默认0
Integer retryCount = (Integer) message.getMessageProperties()
.getHeaders().getOrDefault("retry-count", 0);
try {
// ====================== 1. 先加Redis分布式锁 ======================
// 30秒过期,防止服务宕机死锁
Boolean lockSuccess = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
// 拿不到锁:说明有线程正在处理,直接拒绝,不重试
if (Boolean.FALSE.equals(lockSuccess)) {
channel.basicNack(deliveryTag, false, false);
return;
}
// ====================== 2. 幂等判断 ======================
Order order = orderMapper.selectById(orderId);
// 订单不存在/不是待支付状态:说明已经处理过,直接ACK,不重复执行
if (order == null || !"WAIT_PAY".equals(order.getStatus())) {
channel.basicAck(deliveryTag, false);
return;
}
// ====================== 3. 乐观锁CAS更新(核心兜底) ======================
// 只有状态是WAIT_PAY,才能改成PROCESSING,原子操作
int updateRows = orderMapper.casUpdateStatus(orderId, "WAIT_PAY", "PROCESSING");
// 更新行数=0:说明被其他线程抢先处理了,直接ACK结束
if (updateRows == 0) {
channel.basicAck(deliveryTag, false);
return;
}
// ====================== 4. 真正执行业务逻辑 ======================
// 这里写你的核心业务:扣库存、调用支付、生成物流单等
// ......
// 业务执行成功:更新订单最终状态
orderMapper.casUpdateStatus(orderId, "PROCESSING", "SUCCESS");
// 业务成功:手动ACK,消息从队列删除
channel.basicAck(deliveryTag, false);
} catch (Exception e) {
// ====================== 异常处理:重试3次,超过进死信 ======================
retryCount = retryCount + 1;
if (retryCount >= 3) {
// 重试满3次:直接NACK,不重回队列,自动进死信
channel.basicNack(deliveryTag, false, false);
} else {
// 不足3次:把重试次数塞进消息头,重新投递到原队列
message.getMessageProperties().setHeader("retry-count", retryCount);
rabbitTemplate.send("order.exchange", "order.routingKey", message);
// 原消息ACK,避免重复
channel.basicAck(deliveryTag, false);
}
} finally {
// ====================== 无论成功失败,必须释放锁 ======================
redisTemplate.delete(lockKey);
}
}
}
OrderMapper 里的 CAS 方法:
java
@Update("UPDATE `order` SET status = #{targetStatus} " +
"WHERE id = #{orderId} AND status = #{expectStatus}")
int casUpdateStatus(String orderId, String expectStatus, String targetStatus);
@Update("UPDATE `order` SET status = #{status} WHERE id = #{orderId}")
int updateStatus(String orderId, String status);
java
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Update;
import org.springframework.stereotype.Repository;
@Repository
public interface OrderMapper extends BaseMapper<Order> {
/**
* 核心:用status做乐观锁的CAS原子更新
* 只有订单当前状态是expectStatus,才会更新成targetStatus
* 数据库行锁保证:同一时间只有1个线程能更新成功
*/
@Update("UPDATE `order` SET status = #{targetStatus} " +
"WHERE id = #{orderId} AND status = #{expectStatus}")
int casUpdateStatus(String orderId, String expectStatus, String targetStatus);
}
-
消息来了先加 Redis 分布式锁同一订单同时只能有一个线程处理,防止并发重复。
-
拿锁后先查数据库状态如果已经不是待支付,说明处理过,直接 ACK,不再执行。
-
执行业务并更新订单状态 状态一旦改变,天然实现幂等,后面再来多少条消息都不会重复执行。
-
然后在进行CAS判断如果说状态是WALL_PAY(待支付)就可以改然后改成已支付,然后下面在判断是不是Processing,是的话就改成已支付
-
成功就 ACK,消息删除告诉 RabbitMQ 消费完成。
-
失败就计数,最多重试 3 次超过 3 次直接 NACK 进死信,不卡死队列。
-
finally 里一定删锁避免死锁,保证下一次能正常消费。
用分布式锁防并发,用数据库状态做幂等,还有CAS,用手动 ACK 保证可靠性,用重试 + 死信避免死循环,从根源彻底防止消息重复消费。
为什么要加锁,其实就是当第一个线程超时的时候,然后发送NACK,另一个线程也来执行逻辑,所有判断都一路无阻然后在执行扣减库存然后就会多扣,加了锁此时第一个线程拿到锁但是锁还没有过期即使他超时了,然后其他消费者线程也执行这个逻辑是会被阻拦到拿锁那一步的。
为什么要加幂等性就是当第一个消费者线程锁过期了,然后第二个消费者线程进来,然后如果说不加幂等判断当线程1执行完,然后线程2也会继续执行然后就多扣了,你进行幂等判断当线程1执行完那个status就会被改变了,然后线程2就会被阻拦到这一步。
为什么要用CAS:就是当有一种情况就是当锁达到过期时间进行释放,此时也超时了,那么就会发送NACK然后就会再开一个线程执行这些逻辑,然后那个线程拿到锁然后判断什么的都成功了,此时原来那个超时的线程也要开始该状态了,如果你不加cas那他俩都会进行改状态,也会改成功,然后当然下面的扣库存也会进行成功,就没有办法防止重复消费了,所以要加CAS。
一、整套防重复机制总结
用分布式锁防并发,用数据库状态做幂等,用 CAS 做最终兜底,用手动 ACK 保证消息可靠性,用重试 + 死信避免死循环,从根源彻底防止消息重复消费。
二、为什么要加分布式锁?
当第一个消费者线程执行超时 ,MQ 会自动返回 NACK,消息重新入队,第二个线程就会开始执行。如果不加锁,两个线程会同时一路执行到底 ,都去扣库存、改状态,导致重复扣减、数据错误。
加了分布式锁后:同一时间只有一个线程能拿到锁 。即使第一个线程超时了,只要锁没过期,其他线程在拿锁这一步就会被直接拦住,根本进不来,从源头挡住并发重复。
三、为什么要加幂等判断(状态判断)?
极端场景:第一个线程的锁过期自动释放了,第二个线程成功拿到锁。如果不加幂等判断,当线程 1 执行完业务、线程 2 也会继续执行,依然会重复扣库存、重复处理。
加了幂等判断:线程 1 执行完后,订单状态会被修改 。线程 2 进来后,第一步就查到状态已变,直接被拦截,不再执行业务,避免重复执行。
四、为什么必须加 CAS(乐观锁)?
最极端的场景:第一个线程锁过期释放了 ,同时消费超时,返回 NACK。第二个线程进来,拿到锁 + 状态判断都通过 。此时,原来的旧线程恢复了,也要开始改状态、扣库存。
如果不加 CAS:两个线程都会修改成功,都会扣库存,重复消费无法避免!
加了 CAS:
sql
UPDATE order
SET status = ?
WHERE id = ? AND status = WAIT_PAY
这是数据库原子操作 ,只有一个线程能更新成功 。另一个线程更新行数 = 0,直接被拦住,绝对不会执行库存扣减。
CAS 是最后一道防线,彻底杜绝锁超时带来的重复执行问题。
五、整套机制的三层防护(最核心)
- 分布式锁:挡住并发,同一时间只让一个线程进
- 状态幂等:挡住已处理完的重复消息
- CAS 乐观锁:兜底挡住锁过期的极端情况
六、最终一句话(最强总结)
分布式锁防并发,状态幂等防重复,CAS 兜底防锁超时, 三层防护层层兜底,从根源彻底杜绝消息重复消费。
RocketMQ如何保证消息的顺序性:
先来看为什么会出现顺序性问题:
- 主体:A、B、C 是三个先后发起下单请求的用户,正常业务要求:必须先完整处理完先下单的 A 用户,再处理 B,再处理 C,全局顺序不能乱
- 问题根源 1(生产者集群闯的祸):你用了订单服务集群(多实例部署),A 用户的下单请求落到了实例 1,B 用户的落到了实例 2,C 用户的落到了实例 3。哪怕 A 用户是先点的下单、先触发的消息发送,但实例 1 刚好遇到网络波动,导致 A 用户的消息,比后下单的 B 用户的消息晚到 MQ,队列里的顺序直接变成了
B → A → C,入队顺序本身就错了。 - 问题根源 2(消费者集群补的刀):你用了消费者集群,多个实例同时监听这个队列。哪怕队列里顺序是对的
A→B→C,MQ 也会把 A 分给消费者 1,B 分给消费者 2,C 分给消费者 3,并行处理。结果 A 的处理卡了,B、C 先处理完了,最终执行顺序变成了B→C→A,哪怕入队顺序对,执行顺序也乱了。
要 100% 保证「全局用户请求严格按顺序处理」,唯一的、缺一个条件都不行的方案,就是你一直坚持的:
生产端:禁用生产者集群,只用单生产者单实例,同一个线程严格按用户请求的先后顺序串行发送消息,保证先发起的 A,一定先入队,彻底杜绝网络波动导致的入队乱序
或者是用分布式锁,它们共用同一把锁也就是锁都是长得一样的也就是那个key,在生产者就拿,发送成功后在释放,然后再让别的去拿。
如何处理RocketMQ消息积压问题:
一、源头控制(生产者侧)
-
生产者限流限制消息发送速率,避免生产速度远大于消费速度,从源头减少队列压力。
-
非核心消息降级 / 丢弃日志、统计、通知类非核心消息可临时关闭发送,优先保障核心业务消息。
二、消费速度优化(消费者侧核心)
-
优化消费逻辑中的 SQL加索引、避免全表扫描、减少大事务、批量操作,降低单条消息处理耗时。
-
减少外部慢调用避免消费时调用慢接口、慢 RPC、冗余 IO,能异步的都异步。
-
提高消费并发增加消费者线程数 / 新增消费者实例,提升整体消费能力。(注意:全局有序场景不能随便加并发)
-
调整预取数 prefetch适当调大消费者一次性拉取消息数量,减少网络 IO 开销。
-
关闭不必要的手动重试 / 死循环重试避免失败消息反复消费占用消费线程。
三、队列架构急救(严重积压时)
-
废弃原积压队列,新建队列接收新流量旧队列不再使用,新消息直接发到新队列,保证当前业务不阻塞。
-
旧队列消息转移到死信 / 临时队列存量积压消息转发到死信队列或临时队列,用独立消费者慢慢消化,不影响主流程。
-
批量清理无效消息过期、重复、已失效的消息直接 ACK 丢弃,快速减少队列长度。
四、Broker 层面优化
- 队列分片 / 多队列拆分按用户 ID / 订单 ID 哈希路由到多个队列,并行消费提升吞吐量。
- 清理队列冗余、避免队列过长
- 提升 MQ 节点资源(CPU、内存、磁盘)
五、兜底保障
- 消费幂等,防止重复处理导致业务异常
- 死信队列兜底,消费失败消息不丢失
- 监控报警,堆积数量、消费速度实时告警
RocketMQ如何解决消息丢失问题
1. 生产者发消息:同步发送 + 事务注解
(1)同步发送(sync send)
- 生产者发送消息 → 阻塞等待 Broker 确认
- 收到
SEND_OK才算发送成功 - 优点:可靠性最高,消息不会丢
- 缺点:性能比异步、单向发送低
适用场景:订单创建、支付、扣费等核心业务消息,绝不允许丢失。
(2)加事务注解(分布式事务消息)
以 RocketMQ 事务消息 为例:
流程
- 生产者发送 半消息 Half Message
- Broker 响应半消息发送成功
- 执行本地事务(@Transactional 注解)
- 根据本地事务结果,向 Broker 提交
COMMIT或ROLLBACK - 如果没提交 / 超时,Broker 会回查生产者确认状态
作用
保证:本地事务成功 ↔ 消息一定发送成功 本地事务失败 ↔ 消息一定不发送
彻底解决:本地事务成功但消息没发出去 / 消息发了但本地事务回滚 的问题。
2. 异步发送失败:回调机制处理
异步发送:producer.sendAsync(msg, callback)
回调接口(Callback)
两个方法:
onSuccess():消息发送成功onException():发送失败(网络异常、Broker 宕机、流量超限等)
失败时做什么?(生产实战)
- 日志记录(消息 ID、内容、时间、错误信息)
- 存入 DB 重试表
- 定时任务重试(重试 3~5 次)
- 仍失败 → 告警 + 人工介入
目的:异步发送不能丢消息,失败必须有兜底。
3. 队列端:同步刷盘
什么是刷盘?
消息到 Broker 后,从 内存 → 写入磁盘 的过程。
同步刷盘(SYNC_FLUSH)
- 消息写入磁盘成功后 ,才返回
ACK给生产者 - 只要 Broker 回复成功,消息一定落盘
- 机器断电、宕机也不会丢
异步刷盘(ASYNC_FLUSH)
- 写到内存就返回成功
- 后台线程批量刷盘
- 性能高,但断电可能丢少量消息
你要记住一句话
同步刷盘 = 数据绝对安全,性能稍低 异步刷盘 = 性能极高,有极低丢消息风险
金融、支付必须用 同步刷盘。
4. 消费者端:消息重试机制(最重点、最复杂)
(1)什么时候会重试?
- 消费者业务抛异常
- 消费超时
- 主动返回
RECONSUME_LATER
(2)重试规则(RocketMQ 为例)
默认 16 次重试,间隔越来越长:
- 10s
- 30s
- 1min
- 2min
- 4min
- 6min...直到最后 2 小时
超过最大重试次数 → 进入 死信队列(DLQ)
(3)死信队列(Dead Letter Queue)
- 重试多次仍失败的消息
- 不会丢弃,进入专门的死信队列
- 人工排查:数据问题、接口挂了、依赖异常等
(4)消费重试必须注意
- 消费者必须幂等重试会导致重复消费 → 必须保证重复消费不影响结果
- 不要捕获异常后不处理异常吃掉 = 消息被认为消费成功,丢消息
- 慢消费会导致大量重试队列堵塞 → 系统雪崩
(5)消费重试的本质
Broker 收到消费失败响应 →不删除消息,重新发送给消费者
不是消息真的 "复制",是重投递。
整体串一遍(生产完整链路)
- 生产者同步发送,保证消息不丢
- 加事务注解,保证本地事务与消息一致
- 异步发送失败用回调记录 + 重试
- Broker 同步刷盘,消息落盘才确认
- 消费者失败自动重试
- 最终失败进入死信队列人工处理