Skip to content
可靠性与幂等消费

可靠性与幂等消费

应用层可靠性的两大支柱:断线重连模式(指数退避重连管理器、带拓扑恢复的健壮消费者/发布者),以及幂等消费与消息去重(重复消息的成因、MessageID 设计、Redis / 数据库唯一约束两种去重方案)。


断线重连模式

基础重连策略

rabbitmq-amqp-go-client 基于 Azure AMQP 客户端,内置了一定程度的连接恢复功能。但建议在应用层实现额外的重连逻辑。

package main

import (
    "context"
    "log"
    "time"
    rmq "github.com/rabbitmq/rabbitmq-amqp-go-client/pkg/rabbitmqamqp"
)

const (
    brokerURI       = "amqp://guest:guest@localhost:5672/"
    reconnectDelay  = 2 * time.Second
    maxReconnectDelay = 30 * time.Second
)

// ReconnectManager 管理自动重连
type ReconnectManager struct {
    uri            string
    env            *rmq.Environment
    conn           *rmq.Connection
    currentDelay   time.Duration
    ctx            context.Context
    cancel         context.CancelFunc
}

func NewReconnectManager(uri string) *ReconnectManager {
    ctx, cancel := context.WithCancel(context.Background())
    return &ReconnectManager{
        uri:          uri,
        currentDelay: reconnectDelay,
        ctx:          ctx,
        cancel:       cancel,
    }
}

func (r *ReconnectManager) Connect() (*rmq.Connection, error) {
    r.env = rmq.NewEnvironment(r.uri, nil)

    for {
        select {
        case <-r.ctx.Done():
            return nil, r.ctx.Err()
        default:
        }

        conn, err := r.env.NewConnection(r.ctx)
        if err == nil {
            log.Println("✓ 成功连接到 RabbitMQ")
            r.conn = conn
            r.currentDelay = reconnectDelay  // 重置延迟
            return conn, nil
        }

        log.Printf("✗ 连接失败: %v,%v 后重试...", err, r.currentDelay)
        time.Sleep(r.currentDelay)

        // 指数退避
        r.currentDelay *= 2
        if r.currentDelay > maxReconnectDelay {
            r.currentDelay = maxReconnectDelay
        }
    }
}

func (r *ReconnectManager) Close() {
    r.cancel()
    if r.conn != nil {
        r.conn.Close(context.Background())
    }
    if r.env != nil {
        r.env.CloseConnections(context.Background())
    }
}

带恢复拓扑的健壮消费者

type ReliableConsumer struct {
    uri       string
    queueName string
    rm        *ReconnectManager
    handler   func([]byte) error
}

func (rc *ReliableConsumer) Run(ctx context.Context) error {
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }

        // 连接
        rc.rm = NewReconnectManager(rc.uri)
        conn, err := rc.rm.Connect()
        if err != nil {
            return err
        }

        // 确保队列存在(幂等操作)
        _, err = conn.Management().DeclareQueue(ctx,
            &rmq.QuorumQueueSpecification{Name: rc.queueName})
        if err != nil {
            log.Printf("声明队列失败: %v", err)
            rc.rm.Close()
            time.Sleep(rc.rm.currentDelay)
            continue
        }

        // 创建消费者
        consumer, err := conn.NewConsumer(ctx, rc.queueName,
            &rmq.ConsumerOptions{InitialCredits: 1})
        if err != nil {
            log.Printf("创建消费者失败: %v", err)
            rc.rm.Close()
            time.Sleep(rc.rm.currentDelay)
            continue
        }

        // 消费循环
        err = rc.consumeLoop(ctx, consumer)
        consumer.Close(context.Background())
        rc.rm.Close()

        if err != nil {
            log.Printf("消费循环中断: %v,准备重连...", err)
            time.Sleep(rc.rm.currentDelay)
        }
    }
}

func (rc *ReliableConsumer) consumeLoop(ctx context.Context,
    consumer *rmq.Consumer) error {
    for {
        delivery, err := consumer.Receive(ctx)
        if err != nil {
            return err  // 连接断开,触发外层重连
        }

        msg := delivery.Message()
        body := msg.Data()[0]

        if err := rc.handler(body); err != nil {
            delivery.Discard(ctx) // 处理失败则丢弃
        } else {
            delivery.Accept(ctx) // 处理成功则确认
        }
    }
}

健壮发布者

type ReliablePublisher struct {
    uri       string
    queueName string
    rm        *ReconnectManager
}

func (rp *ReliablePublisher) Publish(ctx context.Context, body []byte) error {
    // 如果未连接,建立连接
    if rp.rm == nil || rp.rm.conn == nil {
        rp.rm = NewReconnectManager(rp.uri)
        conn, err := rp.rm.Connect()
        if err != nil {
            return fmt.Errorf("连接失败: %w", err)
        }

        _, err = conn.Management().DeclareQueue(ctx,
            &rmq.QuorumQueueSpecification{Name: rp.queueName})
        if err != nil {
            return fmt.Errorf("声明队列失败: %w", err)
        }
    }

    publisher, err := rp.rm.conn.NewPublisher(ctx,
        &rmq.QueueAddress{Queue: rp.queueName}, nil)
    if err != nil {
        rp.rm.Close()
        rp.rm = nil
        return fmt.Errorf("创建发布者失败: %w", err)
    }
    defer publisher.Close(context.Background())

    msg := rmq.NewMessage(body)
    result, err := publisher.Publish(ctx, msg)
    if err != nil {
        rp.rm.Close()
        rp.rm = nil
        return fmt.Errorf("发布失败: %w", err)
    }

    // 验证发布结果
    switch result.Outcome.(type) {
    case *rmq.StateAccepted:
        return nil
    default:
        return fmt.Errorf("消息未被确认: %v", result.Outcome)
    }
}

幂等消费与消息去重

问题场景:什么情况下消息会重复?

RabbitMQ 保证的是至少一次投递(At-Least-Once),这意味着同一条消息可能被消费者处理多次。在动手写代码之前,先搞清楚"重复"到底是怎么发生的:

场景一:ACK 丢失(最常见)

时间线:
  1. 消费者从队列拉取消息 offset=42,内容="扣款100元"
  2. 消费者执行扣款,数据库操作成功 ✓
  3. 消费者调用 Accept() 通知 Broker,但此时网络抖动,Accept 包丢了 ✗
  4. Broker 超时未收到确认,认为消息未被处理
  5. Broker 将该消息重新投递给消费者(或同组的另一个消费者)
  6. 消费者再次执行扣款 → 同一笔钱被扣了两次!

这就是幂等问题的核心:业务操作已经完成了,但 Broker 不知道

场景二:消费者宕机

时间线:
  1. 消费者拉取消息,开始处理(耗时操作:调用第三方支付网关)
  2. 处理到一半,消费者进程 crash(OOM、被 kill -9、机器断电)
  3. Broker 检测到连接断开,认为该消息未被处理
  4. Broker 将该消息重新分配给另一个消费者
  5. 另一个消费者从头处理 → 如果支付网关实际上第一次已经扣款成功了,这里就重复了

场景三:生产者重试

时间线:
  1. 生产者发送消息 "订单 ORD-001 支付成功"
  2. 发送超时(网络慢),生产者不知道 Broker 是否收到
  3. 生产者重试,又发了一遍同一条消息
  4. 队列里出现了两条内容相同的消息

什么是幂等?

幂等(Idempotent) 在消息队列场景下的通俗定义:

同一条消息(或同一个业务操作),无论消费 1 次还是 100 次,最终产生的业务效果完全一致。

类比生活场景:

  • 天然幂等:把灯的开关拨到"开"的位置——不管拨多少次,灯的状态始终是"开",不会变成"超级开"。
  • 天然非幂等:ATM 取钱——按一次取 100,按两次取 200,效果累加。

我们的目标是:把"扣款 100 元"这种天然非幂等的操作,通过技术手段变成幂等的

核心思路:给每条消息一个唯一"身份证号"

要解决重复消费,关键是为每条消息分配一个全局唯一的标识符。消费者在处理之前先查"这个标识符我处理过没有?":

  • 没处理过 → 执行业务 → 记录这个标识符 → 确认消息
  • 已处理过 → 直接跳过 → 确认消息(不重复执行)

现在问题变成了两个子问题:

  1. 标识符从哪来? —— 生产者生成还是消费者自己算?
  2. “已处理"的记录存在哪? —— Redis?数据库?内存?

方案一:生产者生成 MessageID + Redis 去重

这是最推荐的方案——由生产者分配一个业务上有意义的唯一 ID。

// ═══════════════════════════════════════════════════
// 生产者侧:发送消息时带上唯一的 MessageID
// ═══════════════════════════════════════════════════

// 示例:订单支付成功通知
orderID := "ORD-2026-0706-001"
eventType := "payment.confirmed"

msg := rmq.NewMessage([]byte(`{"order_id":"ORD-2026-0706-001","amount":100}`))

// MessageID 由"业务实体ID + 事件类型"组成
// 这样即使消息被生产者重发了多次,它们拥有相同的 MessageID
msg.SetMessageID(fmt.Sprintf("%s:%s", orderID, eventType))
// 结果: "ORD-2026-0706-001:payment.confirmed"

publisher.Publish(ctx, msg)
// ═══════════════════════════════════════════════════
// 消费者侧:处理前检查 MessageID 是否已处理过
// ═══════════════════════════════════════════════════

type IdempotentConsumer struct {
    redis  *redis.Client
    // 实际业务逻辑
    processFunc func(msg *rmq.Message) error
}

func (c *IdempotentConsumer) Handle(ctx context.Context, delivery *rmq.Delivery) error {
    msg := delivery.Message()
    messageID := msg.MessageID()

    // ──── 第 1 步:检查是否已处理 ────
    // Redis SET NX 的含义: Set if Not eXists
    // 如果 key 不存在 → 设置成功,返回 true(首次处理)
    // 如果 key 已存在 → 设置失败,返回 false(已处理过,跳过)
    redisKey := fmt.Sprintf("msg:processed:%s", messageID)
    isFirstTime, err := c.redis.SetNX(ctx, redisKey, "done", 24*time.Hour).Result()
    if err != nil {
        // Redis 不可用时怎么办?这是关键的设计决策:
        // 选择 A: 返回错误 → 消息留在队列等待重试 → 安全但可能阻塞
        // 选择 B: 继续处理 → 可能重复但不会丢消息 → 看业务能接受哪种
        log.Printf("⚠️ Redis 不可用,降级处理(允许可能重复): %v", err)
        // 这里选择 B:业务可容忍极小概率的重复(比如日志收集)
        isFirstTime = true
    }

    if !isFirstTime {
        // 这个 MessageID 在 Redis 中已存在 → 消息肯定被处理过了
        log.Printf("⏭ 跳过重复消息: %s", messageID)
        return delivery.Accept(ctx) // 直接确认,不重复执行业务
    }

    // ──── 第 2 步:执行业务逻辑 ────
    log.Printf("📥 首次处理消息: %s", messageID)
    if err := c.processFunc(msg); err != nil {
        // 业务处理失败 → 清除 Redis 标记,允许下次重试
        c.redis.Del(ctx, redisKey)
        log.Printf("❌ 处理失败,已清除标记允许重试: %v", err)
        return err
    }

    // ──── 第 3 步:确认消息 ────
    // 此时 Redis 中已有标记 + 业务处理成功
    // 即使 Broker 没收到 Accept 导致重复投递,下次消费时会命中 isFirstTime=false 的分支跳过去
    log.Printf("✅ 处理成功并确认: %s", messageID)
    return delivery.Accept(ctx)
}

为什么 Redis + 消息确认之间不会出问题?

这是很多人困惑的地方。看看 Redis 标记和 Accept() 的先后顺序和它的影响:

情况一(当前代码):先标记 Redis → 再 Accept
  Redis 标记成功 + Accept 丢了 → 消息重复投递 → 消费者检查 Redis → 已标记 → 跳过 ✓
  Redis 标记成功 + Accept 成功 → 正常完成 ✓

情况二(如果反过来):先 Accept → 再标记 Redis
  Accept 成功 + Redis 标记失败 → 消息已从队列删除,但标记丢了 → 永远丢失消息 ✗
  Accept 失败 + Redis 标记成功 → 消息重新投递 → 已标记 → 跳过 → 消息实际没被正确处理 ✗

结论:先标记、再处理业务、最后 Accept,是安全的顺序。 Redis 充当了"消息被处理过"的铁证,即使后续步骤失败,也不会导致重复的业务效果。

方案二:数据库唯一约束(无需引入 Redis)

如果你的业务本身就要写数据库,那可以利用数据库的唯一索引天然实现去重,不需要额外的 Redis:

type OrderPaymentHandler struct {
    db *sql.DB
}

func (h *OrderPaymentHandler) Handle(ctx context.Context, delivery *rmq.Delivery) error {
    msg := delivery.Message()
    body := msg.Data()[0]

    var event struct {
        OrderID string `json:"order_id"`
        Amount  int    `json:"amount"`
    }
    json.Unmarshal(body, &event)

    // 业务操作 + 去重 在同一个数据库事务内完成
    tx, _ := h.db.BeginTx(ctx, nil)
    defer tx.Rollback()

    // 尝试插入一条"支付记录"
    // payment_records 表有 UNIQUE(order_id) 约束
    _, err := tx.ExecContext(ctx,
        `INSERT INTO payment_records (order_id, amount, processed_at)
         VALUES (?, ?, NOW())`,
        event.OrderID, event.Amount)

    if err != nil {
        // 判断是否是唯一约束冲突(即之前已处理过)
        if isDuplicateKeyError(err) {
            // 已处理过,事务回滚,消息确认
            log.Printf("⏭ 订单 %s 已支付过,跳过", event.OrderID)
            return delivery.Accept(ctx)
        }
        // 其他数据库错误,需要重试
        return err
    }

    // 插入成功 = 首次处理,提交事务
    tx.Commit()

    log.Printf("✅ 订单 %s 支付成功, 金额 %d", event.OrderID, event.Amount)
    return delivery.Accept(ctx)
}

// 判断 MySQL 唯一键冲突
func isDuplicateKeyError(err error) bool {
    if err == nil {
        return false
    }
    // MySQL: Error 1062 (Duplicate entry)
    return strings.Contains(err.Error(), "Duplicate entry")
}

这种方式的好处

  • 不需要额外引入 Redis,减少架构复杂度
  • 去重和业务操作在同一个事务内,ACID 保证原子性
  • 即使消息重复投递,数据库唯一约束天然拦截

MessageID 的设计原则

原则 说明 好例子 坏例子
全局唯一 不同消息的 ID 绝不重复 订单号:事件类型 纯随机 UUID(无法按业务追踪)
业务相关 ID 有业务含义,方便排查 ORD-001:paid 自增数字(集群下不唯一)
确定性 同一业务事件多次发送,ID 不变 基于业务键算出来的值 每次 uuid.New()(每次不同)
可读性 出问题能快速定位 user:123:email_changed 纯 hex 字符串

推荐的 MessageID 生成模式

// 模式 1:业务实体 + 事件类型
msg.SetMessageID(fmt.Sprintf("order:%s:created", orderID))

// 模式 2:业务实体 + 事件类型 + 时间戳(用于需要区分多次同类事件)
msg.SetMessageID(fmt.Sprintf("order:%s:status_change:%d", orderID, time.Now().UnixMilli()))

// 模式 3:业务实体 + 幂等键(你控制唯一性)
msg.SetMessageID(fmt.Sprintf("payment:%s", paymentTransactionID))

生产者端去重

除了消费端幂等,还可以在生产端减少重复发送。给消息设置 MessageID 后,消费者能识别并跳过,所以生产者端去重是可选的辅助手段,消费端幂等才是底线保障

func publishWithRetry(ctx context.Context, publisher *rmq.Publisher,
    orderID string, body []byte) error {

    msg := rmq.NewMessage(body)
    // MessageID 使用业务唯一键,确保重试发送的是"同一条消息"
    msg.SetMessageID("order:" + orderID + ":created")

    // 发送(带重试)
    const maxRetries = 3
    for i := 0; i < maxRetries; i++ {
        result, err := publisher.Publish(ctx, msg)
        if err == nil {
            switch result.Outcome.(type) {
            case *rmq.StateAccepted:
                return nil // 成功
            }
        }

        if i < maxRetries-1 {
            backoff := time.Duration(i+1) * 100 * time.Millisecond
            log.Printf("发布失败,%d/%d 次重试,等待 %v: %v",
                i+1, maxRetries, backoff, err)
            time.Sleep(backoff)
        }
    }
    return fmt.Errorf("发布失败,已重试 %d 次", maxRetries)
}

总结:幂等设计的四个层次

第 0 层(不做任何处理):
  → 消息重复 = 业务数据出错
  → ❌ 不可接受

第 1 层(仅消费端 MessageID + 内存 map 去重):
  → 同进程内不去重,重启后丢失记录
  → ⚠️ 仅适合开发调试

第 2 层(消费端 MessageID + Redis/Database 去重):         ← 本章重点
  → 分布式去重,重启不丢失
  → ✅ 适合绝大多数生产场景

第 3 层(生产端去重 + 消费端幂等双重保障):
  → 最大程度的防护
  → ✅ 适合金融/支付等对数据准确性要求极高的场景

一句话总结:给你的每条消息一个唯一身份证(MessageID),消费前先看"这个身份证的人我是不是已经服务过了?"——是就跳过,不是就服务并登记。这是消息队列编程中必须掌握的核心技能。