ZocDoc 成立于 2007 年,是一家在线医生预约平台。它根据地理位置、保险状态及医生专业为患者推荐医生,并可在平台上直接完成预约。Zocdoc 采取对患者免费,向医生收费的商业模式。
在 Zocdoc,我们提供一个连接病人和医疗服务提供商的平台。虽然,大多数访问 Zocdoc 的人都熟悉我们面向病患提供的产品,这些产品确保人们能在网上找到医生并在线预约,但他们可能不太熟悉我们提供的面向服务提供商的工具。
针对服务提供商,我们负责构建和维护这些系统,让医生能执行各种任务,如确认预约、更新他们的就诊原因或接受的保险,并跟踪他们的表现。我们的目标是帮助医生最大限度地利用 Zocdoc 的市场。
从 2015 年开始向 AWS 过渡以来,Zocdoc 的大多数团队已经从单体应用程序 迁移 到全新的微服务架构( 这里 列出了单体应用的缺点)。
然而,由于我们拥有大量的工具以及单体应用中领域之间的紧密耦合,提供商方面的迁移已经滞后。
这种新架构在可伸缩性、敏捷性和灵活性方面给我们带来很多好处,因此,今年,我们共同努力加快了微服务的采用。这样,随着不断摆脱单体(我们亲切地称之为 OOM 项目),我们最终实现了所提出的“ 微服务优先 ”策略。
Martin Fowler 将其 描述 为,“ 从几个粗粒度的服务开始,比预期的最终服务要大。然后,随着边界的稳定,分解为更细粒度的服务。 “这个策略很有效,因为它使我们能快速构建新的试验工具和功能,首先从一个简单的版本开始,看看如何回应,然后再致力于开发一个完备的、可扩展的产品或微服务。”
我们继续发展这个微服务架构,但由于其数据访问的分布式特性,遇到了一些复杂性,比如在预约时,我们跟踪的核心数据是归提供商所有,比如医生的网站。正确地归因这些数据,以确定会话是从哪里产生的,这对我们来说至关重要。无论它是一个医生工作地点的网站还是通过 Zocdoc 提供的像 SEO 这样的渠道,所以我们需要一种方法来将我们的 appointment-attribution 和 practice-website 的数据串联起来。
然而,在微服务世界中,每个服务拥有的数据是该服务的私有数据,只能通过服务的 API 访问,就像我们的 appointment-attribution-service 和 practice-website-service 一样。(注意:这种封装是必要的,可以确保微服务是松耦合的,并且可以独立开发。否则,如果多个服务共享同一个数据库,那么任何模式更新都需要对所有服务进行耗时的协同更新——这是我们 过去的惨痛教训 )。
此外,我们在 AWS 上运行的不同服务经常使用不同类型的数据库,如 SQL 或类似于 SQL 的 Aurora、DynamoB、ElasticSearch 等,这让数据访问变得更加复杂。最后,这导致了两个主要的分布式数据挑战。
第一个挑战是维护跨多个服务的事务的数据一致性。与使用 ACID 数据库的单体应用不同,我们不能简单地使用本地数据库事务。在分布式系统中实现一致性的一种方法是使用 分布式事务 协议,比如两阶段提交(2PC)。
然而,2PC 通常是非常复杂的,不是现代 Web 服务的一个可行选项。本质上, CAP 定理 强行规定了你要在可用性和一致性之间进行选择。在我们的用例中,可用性是更好的选择,因为我们无法承受由于次要操作(如预约归因)而导致的预订流中断。
第二个挑战出现在实现从多个服务检索数据的查询时。如果这些服务中的任何一个需要来自多个 API 的数据来进行操作,则可能导致复杂的应用程序端连接,通常涉及许多同步请求 / 响应调用。
当微服务像这样根据需要自由地调用其他微服务时,它会创建一个紧耦合的复杂的依赖关系图 **,很难进行推理和扩展(这也是我们过去一直在解决的问题)。此外,如果任何依赖的服务 API 发生故障,在处理时都需要仔细考虑,理清回退逻辑。
对于许多像我们这样的应用程序,解决方案是使用事件驱动架构。这种架构中,在实体或域模型变化时,微服务会异步发布事件,而不是同步调用 API。其他微服务订阅这些事件并更新它们自己的实体,从而发布后续事件。这通常被称为基于编排的 Saga 模式。
它支持跨多个服务的数据一致性,而不使用分布式事务,但提供的保证更弱,如 最终一致性 (通常称为 BASE 模型 )。由于事件驱动的系统本质上是异步的,因此,它们通常比传统的 REST(或 API)架构响应更快,并且可以通过传入事件触发器来激活它们。
这促成了服务之间的松耦合(和易伸缩),因为事件生产者不需要了解使用者以及它如何处理事件。它还消除了典型的请求 - 响应风格的 API 的阻塞 / 等待,释放了服务资源。
事件使用者还可以维护自己的“物化视图”或数据副本,以供临时查询或与自己的数据进行连接。例如,我们的预约归因服务接收到一个 PracticeWebsiteAdded
事件,通知它新添加了 url,它会更新自己的 PracticeUrl
数据存储副本,以便和 Booking 数据进行连接。
虽然通常有许多模式属于事件驱动的范畴,但是这个特定的模式与 Martin Fowler 所说的 事件传递状态转移 是一致的。这种模式会生成大量重复的数据,但是由于目前存储成本较低,通常不需要考虑这个问题。另外,减少耦合和更好的弹性所带来的好处超过了对数据冗余的关注。
然而,要使这些事件驱动的系统可靠,必须满足一个核心条件——服务必须原子地写入其数据库并发布事件。这通常会导致以下基于事务的模式。
在这种方法中,你将在服务的数据库中创建一个额外的“发件箱”表。在接收到修改 / 创建业务实体的请求时,你必须更新你的实体表,并且,作为同一数据库事务的一部分,还必须在表示待发布事件的发件箱表中插入一条记录。然后,一个异步进程会监视该表中的新条目,并将这些事件发布到数据流或消息代理。
这里的思想是跟踪数据库的事务日志,并将实体表中的每个更改作为事件发布。与基于轮询的方法相反,这种基于日志的 更改数据捕获(CDC) 几乎是实时的,而且开销非常小。
Debezium 是一个流行的分布式平台,它为 MySQL、Postgres 和 SQL Server 等多个数据库提供了 CDC 连接器。如果你使用 DynamoDB 数据库的话,AWS 以 DynamoDB 流的形式为 CDC 提供了一种更简单的机制。在任何 DynamoDb 表中,这些流都提供按时间顺序排列的条目级修改序列,并将这些信息存储在日志中长达 24 小时。
在 Zocdoc,我们大量使用了 DynamoDB,因为它是一个完全托管的无服务器 NoSQL 数据库,提供 完美的可伸缩性和性能 ,并且免费提供高达 25GB 的存储空间。考虑到 DynamoDB 流的便利,它是基于 CDC 的事件驱动服务的完美选择。设置完成后,这些流可以调用其他 AWS 服务,如简单通知服务(SNS)或 Lambda。
然后,事件可以扇出到任意数量的订阅者。一个非常有用的 AWS 无服务器模式(我们在 Zocdoc 大量使用)是使用 SNS 将事件发布到一个或多个 SQS 队列。这为我们提供了一种可靠的方法, 可以通过基本可靠的传递异步发送事件,并且还提供了消息限流的好处 。
不过,这种方法有一个缺点。虽然我们在事件流中发布数据存储的所有更改,但数据存储本身只捕获数据的最新状态。结果,我们从设计上就丢了数据。不可重放,不能审计,也没有方法能查询特定历史点的数据状态。这对我们来说是一个明显的限制,因为我们不仅需要动态地确定预约归属,而且还需要能够追溯(以防需要修正)。
因此,对我们来说,了解执行更改的时间和详细信息以及用户标识是非常重要的。我们还考虑把生成的 CDC 事件(如插入、更新、删除,并做少量的转换)存储到我们的 Redshift 分析仓库中,但这意味着我们的 OLAP 数据会背离我们的 OLTP 数据,在一个有很多步骤的数据工程管道里,如果数据在其中的任何一个步骤里丢了,都没有办法恢复。
这把我们导向了一个越来越流行的模式,该模式已经成为传统 CRUD 应用程序的替代方案——事件源。
事件源是将实体状态的更改建模为不可变的事件“日志”。然后,这个“事件日志”或“事件存储”就成了事实的来源,而系统状态纯粹是从它派生出来的。由于保存事件是一个操作,因此,该模式本质上是原子性的,并且最小化了数据更新冲突的可能。这里的一个事件代表了一些在该领域发生的某件事,如: PracticeWebsiteAdded
、 PracticeWebsiteModified
、 PracticeWebsiteStatusChanged
、 AppointmentConfirmed
等。
通常,事件存储将这些事件发布到事件流(或消息代理),使用者订阅这些事件并根据需要处理它们。这可以使用前面提到的 CDC 来实现。如果事件源中的事件语义级别太低,可以考虑发布更高级的域事件,而不是使用另外的事件处理程序。
不幸的是,一旦应用了事件源模式,就无法再轻松地查询数据了。这将我们引向一个不同但密切相关的模式,称为命令查询责任隔离或 CQRS。CQRS 的思想是 隔离命令(写请求)和查询(读请求)之间的职责 ,并在应用程序中以不同的方式处理它们。你甚至可以分割数据存储,创建单独的读和写数据存储,从而实现更好的隔离和独立扩展。
因此,在一个典型的事件源 + CQRS 应用程序中,最终你会得到一个“事件”表(命令会以追加的方式写入)和“ 投影 ”表(也称为“状态表”或“ 物化视图 ”或“持久化读取模式”),通过一个灵活的模式来支持快速高效的查询。服务通过订阅由事件日志发布的域事件来更新该投影。在Zocdoc,我们过去已经成功地实现了这些类型的投影存储,例如我们的 wasabi 基础设施 。
结合 DynamoDB 灵活的基于键值的数据模型和条目级活动的流式推送,我们能够将事件源 + CQRS 应用到我们的 practice-website-service 。最后,我们得到两个不同的 DynamoDB 表,它们支持不同的查询模式,并有一个 DynamoDB 流将这两个表连接起来。对于状态表,我们的模式相当简单。我们使用 practiceId
作为散列(分区)键,使用 Url 作为范围(排序)键,因为我们必须保证( practiceId
,Url)的值唯一。
对于事件,我们不能使用相同的主键,因为对于给定的组合( practiceId
, url
)可能有多个事件,而( practiceId
, EventType
)也不太合适,因为单个条目可能有多个类型为 UPDATED 的事件。很自然地,我们就选择使用( practiceId
, EventId
)作为主键。我们选择用 GUID(保证惟一性)和时间戳(允许按 EventId
排序)的组合作为 Event ID。
然后,我们使用 AWS Lambda 创建触发器,响应事件表 DynamoDB 流中的事件。一个触发器处理这些事件并更新投影表以反映最新的状态。另一个触发器将这些事件发布到 SNS 主题,以流的形式发送到订阅服务。
接收触发器事件:
复制代码
{ "Records":[ { "EventSourceArn":"arn:aws:dynamodb:us-east-1:1234:table/practice-website-events/stream/2019-09-05T19:28:23.205", "AwsRegion":"us-east-1", "Dynamodb":{ "ApproximateCreationDateTime":"2019-12-19T21:05:23Z", "Keys":{ "PracticeId":{ "S":"noj6XuZmMU6aq-7Pg9m5sB" }, "EventId":{ "S":"2019-10-02T16:51:39Z_1234567-890a-bcde-fghi-123456789012" } }, "NewImage":{ "DomainType":{ "S":"ProviderOwned" }, "Version":{ "S":"1.0.0" }, "PracticeId":{ "S":"noj6XuZmMU6aq-7Pg9m5sB" }, "EventId":{ "S":"2019-10-02T16:51:39Z_1234567-890a-bcde-fghi-123456789012" }, "TimestampUtc":{ "S":"2019-06-13T17:05:00Z" }, "Url":{ "S":"realdoctorwebsite.com" }, "Name":{ "S":"PracticeWebsiteAdded" }, "InitiatedBy":{ "S":"johndoe@mail.com" } }, "OldImage":{ }, "SequenceNumber":"499848100000000006586459546", "SizeBytes":307, "StreamViewType":{ "Value":"NEW_IMAGE" } }, "EventID":"f00000ffe0000e448db06a1abcdefghij", "EventName":{ "Value":"INSERT" }, "EventSource":"aws:dynamodb", "EventVersion":"1.1", "UserIdentity":null } ] }
事件源有几个好处。首先,它提供了可靠的数据审计日志,是审计、遵从性、数据治理和数据分析系统的最佳选择。它让实现临时查询成为可能,从而可以确定业务实体在任何时间点的状态。如前所述,它还解决了实现事件驱动的架构原子性和数据一致性中的一些关键问题。
另外,因为它持久化事件而不是域对象,所以它基本上避免了 对象 - 关系阻抗不匹配的问题 。当与 CQRS 相结合时,它提供了一种方便的方式来独立地扩展读和写。
不过,它也有一些不足:
PracticeWebsiteEvent
),所有的消费者都必须监听这些事件,并弄清楚它是否会影响它们。如果一个事件粒度太小(如 PracticeWebsiteUrlChanged
& PracticeWebsiteDomainTypeChanged
),消费者就必须监听多个事件,以便在采取行动之前将它们组合起来。此外,事件永远不会被删除。即使发生了应用程序级的故障或不一致,你也要运行补偿事务,创建更多事件。如果事件范围太小,还会产生 大量的数据 。通常,事件粒度是一个需要解决的棘手问题,需要投入大量时间来理解域并与域专家进行交谈。新近的热门技术 事件风暴 可以帮助使用更多 领域驱动的设计 方法解决这个问题。 如果你特别关注最终一致性,那么你可以采用另一种方法。与使用 Lambda 侦听 DynamoDB 流并更新投影表不同,你可以在接收请求时 以事务方式同时写入 事件存储和状态表,从而消除读取存储更新的任何延迟。这是 事务性发件箱模式 和 事件源模式 的混合,后者提供了额外的好处,允许你在持久化任何更改之前使用状态表验证命令。
虽然 DynamoDB 确实提供了 API 以事务方式更新多个表 ,但是你必须使用它们的底层 API,而不是更流畅的 文档或对象持久性模型 。使用这种方法,还会失去读写独立伸缩的能力,并可能增加写 API 的整体延迟。因此我们发现,更好的方法是使用 CQRS 方法,但是在将来可能会考虑转向其他方法(特别是当 AWS SDK 开始使用对象持久性 API 支持多个事务时)。
还有其他解决最终一致性的方法,它们也可能适合你的用例,这取决于你的需要,比如维护数据的内存缓存和在读取期间检查事件存储。
事件驱动架构是分离微服务并获得更好的可伸缩性的最佳选择。在设计事件驱动的系统时,我们需要对导致域实体更改的事件建模,并自动将它们发布到事件流。在 AWS 中,DynamoDB 流为我们提供了一种很好的方式来实现这一点。
当我们希望永远保留这些事件并将它们用作事实来源时,我们可以采用事件源模式。在此模式中,事件被持久化到充当记录系统的事件存储中。它通常与 CQRS 模式结合使用,从存储的事件创建物化视图。虽然这种方法有几个好处,比如完全可重放性、可审计性和原子性,但它不是银弹。你最终会创建更复杂的系统,并被迫处理最终一致性。
关于作者
Anwesha Das 是 Zocdoc 提供商团队的一名工程师。此前,她曾在金融行业开发实时交易应用程序。她有物理学背景,对云计算、系统架构和数据工程有着浓厚的兴趣。
英文原文:
Breaking Down the Hype: Promises and Pitfalls of Event Driven Architecture