队列处理规格:Exactly-Once 与 At-Least-Once 的取舍
只要团队里有人说出"exactly-once 队列"这几个字,我就会把话题叫停。这是一句营销话术,不是投递保证,按它写出来的规格一定会埋下那种凌晨三点才会炸的 Bug。我的做法是,在规格里强制点名真正的保证——at-least-once 加消费者侧去重——并且把幂等、死信和重放规则写死,以此决定消费者到底是不是靠谱的。
内容整理说明
复查日期: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 的成本曲线完全不一样。
- 资金流转(扣卡、转账、退款):规格定为 at-least-once 投递,消费者必须严格幂等,key 绑定到支付意图 ID。这里为 broker 侧的 exactly-once 能力付钱是合理的,因为重复一次的代价可能就是一笔 chargeback。
- 邮件和通知发送:at-least-once 就够了,以 (user_id, template_id, trigger_event_id) 为 key 做 24 小时的去重窗口。重复一封欢迎邮件是烦人;漏一封就是工单。
- 埋点和监控指标:通常 at-most-once 才是正确选择。丢 0.01% 的点击流事件,比为每条事件都上持久化和去重设施便宜得多。规格里要显式写出可接受的丢失率。
- 审计日志:at-least-once,不去重,只追加的落地。重复可以容忍;缺口是合规问题。
幂等消费者设计:去重表、自然键、状态机守卫
我的默认做法是一张以消息自然标识为 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,还是直接丢掉。我的经验法则是:
- 瞬时基础设施故障(超时、依赖返回 503、连接被重置):原地重试,指数退避从 1s 起步,翻倍到 5 分钟封顶,最多 6 次。之后进 DLQ。
- 毒丸消息(payload 非法、schema 违规、缺必填字段):不重试。第一次失败就进 DLQ,并把原始 payload 和解析错误作为元数据附上。
- 业务拒绝(用户已删除、账号被封、幂等冲突):ack 并丢弃,同时打一条结构化日志。这不是失败,是预期结果。
- 未知异常:最多重试 3 次,然后 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 和下一次重试时间。没有这个视图,队列问题只能靠日志考古。
继续阅读
编辑说明与免责声明
本文用于软件工程教学与实践参考,不构成法律、税务或投资建议。示例场景用于解释规格方法,不对应真实客户数据。
- 作者信息:Spec Coding 编辑部
- 编辑政策:编辑与事实核查政策
- 联系方式:联系页面