如何保证消息不重复发送?OpenReply原子认领、幂等投递与失败重试的可靠性设计
【免费下载链接】openreplyThe open-source Manychat alternative项目地址: https://gitcode.com/gh_mirrors/op/openreply
OpenReply 是一款开源的 ManyChat 替代品,核心功能是把 Instagram 评论自动转成私信(Comment-to-DM 自动化)。这类工具最危险的故障不是"消息没发出去",而是"同一条消息发了几十遍"——用户收件箱瞬间变成垃圾轰炸。本文拆解 OpenReply 如何用原子认领、幂等投递与失败重试三道防线,保证 Instagram 私信消息不重复发送。
💥 为什么消息会重复发送?三个真实的坑
在设计任何自动发消息的系统前,先要明白重复是怎么发生的。OpenReply 的源码注释里记录了一个真实事故 🚨:
- Webhook 天生不可靠:Instagram 的回调事件会丢、会重发,轮询兜底任务也可能把同一条评论再次入队。
- API 报错 ≠ 没发出去:Meta 接口曾返回通用错误码 1(OAuthException),但消息实际已经送达用户。如果把这个错误当普通失败重试,同一个人会收到几十条一模一样的私信。
- 进程崩溃在"半路":Worker 刚发出请求、还没来得及写结果日志就崩溃了,重启后任务重新执行——消息是不是已经发出去了?没人知道。
OpenReply 的解法可以概括为一句话:"宁可漏发一条,也不要多发一条"(fail-closed on uncertainty)。
🔒 第一道防线:原子认领,发送前先在数据库"占座"
普通做法是"先查一下有没有发过,没发过就发",但"查"和"改"之间存在时间窗口,两个 Worker 可能同时通过检查。OpenReply 的做法更严谨:在调用 Instagram API 之前,先用一条原子 UPDATE 抢占发送权。
核心逻辑在 claimCommentDelivery:
- 用
updateMany同时满足三个条件:状态不是 SENT、未处于认领中、尝试次数未超上限; - 命中后把
dmDeliveryUnconfirmed(投递未确认)标记置为true,这个标记就是一条**"在途认领"**; - 返回
count === 1——只有真正抢到这一行的那个 Worker 才能继续发消息,其他并发者直接放弃。
这套设计的关键细节:
- ✅成功发送:用 SENT 状态替换认领标记,完成闭环;
- ✅明确的拒绝(如超出 24 小时消息窗口):释放认领,允许换方式重试;
- ✅网络错误、进程崩溃、结果写库失败:认领保持原样,宁可停在"不确定"状态。
官方文档 docs/delivery-retries.md 明确写道:对不确定的投递,"先人工检查收件箱,再手动重试"。
🪞 第二道防线:幂等投递,同一事件来几遍都只发一次
即便有了原子认领,同一事件仍可能从多个入口进来(Webhook、轮询、按钮点击回调)。OpenReply 在三个层面都做了幂等:
1. 数据库唯一键:DmLog 表
每个"活动 + 评论"组合在 DmLog 表中有唯一记录(automationId + commentId)。Worker 处理前先查这条日志:已 SENT 的私信腿直接跳过;已发的公开回复腿靠publicReplySentAt判重,即使私信早就发出、公开回复因限流没发出去,重试时只会补发缺失的那一半,不会重发私信。
2. 主键约束:PostbackDelivery 表
用户点击 DM 按钮触发的回调(postback)用一次create抢占:主键冲突(Prisma 错误码 P2002)就说明这次点击已经处理过,直接返回 false。该表结构见 durable_postback_delivery 迁移。
3. 队列层:确定性 Job ID
BullMQ 队列 lib/queue/client.ts 中,限流重排队列使用comment_<账号>_<评论>_retry_<次数>这样的确定性 Job ID,同一事件重复入队会被队列自动去重;而轮询扫荡则故意不使用固定 Job ID,避免旧任务记录"误伤"新轮次的重试。
另外还有一个 Meta 平台层面的巧思:Instagram 每条评论一辈子只能收到一条私聊回复。当多个活动同时命中同一评论时,先到先得,其余活动被标记为SKIPPED_DEDUP(见 dm-worker.ts 的去重判断),既省了一次必然失败的 API 调用,日志里也写清了原因。
🔁 第三道防线:失败重试——只重试"确定失败"的
重试策略是这套设计中精妙度最高的部分。OpenReply 把每次失败分成两类:
| 类型 | 例子 | 处理方式 |
|---|---|---|
确定拒绝(isConfirmedSendRejection) | Meta 错误码 10 / 100 / 200 / 551、限流、Token 过期 | 释放认领,允许重试 |
不确定(DeliveryUnconfirmedError) | Meta 错误码 1、网络超时、未知响应 | 立即停止自动重试,标记为"投递未确认" |
分类逻辑见 delivery-errors.ts。它的DeliveryUnconfirmedError错误信息甚至直接告诉运维人员:"自动重试已停止,请先检查 Instagram 收件箱再重试"。
重试预算同样是硬约束:
- 每个"活动 + 评论"组合跨 Webhook、重试、轮询所有任务共享最多 3 次发送尝试(MAX_COMMENT_SEND_ATTEMPTS),新任务 ID 无法重置这个额度;
- 队列级退避为5 分钟 → 15 分钟 → 45 分钟三级递增;
- 遇到限流则按延迟重新排队,且溢出部分进入队列等待而不是丢弃。
🧹 兜底扫荡:评论对账器如何"只补漏、不重发"
Webhook 会丢评论(被折叠的"加载更多"、Instagram 过滤的评论等),所以 Worker 里还有一个定时轮询的评论对账器作为安全网。它每轮:
- 只扫描活动绑定的帖子最近 72 小时的评论;
- 跳过账号已回复过的评论(直接读 Instagram 上的实际回复);
- 查 DmLog 构建"已完成集合"——私信已发 / 未确认 / 次数耗尽、公开回复已发,都算处理过,绝不二次入队;
- 单轮每个活动最多入队 30 条,防止爆款帖子瞬间冲垮评论 API 的限流。
扫荡器负责"发现漏网之鱼",真正的防重仍交给前两道防线——两者分工非常清晰。
📋 总结:一张表看懂可靠性设计
| 防线 | 机制 | 防住什么 |
|---|---|---|
| 原子认领 | updateMany条件更新 + 在途标记 | 并发 Worker 同时发送 |
| 幂等投递 | DmLog 唯一键 + PostbackDelivery 主键 + 确定性 Job ID | 同一事件多渠道重入 |
| 智能重试 | 确定拒绝才重试、3 次硬上限、递增退避 | 把"可能已送达"当失败重试 |
| 对账扫荡 | 轮询补漏 + 已完成集合过滤 | Webhook 丢评论 |
这套设计没有任何魔法,全是数据库约束、唯一键和保守的错误分类——但它把"消息不重复发送"从一个愿望变成了可以被并发、崩溃、重放反复验证的工程保证。对正在做消息自动化(尤其是依赖第三方 API 的场景)的开发者来说,这三个文件值得精读:comment-delivery.ts、delivery-errors.ts、comment-reconciler.ts。
【免费下载链接】openreplyThe open-source Manychat alternative项目地址: https://gitcode.com/gh_mirrors/op/openreply
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考