可靠性与幂等消费
应用层可靠性的两大支柱:断线重连模式(指数退避重连管理器、带拓扑恢复的健壮消费者/发布者),以及幂等消费与消息去重(重复消息的成因、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 元"这种天然非幂等的操作,通过技术手段变成幂等的。
核心思路:给每条消息一个唯一"身份证号"
要解决重复消费,关键是为每条消息分配一个全局唯一的标识符。消费者在处理之前先查"这个标识符我处理过没有?":
- 没处理过 → 执行业务 → 记录这个标识符 → 确认消息
- 已处理过 → 直接跳过 → 确认消息(不重复执行)
现在问题变成了两个子问题:
- 标识符从哪来? —— 生产者生成还是消费者自己算?
- “已处理"的记录存在哪? —— 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),消费前先看"这个身份证的人我是不是已经服务过了?"——是就跳过,不是就服务并登记。这是消息队列编程中必须掌握的核心技能。