Jeremy Carter 的文章《思考 Actor:第 1 部分》討論了 Actor 模型作為管理現代軟體應用程式(尤其是分散式系統)狀態的框架。以下是主要要點的總結:
每個軟體開發人員可能都接觸過某種分層架構。我們傾向於將元件分類為最適合的層,然後在同事不同意時與他們辯論 - 但最終一切都可以分類。
因此,您可以使用當前的 "最佳實踐",建立一個簡單的網路應用程式。 它可能會使用控制器、服務和儲存庫層,然後將其連線到 SQL 資料庫,就可以了! 你有一個名為 order 和 order_item 的表。 編寫一個控制器 OrderController,它將與 OrderService 對話,然後使用 OrderRepository。 您可以將系統連線到某種快取上,並提供更多的功能。
對於大多數應用程式來說,類似這樣的方法效果很好。直到你需要分發它(因為它在檔案 → 新的 Web 應用程式命令中)。因此,你再次環顧四周,看看什麼是“最佳實踐”。你加入了某種形式的負載平衡,在它前面放置了一個 API 閘道器,新增了更多快取。站在 10 英尺後方,你會看到一整套新挑戰:網路延遲、併發性、資料一致性、爭用、資料庫效能、容錯性和可觀察性。
那麼,所有規定的層級都是必要的嗎?這些挑戰是各自獨立的,還是都有其根本原因?
問題究竟出在哪裡?
乍一看,您可能會認為我們面臨的新挑戰都是獨立的,並且可以以某種分層的方式解決。
將 API 直接放在資料庫前面似乎是一個簡單的解決方案:公開執行 CRUD 操作的端點,讓資料庫完成繁重的工作。這看起來很完美,因為 Web 是無狀態的,每個請求都是自己的操作。遺憾的是,這種方法過於簡化了實際現實世界應用程式的複雜性。
問題始於我們的資料模型以及我們如何與其互動。原因如下:
貧血資料模型
貧血資料模型是一個重大問題,即域物件缺乏有意義的行為和封裝。這會導致系統脫節,業務邏輯分散在不同層(控制器、服務)中,而不是集中在域物件中。軟體設計和業務流程之間的這種錯位使應用程式開發和維護變得複雜。
貧血資料模型很容易被發現。到處都是領域物件,只是簡化為 DTO,沒有封裝任何行為或業務邏輯。我們看到的不是豐富、有意義且模型良好的物件,而是分散在控制器或服務等層中的由業務邏輯修改的屬性。
當缺乏封裝時,您會看到一個脫節的系統,這使得理解完整的邏輯流程變得更加困難。
以下是貧血資料模型的一個例子:只有setter/getter方法的DTO物件。
業務邏輯不一致
業務規則和域邏輯很少與資料庫操作 1:1 關聯。因為我們大多數人被教導以關聯式資料庫的方式思考,所以我們傾向於以行和表的方式思考。
例如,人們很容易將“下訂單”或“向訂單新增商品”等操作視為或表上的簡單INSERT或UPDATE語句。但是,除非您正在為大學作業編寫 Hello World 應用程式,否則這些操作絕不會那麼簡單。通常,它們涉及多個步驟,例如驗證、與其他系統的通訊、狀態轉換或複雜的業務規則。orderorder_item
RESTful API 模式雖然在許多場景中被廣泛使用且有益,但它卻無意中強化了以實體而非業務操作來思考的觀念。端點喜歡POST /orders或PUT /orders/{id}鼓勵開發人員將 API 視為與資料庫表的直接對映,其中每個請求對應於一個 CRUD 操作或某種資料庫變異。
當您公開 API 時,您實際上可能公開的是流程或工作流。不知何故,我們透過 API 向客戶公開了實體,而不是他們想要執行的實際操作。那麼我們為什麼要這樣做呢?
解決問題
如果你只有一把錘子,那麼你會把所有東西都當成釘子。
作為開發人員,我們很容易將軟體挑戰視為純粹的技術問題。通常,我們的任務是“解決問題”,因此我們專注於修補症狀。當我們處於這種心態時,很容易在 Google 上逐一搜尋問題:併發性?新增一些分散式鎖定機制。資料庫效能?新增更多快取。容錯性?新增重試機制和冪等性。雖然這些解決方案可能單獨起作用,但它們無法解決根本原因:軟體與其服務的業務領域之間的不一致。
人們常說,所有問題都是溝通問題。巧合的是,在這種情況下,問題的根源在於我們如何對領域進行建模——我們將領域模型錯誤地傳達給了程式碼。
- 可能是因為我們一開始就不太瞭解業務領域?
- 可能是因為我們沒有應用領域驅動設計技術?
- 可能是因為我們對軟體的建模方式與資料庫的資料模型和範例緊密耦合?
- 我們是否關注資料庫模式和程式碼而不是客戶問題?
領域建模
領域建模不僅僅是建立資料結構,還涉及捕捉業務流程、行為和互動。
最終目標是建立一個足夠豐富的模型,以真正反映現實世界。
當您擁有貧血資料模型時,我們會發現系統中的物件只是資料容器,沒有任何有意義的行為。更注重領域驅動的方法會讓我們把業務邏輯、狀態和行為封裝在領域物件本身中。
建模時我們需要考慮:
- 關鍵問題、人物和組織
- 使用者想要執行的簡單操作以及他們想要實現的目標
- 狀態和行為(操作、工作流、流程)
- 背景上下文和界限邊界
- 現實世界的規則、限制和例外
Amy Fu 最近發表了一篇博文,我將其引用如下:
- 如果我們編寫的程式碼符合產品的基本思想,它將更有可能在未來的產品變化中存活下來。
其實很簡單——首先根據想法建模,其次根據行為和狀態建模。為什麼這很重要?
反對有狀態的推理
狀態是軟體開發中的一個基本概念 - 它代表系統對世界的瞭解並驅動行為。它被認為是一把雙刃劍。一方面,幾乎每個應用程式都會有某種形式的狀態,另一方面,管理狀態很複雜,尤其是當系統是分散式、併發的或需要容錯時。
當複製錯誤時,您實際上是在嘗試讓系統處於有缺陷的狀態並對其值進行推理。如果每個操作和互動都被視為潛在的狀態變化,那麼我們為什麼不以一種可以隔離和查詢的方式設計和建模我們的狀態呢?
當我們分散式系統時,狀態管理成為一個關鍵挑戰。原因如下:
- 併發性:多個元件或使用者可能同時與同一狀態互動。如果沒有適當的處理,這會導致競爭條件、陳舊資料或損壞。
- 一致性:在分散式系統中,跨節點維護一致的狀態檢視非常困難。
- 可擴充套件性:當多個元件需要訪問或修改狀態時,狀態可能成為瓶頸。有些系統會分割槽/聚合狀態,但這可能很複雜。
- 容錯性:分散式系統必須能夠從故障中恢復。確保節點崩潰、網路分割槽、重試和部署期間的狀態一致性需要額外的設計。當重試時,它會處於相同的狀態嗎?
- 可觀察性:跨多個元件或服務除錯問題可能很困難。在發出請求時,元件處於什麼狀態?
鑑於這些挑戰,許多現代系統儘可能地傾向於無狀態架構。在一個簡單的 Controller-Service-Repository Web 應用程式中,我們的狀態到底在哪裡?
如果我們已經以豐富的現實世界方式對領域進行了建模,那麼我們如何才能將其變為現實?我們可以讓它有狀態嗎?如果可以,它會是什麼樣子?
管理型有狀態的案例
我們需要做些什麼來更好地管理我們的狀態?在理想世界中,這就是我們想要的:
- 我不想把所有東西都寫在控制器、服務和儲存庫中。我的領域中有很多部分需要妥善管理狀態和行為。
- 業務規則、流程和工作流應存在於域物件內,以確保邏輯具有凝聚力和集中性。
- 我希望能夠清楚簡潔地表達領域邏輯,以便管理的所有內容都Order 封裝在一個地方。
- 我希望能夠定義狀態機和轉換,以便系統能夠很好地防止出現錯誤狀態。
- 領域物件應該隨著系統自然擴充套件,支援分割槽和分片而無需額外的複雜性。
- 我不想建立鎖定機制來處理併發。
- 我不想為無序訊息建立排隊機制。
- 系統應該原生支援並執行有效的狀態轉換,從而降低無效或不一致狀態的風險。
- 狀態永續性應該是無縫的,並且對開發人員透明。
- 我希望能夠輕鬆地除錯事物的狀態,我希望能夠輕鬆地注入精確的狀態,以便我可以複製錯誤。
- 領域模型應該支援發出和響應領域事件,從而能夠輕鬆與其他系統和工作流整合。
- 我不想加入自定義的重試和容錯機制。
- 系統應該支援水平和垂直擴充套件,而無需任何額外的複雜性。
- 我希望能夠測試域物件的整合而不需要任何額外的複雜性。
Actor 模型
Actor 模型提供了一種結構化的方法來推理狀態。Actor 系統充當小型獨立物件(稱為 Actor)的執行時,這些物件封裝了狀態和行為。系統具有:
- 封裝:每個參與者都擁有自己的狀態,並透過定義明確的訊息或方法將其公開。外部元件或其他參與者無法直接訪問內部狀態,從而保持事物的純粹性並防止腐敗。
- 行為:通常情況下,參與者會對傳入的訊息做出反應,從而改變狀態、產生響應或建立進一步的訊息。參與者可以與其他參與者對話。
- 隔離性:由於 Actor 按順序處理收到的訊息,因此 Actor 內部不存在併發問題。這簡化了狀態突變和轉換的推理。
- 設計分散式:Actor 可以分佈在各個節點上,狀態自然分割槽。這樣可以實現水平擴充套件,無需共享鎖、複雜事務或協調器。
- 容錯性:訊息之間的狀態可以持久儲存(例如,儲存到儲存、資料庫或事件日誌)。如果 Actor 崩潰,它可以在另一個節點上恢復其狀態並繼續處理。
Microsoft Orleans 、Service Fabric和Dapr等系統提供了虛擬參與者模式。虛擬參與者模式引入了一些抽象來簡化開發。您可以將虛擬參與者視為記憶體中的分散式 OOP 物件(有點像舊的EJB )。該物件具有諸如 之類的方法AddToOrder ,其生命週期由系統管理。可以從外部呼叫該物件(從您的 API 或事件)。
有5個核心特徵:
- 唯一標識:每個虛擬參與者都透過 ID 及其型別唯一標識。例如OrderActor/28afcc20-913b-4415-964b-2dcf465902e3
- 按需啟用:虛擬參與者在需要時自動例項化(啟用)。當它們閒置一段時間後,就會停用。
- 設計有狀態:虛擬參與者封裝自己的狀態。狀態透明地儲存到後端儲存(例如資料庫、Blob 儲存),並在重新啟用時恢復。
- 併發安全:每個參與者按順序處理訊息,一次一個,確保執行緒安全。無需額外努力即可防止競爭條件和死鎖。鼓勵模組化設計,每個參與者獨立運作,只關注自己的狀態和行為。
- 容錯和可靠:如果參與者的主機節點發生故障,則執行時可以重新啟動或在另一個節點上重新分配該參與者,並保持其狀態和可用性。
本文前面要點
- 資料庫建模是以後的事— 首先從資料庫開始設計系統很容易。不要忽略資料建模,而要天真地只關注現實世界的模型。
- 貧血資料模型阻礙可維護性— 狀態和行為的封裝至關重要。貧血模型會導致業務邏輯分散,使系統更難維護和擴充套件。
- 使用領域驅動設計重新思考狀態——包含狀態、行為和轉換的豐富的真實世界模型,更好地滿足業務需求並簡化分散式系統中狀態的推理。
- 參與者模型解決了有狀態的挑戰——參與者封裝狀態和行為,按順序處理訊息,並自然擴充套件,解決分散式系統中的併發性、永續性和容錯性。
- 虛擬演員簡化了分散式系統——Microsoft Orleans 推廣的虛擬演員模式為我們提供了一種大規模建模和執行真實世界模型的方法。
Actor 建模的三大支柱
1 - 所有權
在某些系統(尤其是貧血系統)中,你會發現沒有人真正擁有狀態。應用程式的任何部分都可以修改某些內容。例如,OrderService 和OverdueOrderService可能都被允許修改expiryDate,從而形成了一個複雜的潛在衝突和競爭條件網路。
Actor 模型引入了完整的狀態所有權。任何外部元件都無法直接操縱 Actor 的內部狀態。受併發保護的狀態將成為完全受控的資源 - 複雜性侷限於每個 Actor 的邊界內。
示例:OrderActor完全控制訂單生命週期的 。外部系統和 API 不會直接修改訂單;而是呼叫Create、AddItem和等方法Cancel。參與者決定如何響應,保持其內部一致性。
所有權確保參與者獨立運作,從而簡化了對系統行為的推理並增強了模組化。
2 - 生命週期
Actor 不僅僅是靜態物件。它們具有由執行時管理的生命週期鉤子。虛擬 Actor 模式具有以下事件:
- 啟用— Actor 根據其身份按需啟用。當 Actor 被重新啟用時,它將恢復到之前的狀態。
- 停用— 空閒時(或被提示時)可正常停用,釋放資源。擁有數百萬參與者的系統無需耗盡記憶體或處理能力即可執行。
- 狀態永續性— 狀態可以自動儲存,並且可以跨不同節點或系統重啟後恢復。它可以儲存在簡單的 blob 儲存中,也可以儲存在您選擇的資料庫中。
- 計時器— 計時器允許參與者安排重複或一次性的內部操作。它們對於會話、超時、輪詢、重試和延遲等非常有用。
- 提醒— 一種持久的排程機制。它們在參與者停用和系統重啟後仍然存在,確保不會錯過關鍵的基於時間的操作。它們對於通知、寬限期、續訂和到期非常有用。
示例:
- 當使用者登入或啟動會話時,AUserSessionActor啟用。
- 當會話過期或使用者登出時,該參與者將被停用。
- 如果使用者未登出,則觸發計時器以清理會話。將發出諸如session.created、session.completed和session.timeout 之類的事件。
3 - 事務
參與者模型透過訊息處理重新構想了事務交易:
- 順序訊息處理:每個參與者一次處理一條訊息,從而消除競爭條件和無序處理。
- 原子狀態變化:參與者內部的狀態修改本質上是原子的。
- 冪等設計:訊息可以安全地重試,而不會產生意外的副作用。
一些參與者框架包括 ACID 功能,例如Microsoft Orleans 。這對於建立參與者之間的事務很有用。
示例:處理付款時,PaymentActor確保交易成功完成,然後再更新訂單狀態,即使發生多次付款嘗試也能保持一致性。交易本身也可以是一個參與者,從而可以更精細地控制和隔離付款流程。
一切都是操作: 工作流程或過程
看看你的程式碼庫,看看訪問 API 的請求。 幾乎所有東西都可以寫成操作--工作流或流程。 命令查詢分離(CQS)和命令查詢責任分離(CQRS)等模式透過明確劃分改變狀態的操作(命令)和檢索狀態的操作(查詢)來強化這一點,通常採用 RPC(遠端過程呼叫)風格進行通訊。
一些程式碼庫明確區分了用於命令、查詢和響應的 DTO,例如,它們成為 CreateOrderCommandDto。 這種觀點將重點從資料操作轉移到了管理實現特定業務目標的操作序列上。
將actors 視為獨立、自足的程序,而不是資料庫行。
為actors 採用 RPC 風格的介面,與將互動視為操作相一致。
每個應用程式介面請求都可以對應actors 執行的特定操作,而不是針對實體/資料突變建模。 例如,像 POST /orders 這樣的 API 端點可以轉化為呼叫 OrderActor 上的 Create 方法,該方法負責處理整個建立過程,包括驗證、狀態甚至資料庫更新。
如果角色與外部依賴關係解耦,操作被明確定義並封裝在角色中,那麼就更容易編寫涵蓋特定工作流或流程的測試。
此外,跟蹤命名操作(如 Order.Create)的流程也變得非常容易。 示例:在賬單系統中,檢視發票生成的流程: 在計費系統中,將發票生成視為一項操作,可以將整個工作流程封裝在 InvoiceGenerationActor 中。 現在,OrderActor 與發票無關。 記住所有權、生命週期和交易這三大支柱--發票生成有其獨立於訂單的生命週期。
狀態表達
正確建模狀態轉換對於建立可靠且可預測的基於參與者的系統至關重要。將您的軟體系統想象成一個有生命、有呼吸的事物集合,具有一組明確的狀態、精確的轉換規則以及對其自身生命週期的內在理解。
不要將您的狀態視為一些被動的可變屬性集合。將正規化轉變為具有自身規則和行為的主動智慧構造。
狀態代表制的核心原則是:
- 明確定義——事件產生轉變。進入和退出條件。相關行為和約束。
- 受控轉換 — 不允許任意轉換,只允許有效轉換。轉換是可記錄的、可追蹤的和可預測的。
- 僅由一件事物擁有 — — 只有一個守門人。
Actor 可以承載多個相互連線的狀態機,從而建立複雜但易於管理的系統行為。它們是受保護的秩序飛地。您無需將狀態直接嵌入 Actor,狀態可以是它們自己定義的物件 - 完全可進行單元測試。您甚至可以對 Actor 進行建模以封裝單個狀態機,就像某種控制器一樣。
五個建模技術
有效的參與者建模需要運用源自經典物件導向程式設計 (OOP)、領域驅動設計 (DDD)、函式式和事件驅動風格的技能。以下五種基本建模技術可幫助您設計健壯且可擴充套件的基於參與者的系統:
1-要完成的工作
“待完成的工作”框架是一種方法,您需要了解客戶的具體目標(或操作),前提是客戶會去“租用”產品來完成這項工作。可以將其視為為您需要填補的角色制定工作描述。“這個演員被‘僱用’來做什麼具體工作?”
每個參與者都將成為以下員工:
- 明確的目的(有存在的理由)
- 具體職責(無需擔心其他任何事情)
- 明確界限(不會超越)
- 獨特能力(技能、特質和熱情)
以這種方式建模的參與者往往具有名詞字尾,例如Manager、Coordinator、Controller 和 Warden 。
例如:庫存管理。 |
2.數字孿生
數字孿生是現實世界事物的虛擬複製品,可實現更逼真、更準確的模擬。首先,您需要真實地模擬參與者及其互動,以反映他們的物理對應物。在參與者之間建立更自然、更詳細的關係,而不是僅僅認為事物是“相關的”或“連線”。
數字孿生成為一個獨立的實體,其特點是:
- 反映現實世界物體的特徵
- 瞭解自己的生命週期以及如何獲取新資訊
- 能夠預測並應對潛在情況
- 保持自身的內部狀態和互動規則
舉例說明: 由序列號標識的恆溫器可跟蹤溫度。 它儲存最近 5 分鐘的資料以及平均溫度。 它選擇持續保留當前值,並在記憶體中保留以前值的緩衝區,以便透過統計分析檢測異常。 它還儲存警報設定點,並在進入狀態時通知警報管理器(AlarmManager)角色。 房間代理(RoomActor)擁有恆溫器代理(ThermostatActor),它透過計時器每分鐘只接收一次溫度讀數。
3 - 角色化
角色化是指把流程、程式或任務當作具有不同行為和特徵的個體角色來建模。 它類似於 "待完成的工作",但略有不同的是,它可以用來反映重要性、價值和關鍵性等概念。 不同的角色可以包含不同的行為和反應,從而增加系統執行的靈活性和豐富性。 角色可以是任務或流程管理的一個短暫的包裝,它可以被賦予實現預期結果的特徵。
舉例說明: DataAnalyzerActor 代表資料分析師檢查和分析資料流的能力。 該角色接收事件,並有一個計時器作為看門狗執行,以確保事件仍在不斷髮生。
4 - 工作流和流程編排
在這裡,我們從使用者或企業的角度對映業務流程。這可確保系統與實際操作緊密結合。規劃出構成完整工作流程的必要步驟、互動和事務。
因為我們為這個編排使用了有狀態的 Actor,所以我們可以儲存初始請求,冪等地執行,當然還可以重試任何失敗的步驟。以驅動表示層的方式對 Actor 進行建模可能也很有用 - 這使得更改流程變得容易,而無需編輯 UI。
例如
DeliveryRequestActor 包含了準備送貨、驗證送貨以及將送貨傳送到外部系統接受和處理的幾個步驟。 該角色模擬了我們在移動應用程式中向使用者展示的嚮導式流程。 該角色收集所有表單資料,並向前端提供下一個可用步驟。
如果使用者關閉了移動應用程式,他們可以繼續之前的操作,因為 Actor 可以恢復之前的狀態。 當使用者準備好提交請求時,Actor 可以提供反饋,表示請求已成功提交。
注:考慮一下這在實時多使用者系統中的作用,即兩個人在同一個工作流程中工作。
5 - 聚合
這可能是最難掌握的技術,但一旦掌握,您就會愛上它。聚合參與者借鑑了 DDD 和統計概念。
聚合涉及將您的參與者視為圖表的一部分,其中較小的下游參與者將重要事件、指標或狀態通知上游參與者。此資訊流入聚合樣式的參與者,該參與者可以儲存資訊、傳遞資訊或執行統計分析。這些聚合參與者還可以進一步聚合參與者圖表上的資訊(aggregateception ),從而建立資料整合和處理層。它們就像一個複雜組織系統的執行儀表板,不斷從各種來源綜合資訊。
它們的核心特徵是:
- 下游參與者(更具體、更細粒度的參與者)生成事件和指標
- 上游聚合參與者收集、處理並重新分發這些資訊
- 資料像分層通訊網路一樣流動
這些參與者具有分層智慧。與傳統的資料收集方法不同,聚合參與者不是被動的儲存庫。它們不需要被查詢;它們擁有所有熱門和實時的資料。它們是主動和智慧的節點,可以:
- 瞭解其資料的確切背景
- 能夠運用統計推理和分析來檢測模式和異常
- 可以做出自適應決策,發出事件或立即呼叫其他參與者
示例:
CustomerOrderAggregatorActor 管理每個客戶訂單的具體指標,其關鍵字是客戶 ID。 每個指標都儲存最近 10 個值,以提供簡單的趨勢資訊(上升或下降)。
當該客戶的新訂單狀態發生變化時,這些指標將被跟蹤,以便在 1 毫秒內載入客戶儀表板。 活動訂單、逾期訂單、已發貨訂單、過去 30 天內的訂單;每項指標都會被跟蹤。
RegionOrderAggregatorActor 具有相同的作用,但它只跟蹤特定地區(在其 ID 中)的指標,並驅動地區銷售內部儀表板。
最後,我們還有 OrderAggregatorActor,其標識還包括年和月,例如 2024-01。 該聚合器可確保將這些值持久儲存到 Clickhouse 資料庫中,以便將來進行分析查詢。
思考
有幾種趨勢影響著我們建模系統的方式。其中一些是技術性的、相當合乎邏輯的,另一些則更具哲學性。我剛剛描述的技術的基礎來自:
- 領域驅動設計——軟體設計與業務領域之間的協調。
- 事件驅動系統——非同步通訊和解耦元件
- 分散式系統——資料流、記憶體資料網格和大規模分析。
並非所有 Actor 都需要將其狀態持久儲存到外部儲存提供商(例如資料庫或 Blob 儲存)。您可以選擇建立臨時 Actor,它們僅依賴於記憶體 - 例如路由器、緩衝區、Reducer 或工作處理器。
結論
- 所有權:參與者僅管理其內部狀態,防止外部修改。它們不應對映到您的實體,而是對映到您的業務流程和客戶互動。
- 生命週期管理:Actor 具有動態生命週期,包括啟用、停用、狀態永續性、計時器和提醒,從而實現可擴充套件系統,以高效利用資源並以容錯方式維護狀態。無需 CRON 作業或查詢來查詢誰需要提醒通知電子郵件 - 讓 Actor 按需喚醒並執行自己的節目!
- 事務完整性:透過順序訊息處理、原子狀態改變和冪等設計,參與者確保一致且可靠的操作,消除競爭條件並優雅地處理重試。
- 狀態表示:參與者應使用明確定義的事件和受控轉換來積極管理狀態轉換,確保可預測的行為和封裝的狀態機以增強可靠性。將有限狀態機嵌入到參與者的狀態中以實現最終控制。
- 建模技術:採用“待完成的工作”、數字孿生、擬人化、工作流編排和聚合等策略來建立強大、可擴充套件且完全領域一致的參與者系統,以反映現實世界的業務流程和工作流。