详细阐述多渠道发布 Agent 的幂等设计与重试策略:如何通过 marker 标记避免重复发布,以及 429 限速下的退避实现。
一个定时发布的 Agent 几乎是完全面向外部服务的 I/O 操作,而你既不拥有这些服务,也无法控制那个时钟。我们的 Agent 每篇文章会分发到四个目标:搜索引擎索引 ping、微博、联邦社交网络和开发者社区网站。最后那个网站限流非常严格,所以我们会把请求间隔控制在 75 秒,遇到 429 就退避。一篇三篇文章的运行因此在分发阶段就要花费超过四分钟,而其中大部分时间都在休眠。
四分钟足够被干掉了。CI 任务撞上墙钟上限。容器在休眠中途被驱逐。有人合上了笔记本。运行死掉并不是有趣的部分。有趣的是下一次运行会怎么处理它。
重试很容易;知道已经发生了什么却很难
真正让你付出代价的失败不是崩溃——而是一次没有留下明显痕迹的部分分发。三篇文章乘以四个渠道是十二次远程调用。进程死在第七次调用时。第一篇文章已经遍布各处。第二篇到达了两个渠道中的两个。第三篇除了在你的 git 历史里,哪儿都不存在。
现在选一个重试策略。重跑整个任务,第一篇文章就会被二次发布到所有没有服务端去重的渠道。跳过任何带有 published 标记的文章记录,第二篇文章就再也收不到剩下两个渠道的发布通知了——下次不会,永远不会。两种策略出错的原因是同一个:它们追踪状态的粒度和工作的实际粒度不匹配。
我们交付过一个更差的版本。有很长一段时间,我们的发布命令会构建和部署网站,但根本不会触发同步步骤。57 篇文章上线了,却从未在任何地方被通告。没有抛出任何异常。每次退出码都是零。我们是通过读脚本才发现的,不是通过读日志,因为日志里根本没有什么可读的。
一个报告成功的任务只告诉你进程结束了,而不是工作发生了。每个渠道都需要在远程调用返回后写入一条per-item记录。如果你判断一篇文章被通告的唯一证据是运行没有崩溃,那你并没有证据——你只是缺少一种特定类型的错误。
由此得出的规则是:状态属于(item, channel)这个组合。不属于运行。不属于 item。
在写重试循环之前先设计账本
三个属性做真正的工作,它们都不是重试循环本身。
分两阶段写入。在远程调用之前先把组合标记为 in_flight,在之后将其解析为 done 或 failed。在网络写入和账本写入之间被 kill 并不是假设——这是最有可能死亡的地方,因为那个窗口包含了网络。没有两阶段记录,你就无法区分从未发送和已发送但未记录,而这两种状态需要完全相反的处理。
存储远程系统给你的标识符。响应中返回的文章 ID 或 URL 是你日后无需猜测就能对账的依据。它还会把一条模糊的 in_flight 记录变成一个可以通过查询来回答的问题。
把账本放在运行之外。不是进程内存,不是任务的临时目录,不是随 worker 一起消亡的内存队列。存一份已提交的 JSON 文件或一张表。我们的账本放在仓库里,这意味着 diff 精确显示了什么东西在什么时候发了出去——这和我们在构建之前生成文章元数据而不是在构建期间生成是同一个原因。
有了这些,resume 就不是一个模式,而是一个过滤器:
// pending work is a query over the ledger, not a resume cursor
const pending = [];
for (const item of items) {
for (const channel of CHANNELS) {
const row = ledger.get(item.slug, channel.id);
if (!row || row.state === 'failed') pending.push({ item, channel });
else if (row.state === 'in_flight') pending.push({ item, channel, verify: true });
}
}
恢复的运行和全新的运行现在走相同的代码路径。没有恢复分支需要维护,也没有 --resume 标志需要在凌晨两点时记住。这比看起来重要得多,因为你无法可靠地测试恢复分支:kill 可能落在任何地方,而你写测试的那些 case 都是你已经想到的。
当 API 不提供幂等性时,用一次读取来购买
发布 API 很少像支付 API 多年前标准化那样提供 Idempotency-Key header。实际上你面对的是三个层级之一。
规范 URL 是任何内容型事物的天然去重 key,大多数渠道都允许你搜索自己发布的文章。只有当一条记录卡在 in_flight 时才付出这次读取的成本——在干净的运行中它永远不会触发,所以成本在常见情况下为零,在真正需要它的场景下一次请求。
在写任何这些代码之前还有一种分类值得明确说明:逐渠道决定重复是否无害。索引 ping 生来就是幂等的,所以自由 ping,把它当作至少一次。社交帖子是公开且永久的,所以偏好最多一次,接受一次错过的通告而不是重复——一篇缺失的帖子明天可以手动补发,一条重复的帖子无法从用户的时间线中抹掉。把一个策略均匀地套用到两类渠道上,同一个链接就会在信息流里出现三次。
一旦账本 schema 定下来,重构在很大程度上是机械性的,而这恰恰是那种重复的、规格明确的编辑,适合交给 coding agent 来做,你自己来把握 schema 决策。
这些都不能让 agent 变得更有能力。它只是让 agent 的失败变得廉价——而对于任何按计划运行的系统,这才是决定你是否继续跑它的那个属性。
Originally published at pickuma.com. Subscribe to the RSS or follow @pickuma.bsky.social for new reviews.