MESSAGE QUEUE · 消费治理专题

MQ 消费幂等、重试与死信:重复消息怎样只产生一次结果

MQ(Message Queue,消息队列)是在生产者和消费者之间暂存、转发消息的系统。幂等是同一个业务操作执行一次或重复执行多次,最终业务结果都相同;重试是暂时失败后再次尝试;死信是被拒绝、过期或超过投递限制后转入专门出口的消息。三者共同把“至少一次交付”变成可运营的业务流程。

RabbitMQ 4.x 示例重点:去重原子性 · 退避 · 毒消息前置:可靠交付
一、消息为什么会重复:成功与确认之间存在窗口

Broker(负责保存和转发消息的服务端)投递消息后,需要消费者返回 acknowledgement(完成确认,简称 ack)。如果消费者把积分写入数据库后、尚未来得及 ack 就崩溃,Broker 只知道“没有收到完成确认”,于是重新投递。第一次业务其实已经成功,第二次投递仍是可靠系统的正常行为。

messageId=88 的两次投递:消息重复,业务结果不重复 投递 #1收到 msg-88本地事务积分 +10,记录 88ack 前崩溃Broker 不知道成功投递 #2仍是 msg-88 唯一索引查询:88 已存在不再增加积分 安全返回成功并 ack最终积分只增加一次 状态可以从数据库数出来processed_messages:1 行(message_id=88)points_ledger:1 行(source=88, delta=10)第二次新增行数:0消息投递次数:2;业务生效次数:1
最应记住:幂等不是阻止 Broker 重投,而是让同一业务标识再次到达时不重复改变结果。常见误读是用进程内内存 Set 去重,却忽略重启和多实例。
二、四种幂等手段:按业务不变量选择
手段适合关键点代价
数据库唯一约束消息 ID、订单号唯一插入重复时由最终存储拒绝要保留去重记录并清理
幂等消费表通用事件消费消费记录与业务修改同一事务表增长、跨库困难
业务状态机订单 CREATED→PAID→SHIPPED只允许合法状态迁移要设计乱序与终态
条件更新/版本号余额版本、库存状态WHERE 中携带期望状态冲突要重读或丢弃
业务键优先:同一次支付重试可能产生不同“投递 ID”,但支付单号相同。幂等键必须代表业务上的同一动作,不能盲目使用每次网络请求新生成的随机 ID。
三、去重与业务修改必须在同一原子边界

错误:先记已处理

写去重表成功后,积分更新失败。重试看到“已处理”直接跳过,业务永久缺失。

错误:先改业务

积分更新成功后,去重表写入失败。重试再次增加积分。

BEGIN;
INSERT INTO processed_messages(consumer, message_id)
VALUES ('points', 'msg-88'); -- UNIQUE(consumer, message_id)

INSERT INTO points_ledger(user_id, source_id, delta)
VALUES (7, 'msg-88', 10);    -- 业务变化
COMMIT;
-- 唯一键冲突表示此前事务已经整体成功,可安全 ack。
跨库边界:去重表和业务表不在同一个事务存储时,不能假装两次写入原子。可把业务设计为天然幂等、使用同一落库点、事务消息,或接受补偿与对账。
四、重试分类:暂时失败、永久失败和结果未知
错误例子动作
暂时失败数据库瞬时超时、下游限流指数退避加随机抖动,有上限
永久失败字段缺失、业务规则拒绝不要原队列无限重试,进入死信并告警
结果未知调用下游超时,不知道是否成功先按幂等键查询状态,再决定重试

指数退避表示每次等待逐步变长,例如 1 秒、2 秒、4 秒、8 秒;随机抖动是在此基础上增加随机量,避免大量消费者同一时刻再次冲击下游。

下一次等待 ≈ min(上限, 基础时间 × 2重试次数) + 随机抖动
重试风暴:立即 requeue 会让同一毒消息在队头高速循环,占满 CPU 和网络。延迟队列、递增等待、次数上限和并发隔离必须一起设计。
五、死信队列:不是垃圾桶,而是人工与自动修复入口

DLQ(Dead Letter Queue,死信队列)是保存当前正常消费者无法继续处理消息的专用队列。TTL(Time To Live,存活时间)规定消息还能在队列保留多久;以 RabbitMQ 为例,消息被拒绝且不重新入队、TTL 到期、队列超长或多数副本队列超过投递限制时,都可能进入死信流程。

毒消息完整生命周期:每个失败都有次数和出口 尝试 1失败 → 等 1s 尝试 2失败 → 等 2s 尝试 3仍失败 → 达上限 DLQ保存原因与载荷 修复台检查失败原因、次数、业务状态代码版本与原始 messageId 可恢复:修数据/发版后重新投递沿用原业务幂等键,避免重复结果 不可恢复归档、告警、人工补偿 任何重新投递都必须有速率限制,不能把整批死信一次倒回主队列。
最应记住:死信必须保留“为什么死、试过几次、如何恢复”,并有重新投递与终止两条出口。常见误读是把 DLQ 当成无限堆积、无人查看的垃圾桶。
六、乱序与并发:幂等不代表顺序正确

订单事件按 CREATED、PAID、CANCELED 产生,却可能以 PAID、CREATED、CANCELED 到达。每条消息单独不重复,仍可能把状态倒退。

方案适合注意
同一业务键固定分区/队列要求每个订单内部有序单个热点键可能限制吞吐
状态机条件迁移有明确合法状态拒绝 PAID→CREATED
业务版本号状态更新可比较新旧只接受 version 更大的事件
缓冲等待缺失事件允许短暂等待需要超时与补偿出口
区分两问:幂等回答“同一个动作再来一次怎么办”;顺序控制回答“不同动作以错误顺序到来怎么办”。
七、消费者伪代码:事务、确认和错误分类放在哪里
func Consume(msg Message) {
    err := db.WithTransaction(func(tx Tx) error {
        if tx.Processed("points", msg.BusinessID) { return nil }
        if err := tx.ApplyPoints(msg); err != nil { return err }
        return tx.MarkProcessed("points", msg.BusinessID)
    })

    switch classify(err) {
    case Success:
        msg.Ack()
    case Temporary:
        msg.RetryWithBackoff()
    case Permanent:
        msg.RejectWithoutRequeue() // 由策略送入 DLQ
    }
}
确认线程边界:不同客户端对 ack 所属 channel/session 有限制;以 RabbitMQ 为例,投递必须在接收它的 channel 上确认。并发处理时要遵守客户端库的线程安全规则。
八、坑点、面试话术与应用场景

高频坑点

  • 用 Redis 临时键做唯一去重,键过期后重复又生效。
  • 去重记录和业务修改分开提交。
  • 任何异常都立即 requeue。
  • 死信不告警、不保留原业务键和失败原因。
  • 批量重放死信没有限速。
  • 只做幂等,不处理版本倒退和乱序。
90 秒回答:可靠 MQ 会因确认丢失和消费者崩溃产生重复,所以幂等键要代表同一业务动作,并让去重记录与业务更新位于同一事务。暂时错误使用有上限的指数退避加抖动,永久错误不要无限重试,而要进入带原因、次数和原始标识的死信队列。修复后重放仍沿用原幂等键并限速。幂等只解决重复,乱序还要靠同键分区、状态机或版本号。

应用场景

积分、通知、订单状态同步、Webhook 等至少一次投递流程需要消费幂等。天然覆盖写入和可安全替换的结果可用业务键直接幂等;累加、扣款等非幂等动作应使用账本、唯一约束或条件状态迁移。

资料依据