Originally published on nejckorasa.github.io.

當團隊從單體架構轉向微服務與事件驅動的非同步系統時,他們繼承了一類原本屬於其他人的問題:工作在中途失敗、步驟不能執行兩次、呼叫在工作完成前就返回。Temporal 是一個持久執行引擎,能夠處理這類問題——你定義一個多步驟流程,它保證即使 worker 在中途當機,流程仍能執行到完成。

我花了近十年的時間在銀行的資金移動核心(帳本、支付、信用卡)建立分散式系統——其中很多使用 Temporal,從短暫的請求觸發工作流到持續數週的工作流都有。這是我給正在進行這項轉型的團隊的高層級指南:是值得內化並在部署前理解的原則,而不是完整教學。其中大多數原則其實與 Temporal 無關,而是非同步轉型所要求的習慣——Temporal 只會在你跳過某個習慣時迅速懲罰你。

持久執行:它解決的問題

分散式工作會在中途失敗。你呼叫服務 A,它成功了。你呼叫服務 B,它逾時。Pod 在呼叫 C 前就死了。現在你有半完成的工作,卻不知道進行到哪一步。通常的解決方法是一堆狀態欄位、找卡住資料列的 cron job,以及為每個步驟手動撰寫的重試邏輯。

Temporal 的承諾是任何啟動的流程都會執行到結束。執行時的畫面:有一個 Temporal 服務(它自己的叢集),你的應用程式執行 worker 程序來輪詢它並執行你的程式碼。當工作流執行時,Temporal 會將每一步都記錄到事件 history 中。如果 worker 當機,另一個 worker 會接手工作流,並 重播 該 history 來重建狀態,然後從中斷的地方繼續執行,重試任何失敗的操作。

history 是事實來源,且能在當機後存續。以下大多數規則都源自這一個事實。

黃金法則:Workflows 決定,Activities 執行

Temporal 中有兩種程式碼。

  • Workflow 是編排。它說明 什麼 要執行以及 以什麼順序 執行:呼叫 A,然後 B,等待,然後 C。
  • Activities 是實際工作。每個 activity 做一些實際的事情——服務呼叫、資料庫寫入、Kafka 發布——每個 activity 都會獨立重試。

規則:所有業務邏輯以及與外部世界的互動都放在 activities 中。Workflow 保持為一個無聊、可讀的步驟清單。

這源自 replay。Worker 透過重新執行其程式碼對照已記錄的 history 來重建工作流,因此該程式碼必須是 確定性的:對照相同的 history 重新執行,它會做出相同的決定。不要在 workflow 中進行原始時鐘讀取、rand() 或網路呼叫(SDK 提供了時間和隨機性的確定性版本)。所有非確定性的操作都放入 activities,其 結果 會被記錄並重播。

將 Temporal 保持在單一服務內

這是我最想爭取的原則,因為這是 Temporal 自身行銷容易引導你掉入的陷阱。

Temporal 允許你在一個服務中定義工作流,並在任何地方執行其 activities——十個服務都可以。一個工作流編排你的整個系統聽起來很棒,我曾看到有人大力推廣這個承諾。

這是耦合陷阱。當 activity 存在於另一個服務中時,該服務需要 Temporal 整合以及它原本不需要的共享命名空間。更糟的是,其 activity 的輸入/輸出結構現在綁定到 你的 工作流。同事改變了他們 activity 的形狀,舊格式的結果在你的 history 中無法再反序列化到新程式碼中,而你執行中的工作流就會中斷。你透過雙方都看不到的機制耦合了兩個團隊的部署。

在服務邊界劃分。Workflow 及其所有 activities 由單一服務擁有。當 activity 需要另一個服務時,它發出普通的 HTTP 呼叫或 Kafka 事件——這是大家都理解的無聊、通用做法。Temporal 成為單一服務的實作細節,且沒有工作流跨越兩個團隊。

帳戶建立是個好例子。整個工作流包含四個 activities:

  1. 標記建立已開始 - 寫入我們自己的資料庫。
  2. 向支付處理器註冊帳戶 - HTTP。
  3. 在帳本中建立帳戶 - HTTP。
  4. 標記建立已完成並發出 account_created 事件 - 寫入我們的資料庫,發布到 Kafka。

Workflow 只是指定順序;每個 activity 執行工作並按 ID 載入所需資料,因此豐富資料不必跨越服務邊界傳遞。

Workflow 是非同步的:宣告結果

如果你來自請求/回應模式,這是心態轉變。啟動工作流幾乎立即返回——它 不會 等待工作完成。工作在 worker 上在背景執行,因此呼叫者無法從回應中讀取結果;結果必須透過其他方式返回。

這就是為什麼帳戶建立工作流以發出 account_created 結束。事件不是裝飾——這是其他人得知帳戶實際建立的方式。任何需要回應的人(發送歡迎電子郵件、配置卡片)都訂閱該事件,而不是阻塞在你的呼叫上。這對事件驅動系統很好;但當呼叫者需要在同一個請求中得到答案時,這就不適合。

也要宣告失敗路徑,但前提是你有失敗路徑。由於無限重試,工作流可能會一直持續到成功,因此沒有終端失敗可以報告。如果你在 N 次嘗試後放棄,請在該路徑上發出某個事件,讓下游不會一直等待永遠不會來的成功。

Activities:冪等、可重試,以及例外陷阱

Temporal 會自動重試 activities,使用指數退避和無限次嘗試,直到成功。你繼承了這個行為——你不需要請求——接下來有兩件事。

首先,冪等性是正確性,而不是衛生。 任何 activity 都可能 執行多次,如果執行兩次會做錯事,那麼你遲早會遇到它。在帳本中這很具體:一個發布交易的 activity 如果執行兩次,就會移動兩次資金。解決方法是任何重試呼叫者背後的普通做法——在 綁定到操作身份的穩定鍵(交易 ID 加上步驟)上對寫入去重複,永遠不要在每次嘗試時使用新的 UUID,這會使檢查失效。每個 activity 最終都呈現相同的形狀:按 ID 載入狀態,如果該鍵已存在則提前返回,執行工作,提交。

其次,你如何發出失敗訊號決定它是否重試。 引發例外表示「重試我」,所以要慎重:

  • 暫時性失敗(逾時、503):讓它引發。它會重試並恢復。卡住比失敗好。
  • 預期的「否」(已拒絕、已存在):不要引發。將它作為正常結果返回,讓工作流繼續。對永遠不會改變的業務結果拋出例外,你就是在永久答案上建立無限迴圈。這是整個主題中最尖銳的邊緣。
  • 真正無法恢復的失敗(錯誤輸入):引發 不可重試 的錯誤,讓它快速失敗。

對於「停止或永遠重試」,我的預設是從無限重試開始並監控:如果工作流必須成功,那麼加上好的警報就是最簡單可行的方法,一旦你部署修復,它就會自我修復。但無限次嘗試只有在每次嘗試都有 StartToClose 逾時的情況下才安全。activity 如果 掛起 而不是當機,就永遠不會產生失敗來重試,因此它會永遠卡住。這才是真正的陷阱,而不是無限重試。

Workflow ID 是免費的並行鎖

啟動工作流時設定 workflow ID,Temporal 保證在任何時間只有一個具有該 ID 的工作流在執行。這是你不必建立的序列化鎖,而 ID 方案決定了粒度:account_id-create-account 只序列化該操作,而 account_id 本身則序列化該帳戶的 所有 操作(代價是每個帳戶的吞吐量)。

我曾在帳本前面的帳戶生命週期服務中使用過這個功能。某些更新必須嚴格序列化,而在帳戶 ID 上設定工作流鍵值讓我們做到了這一點,無需鎖定表。ID 就是鎖。(它只在工作流執行期間有效;關閉後的重用由政策管轄。)

Temporal 進行編排;它不保存你的狀態

Temporal 執行流程。它不是你的狀態所在之處——無論是工作流攜帶的即時狀態,還是發生事件的持久記錄。兩個習慣保持這個邊界清晰。

保持執行中的上下文最小。 不要用資料載入工作流並向下傳遞快照給 activities。讓工作流傳遞 ID,讓每個 activity 在執行時從資料庫載入所需資料並決定如何進行(包括其冪等性檢查)。早期擷取的快照會過時,寫回會覆蓋並行更新;傳遞 ID 避免了這一點,將敏感資料排除在有效載荷之外,並保持事件 history 簡潔。

將持久狀態保留在你自己的資料庫中,而不是 Temporal 中。 Temporal 會在保留期間內保留 history,然後會老化——因此當六個月後有人問帳戶發生了什麼事時,這不是你要尋找的地方。這涵蓋兩件事。你的 領域狀態(餘額、狀態、報告和電子郵件背後的資料)顯然屬於你的資料庫。但工作流自己的 進度 也屬於此:第一個 activity 寫入 creation_started,最後一個寫入 creation_completed。這讓 Temporal 純粹進行執行時編排,而你的資料庫擁有記錄。

寫下進度也給你重要的警報。Temporal 內建的延遲指標只有在工作流 結束 時才會觸發;沒有指標顯示 開啟中 的工作流已經執行了多久。所以我們保留了自己的指標:每小時作業會標記在閾值內尚未達到 completed 的開始資料列。這是一個簡單的資料庫查詢——但前提是你寫了這些記錄。(也要對每個工作流類型的有意義 SLA 發出警報,以及任何卡在巨大重試次數上的 activity——這是隱藏無法自行解決的失敗的無限重試。)

這就是 雙寫問題:對兩個系統(這裡是 Temporal 和你的資料庫)進行兩次寫入,它們無法共享交易,因此兩者之間的當機會導致兩者不同步。你無法使它們原子化,所以要排序它們以選擇你可以接受的失敗。首先啟動工作流——Temporal 在返回前持久記錄它——讓它的第一個 activity 寫入「已開始」資料列;當機只意味著工作流會重試直到資料列落地,而不是沒有人會處理的孤兒資料列。資料庫和 Kafka 版本出現在下面的 Kafka 部分,其中 outbox 模式可以完全消除它。

演進工作流:向前修復,以及長時間執行的陷阱

工作流是變更成本最高的部分,所以保持它簡短:沒有聰明的分支,直線進行,複雜性推到 activities 中。

當你修復錯誤時,這會得到回報。Activity 中的錯誤是無痛的: 修復程式碼,部署,下一次重試就會執行新程式碼——工作流定義從未改變,因此確定性永遠不會受到威脅。Workflow 中的錯誤是困難的情況: 執行中的工作流正在重播舊 history,而新的編排程式碼可能與之分歧。所以將大腦保留在 activities 中,並設計向前修復。

這個困難情況在 長時間執行的工作流 中變成永久的,這是營運痛苦的最大來源。我曾處理過執行數週的帳戶關閉工作流——餘額可以關閉前的法規持有。開啟的時間窗越長,底層發生變更的機會就越多,而你一直都要維護該程式碼路徑的即時運作。一個 30 天前卡住的工作流就是你無法安全退役其定義的工作流。

所以持續數週的工作流需要一個在執行中變更它的計劃:清空舊的工作流,或就地修補讓舊的執行走舊路徑。Temporal 緩解了這一點——Continue-As-New 將長工作流檢查點到新的 history 以保持其有界且可變,而持久的 timerssignals 以原生方式模擬等待,而不是輪詢迴圈。但真正的教訓更簡單:風險與工作流保持開啟的時間成正比,所以保持它們簡短。

知道什麼時候 Kafka Consumer 就夠了

顯而易見的問題是「為什麼不直接使用 Kafka?」誠實的答案是:你 可以 使用 Kafka 編排多步驟流程——讀取訊息、做工作、提交 offset、發出下一個。問題是你自己建立所有機制:重試、死信佇列、退避、每個步驟的狀態、卡住地方的可見性。

Temporal 給你這些,加上 每個 activity 的重試配置,這是你要在 Kafka 中手動完成的:activity A 每十分鐘重試一次並退避,B 不可重試,C 最多五次嘗試。持久執行本質上是一個在每一步之間都有檢查點的 Kafka consumer,已經為你寫好了。

那麼什麼時候值得額外的平台?當流程需要 等待、被 監控,或有 多個副作用要在之間檢查點 時。在此之下——一個副作用,無需等待,重試然後死信就足夠——consumer 是正確的工具,而 Temporal 是額外負擔。

如果你確實走 consumer 路線,你的提交順序就是整個遊戲:

read msg → do work → commit DB → emit event → ack input (last)

Enter fullscreen mode Exit fullscreen mode

在資料庫提交後發出(在此之前,失敗的提交會留下虛幻事件),最後 ack 輸入(先 ack 後當機會丟失工作)。這又是資料庫和 Kafka 雙寫:排序選擇較小的失敗,但提交和發出之間的當機仍然會丟失事件。要完全彌補這個差距,請使用 transactional outbox pattern——在與狀態變更相同的交易中將事件寫入 outbox 表,讓 relay 之後發布它。Temporal 開箱即用地給你這種持久性;consumer 讓你自己建立它。

這一切都不會讓 Kafka 成為輸家。Kafka 獲勝的地方是 fire-and-forget 領域事件:你發出「帳戶已建立」並不擁有下游對它的操作。Temporal 是用於你擁有的工作流,你關心每一步都到達結束。

一個替代方案:DBOS

如果你猶豫的是要執行和操作的獨立叢集,DBOS 值得一看。這是持久執行的一種更輕量的方法:一個將工作流狀態持久化到你已經運行的 Postgres 資料庫的函式庫,而不是你建立並照顧的獨立服務。你用 Temporal 更豐富的工具和規模換取更少的營運表面——當你的工作流規模適中且你不想擁有另一個叢集時,這是一個合理的交易。

從哪裡開始

以上所有內容都源自一個想法:history 是事實來源,worker 可以在任何時間重播它。 確定性、將邏輯保留在 activities 中、冪等重試、將領域狀態保留在你自己的資料庫中——它們都源自這一點。理解這一點,其餘的就不再感覺像檢查清單。

能節省最多痛苦的一個流程習慣:抵制直接在生產環境中迭代。啟動本地開發伺服器(temporal CLI 可以在一個命令中給你一個),讓工作流端到端執行,並從小事開始。並依賴 Web UI——檢查工作流的完整 history,然後手動終止或重播它,這是佇列加死信設定永遠無法給你的具體功能之一。


If you found this useful, I write about distributed systems and engineering practices at nejckorasa.github.io. You can also find me on LinkedIn and X.