消息幂等与顺序:MQ 场景的两道必考题

用消息队列一定会遇到两个问题:消息重复投递消息乱序。这不是"配置好就不会发生",而是 MQ 语义(at-least-once)决定的必然现象。这篇文章把两个问题的解法讲透。

一、为什么重复是常态

RabbitMQ/Kafka 的投递语义:

  • at-most-once:最多一次(可能丢)
  • at-least-once:至少一次(可能重复)← 默认
  • exactly-once:恰好一次(分布式系统里极难)

生产用 at-least-once + 业务幂等——这是分布式消息的标准姿势。所以:消费端必须假设"每条消息可能收到两次"。

二、幂等三方案(按优先级)

方案 1:数据库唯一键(最可靠)

1
2
3
4
5
6
7
-- 消费记录表:业务幂等键做唯一索引
CREATE TABLE consume_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    biz_key VARCHAR(64) NOT NULL,       -- 业务幂等键
    status TINYINT,
    UNIQUE KEY uk_biz_key (biz_key)     -- 重复插入会报错
);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func consume(ctx, msg) error {
    // 幂等键 = 业务事件的稳定标识
    bizKey := fmt.Sprintf("%s:%d", msg.Type, msg.BizID)
    // 插入消费记录,唯一键冲突 = 已消费过
    _, err := db.Exec("INSERT IGNORE INTO consume_log(biz_key) VALUES(?)", bizKey)
    if err != nil {
        return err      // 真错误,重试
    }
    if rowsAffected == 0 {
        return nil      // 已消费过,幂等跳过
    }
    // 执行业务
    return doBiz(msg)
}

唯一键 + INSERT IGNORE 是数据库幂等的最稳姿势:并发重复也能扛(唯一索引天然原子)。

方案 2:Redis 幂等键(高频场景)

1
2
3
4
5
6
// 前面 Redis 防重文章讲过:SETNX 拿"处理权"
ok, _ := redis.SetNX(ctx, "consume:"+bizKey, 1, 24*time.Hour)
if !ok {
    return nil   // 已处理
}
return doBiz(msg)

适用:高吞吐、容忍"24h 窗口后可能重复"的场景。注意:Redis 幂等要配合业务可重入(万一 Redis 丢了 key,重复执行也不能产生脏数据)。

方案 3:业务本身幂等(最优解)

1
2
3
4
// 业务天然幂等:状态机推进
// 投递单状态:CREATED → PAID → DELIVERED,重复消费只是重复"置为已支付",无副作用
UPDATE delivery SET status='PAID' WHERE id=? AND status='CREATED'
// 影响行数 0 = 已处理过

这才是终极方案:业务设计成幂等操作,消息重复多少次都没事。能设计成状态机推进的,就别依赖外部幂等。

选型总结

场景 方案
钱相关(支付、扣款) 数据库唯一键(最强)
高吞吐(通知、统计) Redis 幂等键
状态流转类 业务状态机幂等(最优)

三、顺序问题:什么时候真的需要有序

先泼冷水:90% 的业务不需要全局顺序。只有这些场景才需要:

1 2 3 . . . A B B A

判断标准:消费结果依赖"前一条处理完"吗?不依赖 → 不需要顺序,用并发消费提速。

四、顺序三方案

方案 1:单分区 + 按业务键路由(Kafka 推荐)

1
2
3
// Kafka:同 key 的消息进同一分区,分区内有序
producer.Send("delivery-events", key=deliveryID, value=msg)
// key = 业务 ID → 同一投递单的消息永远一个分区 → 消费侧单分区顺序处理

为什么是分区内有序:Kafka 只有分区内有序,全局有序 = 单分区 = 无并发。用业务键做分区键,让"需要有序的"进同一分区,其他随便分。

方案 2:RabbitMQ 单队列 + 顺序消费

1
2
3
4
5
// RabbitMQ:同一队列天然 FIFO,但要消费端单线程
// 多消费者并发 = 乱序(每个 consumer 拿到的时间可能不同)
// 解决方案:
// 1. 需要有序的消息走专用队列,该队列单消费者
// 2. 或按业务键 shard 到多个队列(每个队列单消费者)

代价:单消费者 = 吞吐受限。只对"必须有序"的消息这么干,其余走并行队列。

方案 3:消费端重排(兜底)

1
2
3
4
5
6
7
8
// 收到乱序消息,业务上带版本号/时间戳
// 处理前校验:版本 < 当前已处理 → 丢弃
// 版本 > 当前 + 1 → 缓存等待(等前面的补上)
type deliveryMsg struct {
    ID      int64
    Version int   // 业务版本号
}
// 消费端:只处理 version == lastVersion+1 的

五、顺序 + 幂等组合(真实场景)

d e l i b v i e z r _ y k I e D y = d e l i v e r y : { i d } : { v e r s i o n }

六、踩坑记录

  1. 重试导致乱序:消费失败重试,重试期间后面消息先处理了 → 幂等键用版本号,重试的消息版本对不上直接丢
  2. 批量消费乱序:Kafka 批量 poll 再并行处理 → 有序消息必须单线程处理(或按 key 分 shard)
  3. 重平衡乱序:消费者组重平衡,partition 换消费者 → 消息带业务版本号兜底,别依赖"天然顺序"
  4. 死信重放:死信队列重放可能打乱原始顺序 → 重放走独立队列,按版本重排

总结

消息系统的两个核心认知:

  1. 重复是常态,幂等是必须:数据库唯一键(钱)、Redis 幂等键(吞吐)、业务状态机(最优)
  2. 顺序是稀缺的:90% 不需要,需要时用"业务键分区 + 单消费者 + 版本号兜底"
  3. 组合:有序消息 = 分区有序 + 幂等键带版本 + 重试不乱序

别追求全局顺序——那是放弃并发。 把"必须有序的"隔离出来,其余并行,才是消息系统的正确打开方式。