【MQ】MQ消息队列幂等性设计与踩坑实战

MQ消息队列幂等性设计与踩坑实战

前言

这篇是消息队列系列的学习笔记,这次整理的是 MQ 幂等性设计。

说实话,写这篇文章是因为最近实习的时候踩了个大坑。当时在写一个业务,用到了 MQ 消费消息,我心想幂等性嘛,简单,消费前先往 Redis 里塞个标识不就完了?结果呢,业务执行到一半失败了,抛了个异常,消息重新投递回来准备重试------诶,发现 Redis 里已经有幂等标识了,直接跳过,不处理了。得,这条消息就这么"丢"了,数据也没入库成功。

当时我就懵了:明明是为了防重复消费加的幂等,怎么反过来把正常的重试给拦住了?

后来仔细研究了一下,发现 MQ 幂等性这事儿,还真没那么简单。今天就来聊聊这个话题,顺便分享一下我踩坑后总结出来的最佳实践。

🏠个人主页:山沐与山


文章目录


一、什么是幂等性?为什么 MQ 要保证幂等?

1.1 先搞懂幂等性

什么是幂等性?说白了就是:同一个操作,执行一次和执行一百次,效果是一样的

数学上有个公式:f(f(x)) = f(x),翻译成人话就是------你重复做这件事,结果不会变。

举几个例子你就明白了:

操作 是否幂等 为什么
查询订单详情 幂等 查一百次,订单还是那个订单
根据 ID 删除记录 幂等 删一次是删,删十次还是删了
把用户状态设成"已激活" 幂等 设一次是激活,设一百次还是激活
创建订单 不幂等 创建一次是一个订单,重复创建就有多个订单了
库存扣减 不幂等 扣一次是扣 1,重复扣就扣多了
PV 统计 +1 不幂等 加一次是 +1,重复加就多加了

看到没?关键区别在于:幂等操作不会因为重复执行而产生副作用,而非幂等操作会。

1.2 MQ 为啥会重复消费?

你可能会问:消息不是消费完就没了吗,哪来的重复消费?

别天真了,分布式系统里,重复消费简直是家常便饭。来看看常见的几种情况:

场景一:生产者发送重试

复制代码
生产者发消息 ──────→ MQ
      ↑              │
      │  网络超时     │ 其实已经收到了
      └──────────────┘
生产者以为失败了,又发了一遍,MQ 就收到两条一样的消息

场景二:消费者处理完但 ACK 失败

复制代码
MQ ──────→ 消费者处理完了
   ←────── 准备发 ACK
           网络抖动,ACK 丢了

MQ 以为你没处理完,又投递了一遍

场景三:消费者 Rebalance

这个在 Kafka、RocketMQ 里很常见。消费者组重新分配分区的时候,可能导致同一条消息被不同消费者拿到。

场景四:消费者宕机

消费者处理到一半,服务器挂了。MQ 没收到 ACK,消息重新投递给其他消费者。

你说说,这些情况谁能保证不发生?所以 MQ 消费端必须要有幂等性保障。


二、MQ 重复消费的场景分析

2.1 重复消费会带来什么问题?

以电商场景为例:

java 复制代码
// 假设这是你的消费逻辑
public void onMessage(OrderMessage message) {
    // 扣减库存
    inventoryService.decrease(message.getProductId(), message.getQuantity());
    // 更新订单状态
    orderService.updateStatus(message.getOrderId(), "PAID");
    // 发送短信通知
    smsService.sendPaymentSuccess(message.getUserPhone());
}

如果这条消息被消费两次会怎样?

  • 库存被扣了两次(本来买 1 件,扣了 2 件的库存)
  • 用户收到两条"支付成功"短信(虽然不致命,但用户体验差)

再比如我实习时做的短链接统计:

java 复制代码
public void onMessage(StatsMessage message) {
    // PV +1
    linkStatService.increasePV(message.getShortUrl());
    // UV 统计
    linkStatService.recordUV(message.getShortUrl(), message.getUserId());
}

重复消费的话,PV 数据就不准了。本来访问了 100 次,统计出来 200 次,这数据还怎么看?

2.2 幂等性方案对比

市面上常见的幂等性方案:

方案 原理 优点 缺点
数据库唯一索引 利用数据库唯一约束 强一致性 性能差,有数据库瓶颈
Redis SETNX 利用 SETNX 原子操作 性能高 需要处理好过期时间
业务状态机 判断业务状态是否允许操作 贴合业务 实现复杂
Token 机制 先获取 Token,操作时校验并删除 通用性强 多一次交互

今天重点讲 Redis SETNX 方案,因为在 MQ 场景下它是性能和实现复杂度的最佳平衡点。


三、幂等性方案设计:三态模型

3.1 为什么简单的"存在即消费过"行不通?

很多人第一反应是这样做:

java 复制代码
// 错误示范!
public void onMessage(Message message) {
    String messageId = message.getId();

    // 判断是否已消费
    if (redis.exists(messageId)) {
        return; // 存在就跳过
    }

    // 标记为已消费
    redis.set(messageId, "1", 2, TimeUnit.MINUTES);

    // 处理业务
    doBusinessLogic(message);
}

看起来没毛病?问题大了!

问题一:消费到一半挂了

复制代码
T+0   标记 messageId 到 Redis
T+1   开始处理业务
T+5   服务器宕机了!业务没处理完
T+6   MQ 重新投递消息
T+7   发现 Redis 里有标识,直接跳过

结果:消息永远不会被正确处理了!

这就是我实习时踩的坑------先标记后处理,一旦处理失败,标识还在,后续重试全被拦住。

问题二:业务失败了怎么办

java 复制代码
try {
    doBusinessLogic(message);
} catch (Exception e) {
    // 抛异常了,业务没成功
    // 但是 Redis 里已经有标识了
    // 后续重试还是会被跳过
}

同样的问题,业务失败了,幂等标识却留着,重试机制形同虚设。

3.2 三态模型:未消费 / 消费中 / 已完成

解决方案是引入三个状态

状态 Redis 表现 含义
未消费 Key 不存在 从未处理过这条消息
消费中 Key 存在,Value = "0" 正在处理,或者处理中断了
已完成 Key 存在,Value = "1" 已经成功处理完

状态流转图:

复制代码
                    ┌─────────────┐
                    │   未消费    │
                    │ (Key不存在) │
                    └──────┬──────┘
                           │
                           │ SETNX 成功,Value="0"
                           ▼
                    ┌─────────────┐
         ┌──────────│   消费中    │──────────┐
         │          │ (Value="0") │          │
         │          └──────┬──────┘          │
         │                 │                 │
      消费失败          消费成功          宕机了
      删除 Key        Value 改成 "1"    (什么都没做)
         │                 │                 │
         ▼                 ▼                 ▼
   ┌─────────────┐  ┌─────────────┐  ┌─────────────┐
   │   未消费    │  │   已完成    │  │   消费中    │
   │  (可重试)   │  │ (Value="1") │  │ (等TTL过期) │
   └─────────────┘  └─────────────┘  └─────────────┘

关键点:

  1. 消费成功才标记为完成
  2. 消费失败要删除标识,允许重试
  3. 宕机了等 TTL 过期后可以重新消费

四、核心代码实现

4.1 幂等处理器

来看看核心的幂等处理器怎么写:

java 复制代码
@Component
@RequiredArgsConstructor
public class MessageQueueIdempotentHandler {

    private final StringRedisTemplate stringRedisTemplate;

    private static final String IDEMPOTENT_KEY_PREFIX = "mq:idempotent:";

    /**
     * 尝试获取消费权
     * @return true-首次消费,false-重复消息
     */
    public boolean isMessageProcessed(String messageId) {
        String key = IDEMPOTENT_KEY_PREFIX + messageId;
        // SETNX + 过期时间,原子操作
        return Boolean.TRUE.equals(
            stringRedisTemplate.opsForValue()
                .setIfAbsent(key, "0", 2, TimeUnit.MINUTES)
        );
    }

    /**
     * 检查消息是否已完成
     */
    public boolean isAccomplish(String messageId) {
        String key = IDEMPOTENT_KEY_PREFIX + messageId;
        return Objects.equals(
            stringRedisTemplate.opsForValue().get(key),
            "1"
        );
    }

    /**
     * 标记消费完成
     */
    public void setAccomplish(String messageId) {
        String key = IDEMPOTENT_KEY_PREFIX + messageId;
        stringRedisTemplate.opsForValue().set(key, "1", 2, TimeUnit.MINUTES);
    }

    /**
     * 删除幂等标识(消费失败时调用)
     */
    public void delMessageProcessed(String messageId) {
        String key = IDEMPOTENT_KEY_PREFIX + messageId;
        stringRedisTemplate.delete(key);
    }
}

4.2 四个方法的作用

方法 作用 调用时机
isMessageProcessed 尝试获取消费权 消费开始前
isAccomplish 检查是否已完成 发现是重复消息时
setAccomplish 标记消费完成 业务处理成功后
delMessageProcessed 删除标识 业务处理失败时

4.3 为什么 TTL 设置 2 分钟?

这是个权衡的艺术:

太短(比如 30 秒)有什么问题?

复制代码
T+0s   消费者 A 开始处理
T+25s  业务还在处理中(比如调了个慢接口)
T+30s  Key 过期了!
T+31s  消费者 B 也开始处理这条消息
T+35s  消费者 A 处理完了
T+40s  消费者 B 也处理完了
       → 重复处理了!

太长(比如 30 分钟)有什么问题?

复制代码
T+0s   消费者 A 开始处理
T+1s   服务器宕机了
T+2s   消息被重新投递
T+2s   发现 Key 还在,抛异常等重试
...    一直等,一直重试失败
T+30m  Key 终于过期了,才能正常消费
       → 消息延迟 30 分钟才被处理!

2 分钟是个平衡点:

  • 绝大多数业务处理 10 秒内完成,2 分钟足够覆盖
  • 即使宕机了,最多等 2 分钟就能恢复

五、消费者模板代码详解

5.1 标准消费模板

记住这个模板,以后写消费者就照着来:

java 复制代码
public void onMessage(Message message) {
    String messageId = message.getId();

    // ========== Step 1: 幂等性判断 ==========
    if (!idempotentHandler.isMessageProcessed(messageId)) {
        // 获取消费权失败,说明是重复消息
        if (idempotentHandler.isAccomplish(messageId)) {
            // 已经处理完成了,直接返回
            log.info("[幂等] 消息已处理完成,忽略: {}", messageId);
            return;
        }
        // 还没处理完(可能是消费中断),抛异常让 MQ 重试
        log.warn("[幂等] 消息消费中断,等待重试: {}", messageId);
        throw new RuntimeException("消息未完成流程,需要消息队列重试");
    }

    // ========== Step 2: 业务处理 ==========
    try {
        doBusinessLogic(message);
    } catch (Throwable ex) {
        // ========== Step 3: 异常处理 ==========
        // 关键!删除幂等标识,允许重试
        idempotentHandler.delMessageProcessed(messageId);
        log.error("[消费异常] messageId={}", messageId, ex);
        throw ex;  // 重新抛出,让 MQ 知道消费失败
    }

    // ========== Step 4: 标记完成 ==========
    idempotentHandler.setAccomplish(messageId);
    log.info("[消费成功] messageId={}", messageId);
}

5.2 为什么要双重检查?

你可能会问:isMessageProcessed 返回 false 了,为什么还要再调 isAccomplish

因为返回 false 有两种可能:

  1. 消息已经处理完了(Value = "1"
  2. 消息正在处理中,或者处理到一半中断了(Value = "0"

这两种情况的处理方式完全不同:

  • 第一种:直接忽略,不需要做任何事
  • 第二种:抛异常,等 MQ 重试或者等 TTL 过期
java 复制代码
if (!handler.isMessageProcessed(messageId)) {
    if (handler.isAccomplish(messageId)) {
        return;  // 情况1:已完成,安全忽略
    }
    throw new RuntimeException("需要重试");  // 情况2:中断了,等重试
}

5.3 为什么要捕获 Throwable 而不是 Exception?

java 复制代码
} catch (Throwable ex) {  // 注意是 Throwable
    idempotentHandler.delMessageProcessed(messageId);
    throw ex;
}

Java 的异常体系:

复制代码
               Throwable
                   │
       ┌───────────┴───────────┐
       │                       │
     Error                Exception
   (JVM 错误)             (程序异常)
       │                       │
 OutOfMemoryError      RuntimeException
 StackOverflowError         ...

Exception 只能捕获程序异常,而 Error 是 JVM 级别的错误(比如内存溢出)。

在消息消费场景下,即使发生了 OutOfMemoryError,我们也希望能删除幂等标识,给下次重试一个机会。所以要用 Throwable


六、RocketMQ 幂等性实战

原文档里 RocketMQ 的例子被注释掉了,这里补充一下完整的 RocketMQ 幂等性实现。

6.1 生产者发送消息

java 复制代码
@Component
@RequiredArgsConstructor
public class OrderMessageProducer {

    private final RocketMQTemplate rocketMQTemplate;

    public void sendOrderMessage(OrderDTO order) {
        // 生成消息唯一标识
        String messageId = UUID.randomUUID().toString();

        Message<OrderDTO> message = MessageBuilder
            .withPayload(order)
            .setHeader(RocketMQHeaders.KEYS, messageId)  // 设置到 Keys
            .build();

        rocketMQTemplate.send("order-topic", message);
        log.info("[发送消息] messageId={}, orderId={}", messageId, order.getId());
    }
}

RocketMQ 里通常用消息的 Keys 属性来存放唯一标识,这个属性在消费端可以通过 MessageExt.getKeys() 获取。

6.2 消费者处理消息

java 复制代码
@Slf4j
@Component
@RequiredArgsConstructor
@RocketMQMessageListener(
    topic = "order-topic",
    consumerGroup = "order-consumer-group"
)
public class OrderMessageConsumer implements RocketMQListener<MessageExt> {

    private final MessageQueueIdempotentHandler idempotentHandler;
    private final OrderService orderService;

    @Override
    public void onMessage(MessageExt message) {
        // 获取消息唯一标识
        String keys = message.getKeys();

        // 幂等性判断
        if (!idempotentHandler.isMessageProcessed(keys)) {
            if (idempotentHandler.isAccomplish(keys)) {
                log.info("[幂等] 消息已处理,忽略: {}", keys);
                return;
            }
            throw new RuntimeException("消息未完成流程,需要消息队列重试");
        }

        try {
            // 解析消息体
            OrderDTO order = JSON.parseObject(message.getBody(), OrderDTO.class);
            // 执行业务
            orderService.processOrder(order);
        } catch (Throwable ex) {
            idempotentHandler.delMessageProcessed(keys);
            log.error("[消费失败] keys={}", keys, ex);
            throw ex;
        }

        idempotentHandler.setAccomplish(keys);
        log.info("[消费成功] keys={}", keys);
    }
}

6.3 RocketMQ 消息唯一标识的选择

RocketMQ 提供了几种消息标识:

标识 获取方式 特点
msgId message.getMsgId() Broker 生成,全局唯一
offsetMsgId message.getOffsetMsgId() Broker 存储位置
keys message.getKeys() 业务自定义
uniqKey message.getProperty(MessageConst.PROPERTY_UNIQ_CLIENT_MSG_ID_KEYIDX) 客户端生成

推荐使用 keys,因为:

  1. 业务可控,自己生成的 ID 自己清楚
  2. 支持通过 keys 查询消息(运维友好)
  3. 生产者和消费者使用同一个标识,语义清晰

6.4 RocketMQ 重试机制与幂等配合

RocketMQ 消费失败会自动重试,重试策略:

复制代码
第 1 次重试:10s 后
第 2 次重试:30s 后
第 3 次重试:1min 后
第 4 次重试:2min 后
...
第 16 次重试:2h 后
超过 16 次:进入死信队列

这就是为什么我们设置 2 分钟 TTL 的另一个原因------正好能覆盖前几次重试。如果第一次消费中断(没删标识的情况),2 分钟后 Key 过期,正好第 4 次重试来了,就能正常消费。


七、踩坑实录与解决方案

7.1 坑一:先标记后处理

这是我实习时踩的坑,前面说过了,再强调一遍:

java 复制代码
// 错误写法!
public void onMessage(Message message) {
    String id = message.getId();
    redis.set(id, "1");  // 先标记
    doBusinessLogic(message);  // 后处理
}

问题:业务失败了,标识还在,重试被拦住。

正确做法:三态模型,成功才标记完成,失败删标识。

7.2 坑二:忘记删除标识

java 复制代码
// 错误写法!
try {
    doBusinessLogic(message);
} catch (Exception e) {
    log.error("消费失败", e);
    throw e;  // 没删标识!
}
idempotentHandler.setAccomplish(messageId);

问题 :消费失败了,标识还是 "0",下次重试会被当作"消费中断",抛异常等重试。虽然最终 TTL 过期后能恢复,但白白等了 2 分钟。

正确做法 :catch 块里一定要 delMessageProcessed

7.3 坑三:只有 Exception 没有 Throwable

java 复制代码
// 不够好的写法
try {
    doBusinessLogic(message);
} catch (Exception e) {  // 只捕获 Exception
    idempotentHandler.delMessageProcessed(messageId);
    throw e;
}

问题 :如果发生了 OutOfMemoryErrorError,catch 块不会执行,标识不会被删除。

更稳妥的做法 :用 Throwable

7.4 坑四:TTL 太短或太长

前面分析过了:

  • 太短(30秒):业务没处理完,Key 就过期了,可能重复消费
  • 太长(30分钟):宕机后等太久才能恢复

建议:根据业务处理时间设置,一般 2-5 分钟比较合适。

7.5 坑五:消息 ID 不唯一

java 复制代码
// 错误示范!
public void sendMessage(Order order) {
    Message message = new Message();
    message.setId(order.getId());  // 用订单 ID 作为消息 ID
    mqTemplate.send(message);
}

问题:如果同一个订单发了多条不同的消息(比如创建、支付、发货),它们的消息 ID 都一样,后面的消息会被当作重复消息跳过。

正确做法:消息 ID 要唯一,用 UUID 或者雪花算法。


八、常见问题

8.1 消息一直被跳过怎么办?

问题描述:消息每次来都被判定为"已消费",但业务其实没成功。

排查步骤

  1. 检查是不是 setAccomplish 调用太早了(应该在业务成功后调用)
  2. 检查 catch 块里有没有 delMessageProcessed
  3. 检查消息 ID 是不是唯一的

8.2 宕机后消息延迟处理怎么办?

问题描述:服务宕机后,消息要等 2 分钟 TTL 过期才能消费。

这是正常的,因为我们无法区分"真的在消费中"和"消费中断了"。2 分钟是可接受的延迟。

如果实在接受不了,可以:

  1. 缩短 TTL(但要确保业务能在 TTL 内完成)
  2. 服务重启时主动清理"消费中"状态的标识(比较复杂)

8.3 高并发下性能怎么样?

每次消费要访问 3-4 次 Redis:

  1. isMessageProcessed - SETNX
  2. isAccomplish(可能)- GET
  3. setAccomplish - SET
  4. delMessageProcessed(失败时)- DEL

单机 Redis 10w+ QPS 没问题。如果还是瓶颈,可以考虑:

  • 用 Redis Cluster 分散压力
  • 用 Lua 脚本合并操作
  • 加本地缓存(已完成的消息 ID 缓存在本地)

8.4 和分布式事务什么关系?

这是两个不同的问题:

  • 幂等性:解决重复消费的问题
  • 分布式事务:解决多个操作要么全成功要么全失败的问题

比如"扣库存 + 更新订单状态"这个场景:

  • 幂等性保证:这个操作不会因为重复消费而多执行
  • 分布式事务保证:扣库存和更新订单状态要么都成功,要么都失败

两者可以配合使用,但不能互相替代。


九、总结

MQ 幂等性设计,核心就是 三态模型 + 正确的状态流转

复制代码
┌───────────────────────────────────────────────────────────────┐
│                      MQ 幂等性核心要点                          │
├───────────────────────────────────────────────────────────────┤
│                                                               │
│  1. 三态设计                                                   │
│     • 未消费 → 消费中 → 已完成                                  │
│     • 用 Redis Value 区分:不存在 / "0" / "1"                   │
│                                                               │
│  2. 状态流转                                                   │
│     • 消费前:SETNX 设置为 "0"                                  │
│     • 消费成功:改成 "1"                                        │
│     • 消费失败:删除 Key,允许重试                               │
│     • 宕机:等 TTL 过期后自动恢复                                │
│                                                               │
│  3. 双重检查                                                   │
│     • 重复消息分两种:已完成(忽略)/ 消费中断(等重试)           │
│                                                               │
│  4. TTL 权衡                                                   │
│     • 2 分钟左右,覆盖业务处理时间 + 兼顾故障恢复                 │
│                                                               │
└───────────────────────────────────────────────────────────────┘

最后再唠叨一句:幂等性不是银弹,它只解决重复消费的问题。其他问题比如消息丢失、顺序消费、分布式事务,还得用其他方案。

问题 解决方案
重复消费 幂等性(本文)
消息丢失 ACK 机制 + 持久化
顺序消费 同一分区 + 单消费者
分布式事务 事务消息 / TCC / Saga

好了,今天就聊到这。希望这篇文章能帮你避开我踩过的坑。


热门专栏推荐

等等等还有许多优秀的合集在主页等着大家的光顾,感谢大家的支持

文章到这里就结束了,如果有什么疑问的地方请指出,诸佬们一起来评论区一起讨论😊

希望能和诸佬们一起努力,今后我们一起观看感谢您的阅读🙏

如果帮助到您不妨3连支持一下,创造不易您们的支持是我的动力🌟

相关推荐
全栈独立开发者7 小时前
点餐系统装上了“DeepSeek大脑”:基于 Spring AI + PgVector 的 RAG 落地指南
java·人工智能·spring
dmonstererer7 小时前
【k8s设置污点/容忍】
java·容器·kubernetes
super_lzb7 小时前
mybatis拦截器ParameterHandler详解
java·数据库·spring boot·spring·mybatis
程序之巅7 小时前
VS code 远程python代码debug
android·java·python
liulilittle7 小时前
XDP VNP虚拟以太网关(章节:一)
linux·服务器·开发语言·网络·c++·通信·xdp
我不是8神7 小时前
Qt 知识点全面总结
开发语言·qt
CV工程师的自我修养7 小时前
数据库出现死锁了。还不知道什么原因引起的?快来看看吧!
数据库
我是Superman丶7 小时前
【异常】Spring Ai Alibaba 流式输出卡住无响应的问题
java·后端·spring
墨雨晨曦887 小时前
Nacos
java
Ralph_Y7 小时前
多重继承与虚继承
开发语言·c++