微服务使用EDA事件溯源遭遇的五个陷阱及应对办法 -Wix


事件驱动架构非常强大,非常适合分布式微服务环境。通过引入代理中介,事件驱动架构提供了解耦架构、更容易的可扩展性和更高程度的弹性。


上图请求回复(客户端-服务器)与事件流(发布-订阅)

但与请求-回复客户端-服务器类型架构相比,正确设置要困难得多。

在过去的几年里,在 Wix,我们一直在逐渐将我们不断增长的微服务集(目前为 2300 个)从请求-回复模式迁移到事件驱动的架构。下面是Wix 工程师在我们对事件驱动架构的实验过程中遇到的5 个陷阱。

这些陷阱给我们带来了巨大的痛苦,包括生产事件、需要的重写和陡峭的学习曲线。对于每个陷阱,我都提供了今天在 Wix 使用的经过实战验证的解决方案。

1. 写入数据库,然后触发没有原子性的事件
例如,考虑一个简单的 ecom 流程(我们将在本文中使用这个示例)
付款处理完成后,应更新产品库存以反映该产品是为客户保留的。


上图写入数据库并产生事件是一个非原子动作

不幸的是,将支付完成状态写入数据库(动作事件1),然后向 Kafka(或其他消息代理)生成“支付完成”事件(动作事件2)并不是原子操作。可能存这两个动作事件中仅发生一个完成另外一个无法完成的情况。
例如,数据库不可用或 Kafka 不可用等情况可能会导致分布式系统不同部分之间的数据不一致。在上述情况下,库存水平可能与实际订单不一致。

Atomicity Remedy I — Greyhound 弹性生产者
有几种方法可以缓解这个问题。在 Wix,我们使用两种方式。第一个是我们自己的名为Greyhound的消息传递平台,它允许我们确保事件最终通过弹性生产者写入 Kafka 。这种缓解措施的一个缺点是您最终可能会对下游事件进行无序处理。


上图生产者回退到 S3。专门的服务将消息恢复到 Kafka

Atomicity Remedy II — Debezium Kafka 源连接器
确保数据库更新操作和 Kafka 生成操作都发生并且数据保持一致的第二种方法是使用Debezium Kafka 连接器。Debezium 连接器允许自动捕获数据库中发生的所有更改事件 ( CDC )(对于 MySQL,通过binlog)并将它们生成为 Kafka 事件。 Kafka ConnectDebezium DB 连接器一起保证事件最终将生成到 Kafka。此外,还保证将保持活动事件的顺序


上图Debezium 连接器确保更改事件最终与 DB 一致

请注意,Debezium 还适用于其他事件流平台,例如Apache Pulsar

2. 到处使用事件溯源
事件溯源是一种模式,在这种模式下,服务不会在业务操作时更新实体的状态,而是将事件保存到其数据库中。该服务通过重播事件来重建实体的当前状态。 这些事件也发布在事件总线上,这样其他服务也可以在其他数据库上创建物化视图,这些视图通过重放事件来优化查询。


上图事件溯源——将更改事件持久化到事件存储。播放事件以达到当前状态

虽然这种模式有一些优势(可靠的审计日志、执行“时间旅行”——能够在任何时间点获取实体的状态,并在相同数据上构建多个视图),但它远不止于此比更新存储在数据库中的实体状态的CRUD服务复杂。

事件溯源的缺点包括:

  1. 复杂性——为了确保读取性能不受您需要播放的不断增长的事件列表的影响,必须不时拍摄实体状态快照以减少性能损失。 这增加了系统的复杂性,后台进程可能有自己的问题,而且当它出现时,数据仍然过时。最重要的是,拥有 2 个数据副本意味着它们可能会不同步。
  2. Snowflake 性质 — 与 CRUD ORM解决方案不同,创建通用库和框架来简化可以全局解决适用于每个用例的快照和读取优化的开发更加困难。
  3. 仅支持最终一致性(写后读用例有问题)

事件溯源替代方案 — CRUD+CDC
利用简单的 CRUD 功能和发布数据库更改事件 ( CDC ) 以供下游使用(例如,创建查询优化的物化视图)可以降低复杂性、增加灵活性,并且仍然允许针对特定用例进行命令查询分离 ( CQRS )。

对于大多数用例,服务可以公开一个简单的读取端点,该端点将从数据库中获取实体的当前状态。随着规模的增加和需要更复杂的查询,可以使用附加的已发布更改事件来创建专门为复杂查询量身定制的自定义物化视图。


上图CRUD — 从 DB + CDC 简单读取外部物化视图

为了避免将数据库更改作为合同暴露给其他服务,并在它们之间创建耦合,服务可以使用 CDC 主题并生成类似于在事件源模式中创建的事件流的更改事件的“官方”API。

3.没有上下文传播
切换到事件驱动架构意味着开发人员、devops 和 SRE 在调试生产问题和跟踪整个系统中最终用户请求的处理方面可能会遇到更多困难。 与请求-回复模型不同,没有明确的 HTTP/RPC 请求链可以遵循。调试代码更加困难,因为事件处理代码分散在服务代码中,而不是通过单击通常在同一对象/模块中找到的函数定义来顺序跟踪。

例如,考虑我在本文中使用的电子商务流程。Orders 服务必须使用来自 3 个不同主题的多个事件,这些主题都与相同的用户操作(在网上商店中购买商品)相关。


上图完全事件驱动的微服务,难以遵循请求流

其他服务也使用来自一个或多个主题的多个事件。假设发现某些库存水平不正确。能够调查所有相关的订单处理事件至关重要。否则,将需要很长时间才能查看各个服务日志并尝试将不同的证据手动连接到一个有凝聚力的叙述中。
自动上下文传播

自动为所有事件添加更广泛的请求上下文的标识,使得过滤与最终用户请求相关的所有事件变得非常简单。在我们的 ecom 示例中,添加了 2 个事件标头 — requestId 和 userId。这两个 ID 都可以极大地帮助调查。


上图自动为每个事件附加用户请求上下文,以便于跟踪和调试

在 Wix,Greyhound在生成和使用事件时自动传播最终用户请求上下文。此外,请求上下文也可以在日志基础设施中找到,这样可以针对特定用户请求过滤日志。

4. 发布大负荷事件
在处理大型事件有效负载(大于 5MB 的有效负载,例如图像识别、视频分析等)时,可能很想将它们发布到 Kafka(或 Pulsar),但存在大大增加延迟、降低吞吐量和增加内存压力的风险(尤其是在不使用分层存储时)
幸运的是,有几种方法可以解决这个问题。包括引入压缩,将有效负载拆分为块,并将有效负载放入对象存储中,并在流平台中传递一个引用。

大负荷补救措施 I — 压缩
KafkaPulsar都允许压缩有效载荷。您可以尝试几种压缩类型(lz4、snappy 等)来找到最适合您的有效负载类型的一种。如果您的有效负载有点大(高达 5MB),则 50% 的压缩可以帮助确保您保持消息代理集群的良好性能。
Kafka 级别的压缩通常优于应用程序级别,因为可以批量压缩有效负载,从而提高压缩率。

大负荷补救措施 II — 分块
另一种减轻代理压力并超越消息大小限制的方法是将消息拆分为块。 虽然分块已经是 Pulsar 的内置功能(有一些限制),但对于 Kafka,分块必须发生在应用程序级别。
可以在此处此处找到如何在应用程序级别实现分块的示例。基本前提是生产者发送带有额外元数据的块,以帮助消费者重新组装它们。


上图将生产者分裂成块,消费者弄清楚如何组装

这两个示例方法的不同之处在于它们如何将块组装回原始有效负载。第一个示例将块保存在一些持久性存储中,并且一旦生成了所有块,消费者就会获取它们。第二个示例使消费者在主题分区中向后搜索到第一个块,一旦所有块都到达。

大负荷补救措施 III — 对象存储参考
最后一种方法是将有效负载简单地存储在对象存储(例如S3)中,并将引用(通常是 URL)传递给事件有效负载中的对象。这些对象存储允许在不影响第一个字节延迟的情况下保留任何所需的大小。
确保在生成链接之前将有效负载完全上传到对象存储非常重要,否则消费者将需要不断重试,直到它可以开始下载它。

5. 不处理重复事件
大多数消息代理和事件流平台默认保证至少一次交付。这意味着某些事件在流中重复或可能被处理两次(或更多)。 确保重复事件的副作用只发生一次的术语是幂等性。
考虑一下我在整篇文章中一直使用的简单 ecom 流。如果由于某些处理错误而导致重复处理,则记录到库存数据库中的已购买商品的库存水平可能会比实际下降更多。


上图双重消费者处理导致库存水平变得不正确

其他副作用包括多次调用第 3 方 api(在我们的 ecom 案例中,这可能意味着为相同的事件和项目调用具有减少级别的库存服务两次)

幂等性补救措施——revisionId(版本控制)
在需要事件处理的幂等性的情况下,乐观锁定技术可以作为灵感。使用这种技术,在发生任何更新之前,首先读取存储实体的当前版本 ID(或版本)。如果多方尝试同时(同时)更新实体(同时增加版本),第二次尝试将失败,因为版本将不再与之前读取的内容匹配。
在对重复事件进行幂等处理的情况下,revisionId 必须是唯一的并且是事件本身的一部分,以确保两个事件不共享相同的 id,并且相同 revisionId 的第二次更新将(静默)失败即使不会同时发生。


上图为每个事件附加 transactionId 以避免重复处理
特别是对于 Kafka,有可能只配置一次语义,但由于某些故障仍然可能发生 DB 重复更新。幸运的是,这种情况下的 txnId 可以只是保证唯一的主题分区偏移量。


概括
向事件驱动架构的迁移可以是渐进的,以减少与之相关的风险,包括更难的调试和心理复杂性。微服务架构允许灵活地为每个不同的服务选择模式。HTTP/RPC 端点可以作为事件处理的一部分被调用,反之亦然。

作为这种逐步迁移方法的结果,我强烈建议采用CDC模式(数据库更改作为事件流式传输)作为确保数据一致性(陷阱 #1)并避免与全面事件溯源相关的复杂性和风险的一种方式(陷阱#2)。CDC 模式仍然允许将请求-回复模式与事件处理模式并排放置。

修复陷阱 #3(在您的事件流中传播用户请求上下文)将大大提高您快速找到生产事件根本原因的能力。

陷阱#4 和#5 的补救措施适用于更具体的用例——在陷阱#4 的情况下非常大的有效负载和在#5 的情况下的非幂等副作用。如果不需要,则无需执行建议的更改。尽管压缩 (#4) 和 transactionIds (#5) 是您可以默认添加的最佳实践。