入门指南
后台任务失败之后:三次重试、整批重投与一条消息的四种去向
生产者拿到 200 只说明消息已入队。把默认 3 次重试、整批重投、死信队列与保留期拆成四件可核对的事,再给一份上线验收单。
入队成功不等于任务完成;失败后的去向要单独设计。

先看答案
- 适合谁
- 把发邮件、生成图片或同步 API 放进后台队列的一人公司
- 只看生产者有没有报错、没看过消费失败的人
- 已经配了重试,但说不清消息最后去了哪里的人
- 你会获得
- 先把失败后的四种去向画清楚,再决定重试几次。
- 发布时间
- 实质更新
- 发布
- AOPC 编辑部
60 秒结论
一分钟看懂这篇文章
入队成功不等于任务完成;失败后的去向要单独设计。
人负责
- 决定哪些任务可以异步
- 安排死信队列的负责人
AI 负责
- 核对重试与批处理语义
- 生成幂等键与验收记录
- 画链路
- 定终态
- 配重试
- 验失败
直接答案
「消息发出去了」和「这件事做成了」之间,隔着四段完全不同的机制。把它们混成一句话,就会得出「生产者返回 200,所以邮件已发」这种结论。
| 层 | 你实际看到的 | 能证明什么 | 不能证明什么 |
|---|---|---|---|
| ① 入队 | send() / sendBatch() 没有抛错 |
消息已被队列接收 | 会被投递、会被处理 |
| ② 投递 | 消费者被调用、收到 batch | 队列把消息交给了消费者 | 处理逻辑成功 |
| ③ 处理 | 函数正常返回,或显式 ack() |
这一条(或这一批)被确认 | 外部副作用只发生了一次 |
| ④ 失败去向 | 重试耗尽后进死信队列,或被删除 | 消息有了明确终态 | 有人会去看、会去补 |
这份手册以 Cloudflare Queues 的公开文档为样本,拆开四件可核对的事:重试几次、一批失败重投多少、失败消息去哪、消息能留多久。它不是「队列选型指南」,也不假设你用的是哪家服务——不同产品的默认值不同,但需要问的问题是一样的。
一、默认三次之后,消息有四种去向
Cloudflare Queues 的原文很直接:
When a message is failed to be delivered, the default behaviour is to retry delivery three times before marking the delivery as failed. You can set
max_retries(defaults to 3) when configuring your consumer, but in most cases we recommend leaving this as the default.
三个要点容易被读错:
- 3 是默认值,不是上限。 平台允许把
max_retries配到 100(Limits 页的「Message retries」)。文档同时建议大多数情况保留默认值。 - 重试是「投递失败」后的动作,不是「业务失败」的自动补偿。 你的消费者抛错、超时或没有确认,才会触发重投;如果消费者把错误吞掉并正常返回,队列会认为这条已经处理完。
- 重试耗尽不是终点,而是一个分叉。 原文:达到配置的最大重试次数后,消息会从队列中删除;如果配置了死信队列(DLQ),则写入 DLQ。
于是每条消息最终只会落在四个位置之一:
| 终态 | 触发条件 | 需要谁来处理 |
|---|---|---|
| 已确认 | 消费者成功返回或显式 ack() |
无人;但要有幂等与对账 |
| 重试中 | 投递失败,次数未达上限 | 消费者;可能带延迟 |
| 已删除 | 达到重试上限且没有配 DLQ | 没人——消息永久消失 |
| 进 DLQ | 达到重试上限且配了 DLQ | 你;DLQ 也要有消费者 |
还有一个常被忽略的删除路径:保留期。Limits 页写明消息保留期「Configurable up to 14 days」,而 Workers Free 计划是「non-configurable at 24 hours」;达到保留上限的消息会从队列删除。也就是说,即使重试次数还没用完,放太久的消息也会消失。
成本口径也要一起看:原文注明「Each retry counts as an additional read operation per Queues pricing」。重试不是免费的内部动作,每次重投都算一次读操作。把 max_retries 从 3 调到 100,改的不只是行为,还有账单。
二、一条失败,整批重投:批处理的真实代价
为了减少调用次数,消费者通常按批拿消息。Cloudflare 的批处理默认值是:
| 设置 | 默认 | 最小 | 最大 |
|---|---|---|---|
max_batch_size |
10 条 | 1 条 | 100 条 |
max_batch_timeout |
5 秒 | 0 秒 | 60 秒 |
两个限制「谁先到就按谁投递」:攒够 100 条就投,或者等满 5 秒就把当前已有的消息投出去。空队列时,push-based 消费者不会被调用;pull-based 消费者即使队列为空也会产生读操作。
真正需要设计的是失败语义。原文:
When a single message within a batch fails to be delivered, the entire batch is retried, unless you have explicitly acknowledged a message (or messages) within that batch. For example, if a batch of 10 messages is delivered, but the 8th message fails to be delivered, all 10 messages will be retried and thus redelivered to your consumer in full.
一批 10 条,第 8 条失败,10 条全部重投。如果前 7 条已经发过邮件、扣过库存、写过数据库,它们会被再执行一遍。这不是 bug,是「批」这个单位的默认语义。
规避方式不是「让失败的那条单独重试」,而是在处理每条消息后立刻显式确认:
Messages that are explicitly acknowledged will not be re-delivered, even if your queue consumer fails on a subsequent message and/or fails to return successfully when processing a batch.
确认的优先级也有明确规则:第一次方法调用生效。先 ack() 再 retry(),retry() 被忽略;先 retry() 再 ack(),ack() 被忽略;对单条消息的调用优先于对整批的 ackAll() / retryAll()。
由此得到一条可执行的顺序(编辑判断):先完成副作用,再 ack()。反过来「先确认再发邮件」,一旦发信失败,消息已经不会被重投,只能靠对账发现。确认是「我做完了」的收据,不是「我准备做」的许可。
三、至少一次:重复不是异常,是契约
即使你把 ack() 放对了位置,也不能假设每条消息只来一次。Delivery guarantees 页写的是:
Queues provides at least once delivery by default … messages are guaranteed to be delivered at least once, and in rare occasions, may be delivered more than once.
同一页还解释了为什么不默认做「恰好一次」:更强的保证需要更多确认与状态,会增加延迟、降低吞吐,「exactly once delivery incurs additional overheads in any messaging system」。
所以消费端必须能承受重复。官方给的方案是:
generating a unique ID when writing the message to the queue and using that as the primary key on database inserts and/or as an idempotency key to de-duplicate the message after processing.
注意「when writing the message to the queue」:幂等键要在生产端生成,随消息一起入队。如果等消费时才生成,两次投递会各自拿到一个新 ID,去重就失效了。这个推论不是原文的句子,但它是「写入时生成」的直接含义。
落地时有三层可选,强度依次上升:
- 数据库唯一键:把消息 ID 作为业务表的主键或唯一索引,重复插入直接失败。
- 处理前去重表:消费开始时写一条「已见」记录,写不进去就跳过。
- 把幂等键交给下游:原文举例,把该键作为上游邮件 API 或支付 API 的 ID,让对方替你拒绝重复,「without you having to carry additional state in your application」。
第三层最省事,但只适用于支持幂等键的下游。前两层要自己维护状态,还要考虑状态表本身失败时怎么办——这属于系统设计,不在本文取证范围内。
四、死信队列不是自动兜底,它有三个前提
DLQ 常被当成「失败消息的安全网」。原文的定义是:DLQ 是消息在消费者达到 max_retries 后仍然投递失败时被送往的地方;它和普通队列一样,可以独立生产和消费。
三个前提缺一不可:
前提一:你要显式配置它。 DLQ 写在消费者配置里(dead_letter_queue),不是平台默认开启。原文写得很清楚:
Without a DLQ configured, messages that reach the retry limit are deleted permanently.
前提二:它必须和源队列在同一 jurisdiction。 如果指定的队列名不存在,Cloudflare 会在与源队列相同的 jurisdiction 自动创建它。跨区放置不是「配一下就行」的选项。
前提三:DLQ 自己也要有消费者。 原文:
To process messages placed on your DLQ, you need to configure a consumer for that queue as you would with any other queue.
没有消费者的 DLQ 只是一个暂存区:消息在 DLQ 中无活跃消费者时,保留四天后被删除。所以「配了 DLQ」不等于「不会丢」,只等于「多了一个四天的缓冲窗口,前提是有人在这四天里处理它」。
一个可执行的检查顺序(编辑判断):先确认 DLQ 存在且与源队列同区,再确认它有消费者,最后确认消费者失败时有人收到告警。三者缺一,DLQ 只是把「立刻丢」换成了「四天后丢」。
五、重试节奏要自己写:没有内建的指数退避
延迟投递是有的:发送时可以用 delaySeconds,重试时也可以对单条或整批设置延迟,最长 24 小时。队列级别还能用 --delivery-delay-secs 设一个全局默认延迟;单条消息的 delaySeconds 优先于队列级设置,delaySeconds: 0 会忽略全局延迟、让消息进入下一批。
但「第 1 次等 1 分钟、第 2 次等 5 分钟、第 3 次等 30 分钟」这种退避曲线,文档没有作为内建能力提供。它给的是做法:
You can apply a backoff algorithm to increasingly delay messages based on the current number of attempts to deliver the message.
每条投递到消费者的消息都带一个 attempts 属性,记录已尝试次数;文档示例让读者自己写一个 calculateExponentialBackoff(attempts, baseDelaySeconds) 辅助函数,再在 retry() 时传入延迟。换句话说,退避是你写的代码,不是平台的默认行为。不写,就是按平台节奏重投。
另一个细节:用 retry() 或 retryAll() 重投,不会让启用了消费者并发的队列缩容。也就是说,持续失败不会自动把并发降下来;需要限流就得自己在消费端做。
六、上线前的一份验收单
下面十项按「从生产端到失败端」排序。前六项是机制核对,后四项是运营核对;具体数值是你的选择,不是厂商推荐。
| # | 检查项 | 通过标准 |
|---|---|---|
| 1 | 消息体大小 | 不超过 128 KB;内部元数据约 100 字节也计入 |
| 2 | 批量发送 | 单次 sendBatch 不超过 100 条或 256 KB |
| 3 | 幂等键 | 生产端生成唯一 ID,随消息入队,消费端用它去重 |
| 4 | 确认位置 | 副作用成功之后才 ack();批内逐条确认 |
| 5 | 重试上限 | 明确 max_retries 的值,并知道默认是 3、上限是 100 |
| 6 | 失败终态 | 配了 DLQ,且 DLQ 与源队列同区、有消费者 |
| 7 | 保留期 | 知道当前计划的上限(Free 固定 24 小时,付费可配到 14 天) |
| 8 | 处理时限 | 单次 consumer 调用墙钟上限 15 分钟,长任务拆批或改异步 |
| 9 | 吞吐 | 单队列 5,000 条/秒;超过时 send()/sendBatch() 抛 Too Many Requests |
| 10 | 观测 | 能看到重试次数分布、DLQ 深度、最老消息年龄、去重命中数 |
第 10 项最容易被省掉,但它决定了前九项是否真的在工作。只看「队列深度」不够:深度为 0 可能是全部成功,也可能是消息已被删除。至少要有 DLQ 深度和最老消息年龄两个指标,前者抓失败终态,后者抓「消费者停了但生产者还在写」。
七、四种常见误读
误读一:「生产者返回 200,任务就完成了。」
send() 成功只证明入队。投递、处理、副作用、失败去向都在后面,任何一段都可能断。
误读二:「重试 3 次,所以最多试 3 次就一定会成功。」 3 次是投递尝试的默认上限,不是成功保证。达到上限后,消息要么进 DLQ,要么被永久删除。
误读三:「配了 DLQ,消息就不会丢。」 DLQ 需要同区、需要消费者。没有活跃消费者的 DLQ,消息四天后仍会被删除;保留期到期也会删除。
误读四:「至少一次,约等于一次。」 「至少一次」明确允许重复。没有幂等键,重复投递就会变成重复发信、重复扣款、重复建单。
八、本周就能做的五件事
- 画一张四段图:入队、投递、处理、失败去向,每段写下「我怎么知道它发生了」。
- 找一条真实消息,追到终态:它是被确认、在重试、进了 DLQ,还是被删除?
- 检查
ack()的位置:是否在副作用之前?是否批内逐条确认? - 确认幂等键在生产端生成,并至少有一层数据库唯一约束兜底。
- 给 DLQ 配消费者和告警,然后手动投一条坏消息,看它是否真的走完这条路。
如果只能记住一件事:队列的可靠性不取决于重试次数,而取决于失败消息有没有一个有人负责的终点。 重试只是把失败延后,DLQ 只是把失败收拢;没有人看,两者都只是延迟删除。
边界说明:本文所有机制性陈述均来自 Cloudflare Queues 官方文档(2026-10-27 访问,四个页面均 HTTP 200),不写「保证恰好一次」「失败一定重试」「消息一定不丢」,也不写任何丢失率、延迟或成本数字。不同队列产品的默认重试次数、批大小、保留期与 DLQ 语义不同,本文的数值不可直接搬到其它产品;「先副作用后 ack」「四天窗口内处理 DLQ」「十项验收单」中的运营顺序属于编辑判断,已逐处标注。幂等键在消费端生成会失效,是由原文「when writing the message to the queue」推出的直接含义,非原文例句。
来源
以下页面均于 2026-10-27 逐条访问并验证为 HTTP 200;字节数为当日抓取的实际大小。
- Batching, Retries and Delays · Cloudflare Queues核查于 2026/10/27(200,175 111 B,Last updated 2026-04-21):默认重试 3 次、
max_retries可配、达到上限后删除或写入 DLQ;每次重试计一次读操作;单条失败导致整批重投的 10 条示例;显式ack()阻止重投与「第一次调用生效」的优先级;max_batch_size默认 10/最大 100、max_batch_timeout默认 5 秒/最大 60 秒;延迟最长 24 小时、队列级延迟与delaySeconds: 0的覆盖关系;attempts属性与自写指数退避;retry()/retryAll()不触发缩容。 - Delivery guarantees · Cloudflare Queues核查于 2026/10/27(200,94 368 B,Last updated 2026-04-21):默认至少一次投递、极少数情况可能重复;恰好一次需要额外确认与状态、增加延迟并降低吞吐;在生产端写入唯一 ID 并作为数据库主键或幂等键,或交给上游邮件/支付 API 去重。
- Limits · Cloudflare Queues核查于 2026/10/27(200,111 347 B,Last updated 2026-04-21):消息 128 KB、重试上限 100、批大小上限 100 条、
sendBatch100 条或 256 KB、批等待上限 60 秒、单队列 5,000 条/秒、保留期最长 14 天(Free 固定 24 小时)、backlog 25 GB、并发调用 250、consumer 墙钟 15 分钟、CPU 5 分钟、visibilityTimeout12 小时、延迟 24 小时;超过吞吐时send()/sendBatch()抛Too Many Requests。 - Dead Letter Queues · Cloudflare Queues核查于 2026/10/27(200,101 797 B,Last updated 2026-10-12):DLQ 在消费者配置中定义、与源队列同 jurisdiction、不存在则自动创建;未配 DLQ 时达到重试上限的消息被永久删除;DLQ 需要自己的消费者;无活跃消费者时消息保留四天后删除。