2021 年一个发布任务完成后需要发群通知。消费者已经把消息发送出去,却在 ACK 前网络超时;队列认为处理失败,重新投递,同一个发布结果在群里出现两次。我们最初把重试次数从 3 改成 1,重复少了,但瞬时故障也不再恢复。

至少一次投递系统里,重复不是罕见异常,而是协议允许的结果。消费者必须让同一业务事件执行多次仍得到同一结果。

消息消费者从幂等检查、业务写入到 ACK 的处理流程

图 1:幂等记录与业务结果要共享一致性边界;否则“标记完成”和“实际完成”会分离。

幂等键来自业务事件,不来自消费尝试

每次重投都会产生新的 deliveryTag,不能拿它去重。我们在事件创建时生成稳定 eventId

type ReleaseCompleted = {
  eventId: string;
  eventType: "release.completed";
  releaseId: string;
  artifactId: string;
  occurredAt: string;
};

同一个发布完成事实,无论投递多少次都保持 eventId。生产者还要防止业务事务成功但消息未发出,因此采用 outbox:业务写入和待发布事件在同一数据库事务提交,后台再把 outbox 发送到队列。

数据库副作用用唯一约束兜底

如果消费者写的是数据库,可以让处理记录和业务写入处于同一事务:

BEGIN;
 
INSERT INTO consumed_events (consumer, event_id, consumed_at)
VALUES ('release-notifier', :event_id, NOW())
ON CONFLICT DO NOTHING;
 
-- 只有上一步确实插入一行时才执行业务写入
INSERT INTO notifications (event_id, channel, payload)
VALUES (:event_id, :channel, :payload);
 
COMMIT;

唯一约束是并发去重的最终防线。先查询“是否存在”再插入,中间有竞态,两个消费者可能同时认为不存在。

外部 API 没有事务怎么办

群机器人不支持与本地数据库同一事务。我们只能选择并明确失败窗口:

  1. 先写本地 pending 记录;
  2. 调用外部 API,并携带对方支持的幂等键;
  3. 保存外部回执,状态改为 sent
  4. 超时但结果未知时先查询,不立即盲目重发;
  5. 对方不支持查询或幂等时,接受“可能重复”,在消息正文带 eventId 供人工识别。

所谓 exactly-once 往往只在某一层成立。跨越数据库和第三方系统后,更诚实的目标是 effect-once:通过幂等、查询和补偿,让业务效果尽量只发生一次。

重试按错误类型决定

失败 是否重试 处理
网络超时,结果未知 延迟重试前先查询 保持同一 eventId
429 按 Retry-After 不占用紧密循环
400 参数非法 不重试 进入死信并告警生产者
401 凭证失效 短暂停止消费 修复凭证后重放
500 指数退避 超限进入死信

重试次数不是可靠性的唯一参数。错误分类、退避、死信和人工重放共同组成恢复路径。

ACK 放在副作用确定之后

消费者只有在业务结果已经提交,或者确认事件过去处理过时才 ACK。进程在处理中崩溃,消息会重投;幂等边界负责吸收重复。不要为了避免重复提前 ACK,那会把可恢复的重复变成不可恢复的丢失。

上线后我们观察重复投递率、幂等命中数、处理延迟、死信数量和未知结果停留时间。重复投递率突然升高可能意味着消费者太慢或网络异常,即使业务被幂等保护,也值得处理。

这次重复通知很小,却把异步系统最核心的事实暴露出来:队列保证的是消息怎样到达,不保证你的业务副作用只发生一次。这个责任最终仍在消费者的数据模型和事务边界里。