Files
cs-note/hhs/MQ/05-可靠性保障/14-MQ-消息幂等性.md
T
2026-05-24 20:51:06 +08:00

7.9 KiB
Raw Blame History

tags, create time
tags create time
MQ
幂等性
去重
分布式系统
2026-05-24 19:52

MQ 消息幂等性

概述

消息重复是分布式系统的常态,而不是异常。无论是网络超时重试、Consumer Rebalance 还是 Producer 重试,都可能产生重复消息。幂等性保证"执行多次与执行一次效果相同",是处理重复消息的核心手段。本文介绍四种常见的幂等实现方案,并给出适用场景对比。

正文

为什么会产生重复消息

在讨论幂等方案之前,先搞清楚重复消息的来源。知己知彼,才能对症下药。

网络超时重试:Producer 发送消息后等待 ACK 超时,不确定 Broker 是否收到,于是重试。如果 Broker 其实已经收到了第一条,重试就会产生重复。这是最常见的重复来源。

Consumer Rebalance:Consumer Group 发生 Rebalance 时(如消费者加入或退出),分区会被重新分配。旧 Consumer 可能已经拉取了消息但还没来得及提交 Offset,新 Consumer 又会重新拉取同一批消息。

Producer 重试:Broker 返回一个可重试的错误(如 NotLeaderForPartition),Producer 自动重试发送。如果上一次请求其实已经成功写入,重试就会重复。

[!question] 能不能通过"先查询再处理"的方式避免重复?比如先查数据库,如果订单已存在就跳过?

这种方案存在经典的 TOCTOU(Time of Check to Time of Use) 问题:查询时订单不存在,但在你插入之前,另一个重复消息刚好完成了插入。两个消费者都认为订单不存在,都执行了插入操作。所以"先查询再处理"不能保证幂等,必须借助原子性的去重机制。

幂等的定义

幂等(Idempotent)的数学定义是:f(f(x)) = f(x)。应用到消息处理中,意味着同一条消息被消费一次和被消费 N 次的结果完全一样。

注意幂等不等于"不处理"。幂等是"处理了但效果只生效一次"。比如扣减库存,幂等的做法是记录已处理的消息 ID,重复消息直接跳过扣减逻辑。

方案一:唯一 ID + 去重表

最直观的方案:每条消息携带一个全局唯一的 MessageID,消费前先查去重表,如果已存在则跳过。

Redis Set 实现:将 MessageID 写入 Redis Set,利用 SADD 的原子性实现"检查 + 写入"一步完成。

数据库唯一索引:在去重表上对 MessageID 建唯一索引,插入重复记录时会触发唯一约束冲突,直接跳过。

两种实现各有优劣:Redis 性能高但需要处理过期策略;数据库可靠但吞吐受限于磁盘 I/O。

// 基于 Redis 的消息去重实现
func processWithDedup(rdb *redis.Client, msg *Message) error {
    dedupKey := fmt.Sprintf("mq:dedup:%s", msg.ID)

    // SADD 原子操作:如果 key 不存在则添加并返回 1,已存在则返回 0
    added, err := rdb.SAdd(context.Background(), dedupKey, "1").Result()
    if err != nil {
        return fmt.Errorf("redis sadd: %w", err)
    }

    if added == 0 {
        // 已经处理过,直接跳过
        log.Printf("duplicate message %s, skipped", msg.ID)
        return nil
    }

    // 设置过期时间,避免 Redis 内存无限增长
    rdb.Expire(context.Background(), dedupKey, 24*time.Hour)

    // 执行业务逻辑
    if err := processOrder(msg); err != nil {
        // 业务处理失败,删除去重标记,允许重试
        rdb.Del(context.Background(), dedupKey)
        return err
    }

    return nil
}

这段代码利用了 Redis SADD 的原子性,把"检查是否重复"和"标记已处理"合成一个操作,避免了 TOCTOU 问题。注意业务处理失败时要删除去重标记,否则消息就永远无法被重试了。

方案二:乐观锁(版本号控制)

适用于"更新已有数据"的场景。每条记录带一个版本号,更新时带上版本号条件:

UPDATE inventory SET stock = stock - 1, version = version + 1
WHERE sku = 'A001' AND version = 5;

如果版本号不匹配(说明已经被其他消息更新过),affected_rows 返回 0,业务层判定为重复操作。

这种方案不需要额外的去重表,利用了数据库自身的并发控制能力。适合库存扣减、账户余额变更等场景。

[!question] 乐观锁方案能处理"插入"类操作吗?比如创建订单?

不太适合。乐观锁天然适合"更新"场景,因为更新时已有记录和版本号。对于"插入"类操作,还是用唯一 ID + 去重表更直接。当然你也可以用 INSERT ... ON CONFLICT DO NOTHING(PostgreSQL)来实现插入幂等。

方案三:Token 机制

Token 机制的核心思路是:先获取令牌,再执行操作。

  1. Consumer 处理消息前,先向 Token 服务申请一个全局唯一的 Token。
  2. 消费者带着 Token 执行业务操作。
  3. 业务服务端校验 Token 是否有效,有效则执行并销毁 Token,无效则拒绝。

Token 服务保证每个 Token 只能使用一次,天然实现了幂等。这种方案适合跨系统的幂等控制,比如下游 API 的幂等调用。

缺点是多了一次网络调用获取 Token,且 Token 服务本身需要高可用。

方案四:状态机约束

很多业务天然具有状态流转特性,利用状态机的单向性可以实现幂等。

比如订单状态:待支付 → 已支付 → 已发货 → 已完成。消费"支付成功"消息时,执行:

UPDATE orders SET status = '已支付'
WHERE order_id = '1001' AND status = '待支付';

如果订单已经是"已支付"状态,affected_rows 为 0,自然幂等。不需要额外的去重表,业务逻辑和幂等校验融为一体。

flowchart TD
    A["收到消息"] --> B["提取消息 MessageID"]
    B --> C["查询去重表"]
    C --> D{"是否已处理?"}
    D -->|"是"| E["跳过,记录日志"]
    D -->|"否"| F["执行业务逻辑"]
    F --> G{"业务执行成功?"}
    G -->|"是"| H["写入去重表"]
    H --> I["提交消费 Offset"]
    G -->|"否"| J["不写去重表"]
    J --> K["等待重试"]

各方案适用场景对比

方案 实现复杂度 性能影响 适用场景 局限性
唯一 ID + 去重表 低 低(Redis)/ 中(DB) 通用场景,最常用 需要额外存储,需处理过期清理
乐观锁 低 低 更新类操作(库存、余额) 不适合插入操作,存在 ABA 问题
Token 机制 高 中(额外网络调用) 跨系统 API 幂等 Token 服务需高可用
状态机约束 低 低 有明确状态流转的业务 业务模型必须支持状态机

实际项目中,这几种方案往往会组合使用。比如:订单系统用状态机约束核心流转,同时用唯一 ID + 去重表作为兜底。没有银弹,关键是理解每种方案的适用边界。

[!question] 如果消息体本身很大,用 Redis Set 存 MessageID 会不会有内存问题?怎么优化?

Redis Set 存的是 MessageID(通常是 36 字符的 UUID),不是消息体本身,所以内存占用可控。但如果消息量极大(每天数亿条),可以考虑以下优化:

  1. Bloom Filter:用极小的内存(每元素几个 bit)判断消息"可能存在"或"一定不存在"。存在误判(false positive),但不会漏判。适合作为第一层快速过滤。
  2. 分片 + 过期:按日期分 Key(如 mq:dedup:2026-05-24:{msgID}),过期后自动清理。
  3. 数据库归档:Redis 只保留近期去重数据,历史数据归档到数据库。去重窗口通常只需要几天到一周。

关联笔记