队列处理规格:Exactly-Once 与 At-Least-Once 的取舍

队列处理规格:Exactly-Once 与 At-Least-Once 的取舍
Spec Coding 编辑部 · Spec-First 工程实践内容

只要团队里有人说出"exactly-once 队列"这几个字,我就会把话题叫停。这是一句营销话术,不是投递保证,按它写出来的规格一定会埋下那种凌晨三点才会炸的 Bug。我的做法是,在规格里强制点名真正的保证——at-least-once 加消费者侧去重——并且把幂等、死信和重放规则写死,以此决定消费者到底是不是靠谱的。

发布于 2026-03-01 · ✓ 已更新 2026-04-29 · 阅读约 6 分钟 · 作者:Spec Coding 编辑部 · 审校:编辑与事实核查政策

内容整理说明

复查日期:2026-04-29。本文作为定向参考保留,主要指导已并入 API 契约 Hub。它不再出现在 sitemap、RSS 和站内搜索推荐中。

"exactly-once"是谎话,规格应该怎么写

那些号称 exactly-once 的 broker(Kafka 事务、SQS FIFO、带发布确认和消费者 ack 的 RabbitMQ),在可恢复场景下依然会把同一条消息投递两次。消费者在副作用已经发生、ack 还没发出时崩掉;broker 在一批消息消费到一半时发生 rebalance;网络分区重放一次还在路上的 commit。所谓的"once"只管 broker 自己的账本,不管下游可观测到的效果。因此我写每一份队列规格时,都会在靠前的位置留下一句话:投递为 at-least-once;exactly-once 行为由消费者通过幂等键自行实现。这一句把所有歧义掐死,也把消费者作者欠契约什么讲清楚了。

哪种语义值得花钱买

我按流来选语义,而不是按系统来选,因为不同 payload 的成本曲线完全不一样。

幂等消费者设计:去重表、自然键、状态机守卫

我的默认做法是一张以消息自然标识为 key 的去重表,和副作用在同一个数据库事务里写入。以支付队列为例,幂等键用的是上游的支付意图 ID,而不是 broker 的 message ID——因为生产者重试之后,broker 很可能把同一个意图用一个新的 message ID 再发一次。消费者的第一个动作就是执行 INSERT INTO processed_messages (key, processed_at),并带上唯一约束;如果插入冲突,就说明这是一条重复消息,消费者直接 ack,不碰副作用。

当去重表成本太高时,我改用状态机守卫。比如订单从 pending 变成 shipped,就是一次条件更新:UPDATE orders SET status='shipped' WHERE id=? AND status='pending'。影响行数为 0 就说明已经转过一次了,消费者把它当作一次成功的 no-op 处理。这种做法只有在副作用能够干净地映射到一次状态转换时才成立。

顺序保证与什么会把它打破

大多数 broker 提供的是分区内或按 key 的顺序,而不是全局顺序。如果规格里写了"按顺序处理事件",我一定会追问一句:按哪个 key 排序。对账户事件来说,分区 key 是 user_id;不同用户的事件可以交错,但同一个用户的事件必须严格有序。这也是在规模化场景下唯一能活下来的顺序保证。

实践中会破坏顺序的几种做法:同一个分区上跑多个消费者线程并行消费;一次重试在更靠后的消息已经被处理之后才被重新提交;消费者侧做批处理时乱序 commit。这几条我都会在规格里单独点名,作为禁用模式写死。

死信规则:重试、DLQ 还是直接丢

规格必须对每一类失败回答三个问题:消费者要不要重试,要不要进 DLQ,还是直接丢掉。我的经验法则是:

DLQ 不是坟场。规格里要点名值班轮班表、告警阈值(我一般从 15 分钟 10 条起步),以及 DLQ 分诊的显式责任人。没有分诊责任人的 DLQ 早晚会悄悄变成"丢弃队列"。

支付队列的完整例子

下面是我最近给一个支付捕获消费者写的契约。生产者是订单服务,消费者调用支付网关并把结果写到账本里。

- Given an order-captured event with payment_intent_id=pi_abc
  When the consumer receives it for the first time
  Then it inserts pi_abc into processed_payments with status='in_flight',
    calls the gateway, writes the capture result to the ledger,
    updates processed_payments.status, and acks

- Given the same event redelivered after a consumer crash
  When the consumer attempts to insert pi_abc
  Then the unique constraint rejects the insert,
    the consumer reads the existing row,
    and if status='in_flight' it reconciles with the gateway before acking

- Given a gateway 503 response
  When the consumer has retried fewer than 6 times
  Then it nacks the message for redelivery with exponential backoff

- Given a gateway response of "card_declined"
  When the consumer receives it
  Then it writes a declined record to the ledger, acks the message,
    and does not DLQ (declines are a business outcome, not a failure)

与网关对账那一步是团队最容易漏掉的。少了这一步,一个在捕获中途崩掉的消费者根本无法判断扣款到底成功没有,于是最"安全"的默认动作(重试)就变成了最危险的动作(重复扣款)。

重放与重处理流程

重放是检验规格质量的地方。规格必须能回答:操作者是否可以安全地重放最近 24 小时的消息,而不会造成重复的副作用?如果答案是否定的,那消费者其实并不幂等,规格就是在撒谎。我要求每个队列都带一份落地的重放 runbook,写清楚完整的命令、影响范围,以及预期的 no-op 比例——通常就是 100%。重放过程中只要出现任何非零的副作用比例,就是去重层的 Bug。

延迟 SLO 与告警阈值

没写延迟 SLO 的队列规格就是不完整的。我会写两个数字:稳态下消费者的 p95 延迟,以及告警阈值。对支付队列,p95 小于 30 秒,告警阈值是持续 2 分钟超过 5 分钟延迟。对邮件队列,p95 小于 2 分钟,告警阈值是 15 分钟延迟。告警阈值永远至少是 SLO 的 10 倍,这样日常抖动不会随手把值班同学叫起来。规格里还会写清楚告警意味着什么:单次延迟告警是"去查",1 小时内第二次告警就是"回滚上一次发布"。

队列规格要先设计重复消息

“exactly once”通常是愿望,不是系统语义。更可靠的写法是承认消息可能重复,然后规定 consumer 如何幂等、什么时候进死信队列、如何重放。

Queue consumer rules:
- dedupe key: payment_id + event_type
- processing lock: 5 minutes, renewable
- retry: 5 attempts with exponential backoff
- DLQ after: permanent validation error or retry exhaustion
- replay: operator can replay by event_id range
- evidence: duplicate delivery test creates one ledger entry

边界:不要把“至少一次投递”写成“用户至少收到一次通知”。队列语义和用户体验之间还隔着 consumer、幂等和抑制规则。

死信队列不是垃圾桶

DLQ 里的消息要有状态、owner 和处理 SLA。规格要说明哪些错误可以重放,哪些必须人工修数据,哪些要永久丢弃并记录原因。否则 DLQ 只是把生产问题搬到没人看的表里。

队列测试要模拟重复投递、并发消费、handler 崩溃、DLQ 重放和旧消息回放。断言结果表、幂等表、任务状态、日志字段和报警 owner。这样 at-least-once 才不会变成 at-least-one-incident。

消费者还要暴露处理状态:received、processing、succeeded、retrying、dead_lettered。API 或运维页面能按 event_id 查到当前状态、最后错误、retry_count、owner 和下一次重试时间。没有这个视图,队列问题只能靠日志考古。

关键词:at-least-once 投递 · 幂等消费者 · dead-letter queue · 消息去重 · 队列重放 · 消费者延迟 SLO

编辑说明与免责声明

本文用于软件工程教学与实践参考,不构成法律、税务或投资建议。示例场景用于解释规格方法,不对应真实客户数据。