延迟、重试、死信与补偿:失败消息怎样停止自旋并最终收敛
把暂态失败、永久失败、毒消息和业务冲突分流,设计退避、重试预算、死信修复、定时触发与补偿状态机。
RocketMQ 官方分别定义 Send Retry 与 Consumption Retry,说明生产发送失败和消费执行失败属于不同恢复链。
先分类,再决定是否重试
网络闪断、连接池暂时耗尽和下游限流属于可能自愈的暂态失败;字段缺失、签名非法和未知事件版本属于永久失败;唯一约束冲突、余额不足等业务拒绝不是基础设施故障;代码在特定数据上稳定崩溃则是毒消息。把所有异常统一 requeue,会让永久失败占满消费者和日志。
分类器应输出 failureClass、retryable、nextAttemptAt、attempt、budgetDeadline 和诊断摘要。异常类名不能直接等于重试策略:同一个 TimeoutException 可能发生在请求写出前,也可能发生在下游已经提交后。后者必须先查询最终状态或使用幂等键,不能盲目重放副作用。
退避同时保护下游和恢复时间
指数退避把连续压力摊开,随机抖动避免大量消息同刻苏醒。重试还需要总预算:最大尝试次数、最大墙钟时间、单租户并发和全局在途数。只设置 attempts=100 而没有时间预算,会让一条消息在数天后仍突然执行;只设置短时间又会让计划内维护全部进入死信。
延迟队列适合分钟或小时级的近程触发,不应成为多年业务预约的唯一权威。长期预约要存入可查询、可修改、可分片扫描的业务表,临近执行窗口再投递消息。否则队列迁移、TTL、磁盘容量和消息格式演进都会绑住长期承诺。
死信队列不是垃圾桶
进入死信时必须保留原 eventId、来源、首次与最后失败时间、尝试历史、契约版本和脱敏诊断。运维动作至少包括查看、批量分类、修复后重放、跳过并记录理由、转为人工工单。直接把 DLQ 全量重放到主队列会重新制造事故,重放必须有速率限制、筛选条件和幂等验证。
补偿不是数据库回滚,而是新的业务动作。例如库存已释放后不能把历史事务撤销,只能发起重新预留;退款失败不能假装支付从未发生,只能进入待退款状态并持续对账。每个补偿动作本身也会失败和重复,因此需要独立 id、状态、截止时间与人工接管点。
用所有权转移图读懂可靠性
失败矩阵比“保证不丢”更可执行
工程选择的核心是把不可避免的不确定窗口转成可重复、可查询、可补偿的状态。不要用无限重试掩盖未知结果,也不要用提前确认换取表面低 lag。
重试拓扑要避免头阻塞和顺序幻觉
把失败消息立即放回原队列头部,会让同一条毒消息反复抢占消费者。更稳妥的拓扑把不同退避级别放入独立延迟层,到期后重新进入主处理链;每次转移都保留原 eventId 和 attempt。若业务要求同 key 严格顺序,失败消息离开主队列后,后继消息是否允许执行必须由状态机决定,不能一边绕过失败一边声称仍然有序。
重放工具应先做 dry-run,展示选择条件、预计条数、消息版本、目标路由和速率;执行时写审计批次 id,并允许暂停。修复 payload 会产生新消息内容时,应保留原消息哈希、修复规则和新 schema 版本,避免运维人员悄悄改写历史而无法复盘。
用两个 Java 状态模型固定不变量
下面的模型不连接真实 Broker。它们把本篇最容易混淆的状态、位置或所有权变化压缩成确定输出;随后再用 RabbitMQ、Kafka 或 RocketMQ 的集成环境验证协议、持久化、重平衡和故障时序。
javac --release 17 -Xlint:all -Werror examples/backend-development/message-event/delay-retry-dlq-compensation/RetryStateMachineDemo.java examples/backend-development/message-event/delay-retry-dlq-compensation/PoisonMessageDemo.java
java -cp examples/backend-development/message-event/delay-retry-dlq-compensation RetryStateMachineDemo
java -cp examples/backend-development/message-event/delay-retry-dlq-compensation PoisonMessageDemoattempts=[1, 2, 3] delaysMs=[1000, 2000, 4000] finalState=SUCCEEDED budgetExhausted=false
message=evt-bad classified=PERMANENT mainQueueRequeues=0 deadLettered=true repairAction=FIX_SCHEMA模型输出变化时,应定位是哪条业务不变量被修改,而不是只更新示例字符串。真实集成测试还要注入进程崩溃、响应丢失、Broker 切换和下游超时,因为这些窗口无法由纯内存模型模拟。
契约、权限与数据生命周期不能交给默认值
用分层测试证明协议与业务同时成立
把设计决定写成可以被证伪的承诺
一条消息链的设计记录不应只写“采用某某 MQ 保证可靠”。它需要明确:权威事实存在哪里,生产与发布之间是否有双写,什么时刻向调用方返回成功,Broker 接收证据是什么,复制与持久化条件是什么,消费者在何时确认,重复由哪一个唯一约束吸收,顺序精确到哪个 key,失败多久进入人工处理,以及业务能够容忍多长延迟。
指标、日志与容量要围绕状态迁移
容量模型至少计算平均/峰值每秒消息数、平均/p99 字节、保留时间、副本倍数、重试放大、批处理密度、消费者处理时长与下游并发。恢复测试要在稳定生产流量仍存在时注入积压,验证净排空速率,而不是暂停生产后测一个理想峰值。
上线与演练以可恢复为验收标准
故障演练依次注入生产响应丢失、Broker 节点切换、消费者提交后崩溃、下游超时、永久坏消息和热点 key。每次演练都回答三件事:业务不变量是否保持、重复或延迟是否有持久证据、系统是否在容量预算内自动或人工收敛。
