3.12

View in English

3.12 事件驱动架构与消息传递

概述与动机

事件驱动架构(Event-driven architecture,EDA)是一种组件通过产生事件和响应事件来通信、而不是直接互相调用的风格。事件是一个事实:某件已经发生的事情,例如“OrderPlaced(订单已下达)”或“PaymentCaptured(付款已收到)”。生产者宣布这个事实后就继续前进,任意数量的消费者可以按照各自的节奏做出反应,而生产者并不知道是谁在监听。这与第 2.3 章讨论的请求,响应调用姿态不同,在那种模式中,调用方要求某个特定服务执行某项操作并等待答复。

对大型组织而言,其吸引力在于大规模的解耦。当你拥有数十个团队和数百个服务时,用直接的点对点调用把一切连接起来会产生一张脆弱的网:一个团队的改动会破坏另一个团队的功能,而且没有人能追溯原因。事件让团队通过一个共享的事实流来集成,而不是通过彼此的内部实现,新的消费者只需订阅即可加入,生产者不需要修改一行代码。正是这一特性,而不是单纯的吞吐量,使事件驱动方法在企业中不断取代纠缠不清的集成方式、在政府中不断把各自拥有独立系统的机构连接起来。

公共部门还能获得容易被低估的第二个好处:一份持久、有序的事件记录本身就是审计与透明度资产。当公民询问某项福利决定为何如此作出时,一份导致该决定的不可变事件日志能够直接给出答案。但事件驱动设计并非没有代价,也并非总是正确的选择:异步流程更难追踪、更难推理,也容易被过度使用。本章将明确表明,在何种情况下解耦与规模的收益能够抵消所增加的复杂性,又在何种情况下一次简单的同步调用本会更好地满足需求。本章建立在第 3.3 章所讨论的分布式系统现实基础之上,如果你尚未阅读,建议先阅读该章。

关键原则

  • 事件是事实,不是指令。 事件描述已经发生的事情;命令则请求某件事情发生。要将两者区分清楚,并用过去时态命名事件。
  • 解耦才是重点。 生产者不应该知道、也不应该关心谁在消费它们的事件。如果它们知道,那你所拥有的其实是披着消息传递外衣的耦合。
  • 为“至少一次”交付而设计。 “恰好一次”交付是一个神话。让每个消费者都具备幂等性,这样重复消息就不会造成危害。
  • 顺序是一种需要付出代价的保证。 你能获得的是分区内的顺序,而不是跨主题(topic)的顺序。要审慎地选择分区键。
  • 模式(schema)就是契约。 一个事件的结构是一个公开接口;应当像对待已发布的 API 那样谨慎地演进它。
  • 异步不代表不可观测。 如果你无法端到端地追踪一条消息,你就无法运维这个系统。
  • 复杂性必须是应得的。 事件溯源(event sourcing)、CQRS 和 sagas 强大但代价高昂;应当在问题确实需要时才采用它们,而不是默认使用。

建议

在动手构建之前区分事件、命令与消息

这三个词经常被互换使用,而这种混淆会导致真正的设计错误。命令是要求执行某件事情的请求(例如“CapturePayment”),指向一个特定的处理者,并且可以被拒绝。事件是关于某件事情已经发生的通知(例如“PaymentCaptured”),会广播给所有感兴趣的一方,并且不能被拒绝,因为这个事实已经成立。消息是承载这两者中任意一种、用于线路传输的中性信封。这种区分塑造了耦合方式:命令将发送方与特定的接收方及结果耦合在一起,而事件则放弃了对接下来会发生什么的控制。请用过去时态为你的事件命名,当你发现自己正在发布一个实际上意味着“请去做这件具体事情”的“事件”时,你其实写的是一个伪装成事件的命令。

有意识地在队列、日志与发布/订阅之间做选择

并非所有消息传递都是同一种形态,选错类型是一个常见的早期错误。消息队列将每条消息投递给一个消费者,并且通常在处理完成后将其移除,这适合任务分配场景:多个工作者拉取任务,每个任务只处理一次。持久的事件日志(流)按顺序保存事件,并允许多个独立的消费者按各自的节奏读取,还能从任意时间点回放历史,这适合事件分发和审计场景。发布/订阅(Publish/subscribe)由生产者向一个主题发布消息,多个订阅者各自获得自己的一份副本。实用的经验法则是:如果消息是一个应由一个工作者完成的任务,选择队列;如果它是一个许多方现在或将来都可能关心的事实,选择持久日志,这也为你带来了用于故障恢复和引入新消费者的回放能力。关于这些选择如何与你的数据存储策略相互作用,参见第 3.4 章。

优先用编排式协作(choreography)换取自主性,用中心式编排(orchestration)换取控制力

当一个业务流程跨越多个服务时,你需要以两种方式之一来协调它。在编排式协作(choreography)中,每个服务对事件做出反应并发出自己的事件,没有中央大脑:最大程度地解耦,有利于团队自主,但整个流程仅作为涌现行为存在,没有任何单一的地方能够描述它。在中心编排(orchestration)中,一个中央协调者驱动各个步骤并了解整个流程:更易于监控和修改,代价是产生了一个每个步骤都依赖的组件。一个良好的默认做法是:对于松散相关的反应(例如“订单发货后,忠诚度服务给予积分”)使用编排式协作,对于具有明确成功条件且需要报告状态的确定性事务使用中心编排。不要让一个重要的流程仅仅作为分散在十个事件处理器中的部落知识存在。

只在事件溯源和 CQRS 确实值得时才使用它们

事件溯源将状态存储为一个只追加(append-only)的事件序列,而不是一个被不断覆写的当前快照,你通过回放这些事件来重建当前状态。它的好处是拥有完美的审计轨迹、能够重建任意过去状态的能力,以及支持时间性查询;代价是你需要永远维护事件模式的版本,处理回放和快照,并承载一个大多数开发者从未使用过的心智模型。CQRS(命令查询职责分离,Command Query Responsibility Segregation)将写模型与一个或多个读模型分离,使读和写能够独立扩展和演进;它与事件溯源天然搭配,但并不要求必须一起使用。两者都适合那些真正具有审计、合规或复杂查询需求的领域,这也是受监管的金融和政府机构认为它们值得付出代价的原因。对于一个简单的增删改查服务而言,它们是你会后悔的偶发复杂性,因此应将它们应用于你领域中真正需要它们的那部分,而不是不假思索地应用于整个系统。

用 sagas 而非两阶段提交来管理分布式事务

你通常无法围绕多个服务和数据库包裹一个原子事务。分布式两阶段提交会跨网络持有锁,降低可用性,且扩展性很差,因此它很少适合事件驱动系统。saga 模式取而代之:将事务建模为一系列本地事务,每个事务发出一个触发下一步的事件,并为每一步赋予一个在后续步骤失败时可以撤销它的补偿动作。如果“预留库存”成功但“扣款”失败,一个补偿动作会释放库存。saga 既可以是编排式协作的,也可以是中心编排的,而对于任何你必须监控的流程,中心编排通常更胜一筹。由于 saga 拥抱最终一致性,系统在收敛之前会经历若干中间状态(“已预留但未支付”),因此应当设计你的用户体验和审计轨迹,使其如实展示“进行中”和“已补偿”等状态。第 3.3 章从分布式系统的角度覆盖了同样的内容。

为“至少一次”交付而设计,并让消费者具备幂等性

消息系统无法在故障情况下真正实现恰好一次交付,因为表示“我已处理此消息”的确认本身也可能丢失,从而迫使消息被重新投递。你能够实现的是具备幂等处理的至少一次交付,它带来了恰好一次的效果。幂等性意味着处理同一个事件两次所得到的结果与只处理一次相同;要实现这一点,可以在每个事件上使用幂等键,并记录你已经处理过的内容,这样重复消息就能被识别并丢弃。最多一次交付(发出后不管,不重新投递)更简单,但会悄无声息地丢失消息,因此只应把它用于你能够承受丢失的数据。对一些供应商所宣传的“恰好一次”标签要保持怀疑:它通常只意味着在一个系统边界内、在特定条件下的恰好一次,而不是该说法所暗示的端到端保证。

用分区控制顺序,并了解你的消费者组

顺序既不是全局的,也不是免费的;它是局部的,并且是需要付出代价的。一个流被拆分为多个分区(partition),你获得的是分区内的顺序,而不是跨整个主题的顺序。事件通过分区键路由到某个分区,因此选择这个键就是你控制哪些内容保持有序的方式:以客户 ID 作为分区键,同一客户的事件相对彼此保持有序,而不同客户的事件则并行处理。消费者组让一组工作者共享一个主题的多个分区,每个分区由一个工作者处理,这是你在保持每个分区内顺序的同时扩展吞吐量的方式;这正是可扩展性与正确性交汇之处,与第 3.5 章相呼应。选择一个能反映你真实排序需求、并能均匀分散负载的分区键,因为一个把大部分流量集中到一个分区的键会造成一个无论增加多少工作者都无法缓解的热点。

用注册中心和演进规则把模式当作契约来对待

一个事件的结构是一个由你可能从未谋面的团队所消费的公开接口,因此随意更改它会在远处破坏他们的系统。将你的事件模式放入模式注册中心()一个存储每个模式并在生产者试图更改模式时强制执行兼容性规则的共享目录。采用一项明确的策略:向后兼容的更改(添加一个可选字段)是允许的;破坏性更改(移除字段、更改类型、重命名)需要一个新的模式版本和一个迁移计划。这使生产者能够在不需要与每个消费者同步部署的情况下演进,而这正是你选择事件的全部原因。第 2.3 章中同样的接口版本管理纪律在此同样适用,因为一个事件模式本质上就是另一种形式的 API。

用事务性发件箱保证投递,并显式处理失败

一个典型的错误:你的服务写入数据库,然后发布一个事件,结果在两者之间崩溃,于是数据库已经改变,但事件从未发出。事务性发件箱(transactional outbox)模式解决了这个问题:将事件写入一个发件箱表,且与状态变更处于同一个数据库事务中,因此它们要么一起提交,要么一起失败;随后一个独立的中继程序读取发件箱并发布到消息代理,通常借助变更数据捕获来跟踪数据库日志。对于消费失败的情况,死信队列会保存那些反复失败的消息,这样一条毒消息(一条永远不会成功的消息,也许是因为它格式错误)就不会永远阻塞在它后面的队列。加入背压(backpressure)机制,使快速的生产者不能压垮缓慢的消费者:限定你的队列容量,并在队列填满时放慢速度或丢弃负载,而不是耗尽内存。正是这四种机制,将一个演示系统与一个你可以在凌晨三点运维的系统区分开来。

让异步流程端到端可观测

转向事件驱动之后最难应对的代价是:单一的业务动作现在散布在生产者、代理和消费者之间,没有调用栈把它们串联起来。让关联 ID(correlation ID)贯穿每一个事件,这样你就可以跨越每一跳追踪一个逻辑流程,这与第 3.3 章为同步调用所规定的纪律是一样的。将消费者延迟(consumer lag)(每个消费者的读取进度落后实时数据多远)作为一等指标来跟踪,因为不断上升的延迟是问题最早的预警信号,同时监控死信队列深度、处理延迟和重新投递率。如果没有这些,一个悄悄未被成功消费的事件就会变成一个隐形的缺陷,直到数天后以数据缺失的形式浮现出来。

权衡:优点与缺点

方案优点缺点 / 代价
同步请求/响应易于推理、立即得到结果、易于追踪紧密的时间耦合、级联故障、扩展性有限
事件驱动(基于日志的发布/订阅)解耦、独立扩展、可回放、有审计轨迹最终一致性、更难追踪、活动部件更多
消息队列(任务分配)负载均衡、缓冲、便于背压控制每条消息只对应一个消费者,不太适合广播
事件溯源 + CQRS完整历史、时间性查询、读写可独立扩展需要永远维护模式版本、回放复杂、学习曲线陡峭
Saga(相对于两阶段提交)可扩展、可用性高、无分布式锁最终一致性、需要补偿逻辑、更难推理

核心的张力存在于解耦与可理解性之间。你添加的每一个事件都会松开生产者与消费者之间的耦合,换来团队自主性和独立扩展,但与此同时它也抹去了一行本来由同步调用清楚讲述的故事:流程变成了涌现出来的,存在于交互之中,而不存在于任何一个文件里。解决这一问题的方法是有所取舍:在解耦确实有回报的地方使用事件,例如跨团队边界的集成、向众多消费者的扇出(fan-out)、负载高峰的缓冲,以及审计。在你需要立即得到答案和简单心智模型的地方保留同步调用,例如读取数据以渲染一个页面。需要避免的失败模式是把每一次内部函数调用都变成一个事件,然后把这称为架构,这正是第 3.2 章对你所采用的每一种模式所要求的那种架构判断力。

与团队讨论的问题

  1. 对于这个具体的交互,我们真的需要一个事件,还是一次同步调用会更清晰、更安全? 跳过这个问题正是一个系统积累偶发复杂性的方式。诚实的检验标准是:生产者是否需要立刻得到结果(一次调用),还是仅仅在宣布一个其他人可以按自己的节奏做出反应的事实(一个事件)。请以具体的交互为依据,而不是泛泛的偏好,并追问你获得了什么解耦收益、又放弃了什么追踪清晰度。如果调用方阻塞等待这个“事件”被处理完成,那么你实际上构建了一个缓慢、难以调试的同步调用,还额外付出了代价。对于内部的、同一团队内、需要立即得到答案的交互,默认应当是直接调用;将事件保留给松耦合确实有回报的地方。

  2. 当一个消费者收到同一个事件两次时会发生什么,我们是否真正测试过? 至少一次交付保证重复消息一定会发生,因此每个消费者都必须具备幂等性,然而幂等性很容易被声称,也很容易被做错。请走查一个真实的消费者,精确追踪第二次投递是如何被识别并中和的()是通过幂等键、一份已处理事件的记录,还是一个天然幂等的操作。请带来一次真实测试的结果,即重新投递一批消息,并确认没有出现重复扣款、重复记录或重复通知。要特别关注那些离开你数据库边界的副作用,例如邮件、支付和第三方调用,因为这些正是非幂等缺陷直接伤害客户的地方。如果你的团队无法指出一项能够证明重复安全性的测试,就应当假设你并不具备重复安全性。

  3. 当一个事件流程在生产环境中出现故障时,我们需要多久才能注意到,我们能否端到端地追踪一条消息? 异步故障是悄无声息的,因此一个悄然停止处理的消费者可能一直不被察觉,直到数据缺失变成客户投诉或审计缺口。请追问你最早的信号是什么,以及你是否把消费者延迟和死信队列深度作为告警指标来监控,而不是放在没有人看的仪表盘上。请带来一次真实事故或一次演练(game day)的记录,并计时追踪一个关联 ID 跨越生产者、代理和每个消费者所花费的时间。如果答案是“我们在几个服务里 grep 然后猜测”,说明你的可观测性还没有准备好应对你所承担的复杂性。在受监管的行业中,能够准确重建一条消息的流转路径往往是一项合规要求,而不是锦上添花。

  4. 我们将如何在不破坏任何一个现有消费团队的情况下演进一个已有众多团队消费的事件模式? 一个事件的结构是一份公开契约,一旦有数十个消费者依赖它,一个看似无害的更改就可能在远处、没有编译器警告的情况下破坏你从未听说过的系统。这种张力是真实存在的:生产者想要快速迭代并清理自己的事件,而每个消费者都希望这个结构永远保持不变,因此需要提前约定哪些更改是安全的(添加一个可选字段),哪些需要一个新版本和一个迁移窗口(移除字段、更改类型、重命名)。请带来每个主题实际的消费者清单、当前是否有一个模式注册中心强制执行兼容性规则、还是这些结构靠非正式的约定来变更,以及在迁移期间两个版本能够并行运行多长时间。在一个大型企业或跨机构的政府数据共享安排中,一个悄悄被破坏的模式可能会在你并不拥有的系统中破坏记录,并在之后以审计失败的形式浮现,因此应把兼容性执行当作治理事项,而不是礼貌问题。

  5. 对于我们最重要的多服务事务,每个补偿动作实际撤销的是什么,用户和审计人员将会看到哪些中间状态? Saga 用一系列可能各自失败的本地步骤,换掉了单一原子事务这种令人安心的假象,因此系统确实会在收敛之前经历诸如“已预留但未支付”和“已扣款但未发货”这样的状态,若假装并非如此,就是你交付一个会漏钱或产生孤立记录的 saga 的方式。请端到端地走查真实流程,为每一步命名其补偿动作(什么会释放库存,什么会退款),并决定一个你可以监控的编排式 saga 是否胜过一个没有任何单一地方能够描述的涌现式协作。请带来你实际测试过的失败案例,而不仅仅是正常路径,并确认用户体验和审计轨迹如实展示了“进行中”和“已补偿”等状态,而不是将其隐藏起来。在金融、福利或税务系统中,监管者会追问每个中间时刻的记录是什么样的、谁对补偿负责,因此 saga 的各个状态本身就是一份合规证据。

  6. 谁在运维这个消息代理或事件日志,我们是否已经把运维它的真实成本与托管方案做过对比? 消息主干网并不是画在图上就免费存在的基础设施:总有人要为它打补丁、扩展其分区、调整保留策略,在它于凌晨三点报警时响应,并对其容量和故障模式负责。要有意识地在自建开源代理与购买托管服务之间做出选择,权衡控制力和数据驻留与运维负担和许可成本,并诚实评估你的团队是否具备良好运维一个分布式日志所需的深度。请带来总体拥有成本:值班负担、所需的专业技能、保留与存储费用,以及一次代理故障会对每一个依赖的流程造成什么影响。对企业而言,这个答案会塑造一个平台团队的使命;对政府机构而言,采购规则、数据主权要求以及避免供应商锁定的硬性需求可能会压过最便宜的选项,因此应在你承诺一项将运行十年的技术之前,先把这些约束摆到台面上。

行业视角

初创企业。 你最稀缺的资源是工程注意力,因此在扇出(fan-out)真正造成困扰之前应保持同步。当有三件事情需要对一个动作做出反应时,向一个托管队列或日志发布单个事件(例如“OrderPlaced”),而不是自建一个消息代理集群,并将支付以及任何你需要立即得到答案的操作保留为直接调用。不要为了显得更“高级”而采用事件溯源、CQRS 或 sagas;这种复杂性会拖垮一个小团队,拖慢你正赖以竞争的迭代速度。

小型企业。 你没有消息传递专家,也没有运维 Kafka 的意愿,因此应把异步集成当作一件购买而非自建的事情。依靠你现有工具已经发出的事件(webhook、云服务商或 SaaS 平台内置的队列),让一个托管服务承担投递、保留和幂等性的相关管道工作。诚实地把这个选择当作“购买还是自建”的问题来考量:几个可靠的 webhook 处理器胜过一个你无法配备人手、也无法在凌晨三点调试的定制消息代理。

企业。 你的问题是众多团队之间的集成成本,因此回报在于一个受治理的共享平台:一个持久的事件日志、一个具有强制兼容性策略的模式注册中心、清晰的主题所有权,以及标准化的幂等性与发件箱模式,使每个团队不必重复发明它们。正是在这种场景下,取代脆弱的企业服务总线或点对点链接之网会带来真正可观的节省,也正是在这种场景下,具有可见状态的编排式 saga 能让运维人员管理多步骤流程。请明确为可观测性和模式治理投资做预算,因为在这个规模下,一次无声的消费者故障就会演变成跨数十个系统的数据缺失。

政府。 一个持久、有序的事件日志是审计与透明度资产:它以一系列不可变的事实来回答“为什么这项决定是这样作出的”,因此应在问责需求所要求的地方大力采用事件溯源。跨机构数据共享需要基于稳定的事件 ID 实现带去重的至少一次交付,这样一条被重新投递的记录就永远不会创建一个重复的案例,采购决策应当权衡数据主权、代理局限性的披露以及可移植性,以对抗供应商锁定。在适当的情况下,应公开该流程是如何运作的,以及公民如何对一个自动化结果提出异议,并把事件日志作为监督机构将要求查验的、站得住脚的记录保留下来。

示例

初创企业。 一家小型电子商务初创企业最初只有一条同步流程:结账调用支付服务并等待结果。随着业务增长,它希望订单确认邮件、库存更新和忠诚度计划能够对购买做出反应,而把每一个都作为结账内部的又一次同步调用会让结账变得缓慢且脆弱。团队向一个持久日志发布单个“OrderPlaced”事件,让三个独立的消费者各自做出反应,于是结账重新变快,之后再添加第四个反应也不需要改动结账本身。他们保持支付收款为同步操作,因为他们需要在确认订单之前得到明确的是或否答案,这正是在他们这个规模上应当划出的正确界线。

企业。 一家全球性保险公司正被大量点对点集成和一个老化的企业服务总线(Enterprise Service Bus,ESB)所拖累,后者是每个系统都要经过的中心枢纽,已经变成了瓶颈和单点故障。它迁移到一个持久事件日志,让每个业务域发布自己的事实(保单已签发、理赔已提交、赔付已支付),消费团队按需订阅所需内容。一个经过编排、使运维人员能够看到每个理赔状态的理赔 saga,协调了多步骤的理赔结算,并为失败的步骤配备了补偿。一个模式注册中心让保单团队能够演进他们的事件,而不需要在四十个消费系统之间进行同步部署,这正是旧 ESB 所带来的那种脆弱性的对立面。

政府。 一个国家税务机构必须能够向公民和审计人员给出一个站得住脚的答案,回答“我的税额评估为什么是这样得出的”。它用事件溯源来对评估域建模,因此每一次变更都是一个有序日志中的不可变事件,当前的评估结果是对这些事件的回放;当一位公民对某个数字提出异议时,一名审查人员可以重建出任意过去日期的确切状态,并展示出产生该结果的事实序列。跨机构数据共享运行在具有至少一次交付的持久主题之上,每个机构的消费者都基于事件 ID 进行去重,这样一条被重新投递的记录就永远不会创建一个重复的案例。这个事件日志同时也充当监督机构所要求的审计轨迹,把一项合规义务变成了这种设计的副产品。

商业案例:动机、投资回报率与总体拥有成本

事件驱动架构的投资回报由一件事主导:随时间推移的集成成本。点对点集成使这项成本随连接数量增长,而连接数量的增长速度快于系统数量的增长速度,于是集成本身变成了侵蚀你交付能力的一种税。事件让这条曲线变得平坦,因为团队通过一个共享的事实流来集成,新的消费者只需订阅即可加入,生产者在一个已版本化的模式背后演进,因此下一次集成的边际成本会大幅下降。这正是要向领导层讲述的核心投资回报故事:不是原始性能,而是跨众多团队的变更成本的复合式下降。

要诚实地说明总体拥有成本,这样你才会被相信。你承担了需要运维的消息代理基础设施、需要维护的模式治理,以及一条更陡峭的运维学习曲线,因为异步系统确实更难调试,因此应提前为可观测性投资做预算。将这些代价与不在合适场景下采用事件的成本相权衡:一个僵化的集成层,其中每一次更改都是一个跨多团队的协调项目;一个没有人敢碰的、已经变成瓶颈的遗留 ESB;以及无法在不干扰旧能力的情况下添加新能力。对于正在取代脆弱点对点连接的企业和正在构建持久审计轨迹的政府而言,当审计与解耦需求确实存在时,回报最为可观。当这些需求并不存在时,诚实的答案是:一个更简单的同步设计拥有更低的总体拥有成本,你应当如实这样说。

反模式与陷阱

  • 伪装成事件驱动的分布式单体。 必须一起部署、且相互依赖对方内部事件的服务。你添加了一个消息代理,却保留了耦合,于是你同时承受了两种风格的缺点。
  • 把事件当命令用。 发布实际上意味着“请为我做这件具体的事情”的“事件”,这重新制造了紧耦合,还额外付出了延迟和更差的可追踪性。
  • 假设存在恰好一次交付。 消费者在消息代理不可避免地重新投递消息时出现故障、重复扣款或产生重复记录。
  • 对一切都使用事件溯源。 把事件溯源和 CQRS 应用于从不需要历史记录的简单增删改查领域,买来了陡峭的复杂性却没有换来任何好处。
  • 没有模式治理。 生产者随意更改事件结构,在没有任何兼容性检查的情况下,从远处破坏下游消费者。
  • 忽视发件箱模式。 把写入数据库和发布事件当作两个独立的步骤,于是两者之间的一次崩溃会悄无声息地丢失事件,或者发出幽灵事件。
  • 没有死信处理。 一条毒消息阻塞了整个分区,或者失败的消息凭空消失,没有队列来捕获和检查它们。
  • 不可见的流程。 没有关联 ID、没有消费者延迟告警、没有追踪的异步处理,导致故障在变成数据缺失之前一直保持沉默。

成熟度模型

  • 第 1 级,启动(Initiate): 集成是临时的点对点调用,或者虽然存在一个消息代理,但被当作同步请求/响应来使用。重复消息会破坏消费者,没有模式纪律或跨跳追踪,失败的消息会悄无声息地消失。
  • 第 2 级,发展(Develop): 一个消息代理或日志已在部分流程中实际使用,少数团队具备了基本实践:消费者正在变得幂等,死信队列捕获了部分失败。但这种方法在各团队之间并不一致,事件与命令仍然混淆不清,模式靠非正式约定变更,可观测性也很薄弱。
  • 第 3 级,标准化(Standardize): 事件、命令和消息在整个组织中被有意识地区分开来。消费者按照标准具备幂等性,模式存放在一个具有文档化且强制执行的兼容性策略的注册中心中,事务性发件箱保证了投递。带有补偿的 sagas 处理多服务事务,关联 ID 贯穿每一跳,这些实践都被写下来并被一致地应用,而不是任由每个团队各行其是。
  • 第 4 级,管理(Manage): 事件平台被用数据、对照基线来度量和控制。消费者延迟、死信队列深度、重新投递率和处理延迟被作为具有约定阈值的告警指标来跟踪,而不是没有人看的仪表盘;模式兼容性在生产者能够发布变更之前会被自动验证;回放、故障处理和幂等性按固定节奏被测试,而不是靠祈祷。某个具体交互应该是事件还是同步调用,是基于证据来决定的,每个消息代理的容量、保留成本和可靠性也会对照目标进行审查。
  • 第 5 级,编排(Orchestrate): 事件驱动与同步风格的选择在每个交互中都成为一种第二天性,事件溯源和 CQRS 精确地应用于审计与查询需求能够证明其合理性的地方,别无他处。异步流程与同步流程一样可观测,该平台随着需求的变化持续改进(淘汰废弃的主题、演进模式治理、重新平衡分区),消息传递与更广泛的架构和审计策略融为一体,使组织能够随着业务及其义务的变化而调整其事件设计。

讨论思路

  1. 你目前的哪些“事件”其实是隐藏的命令?如果如实地对它们建模,你能消除哪些耦合?
  2. 如果明天你把完整一天的事件重新回放给你的消费者,会发生什么故障?这告诉了你关于幂等性和回放安全性的什么信息?
  3. 你的领域中哪些部分真正需要事件溯源所带来的审计轨迹,哪些部分只是简单状态,一旦引入事件溯源反而会被复杂化?
  4. 你会如何在没有高风险的一次性大爆炸式切换的情况下,从一个遗留的企业服务总线或一张点对点集成之网迁移出来?
  5. 对于你最重要的多服务流程,它是一个带有补偿动作、真正经过编排的 saga,还是一个没有任何单一地方能够描述的涌现式协作?

关键要点

  • 事件驱动架构换来了解耦、独立扩展、可回放性和审计轨迹,其代价是可理解性和运维复杂性,因此应当按每个交互来选择,在收益确实存在的地方才采用它。
  • 保持事件(已发生的事实)、命令(请求执行的动作)和消息(承载它们的信封)彼此区分,因为混淆它们会造成真正的耦合错误。
  • 为至少一次交付而设计,并让每个消费者都具备幂等性;恰好一次交付是一个神话,而恰好一次的效果是一项工程成就。
  • 顺序是按分区计的,模式是应当存放在注册中心中的契约,发件箱、死信队列和背压是使消息传递具备生产就绪能力的管道设施。
  • 使用带有补偿动作的 sagas 而不是两阶段提交,只在审计与查询需求能够证明其陡峭代价合理时才采用事件溯源和 CQRS。
  • 异步流程在失败时是无声的,因此关联 ID、消费者延迟监控和端到端追踪,是一个可运维系统与一个不可见系统之间的区别所在。

参考文献与延伸阅读

  • Martin Kleppmann, Designing Data-Intensive Applications
  • Gregor Hohpe and Bobby Woolf, Enterprise Integration Patterns
  • Chris Richardson, Microservices Patterns (sagas, transactional outbox, CQRS)
  • Sam Newman, Building Microservices
  • Ben Stopford, Designing Event-Driven Systems
  • Adam Bellemare, Building Event-Driven Microservices
  • Vaughn Vernon, Implementing Domain-Driven Design (event sourcing and CQRS)
  • Martin Fowler, “Event Sourcing” and “CQRS” (martinfowler.com articles)
  • Hector Garcia-Molina and Kenneth Salem, “Sagas” (1987)