一、什么是消息幂等性

想象一下这个场景:你在网上购物,点击"提交订单"按钮时网络卡顿了,于是你又点了一次。这时候如果系统不做特殊处理,可能会生成两个相同的订单。消息队列中也存在类似问题——消费者可能多次收到同一条消息,这就是"重复消费"问题。

幂等性就像给系统装了个"去重开关",无论同一条消息被处理多少次,最终效果都只相当于处理一次。举个生活中的例子:给银行卡充值100元,无论你点多少次"确认充值"按钮(假设网络重试),只要最终余额只增加100元,这个操作就是幂等的。

二、RabbitMQ为什么需要幂等处理

RabbitMQ本身提供两种消息确认机制:

  1. 自动确认(收到消息立即删除)
  2. 手动确认(处理完成后手动发送ACK)

但即使使用手动确认,这些情况仍会导致重复消费:

  • 消费者处理消息后,发送ACK前崩溃了
  • 网络抖动导致ACK丢失
  • 生产者重复推送(比如触发了重试机制)
// 技术栈:Java + Spring Boot + RabbitMQ
// 典型的问题代码示例:
@RabbitListener(queues = "orderQueue")
public void processOrder(Order order) {
    // 模拟业务处理
    orderService.createOrder(order); // 如果这个方法被调用两次...
    // 网络故障导致没有发送ACK
}

三、五种实用解决方案

方案1:唯一ID+去重表

就像快递单号一样,给每条消息分配唯一标识。

// 创建去重表SQL:
CREATE TABLE message_idempotent (
    msg_id VARCHAR(64) PRIMARY KEY,
    created_time TIMESTAMP
);

// Java实现示例:
@RabbitListener(queues = "orderQueue")
public void processOrder(Order order, @Header(AmqpHeaders.DELIVERY_TAG) long tag) {
    try {
        if (duplicateCheck(order.getMessageId())) {
            channel.basicAck(tag, false); // 确认已处理
            return;
        }
        orderService.createOrder(order);
        saveMessageId(order.getMessageId()); // 记录消息ID
        channel.basicAck(tag, false);
    } catch (Exception e) {
        channel.basicNack(tag, false, true); // 处理失败,重新入队
    }
}

方案2:Redis原子操作

利用Redis的原子性实现轻量级去重。

// Redis方案示例:
Boolean isDuplicate = redisTemplate.opsForValue()
    .setIfAbsent("order:" + order.getMsgId(), "1", 24, TimeUnit.HOURS);
if (!isDuplicate) {
    return; // 已处理过
}

方案3:数据库唯一约束

通过数据库天然的特性实现:

// 订单表增加唯一约束
ALTER TABLE orders ADD UNIQUE (business_id, order_type);

// 业务代码捕获异常:
try {
    orderDao.insert(order);
} catch (DuplicateKeyException e) {
    logger.warn("重复订单已被过滤");
}

方案4:版本号控制

适合更新操作,类似乐观锁机制:

// 消息体增加版本号
public class OrderMessage {
    private Long orderId;
    private Integer version; // 每次操作+1
}

// 处理时校验版本
UPDATE orders SET status = 'paid', version = version + 1 
WHERE id = #{orderId} AND version = #{version}

方案5:状态机校验

适用于有明确状态流转的业务:

// 检查当前状态是否允许处理
Order order = orderDao.selectById(orderId);
if (!"unpaid".equals(order.getStatus())) {
    return; // 只有未支付订单才处理
}

四、方案选型指南

方案 适用场景 优点 缺点
唯一ID+去重表 所有消息类型 通用性强 需要维护额外表
Redis原子操作 高频消息 性能极高 Redis可能丢失数据
数据库约束 数据库操作类消息 无需额外组件 仅限数据库操作
版本号控制 更新类操作 精准控制 设计复杂度高
状态机校验 有状态流转的业务 业务耦合度低 需要完善的状态设计

五、实战中的注意事项

  1. 消息ID生成原则

    • 使用业务主键(如订单号)+场景标识(如"_PAY")
    • 避免使用UUID等无意义字符串
  2. 防重时效设置

    // 设置合理的过期时间(根据业务调整)
    redisTemplate.expire("order:"+orderId, 7, TimeUnit.DAYS);
    
  3. 特殊场景处理

    // 处理消息时先查业务状态
    Order dbOrder = orderService.getById(orderId);
    if (dbOrder != null && "completed".equals(dbOrder.getStatus())) {
        // 已经处理成功的直接跳过
    }
    
  4. 日志监控建议

    • 记录被过滤的重复消息
    • 监控去重率异常波动

六、总结

就像给快递包裹贴上唯一的条形码,消息幂等处理是分布式系统必备的"防重复"机制。选择方案时要像挑选工具箱里的工具——根据业务特点决定:

  • 简单业务用Redis
  • 重要交易用数据库
  • 复杂流程用状态机

记住三个关键原则:

  1. 唯一标识是基础
  2. 业务判断是防线
  3. 监控报警是保障

下次当你点击"确认支付"却看到页面卡顿时,可以放心地多点几次——因为后端工程师已经用这些方法保护你的钱包了!