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 按需唤醒并运行自己的节目!
- 事务完整性:通过顺序消息处理、原子状态改变和幂等设计,参与者确保一致且可靠的操作,消除竞争条件并优雅地处理重试。
- 状态表示:参与者应使用明确定义的事件和受控转换来积极管理状态转换,确保可预测的行为和封装的状态机以增强可靠性。将有限状态机嵌入到参与者的状态中以实现最终控制。
- 建模技术:采用“待完成的工作”、数字孪生、拟人化、工作流编排和聚合等策略来创建强大、可扩展且完全领域一致的参与者系统,以反映现实世界的业务流程和工作流。