Worker 挂了,同步没有

在批次中途杀掉 worker,跟踪一位联系人:保存的是哪个游标,重放的写入为什么不会落地两次,重启期间由谁掌管同步。然后是两起事故,它们说明恢复只是工作的一半。

数据同步故障恢复

Worker 在一个批次中途挂掉时,正在运行的同步会怎样?

游标只在一个干净批次结束时移动,所以在批次中途被杀掉的 worker 会把游标留在该批次的起点,下一次运行读取同一页。每条记录的投递键由页游标及其位置推导而来,一个保存已完成写入、保留七天的账本防止重放落地两次。每个同步只有一个租约(lease),两次运行不会同时工作。这是容易的那一半。有一个同步在有东西把它暂停之前,一次运行接着一次运行地失败;有一个工作区只有错误、没有一次成功,在有东西察觉之前一直静默。所以现在有一个熔断器,在连续五次失败后暂停同步,还有一个每小时运行的告警,在二十四小时没有成功时触发。

了解更多
在本页面

在 AI Engineer 于 2026年10月3日发布的一场演讲中,一位 Crusoe 的开发者布道师在两台 A100 节点上启动了一个 PyTorch 训练任务。随后,演讲者告诉演示自带的监控系统,有一块 GPU 报出了 XID 79 错误。该节点被标记为下线,任务被取消。进程收到 SIGTERM,并有“最多两分钟来处理这个 SIGTERM,用来保存检查点或刷出日志”。一台备用节点接替上来,“slurm 任务会重启。它会加载检查点,从中断的地方继续训练”。从头到尾略少于十五分钟。没有人被叫起来处理。

同步比训练任务小得多。问题却是一样的:运行需要具备什么,才能让杀掉 worker 只损失几分钟,而且不产生重复?

下面是 Tajo 自己的同步运行器给出的答案,读自代码,而不是宣传册。反向 ETL 描述了这项工作的形态。本文讲的是这项工作挂掉时会发生什么。

跟踪一位联系人

运行从一个已保存的游标开始,一次读取数据源的一页。把这一页的游标记作 c3,我们的联系人是这一页上的第 212 条记录。在写入任何东西之前,关于这位联系人有三件事成立。

第一,它有一个投递键。这个键是同步 id、数据源、页游标、页内位置和记录本身的哈希。游标和记录只进入哈希计算,从不嵌入键中,所以键里不含任何邮箱地址。

第二,写入要经过账本(ledger),而不是直接发往目标系统。调用目标系统之前,这个键会先被认领为待处理。写入成功后,这一行被标记为已完成,并保留七天。

第三,游标还没有移动。它只在批次结束时移动,只在该批次的每条记录都已落地时移动,并且只在运行证明自己仍然掌管该同步之后移动。

现在,在写入和检查点之间杀掉 worker。

收到 SIGTERM 时,每个队列 worker 都在一个共享的十五秒期限内排空任务,无论还有什么在运行,进程都会在二十五秒后退出;systemd 在强制杀掉之前给它四十五秒。没能在这段时间内完成的运行仍被标记为进行中,下一个 worker 会通过队列的卡住任务恢复(stalled-job recovery)机制接手。如果是强制杀掉(hard kill),就完全没有排空。无论哪种情况,同样的两个事实都成立:目标系统里已经有了我们的联系人;我们的游标仍然是 c3。

重放

下一次运行从 c3 开始,读取同一页,为第 212 条记录推导出同一个键,然后询问账本。账本回答:已完成。目标系统不会被调用;已存储的结果被重放,运行继续往下走。在批量路径上,Brevo 的导入端点一次调用接收整页数据,同样的检查会在构建请求之前逐个键执行。如果一页中的每个键都已完成,这一页什么都不会发送。

这就是全部的窍门,值得说清楚它为什么有效。键是从批次开始时的页游标推导出来的,而不是从碰巧正在执行的那次运行推导出来的。一次崩溃、六十秒后的一次重试和明天的一次定时运行,都会为同一条记录推导出同一个键。账本并不在乎是其中哪一个在询问。

再说代价。账本七天后就会遗忘。代码自己的注释说,七天足以覆盖一次延迟的重试和一次短暂的中断,它声称的也仅此而已。从比这更旧的游标重放,会再次写入。而且被强制中断的运行可能要等待。已死运行的租约(lease)最长可保持十五分钟,在定时同步中,租约已空闲的运行要在开始超过一小时后才会被清扫并重新调度。Crusoe 的演示在十五分钟内恢复。我们的并不总是如此。

重启期间由谁掌管同步

同一时间只有一次运行掌管一个同步。运行会在 Redis 中用自己的令牌获取一个租约,有效期十五分钟,每五分钟续期一次。每次写检查点之前,它都会确认自己仍然持有这个租约。如果不再持有,它就停下来。这次运行会被记录为一句用户将会读到的直白的话:“The run lost its slot part-way through and stopped to avoid writing twice.”(运行中途失去了位置,为避免重复写入已停止。)与一个仍然存活的持有者发生冲突的第二次运行不会重试。它以 “Another run of this sync was already in progress”(此同步的另一次运行已在进行中)结束,下一次定时运行照常开始。

修复由谁负责也写得清清楚楚。每个失败代码都对应一句说明发生了什么的话、一句说明下一步该做什么的话,以及一个负责人:你或者 Tajo。因为映射错误而导致目标系统拒绝所有记录,归你负责。租约丢失,归我们负责。运行页面会显示下一步;同步恢复指南列出了是哪一方。

到这里是容易的那一半。一次崩溃的代价是一次重放,没有任何东西落地两次。另一半是两起事故教会我们的。

一次又一次的失败

有一个同步一次又一次地失败。不是在紧密的循环中,而是每个调度周期一次,一次运行接着一次运行,每次都是同一条消息:自定义 id 不能包含冒号。这次运行从未到达目标系统。它在入队过程中就挂了,任何运行器都还没看到它,所以挂在运行器上的告警根本没有可挂靠的东西。

没有任何东西在盯着这串连续失败。这就是全部的发现。

修复方案是一个熔断器。每记录一次失败,它就读取该同步自上次修改以来最近五次已结束的运行。如果五次全是错误,它就暂停同步,写一行审计记录,原因为 circuit_breaker_tripped,并发出一条严重告警。连续失败是从已存储的运行推导出来的,而不是来自 worker 中的计数器。通过任何路径记录的失败都会被计入,包括引发这起事故的入队失败。代码注释说,它本可以在第五次运行时制止那起事故。一次成功会重置连续失败计数。取消暂停会让熔断器重新布防,而不是根据旧记录再次跳闸。Shopify 连接器指南用一句话说了同样的事:连续五次运行失败后,同步会自行暂停,其页面会显示最后一个错误。

熔断器不做的事情是修复任何东西。Crusoe 不经询问就更换节点并重启任务。Tajo 会停下来询问:一个已暂停的同步会一直保持暂停,直到有人修复原因并重新发布它。这个谨慎的默认值是有意为之:自动重试一个写入客户记录的同步,本身就是一次写入。

无人察觉的静默

有一个合理的反对意见,大意如下:“一个连续失败五次就暂停的熔断器,本来也能抓住第二起事故。为什么还要再建一个监视器?”因为第二起事故看起来不像失败。在很长一段时间里,一个工作区有一个活动的同步,错误记录越积越多,却从来没有一次成功,而且没有任何告警。现有的每个信号都挂在活动上:一次运行失败了,一个 Webhook 到达了,一项预算被超出了。零次成功不是一个事件。它是事件的缺席,而熔断器只有在记录到失败时才会运行。一个已经死掉的调度器、一个卡死的队列、一个从未启动的 worker:这些都不会记录任何东西,熔断器也永远不会醒来。

机车上的死人开关正好反过来工作。司机必须一直按住它,一旦静默,列车就会停下。同步版本每小时运行一次,在每小时的第十三分钟。对于每个带有活动同步的工作区,它只问一个问题:过去二十四小时内是否有任何一次运行成功?如果一个工作区中最早的活动同步启用不到一天,就不去打扰它;它还没有一天的时间来成功。更早启用且处于静默的工作区会收到一条严重告警。告警会说明是哪一种静默:有运行记录但没有一次成功,或者根本没有运行记录。每起事故只告警一次。Redis 中的一个标记会让它保持关闭,直到第一次成功运行,然后清除,这样下一起事故还会再次告警。

两个监视器是在事后通过同一个提交加入的。熔断器的注释说,它本可以在第五次运行时制止第一起事故。死人开关的注释说,一个很久以前就已启用、却一次运行都没有的同步必须告警。需要它们的时候,两者都不存在。我们是事后才写出它们的,并把两起事故都记录在文件头部,好让没有人忘记原因。

已经构建的,和尚未构建的

截至撰写本文时,已经构建:

  • 每个干净批次结束时保存游标,崩溃时再保存一次。
  • 每条记录的投递键由页游标及其位置推导而来,并在每次调用目标系统之前与一个七天的账本核对。
  • 每个同步一个租约,有效期十五分钟。
  • 每次运行最多尝试五次,从六十秒开始退避。
  • 一个熔断器,在连续五次失败后暂停同步并记录原因。
  • 一个每小时运行的告警,在二十四小时没有成功时触发。

尚未构建,在依赖它之前值得了解:

  • 死人开关监视的是工作区,而不是同步。一个健康的同步会掩盖同一工作区中另一个静默的同步。
  • 它的告警发送给 Tajo 的运维人员:总会写一行日志,在已配置的情况下还会发送到 Sentry、Webhook 或邮件。它不会出现在同步的页面上。熔断器的暂停会出现。
  • 没有任何东西会重启一个已暂停的同步。由人来重启。
  • 强制杀掉之后的恢复并不快。租约可能保持十五分钟,清扫要等一小时。
  • 两个阈值,五次失败和二十四小时,都是部署级设置。你不能为单个同步分别设置。

由近乎完美的步骤组成的长链仍会失败;出路是定义最终状态并检查它。本文是这件事在运维层面的另一半:定义一次挂掉的运行会留下什么,并检查静默留下的那片空白。

Crusoe 的结束语是“失败不可避免”。我们的结论再往前一步。崩溃是容易的那一半,因为它会留下记录。静默是我们必须主动去寻找的那一半。

常见问题

Worker 在一个批次中途挂掉时,正在运行的同步会怎样?

游标仍然指向被中断批次的起点,因为 Tajo 只在一个批次干净落地之后才移动游标。下一次运行会重新读取同一页。每条记录都带有一个由页游标及其在页内位置推导出的投递键,账本(ledger)会把每一次已完成的写入保留七天,因此已经写入的记录会从账本中重放,而不会第二次发送到目标系统。

为什么重放的同步不会把同一位联系人写入两次?

每次调用目标系统之前,运行都会拿记录的投递键去核对账本。这个键是同步、数据源、页游标、页内位置和记录本身的哈希,因此一次崩溃、一分钟后的重试和第二天的定时运行,都会为同一条记录推导出同一个键。在七天窗口内已标记为完成的键不会再次发送。

同步的死人开关告警是什么?

一种在“没有发生”时触发、而不是在失败时触发的检查。Tajo 每小时查看一次所有带有活动同步的工作区,如果过去二十四小时内没有任何一次运行成功就发出告警,包括根本没有记录到任何运行的情况。每起事故只告警一次,在第一次成功运行后重新布防。它之所以存在,是因为失败告警只能看到被记录下来的失败。

申请抢先体验

请输入您的名字和电子邮箱地址。我们会发送确认 Tajo 访问申请的链接。

获取Brevo

本页的 Markdown: worker-died-sync-didnt.md

正在加载 Markdown…