原文发布于 nejckorasa.github.io。
当团队从单体应用转向微服务和事件驱动的异步系统时,它继承了一类曾经属于他人的问题:半途而废的工作、不能运行两次的步骤、返回结果早于工作完成的调用。Temporal 是一个持久执行引擎,能处理其中的许多问题——你定义一个多步骤流程,它保证即使 worker 在中途崩溃,流程也能运行至完成。
我在银行资金流转核心(账本、支付、信用卡)构建分布式系统已有近十年——其中很多系统都运行在 Temporal 上,从短请求触发的 workflow 到持续数周的 workflow。这是我给准备跨越这一步的团队的高阶指南:值得在上线前内化的原则,而非完整教程。其中大多数原则其实与 Temporal 无关,而是异步转变所需的习惯——当你跳过其中一条时,Temporal 会迅速给你惩罚。
持久执行:它解决的问题
分布式工作会在中途失败。你调用服务 A,它成功了。你调用 B,它超时了。Pod 在调用 C 前就挂了。现在你有了半完成的工作,却没有记录到哪里为止。通常的解决方案是一堆状态列、一个 cron 作业来查找卡住的行,以及为每一步手工编写的重试逻辑。
Temporal 的承诺是:你启动的任何流程都会运行到结束。运行时画面:有一个 Temporal 服务(它自己的集群),你的应用运行 worker 进程来轮询它并执行你的代码。当 workflow 运行时,Temporal 会将每一步记录到事件 history 中。如果 worker 死亡,另一个 worker 会接管 workflow 并 重放该 history 来重建状态,然后从上次中断的地方继续,重新尝试任何失败的操作。
History 是真相之源,它能在崩溃后幸存。以下大多数规则都源自这一事实。
黄金法则:Workflow 做决策,Activity 做执行
Temporal 中有两种代码。
- Workflow 是编排。它决定 什么 运行以及以 什么顺序 运行:调用 A,然后 B,等待,然后 C。
- Activities 是实际工作。每个 activity 都做一些实际的事——服务调用、数据库写入、Kafka 发布——每个 activity 都独立重试。
规则:所有业务逻辑和与外部世界的交互都放在 activities 中。Workflow 保持为一个枯燥、可读的步骤列表。
这源自 重放。Worker 通过针对已记录的 history 重新运行其代码来重建 workflow,因此该代码必须是 确定性的:针对相同的 history 重新运行,它会做出相同的决策。不要在 workflow 中进行原始时钟读取、rand() 或网络调用(SDK 提供了时间和随机性的确定性版本)。所有非确定性操作都放在 activities 中,其 结果 被记录并重放。
将 Temporal 限制在单个服务内
这是我最想捍卫的原则,因为这是 Temporal 自身营销容易让你陷入的陷阱。
Temporal 允许你在某个服务中定义 workflow 并在任何地方运行其 activities——如果你愿意,可以在十个服务中运行。一个 workflow 编排你的整个系统听起来很棒,我也见过很多人大力推动这一承诺。
这是一个耦合陷阱。当 activity 存在于另一个服务中时,该服务就需要一个原本不需要的 Temporal 集成和共享命名空间。更糟糕的是,其 activity 的输入/输出模式现在被硬编码到 你的 workflow 中。当同事改变其 activity 的形状时,history 中旧格式的结果就无法再反序列化为新代码,你正在运行的 workflow 就会中断。你通过双方都看不到的机制耦合了两个团队的部署。
将边界划在服务处。Workflow 及其所有 activities 由一个服务拥有。当 activity 需要另一个服务时,它发起普通的 HTTP 调用或发出 Kafka 事件——这是每个人都理解的普通、通用做法。Temporal 成为单个服务的实现细节,没有 workflow 跨越两个团队。
账户创建是一个很好的例子。整个 workflow 包含四个 activities:
- 标记创建已开始 - 写入我们自己的数据库。
- 向支付处理器注册账户 - HTTP。
- 在账本中创建账户 - HTTP。
-
标记创建已完成并发出
account_created事件 - 写入我们的数据库,发布到 Kafka。
Workflow 只是指定顺序;每个 activity 执行工作并按 ID 加载所需内容,因此丰富数据永远不需要跨越服务边界。
Workflow 是异步的:宣布结果
如果你来自请求/响应模式,这就是思维转变。启动 workflow 几乎立即返回——它 不 等待工作完成。工作在 worker 上后台运行,因此调用者无法从响应中读取结果;结果必须通过其他方式返回。
这就是为什么账户创建 workflow 以发出 account_created 事件结束。该事件不是装饰——它是任何人了解账户实际创建方式的方式。任何需要做出反应的人(发送欢迎邮件、配置卡片)都订阅该事件,而不是阻塞你的调用。这对事件驱动系统很好;当调用者需要在同一请求中得到答案时,这是一个糟糕的选择。
也要宣布失败路径,但前提是你有失败路径。由于无限重试,workflow 可能一直运行直到成功,因此没有可报告的终端失败。如果你在 N 次尝试后放弃,请在该路径上发出信号,这样下游就不会一直等待永远不会到来的成功。
Activities:幂等、可重试,以及异常陷阱
Temporal 会自动重试 activities,采用指数退避和无限次尝试,直到成功。你继承了这一点——你不需要请求它——由此产生两件事。
首先,幂等性是正确性,而非卫生。 任何 activity 都可能 运行多次,如果运行两次做错事,那么迟早会遇到。在账本中这是具体的:发布交易的 activity 运行两次就会移动两次资金。修复方法是任何重试调用者背后的普通做法——在与操作身份绑定的 稳定键 上对写入进行去重(交易 ID 加步骤),而不是每次尝试使用新的 UUID,这会破坏检查。每个 activity 最终都具有相同的形状:按 ID 加载状态,如果该键已经存在则提前返回,执行工作,提交。
其次,你如何发出失败信号决定了是否重试。 抛出异常意味着"重试我",所以要深思熟虑:
- 瞬时失败(超时、503):让它抛出。它会重试并恢复。卡住总比失败好。
- 预期的 "否"(拒绝、已存在):不要抛出。将它作为正常结果返回,让 workflow 继续。如果对永远不会改变的业务结果抛出异常,你就是在永久答案上构建无限循环。这是整个主题中最锋利的边缘。
- 真正不可恢复的失败(错误输入):抛出 不可重试 错误以快速失败。
对于"停止或永远重试",我的默认做法是先从无限重试开始并监控:如果 workflow 必须成功,那么加上良好的警报就是最简单有效的做法,一旦你部署修复,它就会自我修复。但无限尝试只有在每次尝试都有 StartToClose 超时时才是安全的。activity 如果 挂起 而不是崩溃,就永远不会产生可重试的失败,因此它会永远卡在那里。这才是真正的陷阱,而不是无限重试。
Workflow ID 是免费的并发锁
启动 workflow 时设置 workflow ID,Temporal 保证给定 ID 的 workflow 同时只有一个在运行。这是一个你无需构建的序列化锁,ID 方案决定了粒度:account_id-create-account 仅序列化该操作,而 account_id 则序列化该账户的 所有 操作(代价是每账户的吞吐量)。
我在账本前面的账户生命周期服务上使用过这个。某些更新必须严格序列化,按账户 ID 键入 workflow 为我们提供了这一点,无需锁表。ID 就是锁。(它仅在 workflow 运行期间有效;关闭后的重用由策略管理。)
Temporal 进行编排;它不持有你的状态
Temporal 运行流程。它不是你的状态所在之处——无论是 workflow 携带的实时状态,还是已发生事件的持久记录。两个习惯保持这个边界清晰。
保持飞行中的上下文最小。 不要用数据加载 workflow 并向下传递快照给 activities。让 workflow 传递 ID,让每个 activity 在运行时从数据库加载所需内容并决定如何进行(包括其幂等性检查)。早期捕获的快照会过时,写回会破坏并发更新;传递 ID 避免了这一点,将敏感数据排除在载荷之外,并保持事件 history 较小。
将持久状态保存在你自己的数据库中,而不是 Temporal 中。 Temporal 将 history 保留一个保留窗口,然后老化——所以六个月后当有人问账户发生了什么时,你不会去那里找。这涵盖了两件事。你的 领域状态(余额、状态、报告和邮件背后的数据)显然属于你的数据库。但 workflow 自己的 进度 也属于那里:第一个 activity 写入 creation_started,最后一个写入 creation_completed。这让 Temporal 纯粹做运行时编排,而你的数据库拥有记录。
写下进度也给你提供了重要的警报。Temporal 内置的延迟指标仅在 workflow 结束 时触发;没有指标显示 开放中 的 workflow 已经运行了多久。所以我们保留了自己的指标:每小时作业标记在阈值内未达到 completed 的开始行。这是一个简单的数据库查找——但前提是你写了记录。(也要按 workflow 类型对有意义的 SLA 进行警报,以及任何卡在巨大重试次数上的 activity——隐藏着无法自行解决的失败的无限重试。)
这是 双写问题:对两个系统(这里是 Temporal 和你的数据库)的两次写入无法共享事务,因此它们之间的崩溃会导致两者不同步。你无法使它们原子化,所以要排序以选择你能承受的失败。先启动 workflow——Temporal 在返回前持久记录它——然后让它的第一个 activity 写入 "started" 行;然后崩溃只是意味着 workflow 会重试直到行落地,而不是没有 workflow 会处理的孤行。DB 和 Kafka 版本出现在下面的 Kafka 部分,其中 outbox 模式可以完全消除它。
演进 Workflow:向前修复,以及长期运行的陷阱
Workflow 是最昂贵的更改部分,所以保持它小:没有聪明的分支,直线,复杂性推到 activities 中。
这在修复 bug 时有回报。Activity 中的 bug 是无痛的: 修复代码,部署,下次重试运行新代码——workflow 定义从未改变,因此确定性永远不会面临风险。Workflow 中的 bug 是困难的情况: 飞行中的 workflow 正在重放旧 history,新的编排代码可能与之分歧。所以将大脑留在 activities 中并设计向前修复。
这个困难情况在 长期运行的 workflow 中变成永久的,这是最大的运营痛点来源。我处理过运行数周的账户关闭 workflow——余额可以关闭前的监管持有。打开窗口的时间越长,底层发生变化的机会就越多,而你一直在维护该代码路径。30 天前卡住的 workflow 是你无法安全退役其定义的 workflow。
所以数周的 workflow 需要一个在飞行中更改的计划:排空旧的,或者原地修补以便旧运行走旧路径。Temporal 缓解了这一点——Continue-As-New 将长 workflow 检查点到新 history 中以保持有界且可更改,而持久 定时器 和 信号 本地建模等待而不是轮询循环。但真正的教训更简单:风险随 workflow 保持打开的时间而扩展,所以保持它们短。
知道何时 Kafka 消费者就足够了
显而易见的问题是"为什么不只用 Kafka?" 诚实的回答是:你 可以 用 Kafka 编排多步骤流程——读取消息,做工作,提交偏移量,发出下一个。问题是所有机制都要你自己构建:重试、死信队列、退避、每步状态、某物卡住位置的可见性。
Temporal 为你提供了这些,加上 每 activity 重试配置 你在 Kafka 中会手工编写:activity A 每十分钟重试一次并退避,B 不可重试,C 上限五次尝试。持久执行本质上是一个在每步之间检查点的 Kafka 消费者,已经为你编写好了。
那么什么时候值得额外的平台?当流程需要 等待、被 观察,或有 多个副作用需要在之间检查点 时。在那之下——一个副作用,无等待,重试然后死信就足够——消费者是正确的工具,Temporal 是开销。
如果你走消费者路线,你的提交顺序就是整个游戏:
read msg → do work → commit DB → emit event → ack input (last)
Enter fullscreen mode Exit fullscreen mode
在数据库提交后发出(之前,失败的提交会留下幻影事件),最后 ack 输入(先 ack 然后崩溃会丢失工作)。这又是 DB 和 Kafka 双写:排序选择较小的失败,但提交和发出之间的崩溃仍然会丢弃事件。要彻底关闭这个缺口,使用 事务性 outbox 模式——在与状态更改相同的事务中将事件写入 outbox 表,然后让中继随后发布它。Temporal 开箱即用地为你提供这种持久性;消费者让你构建它。
这一切并不使 Kafka 成为输家。Kafka 获胜的地方是 fire-and-forget 领域事件:你发出"账户已创建"而不拥有下游用它做什么。Temporal 是为你拥有的 workflow,你关心每一步都到达终点。
一个替代方案:DBOS
如果运行和操作单独集群是你犹豫的部分,DBOS 值得一看。它是持久执行的更轻量版本:一个将 workflow 状态持久化到你已经在运行的 Postgres 数据库的库,而不是你建立并照看的独立服务。你用 Temporal 更丰富的工具和规模换取更少的运营表面——当你的 workflow 适中且你宁愿不拥有另一个集群时,这是一个合理的交易。
从哪里开始
以上所有都是一个想法的结果:history 是真相之源,worker 可以随时重放它。 确定性、将逻辑保留在 activities 中、幂等重试、将领域状态保留在你自己的数据库中——它们都源自这一点。理解这一点,其余的就不再像清单。
节省最多痛苦的一个流程习惯:抵制直接在生产中迭代。启动本地开发服务器(temporal CLI 让你在一条命令中获得一个),让 workflow 端到端运行,从小事开始。并依靠 Web UI——检查 workflow 的完整 history,然后手动杀死或重放它,这是队列加死信设置永远无法给你的具体事情之一。
如果你觉得这有用,我在 nejckorasa.github.io 撰写关于分布式系统和工程实践的文章。你也可以在 LinkedIn 和 X 上找到我。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.