微服务架构之事件驱动架构
前言
为了解决传统的单体应用(Monolithic Application)在可扩展性、可靠性、适应性、高部署成本等方面的问题,许多公司(比如Amazon、eBay和NetFlix等)开始使用微服务架构(Microservice Architecture)构建自己的应用。
微服务架构(维基百科):
微服务 (Microservices) 是一种软件架构风格 (Software Architecture Style),它是以专注于单一责任与功能的小型功能区块 (Small Building Blocks) 为基础,利用模组化的方式组合出复杂的大型应用程序,各功能区块使用与语言无关 (Language-Independent/Language agnostic) 的 API 集相互通讯。
但是,微服务架构在带来一系列好处的同时,也带来了若干挑战。除了分布式系统固有的复杂性以外,微服务架构也深刻影响了应用和数据库之间的关系,与传统多个服务共享一个数据库的方式不同,微服务架构每个服务都有自己的数据库。对于开发者来说,这就为微服务中的数据管理提出了更高的要求。
微服务架构中的数据管理
在传统的单体应用中,通常使用单个的关系型数据库。这类数据库所提供的事务语义,具备ACID特性。
ACID:
Atomicity(原子性):一个事务中的操作是原子的,其中任何一步失败,系统都能够完全回到事务前的状态
Consistency(一致性):数据库的状态始终保持一致
Isolation(隔离性):多个并发执行的事务不会互相影响
Durability(持久性):事务处理结束后,对数据的修改是永久的
应用得益于数据库的这些特性,能够用简单的方式对数据进行修改与读取,而无需花费太多精力考虑数据一致性问题。
但是,在微服务架构下,为了在微服务之间建立松耦合的关系,通常每一个微服务都会拥有自己独立的数据库,仅仅通过对外暴露的API来进行数据交换。这种情况下,我们就要面临分布式数据管理带来的挑战。也就是说,在实现业务逻辑时,如何保证服务之间的数据一致性。
实时一致性
我们首先考虑在系统中实现实时一致性的情况。比如以一个银行系统为例,客户通常会有一个储蓄账户和一个理财账户。现在,考虑客户从自己的储蓄账户向理财账户转账10000元的场景。
假设现在有两张表 deposit_account 和 finance_account,分别用于存储储蓄账户和理财账户的信息,用户的ID是201。那么,在单一数据库场景下,通过数据库事务可以很容易完成这个操作:
Begin transaction update deposit_account_table set amount=amount-10000 where userId=201;
update finance_account amount=amount+10000 where userId=1;
End transaction
commit;
这样在单体应用中,由于所有数据都是保存在同一个数据库中,通过数据库提供的ACID特性,就可以轻松实现数据的实时一致性。
但是,在微服务架构中,可能的设计是存在两个服务:储蓄服务(Deposit Service)和理财服务(Finance Service),假设由储蓄服务负责处理客户的转账请求。而如下图所示,这两个服务都分别维护自己的数据,因此储蓄服务无法直接访问理财服务的数据,而只能通过API去修改客户的余额。
此时,为了满足订单服务与客户服务之间的实时一致性要求,可以采用分布式事务,比如基于两阶段提交协议(Two-phase commit, 2PC)的实现来做到这一点。(关于2PC,已经有大量的研究成果和成功实践经验,本文将不再做太多阐述,具体可自行参见相关文献和资料)
根据CAP定理,我们追求实时一致性时,通常需要牺牲掉部分可用性。比如以上场景中,当 Finance Service 由于软硬件故障或网络问题而不可用的时候,系统将无法为用户提供内部转账服务。
此外,作为典型的同步操作,2PC也存在着比较比较严重的性能问题,并不适合高并发场景。因此,在数据一致性上我们需要寻求其他的解决方案。
最终一致性
如果我们考虑只保证系统的最终一致性,那么就可以避免使用2PC,从而提高系统可用性和性能。
仍然以以上的用户内部账户之间的转账服务为例。当用户从储蓄账户向理财账户转账时,减少储蓄账户的金额与增加理财账户的金额这两个动作,可以无需在一个事务里面完成,而是分成两步:
-
储蓄服务减去储蓄账户中的金额,并生成一个凭证(消息)发送给理财服务;
-
理财服务收到凭证后,在理财账户中增加相应的金额。
我们会发现以上过程在第1步完成之后,第2步完成之前,储蓄账户与理财账户之间实际上是存在短时间的数据不一致的。但是,只要最终第2步能够完成,系统的数据就仍然能够保持一致性,这就是我们所说的最终一致性。
在最终一致性这个前提下,即使理财服务在某段时间内不可用,系统仍然能够能为用户提供内部转账服务,从而提高了系统的可用性。
而这样一种基于最终一致性的解决方案,就是本文将要介绍的事件驱动的架构(Event-driven Architecture)。
事件驱动的架构
所谓事件驱动的架构,也就是使用事件来实现跨多个服务的业务逻辑。
在这一架构里,当有重要事件发生时,比如更新业务数据,某个微服务会发布事件,其它微服务则订阅这些事件;当某一微服务接收到事件就可以更新自己的业务数据,同时发布新的事件触发下一步更新。而事件的发布与订阅,则依赖于一个可靠的消息代理(Message Broker)。
以上文的场景为例,在事件驱动的架构中,从储蓄账户转账到理财账户的过程如下:
-
储蓄服务将用户的储蓄账户中的金额减少10000,并发布“向理财账户转账”事件;
-
理财服务获取“转账到理财账户”事件, 更新理财账户,将理财账户的金额增加10000,并发布“理财账户转入”事件;
-
储蓄服务获取“理财账户转入”事件,结束本次转账交易。
在这里需要考虑的一个问题,就是转账失败处理。比如以上第2步如果因为“理财账户被冻结无法转入资金”之类的原因失败了,理财服务就应该发布“理财账户转入失败”事件,储蓄服务获取到该事件后,需要对储蓄账户进行回滚,将减少的金额重新增加回去。
以上的过程与传统的数据管理基于ACID模型不一样的是,它是基于BASE模型的。
BASE:
Basically Available(基本可用):系统在出现不可预知的故障的时候,允许损失部分可用性,但不等于系统不可用
Soft State(软状态):允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性
Eventually Consistent(最终一致性):系统保证最终数据能够达到一致
事件发布
在事件驱动的架构中,跨服务完成业务逻辑的一个关键点是每个服务自动更新数据库和发布事件,也就是要以原子粒度更新数据库和发布事件。例如,储蓄服务必须在对储蓄账户表进行更新,然后发布“向理财账户转账”事件,这两个操作需要原子化实现。如果服务在更新数据库之后、发布事件之前崩溃,系统会变得不一致。
保证数据更新与事件发布原子化的方法,有以下几种:
-
使用本地事务发布事件
-
挖掘数据库事务日志
-
使用事件源
使用本地事务发布事件
一个实现原子化的方法是使用本地事务来更新业务实体和事件列表,由一个独立进程来发布事件。具体来说,就是在存储业务实体状态的数据库中,使用一个事件表来充当消息队列。应用启动一个(本地)数据库事务,更新业务实体的状态,在事件表中插入一个事件,并提交该事务。一个独立的消息发布线程或进程查询该事件表,将事件发布到消息代理,并标注该事件为已发布。下图展示了这一设计。
储蓄服务更新储蓄账户的余额,然后在事件表中插入“转账到理财账户”的事件。事件发布线程或进程在事件表中查询未发布的事件并发布,然后更新事件表,将该事件标记为已发布。
这种方法的优点是:
-
使用本地事务,保证了数据被更新时事件一定能够被发布
-
实现简单,只需要系统具备本地事务的能力即可实现
这种方法的一个缺点是,数据更新操作与所要发布的事件之间的对应关系,是由应用的开发者实现的,因此有很大可能出错。
挖掘数据库事务日志
实现原子化的另一种方式是由线程或者进程通过挖掘数据库事务或提交日志来发布事件。应用更新数据库,数据库的事务日志会记录这些变更。事务日志挖掘线程或进程读取这些日志,并把事件发布到消息代理。
比如一个B2C的电商网站,就可以通过挖掘订单数据的更新日志,来进行事件发布。如下图所示:
这一方法的范例是开源的 LinkedIn Databus 项目。Databus 挖掘 Oracle 事务日志并发布与之对应的事件,LinkedIn 则使用 Databus 维持各种来源的数据存储与记录系统一致。
另一个范例则是 AWS DynamoDB 采用的流机制。AWS DynamoDB 是一个可管理的 NoSQL 数据库,其中每个 DynamoDB 流包括 DynamoDB 表在过去 24 小时之内的时序变化,包括创建、更新和删除操作。应用能够读取这些变更,将其作为事件发布。
这种方法的优点是:
-
要发布的事件直接来源于数据库的事务日志,因此不会出错
-
应用无需关注事件的发布,简化了应用开发者的工作
但是这种方法也有一些缺点:
-
事务日志的格式与所使用的数据库相关,因此事件挖掘 的实现会由于数据库的种类或版本的变化而随之需要修改
-
由于是直接从数据库的更新记录生成事件,因此可能会无法逆向推断出业务逻辑,因此并不适合于所有场景(比如前文所述的转账场景)
使用事件源
事件源采用一种截然不同的、以事件为中心的方法来保存业务实体——不同于存储实体的当前状态,应用存储的是状态改变的事件序列。每当业务实体的状态改变,新事件就被附加到事件列表,并且应用可以通过事件回放来重构实体的当前状态。鉴于保存事件是一个单一的操作,因此本质上也是原子化的。
要了解事件源如何运行,可以以储蓄服务为例。在传统的方法中,每次转账交易都会更新储蓄账户表的记录。而使用事件源的时候,储蓄服务以状态更改事件的方式存储用户的储蓄账户,每个事件都包含足够的数据去重建储蓄账户状态。
事件长期保存在事件仓库(Event Store),使用 API 添加和检索实体的事件。同时,事件仓库起到类似上文提及的消息代理的作用,通过 API 让服务订阅事件,将所有事件传达到所有感兴趣的订阅者。所以,事件仓库可以认为是数据库与消息代理的综合体,是事件源方法的支柱。
事件源方法有如下的优点:
-
事件即状态,发布事件就是在更新状态,因此天然具有原子性,并且不会出错
-
由于存储的是事件,而不是域对象,因此避免了对象关系抗阻不匹配的问题(object‑relational impedance mismatch problem)
-
由于存储了所有的业务状态更新事件,因此可以通过事件回放推断出任一时间点的业务实体状态
事件源方法也有以下这些缺点:
-
要实现一个可靠和高性能的事件仓库并不是一件容易的事情
-
应用代码需要根据事件仓库的 API 进行重写
-
事件仓库只直接支持通过主键查询业务实体,因此对于复杂视图的查询比较困难(可以通过CQRS方法解决,具体参见下文)
命令查询分离(CQRS)
在事件源方法中,不再直接存储任何业务实体的状态,而是代之以状态变更事件。在进行复杂视图的查询时,如果还按照与命令操作同样的方式,将会遇到一些困难。比如要发起如下的一个同时涉及储蓄账户和理财账户的查询操作:
SELECT *
FROM DEPOSIT_ACCOUNT deposit, FINANCE_ACCOUNT finance
WHERE deposit.user_id = finance.user_id
AND finance.state = 'active' AND deposit.amount > 100000 AND finance.amount > 5000
在非事件源的方式下,可以很容易的从储蓄账户表和理财账户表查询到相应数据。但是在事件源方式下,事件仓库中存储的是一系列事件,并且只能通过主键(比如 deposit_account.id 或 finance_account.id)去查询相应的业务实体,此时要处理类似 deposit.amount > 100000 这样的查询条件以及条件组合时,是非常复杂和低效的。
为了解决这一问题,可以采用CQRS方法,将命令与查询分离。命令操作仍然通过各服务的 API 以更新事件列表的方式进行,而查询操作则通过一个统一的视图查询服务(View Query Service)完成。
根据存储在事件仓库中的事件集合,可以计算得到每个业务实体的状态,这些状态以物化视图(Materialized View)的方式存储在一个数据库中。当有新的事件产生时,也同样会自动更新视图。这样,视图查询服务就可以像查询普通的数据库数据一样实现各种查询场景。具体的设计可参考下图所示:
结论
在微服务架构中,每个微服务都有其私有数据存储,不同的微服务可能使用不同的数据库。这种架构带来便利的同时,也给分布式数据管理带来挑战,其中最大的挑战就是在实现跨服务的业务逻辑时,如何保持服务之间的数据一致性。
对于许多应用,解决方案就是使用事件驱动的架构。事件驱动的架构带来的挑战是如何原子化地更新状态和发布事件。有几个方法可以做到这一点,包括把数据库用作消息队列、事务日志挖掘和事件源。
参考文献
-
Event-Driven Data Management for Microservices
-
Base: An Acid Alternative
-
Pattern: Event-driven architecture
-
如何用消息系统避免分布式事务?