重试、死信、延迟消息与积压恢复
主线Kafka 3.7.1;RabbitMQ以3.13文档作语义对照。业务恢复引用通用幂等,本页才能回答失败消息放在哪里、何时再试、谁来收敛。消息恢复系统不能只列一个DLQ名字。
错误分类先于重试次数
参数缺失、未知schema、违反终态约束通常是永久性错误,立即重复执行没有价值;连接复位、瞬时限流可能暂态可恢复;支付超时属于结果未知,先查单而非重新扣款。分类依据应包含业务码、协议码、阶段和副作用,不能仅看Java异常类。限流也不等于应该立即重试,若每个消费者都重试,会把保护下游的429变成新的流量洪峰。
每次尝试要携带event_id、operation_id、原topic/partition/offset、首次失败时间、attempt、next_due、错误分类、schema版本与payload引用。attempt定义为已执行次数还是额外重试次数必须统一;首次失败时间跨topic转移应保留,不能每次转投把年龄清零。最终处理状态应包含已恢复、明确失败、等待人工,而不是只统计“进入DLQ成功”。
可重试不代表无限可试。设初次执行加最多三次重试,基准100ms、指数退避、上限2s;第k次等待可从[0,min(2s,100ms×2^k)]取随机数。总deadline还要覆盖每次处理和入队时间。若剩余预算不足以完成下一次有意义尝试,就停止并转入可恢复状态。随机抖动减少同步唤醒,但不创造下游容量。
Producer源码:客户端本来就有一层重试
Kafka 3.7.1 的Sender.completeBatch判断Produce响应:MESSAGE_TOO_LARGE且可拆批时拆分重发;一般错误进入canRetry;不可重试则失败或处理特殊序列情况。canRetry同时检查交付超时、尝试数、批次是否完成,以及事务管理器/RetriableException判断。下面为精简改写:
return !batch.hasReachedDeliveryTimeout(deliveryTimeout, now)
&& batch.attempts() < retries
&& !batch.isDone()
&& (transactionManager == null
? response.error.exception() instanceof RetriableException
: transactionManager.canRetry(response, batch));由此不能断言“retries很大就一定一直发”,delivery.timeout仍会截断;也不能认为业务层再包一层立即重试毫无成本。broker确认丢失可能已追加,应用重建producer重发可能超出原幂等身份保护。源码:Sender completeBatch/canRetry、producer参数。
消费者业务重试不是上述producer重试。Kafka日志没有一个通用的“把此条nack后延迟10秒再投递”协议API;业务应用可暂停分区、转retry topic、使用持久调度表或流处理框架。方案需明确offset何时可提交、重试意图是否已持久化,不能把本地sleep当持久重试系统。
原码:重试次数之外还有总投递期限
原码连续摘录(仅统一公共缩进,截取方法局部):Sender.java,3.7.1,L852–L859。
private boolean canRetry(ProducerBatch batch, ProduceResponse.PartitionResponse response, long now) {
return !batch.hasReachedDeliveryTimeout(accumulator.getDeliveryTimeoutMs(), now) &&
batch.attempts() < this.retries &&
!batch.isDone() &&
(transactionManager == null ?
response.error.exception() instanceof RetriableException :
transactionManager.canRetry(response, batch));
}必须同时满足未超过delivery timeout、attempts未达retries、批次未终结,并且错误被普通可重试异常规则或事务管理器允许。任意一项不满足都不重试;所以配置很大retries不代表无限恢复。应用层再发一批是新的尝试与计时范围,必须另控业务总期限及放大。
三条重试路径及其失败窗口
原分区原地重试保留读取顺序,但慢消息会阻塞该分区其他key。短暂重试可放在有限时间内;长等待应pause并继续poll维持组生命周期,且未完成任务仍受max.poll.interval与应用内存管理约束。把poll线程sleep几十分钟可能导致rebalance,从而重放并形成第二个在途处理者。
旁路retry topic提高主链吞吐,却让同key后续事件越过失败事件。原消费offset只有在转投意图持久成功后才可提交,否则会丢消息;先发retry成功再提交源offset存在重复转投窗口。纯Kafka路径可用事务把retry输出与源offset一起提交,消费者读committed;如果业务写数据库,仍需Outbox/Inbox。事务中止后不得把源offset单独提交。源码范围见TransactionCoordinator。
调度表适合due time、取消、人工认领与精确审计:先持久化任务,再由worker按due领取,带claim_version与租约;执行成功以版本条件推进状态,失败更新next_due。旧worker在租约后恢复仍可能执行,所以要幂等或资源fencing。调度器至少一次唤醒与业务一次效果是两层承诺,数据库扫描成功不能证明外部副作用成功。
延迟系统必须同时使用业务时间和执行预算。跨机器到期通常以持久化时间戳表达,单进程剩余预算用单调时钟计算;节点时钟偏差会改变何时触发,但不应突破资源版本不变量。系统重启后需扫描过期任务,限速恢复,不能一次把一小时积累的任务全部塞进worker。
持久转移先于源确认,依旧可能重复
| 路径 | 源offset推进条件 | 崩溃窗口 | 顺序与成本 |
|---|---|---|---|
| 原分区重试 | 业务已完成交付前缀 | 完成后未提交会重放 | 热key会阻塞整分区;有界pause并继续poll |
| retry topic | 目标已持久承担,或Kafka事务一并带源offset | 先输出后提交重复;先提交后输出遗漏 | 旁路让同key后续事件先到,必须版本守卫 |
| 调度任务表 | 任务与接收边界有明确原子/恢复协议 | 双资源非原子仍需对账/幂等 | 支持due/cancel/认领,增加扫描与状态管理 |
选择第一条不是拒绝恢复吞吐,选择第二条也不是自动获得可靠性。先声明消息允许交换还是严格前置,再算恢复容量;通用领取令牌与Outbox机制只回查权威正文。
RabbitMQ DLX、TTL 与环路
RabbitMQ拒绝且不requeue、过期、队列长度限制等条件可触发dead-letter,quorum队列还涉及delivery-limit。DLX是重新发布路径,目标exchange、路由和权限错误仍可能导致不可恢复结果;默认dead-letter路径并非对所有队列都提供同样的可靠转发确认。quorum的at-least-once dead lettering需要显式配置条件,不能把普通classic默认行为直接当成无损搬运。来源:3.13 DLX、quorum队列。
TTL配DLX可构造延迟层,但队头处理与每消息TTL差异可能影响实际到期投递,不应据此承诺任意精度的定时器。nack(requeue=true)在下游持续坏时可能立即循环重投;消息年龄、x-death和重试次数需要结合观察。不要通过改routing key循环进入原队列却每轮清空失败元数据,最终会把有限重试变成无限循环。
发布confirm、消费ack、转DLX成功是不同确认边界。ack原消息前要明确重试副本已由谁持久承担;prefetch限制未确认消息数,可防止消费者无限提前领取,但prefetch过大会让单个慢实例占住大量任务,过小则吞吐可能受RTT限制。调参应以未确认数、业务耗时和下游并发为输入。
死信是有负责人的业务状态
DLQ需要保留原因、身份、证据与恢复入口。永久schema错误先修转换器,再对指定版本小批回放;授权错误先恢复权限并验证,不能把凭据写入消息;资金UNKNOWN先查单,不允许“全部重试”。恢复者应记录工单/批次、操作人、筛选条件、限速、预期结果和停止阈值。
回放必须沿用原operation_id,另加replay_id作操作审计;不能换event_id来绕过去重。若原业务已达到终态,回放应得到稳定重复结果或冲突说明,而非强制覆盖。永久失败记录应有业务关闭或补偿,不要因“DLQ已经没有积压”就认为用户问题解决。
死信保留与原topic保留不同。原始payload可能含个人数据,应控制访问、脱敏和保留;payload引用需要确保恢复时仍可取到内容。进入DLQ后丢失schema版本或原key,会让修复脚本无法重建合法业务顺序。排障人员应能从一个event_id联查发送、处理、重试、业务结果及位点,而非手工猜测多条日志。
积压算例:净处理率决定恢复时间
令到达率λ、有效完成率μ,单位均为条/秒,积压B单位为条。忽略速率波动时dB/dt=λ−μ;μ大于λ才收敛,恢复时间T=B/(μ−λ)。输入B=1,200,000、λ=8,000、μ=10,000,净消化2,000,T=600秒;若故障重试吃掉30%处理能力,有效μ只有7,000,积压仍增长,不能继续承诺十分钟。
有效完成率指新业务效果最终完成,不能把失败重试次数计成出队吞吐。消息大小和处理成本不同,需观察字节、业务耗时、最老年龄和分区分布。一个热点分区积压百万条,其他分区空闲时增加同组消费者可能无效;数据库安全并发已饱和时再加worker还会延长锁等待。
例:12分区,每分区上限1,000条/秒,下游上限9,000,理论总能力不是12,000而至多9,000;λ8,000时净能力1,000,排空需1,200秒。若每条需要两次写库且重试率20%,应先折算每个新业务完成所需数据库操作,再算μ。把broker消费速率和数据库成功速率混作同一个数会严重低估恢复时间。
流量洪峰前先限定入口、缓冲和保留。日志保留按时间或字节,可能在追赶完成前删除落后记录;consumer lag不阻止Kafka删除旧段。必须比较最老消息年龄与保留剩余时间,必要时增保留/归档并验证磁盘余量。恢复时限速回放,让新业务、重试和补偿共享可明确分配的下游预算。
恢复窗口必须小于保留窗口
保留窗口要覆盖已经落后的消息年龄、预计追赶时间和安全余量。以“消息还保留十分钟”对比600秒理想清空,无法覆盖抖动、故障重试和恢复校验;到期删除不会等消费者追上。先核对最老未完成年龄、日志起始offset与备份材料,再决定增加保留、扩容还是重建。扩保留也需要磁盘预算;没有历史材料时恢复者应明确转快照/人工对账,不能只把offset reset到latest宣称无损恢复。
可运行实验与架构变式
下载完整模型,在仓库根执行:
python3 labs/middleware-senior/reliability_lab.py backlog --json
python3 labs/middleware-senior/reliability_lab.py backlog --case stale --json
python3 labs/middleware-senior/reliability_lab.py backlog --case deferred-repeat --json第一部分输入B=1,200,000、λ=8,000、μ依次10,000/7,000/9,000,输出净率2,000/−1,000/1,000和清空秒数600/null/1,200;null明确表示不能追平,不把除零或负数当恢复时间。它是恒定速率流体计算,不是压测。
第二部分真实使用临时SQLite文件,把对象version初值设10,按12、11、11、13输入,每次关闭连接后重开。12保存为DEFERRED,业务version仍10;11到达后在同事务应用11并补12;重复11不再产生效果;13推进。输出statuses为DEFERRED/APPLIED/DUPLICATE/APPLIED,effects按11/12/13顺序,终态13。后续15等待缺失14,到显式时间11超过due10后隔离并交给对账。没有真实睡眠、后台worker或网络,不把SQLite串行事务等同Kafka顺序保证。
负控按到达12、11无条件应用,实际效果顺序为[12,11]、终态11,证明旁路消费可能倒退。模型中的DEFERRED状态跨连接保留;它没有模拟任意payload/schema冲突,也没有证明任意崩溃点。业务Inbox成功状态不能代替DEFERRED,缺口恢复必须有持久内容与执行责任。stale在初始version10前先投未见过的9,输出STALE而不回退;deferred-repeat在11前重复投12,两次均DEFERRED,不把暂存报告成效果成功。隔离变更先提交再重开查询。本轮已运行三入口与公开断言。
隔离真实实验可准备一个分区、固定1KB消息、以2,000条/秒生产、消费者人为限速1,000条/秒持续60秒。预期积压约60,000条,但需要实测生产与有效完成率确认。随后消费者限到3,000条/秒,若新流量仍2,000,预期约60秒排空。不要伪造精确值,必须计入启动、批量、计时与下游差异。再加入一条无法反序列化消息,测试它是否阻塞全分区、是否在持久转移后才提交源进度。
条件改成严格订单顺序时,可选原分区阻塞、按key隔离或版本缺口缓冲,牺牲吞吐或增加状态复杂度;条件改成通知类可交换任务,可优先旁路重试维持主流。跨机房灾难回放时应有总replay预算,不允许每个站点独立全速重试。架构验收产物是恢复时间计算、持久转移窗口、顺序策略、DLQ认领与停止条件,不是“有重试三次”的配置截图。
闭卷验收
本页 7 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。