2021 年一个发布任务完成后需要发群通知。消费者已经把消息发送出去,却在 ACK 前网络超时;队列认为处理失败,重新投递,同一个发布结果在群里出现两次。我们最初把重试次数从 3 改成 1,重复少了,但瞬时故障也不再恢复。
至少一次投递系统里,重复不是罕见异常,而是协议允许的结果。消费者必须让同一业务事件执行多次仍得到同一结果。
图 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 没有事务怎么办
群机器人不支持与本地数据库同一事务。我们只能选择并明确失败窗口:
- 先写本地
pending记录; - 调用外部 API,并携带对方支持的幂等键;
- 保存外部回执,状态改为
sent; - 超时但结果未知时先查询,不立即盲目重发;
- 对方不支持查询或幂等时,接受“可能重复”,在消息正文带 eventId 供人工识别。
所谓 exactly-once 往往只在某一层成立。跨越数据库和第三方系统后,更诚实的目标是 effect-once:通过幂等、查询和补偿,让业务效果尽量只发生一次。
重试按错误类型决定
| 失败 | 是否重试 | 处理 |
|---|---|---|
| 网络超时,结果未知 | 延迟重试前先查询 | 保持同一 eventId |
| 429 | 按 Retry-After | 不占用紧密循环 |
| 400 参数非法 | 不重试 | 进入死信并告警生产者 |
| 401 凭证失效 | 短暂停止消费 | 修复凭证后重放 |
| 500 | 指数退避 | 超限进入死信 |
重试次数不是可靠性的唯一参数。错误分类、退避、死信和人工重放共同组成恢复路径。
ACK 放在副作用确定之后
消费者只有在业务结果已经提交,或者确认事件过去处理过时才 ACK。进程在处理中崩溃,消息会重投;幂等边界负责吸收重复。不要为了避免重复提前 ACK,那会把可恢复的重复变成不可恢复的丢失。
上线后我们观察重复投递率、幂等命中数、处理延迟、死信数量和未知结果停留时间。重复投递率突然升高可能意味着消费者太慢或网络异常,即使业务被幂等保护,也值得处理。
这次重复通知很小,却把异步系统最核心的事实暴露出来:队列保证的是消息怎样到达,不保证你的业务副作用只发生一次。这个责任最终仍在消费者的数据模型和事务边界里。