☰
如何保证消息不重复发送?OpenReply原子认领、幂等投递与失败重试的可靠性设计
2026/10/3 22:34:24 网站建设 项目流程

如何保证消息不重复发送?OpenReply原子认领、幂等投递与失败重试的可靠性设计

【免费下载链接】openreplyThe open-source Manychat alternative项目地址: https://gitcode.com/gh_mirrors/op/openreply

OpenReply 是一款开源的 ManyChat 替代品,核心功能是把 Instagram 评论自动转成私信(Comment-to-DM 自动化)。这类工具最危险的故障不是"消息没发出去",而是"同一条消息发了几十遍"——用户收件箱瞬间变成垃圾轰炸。本文拆解 OpenReply 如何用原子认领、幂等投递与失败重试三道防线,保证 Instagram 私信消息不重复发送。

💥 为什么消息会重复发送?三个真实的坑

在设计任何自动发消息的系统前,先要明白重复是怎么发生的。OpenReply 的源码注释里记录了一个真实事故 🚨:

  1. Webhook 天生不可靠:Instagram 的回调事件会丢、会重发,轮询兜底任务也可能把同一条评论再次入队。
  2. API 报错 ≠ 没发出去:Meta 接口曾返回通用错误码 1(OAuthException),但消息实际已经送达用户。如果把这个错误当普通失败重试,同一个人会收到几十条一模一样的私信。
  3. 进程崩溃在"半路":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 里还有一个定时轮询的评论对账器作为安全网。它每轮:

  1. 只扫描活动绑定的帖子最近 72 小时的评论;
  2. 跳过账号已回复过的评论(直接读 Instagram 上的实际回复);
  3. 查 DmLog 构建"已完成集合"——私信已发 / 未确认 / 次数耗尽、公开回复已发,都算处理过,绝不二次入队;
  4. 单轮每个活动最多入队 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询