微服务实现
一、数据库驱动的微服务实现
1.使用Spring Data JPA实现数据库驱动微服务
聚合、实体和值对象
随着对象关系映射(Object-Relational Mapping,ORM)以及 Hibernate这样的框架的流⾏,数据库驱动的应⽤的实现变得简单了很多。
对象关系映射指的是对象模型和数据库关系模型之间的映射。对象模型由类声明和类之间的引⽤关系组成,数据库的关系模型指的是数据库中的表和表之间的关系。这两种模型存在阻抗不匹配(Impedance Mismatch)的情况,对象模型可以使⽤继承和多态,⽽关系模型则要求对数据进⾏归⼀化处理。
对象之间的引⽤⽅式很简单,⽽关系模型中则需要定义表的外键。如何在两个模型之间进⾏映射,这也是 ORM 技术的复杂性所在。
当然,ORM技术本⾝并不是很难掌握的技术,Hibernate这样的框架已经为我们屏蔽了很多底层实现细节。我们需要掌握的只是⼀些使⽤模式。⽐如,对象之间的引⽤关系,在⼀对多的映射中,什么时候使⽤单向关系,什么时候使⽤双向关系。这些都是有模式可以遵循的。 ORM 技术中最常使⽤的概念是实体(Entity)。
在领域驱动设计中,与模型相关的有 3 个概念,分别是聚合、实体和值对象。聚合是⼀个抽象的概念,不需要对应到具体的实体。实体需要映射成 ORM 中的实体。值对象通常不会被映射成单⼀实体,⽽是作为其他实体的⼀部分,实体的标识符被映射成数据库的主键。
领域对象
在创建实体类时,⼀个需要注意的问题是避免反模式贫⾎对象,贫⾎对象指的是对象类中只有属性声明以及属性的 getter 和 setter ⽅法。贫⾎对象实际上退化成为属性的数据容器,并没有其他的⾏为。贫⾎对象不符合我们对领域对象的期望,领域对象的⾏为应该是完备的。这⼀点对聚合的根实体尤为重要,聚合的根实体需要负责维护业务逻辑中的不变量。与维护不变量相关的代码都应该直接被添加到实体类中。
数据访问
对于⼀个聚合来说,只有聚合的根实体可以被外部对象所访问,因此只需要对聚合的根实体创建资源库即可。
⼀个需要考虑的问题是数据库表模式的⽣成。Hibernate 这样的 ORM 框架都⽀持从实体声明中⾃动⽣成和更新数据库表模式。
这种做法看起来很简单⽅便,但是存在很多问题。
第⼀个问题是数据库表模式的优化问题。为了优化数据库的查询性能,数据库表模式通常需要由专业的⼈员进⾏设计。由 Hibernate 这样的框架所⽣成的数据库表模式 只是通⽤的实现,并没有对特定应⽤进⾏优化。
第⼆个问题是数据库表模式的更新问题。在更新代码时,如果涉及到对数据库表模式的修改,直接使⽤ Hibernate 提供的更新功能并不是⼀个好选择。最主要的原因是 ⾃动更新的结果并不可控,尤其是需要对已有的数据进⾏更新时。
更好的做法是⼿动维护数据库表模式,并使⽤数据库迁移⼯具来更新模式。
领域层
领域层包括领域对象和服务实现,服务实现直接使⽤资源库来对实体进⾏操作。
在设计服务实现的接⼜时,⼀个常见的做法是使⽤领域对象作为参数和返回值。在这种做法既简单又直接,不过却有两个不⾜之处。
第⼀个不⾜之处在于对外部对象暴露了聚合的实体及其引⽤的对象。
外部对象获取到实体的引⽤之后,是可以通过该对象来修改状态的,可能会产⽣意想不到的结果。
第⼆个不⾜之处是在 Hibernate的实现上,实体引⽤了⼀个地址实体的列表。从性能的⾓度考虑,对于⼀个对象来说,它的地址列表是延迟获取的。
也就是说,只有在第⼀次获取地址列表中的元素时,才会从数据库中读取。⽽读取数据库需要⼀个打开的 Hibernate 会话。当在 REST API 的控制器中访问
Passenger 对象中的⽤户地址列表时,为了操作可以成功,就要求 Hibernate 会话仍然处于打开状态,这带来的结果就是 Hibernate 会话的打开时间过长,影响性能。
更合理的做法应该是在服务对象的⽅法退出时,就关闭会话。
综合上⾯两个原因,直接使⽤领域对象作为服务对象⽅法的返回值,并不是⼀个好的选择,更好的做法是使⽤值对象作为返回值。
值对象作为领域对象中所包含的数据的复制,去掉了领域对象中包含的业务逻辑,只是单纯的作为数据容器。这使得使⽤者在获取数据的同时,又⽆法改变内部实体对象的状态。由于转换成值对象的逻辑发⽣在服务⽅法内部,并不会影响 Hibernate 会话的关闭。这种做法同时解决了上述两个问题,应该是值得推荐的做法。Spring Data JPA中的配置属性 spring.jpa.open-in-view 可以控制会话是否在控制器中打开,该属性的默认值为 true。在应⽤了这种模式之后,该属性的值应该被设置为 false 。
展⽰层
对于微服务来说,其展⽰层就是它们对外提供的 API,这个 API 可以被其他微服务、Web 界⾯和移动客户端来使⽤。⽰例应⽤使⽤ JSON 表⽰的 REST API。对于使⽤Spring Boot 和 Spring 框架的微服务实现来说,暴露 REST API 是⾮常简单的事情,可以 Spring MVC 或 Spring WebFlux。
二、事件驱动的微服务实现
1.事件如何驱动微服务设计与异步消息传递
事件
事件驱动指的是以事件的发布和处理来驱动应⽤的运⾏,它的⽅式符合我们在现实世界中的⼯作模式,通常都是在事件发⽣之后,再进⾏处理。
事件在软件系统的应⽤也由来已久,最典型的应⽤是在⽤户界⾯中。⽤户界⾯的实现通常会维护⼀个事件循环(Event Loop),当⽤户界⾯中的事件产⽣时,⽐如按钮点击和⿏标移动,事件的处理器会被调⽤。对事件的处理都在事件循环中完成,应⽤开发者只需要为感兴趣的事件添加处理器即可。在事件处理器中,除了正常的处理逻辑之外,还可以发布新的事件,从⽽触发对应的处理逻辑,产⽣级联的效果。
事件驱动设计
事件驱动的设计在单体应⽤中已经得到了应⽤。事件驱动的最⼤特点是把⽅法的调⽤、调⽤的执⾏和调⽤结果的获取,这 3 个动作进⾏了时间上的分离。在常见的⽅法调⽤中,⽅法的调⽤、调⽤的执⾏和调⽤结果的获取是同步进⾏的。调⽤者在发出调⽤请求之后,会等待⽅法调⽤的完成,并使⽤调⽤结果进⾏下⼀步操作。事件驱动把这 3 个动作从时间上进⾏了分离,变成了异步的操作。
以新乘客注册的场景为例,当乘客完成注册之后,应⽤需要执⾏⼀些初始化的⼯作。在乘客注册对应的 API 控制器⽅法中,在创建乘客对象并保存之后,可以直接调⽤相应的⽅法来完成初始化,等初始化完成之后,控制器⽅法才返回。这是典型的同步调⽤⽅式。
如果采⽤事件驱动的⽅式,在创建乘客对象并保存之后,可以发布⼀个 PassengerCreatedEvent 事件,当事件发布之后,控制器⽅法就可以返回。PassengerCreatedEvent 事件的处理器⽤来完成初始化⼯作。
在引⼊了事件之后,乘客初始化的动作被分成了两步或三步:
第⼀步是事件的发布,相当于发出⽅法调⽤的请求;
第⼆步是事件的处理,由事件处理器来完成;
除此之外,某些情况下还存在第三步,那就是事件处理结果的返回,这⼀步对应于同步调⽤的⽅法有返回值的情况。
事件驱动的另外⼀个好处是可以实现发布者-消费者(Publisher-Subscriber,PubSub)模式。在同步⽅法调⽤中,每⼀次调⽤只有⼀个接收者。
事件驱动的微服务
在微服务架构的应⽤中,微服务之间的交互使⽤的是跨进程 API 调⽤。同步调⽤其他微服务的 API 并不是简单的事情,需要考虑到被调⽤的微服务可能出错的情况。同步的微服务 API 调⽤,要求被调⽤者在调⽤发⽣时是可⽤的状态,如果被调⽤者当前不可⽤,则需要进⾏重试或进⼊到错误处理逻辑;如果调⽤最终失败,则被调⽤者并不知道请求的存在。
消息传递
在微服务架构的应⽤中,如果使⽤事件驱动的设计,则需要进⾏消息传递。Kafka消息传递的保证性
2.事务性消息模式
事务性消息(Transactional Messaging)的⽬的是保证数据的⼀致性。
对于关系数据库中的事务,我们都不陌⽣。如果上述的两个动作是对同⼀个数据库中表的操作,我们使⽤事务就可以轻松解决。两个动作在同⼀个事务中,如果这两个动作都成功,事务才会被提交,否则事务会⾃动回滚。如果两个动作是对两个不同数据库的操作,那么也可以使⽤ XA 事务的两阶段提交协议(Two-Phase Commit Protocol,2PC)。
事务性消息模式的出发点是解决应⽤中可能会出现的数据⼀致性问题,数据⼀致性问题在微服务架构的应⽤中尤其明显。这是因为微服务相互独⽴,并且⼀般使⽤各⾃独⽴的数据存储,每个微服务负责维护各⾃的数据集,同时与其他微服务进⾏协作来更新相关的数据。在事务性消息模式中,对当前微服务数据的修改由数据库操作来完成,⽽与其他微服务的协作则由事件来完成。这种把数据和事件分离的做法,有其实现上的复杂度。
事务性发件箱模式
事务性发件箱(Transactional Outbox)模式使⽤⼀个数据库表来保存需要发布的事件,这个表称为事件的发件箱。通过使⽤这种模式,发布事件的动作被转换成⼀个数据库操作,因此可以使⽤⼀个本地数据库事务来保证原⼦性。对于保存在发件箱表中的事件,需要⼀个独⽴的消息中继进程来转发给消息代理。
在服务对数据表进⾏操作时,包括插⼊、更新和删除操作,会同时在发件箱表中插⼊对应的事件记录,对这两个表的操作在同⼀个数据库事务中。如果对数据表的操作成功,则发件箱表中必然有对应的事件;如果对数据表的操作失败,则发件箱表中必然没有对应的事件。消息中继负责读取发件箱表中的记录,并发送事件给消息代理。
变化数据捕获
消息中继需要监控发件箱表,当有记录插⼊时,就需要发布消息到消息代理,这种监控数据库变化的技术称为变化数据捕获(Change Data Capture,CDC)。有很多不同的⽅法可以捕获到数据库表中的改动,常见的做法如下所⽰。
更新时间戳
表中包含⼀个字段来记录每⼀⾏的更新时间戳。在检查数据变化时,更新时间戳⼤于上⼀次捕获的时间戳的⾏,都是这⼀次需要处理的内容。
版本号
表中包含⼀个字段来记录数据的版本号。当⼀⾏的数据发⽣变化时,这⼀⾏的版本号被更新为当前的版本号,每次捕获变化时,选择版本号与当前版本号相同的⾏。当捕获完成之后,当前版本号被更新为新的值,为下⼀次捕获做准备。
状态指⽰符
表中包含⼀个字段来标记每⼀⾏是否发⽣了变化。
触发器
当表中的数据产⽣变化时,数据库的触发器负责往另外⼀个历史记录表中插⼊数据来记录对应的事件。在捕获变化时,只需要查询这个历史记录表即可。
扫描事务⽇志
⼤部分数据库管理系统使⽤事务⽇志来记录对数据库的改动。通过扫描和解析事务⽇志的内容,可以捕获数据的变化。
上述⽅法可以根据是否使⽤事务⽇志划分成两类。事务⽇志的好处是对数据库没有影响,也不要求对应⽤的表结构和代码进⾏修改,另外还有更好的性能。
事务⽇志的不⾜之处在于,事务⽇志的格式并没有统⼀的标准,不同的数据库系统有⾃⼰的私有实现,⽽且会随着版本更新⽽变化。这就要求解析事务⽇志的代码需不断更新。
CDC实现技术
事务⽇志
MySQL 和 PostgreSQL 中的事务⽇志
数据库轮询
如果不能通过读取事务⽇志的⽅式来捕获数据变化,可以采⽤数据库轮询的形式,数据库轮询的做法是定期查询数据库中的表数据,来找出变化的⾏。这⾥需
要在表中添加额外的字段,如更新时间戳、版本号或状态指⽰符等。⽐如,可以在发件箱表中添加⼀个字段published来标明每⼀⾏对应的事件是否被发布。在每次查询时,总是选择published字段的值为0的⾏,并尝试发送事件到消息代理。当发送成功后,把对应⾏的published 字段的值更新为 1。
事务性消息对数据⼀致性有着⾄关重要的作⽤,它保证了对关系型数据库的修改和对应的事件的发布这两个动作的原⼦性。
3.事件发布如何进行处理
事件描述
事件⼀般由 3 个要素组成,即标识符、类型和载荷。标识符是事件的唯⼀标识,可以⽤来区分重复的事件;
类型⽤来区分不同的事件,事件类型⼀般使⽤名词加上动词被动语态的形式,如 TripCreatedEvent,在 Java 中,⼀般使⽤事件类的全名作为事件的类型;
事件的载荷由事件的类型来确定,事件类型可以没有载荷。
每个事件都有⼀个来源,表明产⽣该事件的对象。事件来源的类型⼀般是事件类型中作为前缀的名词部分,如果在建模时使⽤了聚合,那么事件的来源通常是聚合中的实体,这样的事件称为领域事件。
领域事件除了上述3个基本属性之外,还包括事件的来源对象所在聚合的根实体的类型和标识符。
在 Java 中,我们使⽤ Java 类来表⽰事件,不同的事件类之间共通的部分很少,⼀般使⽤⼀个标记接⼜(Marker Interface)来声明事件,所有的事件类只需要实现这个标记接⼜即可,你可能会认为事件的接⼜中应该包含⼀个 getId ⽅法来返回事件的标识符。
实际上,事件的标识符对于事件对象本⾝来说并没有意义,我们只需要在发布事件的时候⽣成其标识符即可,并不需要把标识符添加到事件对象模型中。
发布事件
当发布事件时,只需要创建对应事件类的⼀个新对象即可,实际的事件发布由 Eventuate Tram 来完成。
处理事件
由于事件以消息的形式发布到 Kafka,那么处理事件时则需要订阅 Kafka 中的主题,并消费其中的消息。我们需要创建⼀个 DomainEventDispatcher 类的对象,来负责消费主题中的消息并处理。
重复事件处理
消息在传递时提供的是⾄少⼀次的保证性,虽然不会丢失消息,但是会产⽣重复消息。这就意味着,对于同⼀个事件,它的处理器可能会被调⽤多次,对于重复事件的问题,⼀般有两种解决办法。
第⼀种做法是使⽤幂等( Idempotent)处理器,其含义是,对于同⼀个事件,多次调⽤处理器不会产⽣副作⽤。如果⼀个事件处理器是幂等的,那就不需要对重复事件进⾏额外处理,并不是所有的处理器都是幂等的。幂等的处理器需要满⾜业务逻辑和具体实现两⽅⾯的要求。业务逻辑指的是对事件的重复处理在业务上是可⾏的,具体实现指的是代码实现对于重复事件在处理时不会出错。以订单取消的事件为例,从业务逻辑上来说,⼀个订单被取消多次是没有问题的,取消⼀个已经被取消的订单并没有什么影响。在实现上,代码也需要考虑到处理重复事件的情况。
第⼆种做法是去掉重复的事件。每个事件都有⾃⼰的标识符,只需要记录下已经处理过的事件标识符,就可以去掉重复的事件。Eventuate Tram 提供了检测重复消息的功能,DuplicateMessageDetector 接⼜⽤来检测重复的消息,接收到消息的标识符被保存在 received_messages 表中。当需要处理新消息时,⾸先尝试往 received_messages 表中插⼊新的记录,如果插⼊时出现重复键的异常,就说明消息已经被处理过。
配置 CDC
使⽤ Eventuate Tram 发布和处理事件时,必须使⽤ Eventuate CDC 服务,我们只需要启动 CDC 服务对应的 Docker 容器即可。
4.如何设计与实现事件源
事件源技术
数据⼀致性问题的根源在于对象状态与事件的分离,对象的当前状态保存在数据库中,⽽事件则在对象的状态发⽣变化时被发布。事件源技术的核⼼在于使⽤事件来捕获对对象状态的修改,这些事件按照发⽣的时间顺序来保存。当需要获取对象的当前状态时,只需要从⼀个初始状态的对象开始,然后对该对象依次应⽤保存的事件即可,这个过程的最终结果就是对象的当前状态。
事件源技术使⽤事件来保存所有对状态的修改。通过事件的重放,可以实现很多强⼤的功能,如查询对象在任意时刻的状态。
查询对象状态
事件源技术实现中的⼀个重要的问题是如何查询对象的当前状态,对于银⾏账户对象来说,我们需要知道账户的当前余额是多少。我们只需要从对象的初始状态开始,按照时间顺序依次应⽤不同事件所对应的改动,最终得到的结果就是对象的当前状态。
快照
使⽤事件来表⽰对对象状态的修改之后,查询对象的状态变得复杂,需要依次应⽤所有的事件。当事件的数量⾮常⼤时,查询操作的性能会变低,这是因为每次都需要从初始状态开始遍历全部的事件。快照(Snapshot)的作⽤是提⾼查询状态时的性能,快照可以看成是⼀次状态查询的结果,在执⾏查询操作之后得到的对象状态被保存成快照。之后的查询操作不再需要从初始状态开始,⽽是从最近的快照开始,再应⽤快照保存之后产⽣的事件即可。在使⽤了快照之后,每次查询操作所要处理的事件数量可以控制在⼀个合理的范围。
事件反转
由于所有对对象状态的修改都由事件对象来保存,如果产⽣了错误的事件,可以很容易就进⾏纠正。对于⼀个事件,除了可以应⽤事件所对应的修改之外,还可以反转事件所对应的修改。⽐如,对于 AccountCreditedEvent 事件,在进⾏反转时,执⾏的是取款操作。在⼀个事件序列中,如果某个事件的产⽣是错误的,只需要对这个事件及其之后的事件都进⾏反转操作,再重新应⽤正确的事件以及之后产⽣的事件,所得到的结果就是正确的状态。
与外部系统交互
事件源技术的最⼤优势在于可以随时重放事件,有些事件在应⽤时会调⽤外部系统提供的服务来进⾏修改操作。当进⾏事件重放时,这些对外部系统服务的调⽤是不应该发⽣的,这就需要在事件的正常处理和重放时,对外部系统的调⽤采⽤不同的策略。
推荐的做法是把所有与外部系统的交互都封装在⽹关(Gateway)中,⽹关的实现会根据事件处理的状态来确定是否发送实际的调⽤给外部系统。
与调⽤外部系统服务相关的是,事件处理时依赖外部系统提供的数据。⽐如,如果银⾏账户的存款操作⽀持不同的货币,假设AccountCreditedEvent 事件中的⾦额使⽤的不是⼈民币,在处理该事件时,则需要根据当时的汇率来得到⼈民币的⾦额。当进⾏事件重放时,我们需要的是事件产⽣时的汇率值来完成处理,⽽不是重放事件时的汇率值,这就要求外部系统⽀持历史数据的查询。如果外部系统不⽀持查询历史数据,可以在⽹关中保存全部调⽤的结果。
代码更新
第⼀种代码更新是增加新功能,新功能并不影响之前已经处理过的事件。当代码更新之后,新增的功能会对更新之后产⽣的事件⽣效。如果新功能对之前的事件也适⽤,只需要重放之前的事件即可,这是事件源技术的⼀个强⼤功能。
第⼆种代码更新是修复 bug。在 bug 被修复之后,只需要重放事件,对象的状态就会被⾃动修复,如果 bug 涉及到外部系统,那么需要根据 bug 的情况来具体分析,采取不同的策略。在另外的情况下,⽹关会需要计算 bug 修复前后的差异性来进⾏补偿。这些补偿操作由⽹关来完成,对之前所有受到 bug 影响的事件都需要执⾏⼀次。
第三种代码更新是与时间相关的代码处理逻辑。这就要求领域模型可以根据事件的发⽣时间来应⽤对应的处理策略。最简单的做法是⽤⼀系列 if-else 语句来根据事件的发⽣时间,返回不同的值。
审计⽇志
事件源技术的⼀个⾮常重要的应⽤场景是实现审计⽇志(Audit Log),审计⽇志在很多涉及敏感数据的系统中⾄关重要。这些系统要求对数据的所有修改都需要记录下来,⽅便以后查询。在使⽤事件源技术之后,保存的事件序列实际上就形成了审计⽇志,这是使⽤事件源技术带来的直接好处。
事件存储
事件源技术在实现时的⼀个重要考虑是事件的持久化存储。由于事件是有序,⽽且不可变的,我们可以利⽤这些特性实现⾼效的事件存储,典型的实现是采⽤只追加(Apppend Only)的数据存储。当新的事件产⽣,只是往事件⽇志中追加记录,由于事件是不可变的,不需要考虑已有事件的更新。从实现上来说,事件存储类似于数据库中的事务⽇志,以及时间序列数据库(Time Series Database)对数据的存储。
三、跨微服务协作与查询
1.什么是数据一致性与Soga模式
数据⼀致性
要保证的是同⼀⼯作单元中的全部动作在执⾏前后,业务逻辑中所规定的不变量不被破坏。
数据⼀致性问题的⼀个典型场景是在数据库操作中,关系型数据库通过事务来解决⼀致性问题。
数据库事务的 ACID 特性
数据⼀致性问题的⼀个解决办法是保证⼯作单元的原⼦性,也就是说,⼯作单元中的全部动作,要么全部发⽣,要么全部不发⽣。
在关系式数据库管理系统中,事务⽤来作为多个语句执⾏时的单元。
数据库事务满⾜ACID特性,ACID是原⼦性(Atomicity)、⼀致性(Consistency)、隔离性(Isolation)和持久性(Durability)对应的英⽂单词⾸字母的缩写。
原⼦性指的是每个事务都被当成⼀个独⽴的单元,其中包含的语句要么全部成功,要么全部不执⾏。如果事务中的⼀个语句执⾏失败,整个事务会被回滚,不会对数据库产⽣影响。
⼀致性指的是事务只会把数据库从⼀个合法的状态带到另外⼀个合法的状态,并保持数据库的不变量。数据库的不变量与之前提到的业务逻辑的不变量并不相同。数据库的不变量指的是为了保证数据的完整性所定义的规则,包括约束、级联操作和触发器等。常⽤的规则包括,数据库表中的主键必须唯⼀,外键所引⽤的主键必须存在等。
隔离性与事务的并发执⾏有关。事务通常是并发执⾏的,也就是说,多个事务可能同时对同⼀个数据库表进⾏修改。隔离性要求多个事务在并发执⾏的结果,与这些事务按顺序执⾏所得到的结果是⼀样的。也就是说,每个事务都相当于在⾃⼰隔离的空间中运⾏,不受其他事务的影响。
持久性指的是⼀旦事务被提交,那么即便是系统崩溃,该事件仍然处于已提交状态。⼀般的做法是使⽤事务⽇志来记录已提交的事件,持久性保证了事务的执⾏结果不会受到系统崩溃的影响。
最终⼀致性的 BASE 特性
最终⼀致性(Eventual Consistency)指的是,对于⼀个数据项,如果没有对它做新的改动,那么所有对该数据项的访问最终都会返回最后⼀次更新的值。
最终⼀致性所提供的特性是 BASE,即基本可⽤(Basically Available)、软状态(Soft State)和最终⼀致性(Eventual Consistency)的缩写。
基本可⽤指的是基本的读取和写⼊操作是尽可能可⽤的,但是并不保证⼀致性。也就是说,读取操作不⼀定返回的是最近⼀次更新的值,写⼊操作只有在解决冲突之后才会被持久化。软状态指的是由于没有⼀致性的保证,在某个时间点上,我们只能对系统的状态有⼀个⼤致的认知。最终⼀致性的含义如上⾯所述,只需要等待⾜够长的时间,系统的状态就会最终恢复⼀致性。
最终⼀致性的⽬标是提⾼系统的可⽤性,这就要提到分布式系统中的CAP定理。CAP定理指的是⼀个分布式数据存储最多只能提供⼀致性(Consistency)、可⽤性(Availability)和分区容错性(Partition Tolerance)这三个保证的两个保证。
这三个保证的内容分别是:
⼀致性,每次读取操作可以获取到最近⼀次写⼊的值,或者产⽣错误;
可⽤性,每次请求总是可以得到⼀个正确的响应,尽管其中包含的不⼀定是最近⼀次写⼊的值;
分区容错性,当由于节点之间的⽹络原因,造成系统内部的消息丢失时,系统仍然可以继续⼯作。
由于分布式系统中的⽹络错误不可避免,分区容错性的保证是必须要有的。
所以基于CAP定理,当出现⽹络分区时,就需要在⼀致性和可⽤性之间进⾏选择。⼀种做法是直接出错,这样保证了⼀致性,但是会降低可⽤性,因为不能再提供请求的响应;
另外⼀种做法是返回系统已知的最近值,但是该值不⼀定是最新的,这样保证了可⽤性,但是丢失了⼀致性。
这⾥需要注意的是,CAP 定理并不是说永远只能在⼀致性、可⽤性和分区容错性这三者中选择两个。事实上,当⽹络没有问题时,⼀致性和可⽤性是可以兼顾的。⼀致性和可⽤性的取舍,只发⽣在⽹络出现问题时。
微服务架构中的最终⼀致性
微服务架构的本质是⼀个分布式系统,也同样也会遇到⼀致性的问题,这种⼀致性不仅体现在数据层⾯上,更多的是在业务逻辑上。
在微服务架构的应⽤中,⼀个业务场景可能会由多个微服务来协作完成,所有参与的微服务的数据必须在业务逻辑上保持⼀致。
在微服务架构的应⽤中,最终⼀致性是解决数据⼀致性问题的最现实⽅案。当业务流程横跨多个微服务时,完成⼀个业务流程的时间可能会⽐较长。如果从业务流程的⽣命周期全过程中的某个时间点来看,相关的数据可能处于不⼀致的状态。如果等整个业务流程全部完成,那么系统的状态会恢复⼀致性。
在微服务架构中,描述业务流程,需要⽤到 Saga 模式。
Saga 模式
⼀个长时间运⾏的事务,由多个⼩的本地事务组成,它避免了对⾮本地资源的锁定,并通过补偿机制来处理失败。长时间运⾏的事务并不具备数据库事务的全部 ACID 特性,但是组成它的本地事务具有 ACID 特性。如果某个本地事务出现错误,那么对于那些已提交的本地事务,会应⽤其对应的补偿机制来恢复状态。
虽然 Saga 模式起源于数据库系统,它⾮常适合于微服务架构,该模式⽤来保证业务事务(Business Transaction)的数据⼀致性。业务事务可能横跨多个微服务的边界,涉及不同类型的数据存储,还可能有⼈员的参与。这样的业务事务有⾃⼰的状态,⽽且可能耗时漫长,Saga 模式是实现业务事务的良好解决⽅案。
在应⽤ Saga 模式之后,每个微服务更新本地的数据库,并发布事件来推动业务事务往前发展。根据是否有协调者,Saga分成编排型(Choreography)和编制型(Orchestration)两种,其中编制型有协调者。编排型 Saga 中的本地事务由事件来直接触发,⽽编制型中 Saga 的本地事务的触发由协调者来确定。
每个Saga中有多个参与者,每个参与者需要定义所执⾏的操作,以及对应的补偿操作。补偿操作不⼀定与执⾏的操作完全相反。编排型Saga中的业务逻辑散落在每个参与者之中,⽽编制型 Saga 中的业务逻辑由协调者来统⼀管理。
业务事务的进程推进由事件和消息来完成,当业务事务进⾏到最后⼀步时,这个Saga处于已完成的状态。
2.如何使用Soga模式
编制型 Saga
编制型 Saga 使⽤⼀个协调者来管理 Saga 的⽣命周期,每个 Saga 描述⼀个业务事务。Saga 的定义⽤来描述对应的业务事务流程,主要包含具体的步骤,以及步骤之间的递进关系。Saga 定义中有多个参与者,每个参与者可以接受命令并返回响应。在微服务架构的应⽤中,参与者通常来⾃不同的微服务。
在运⾏时,每个 Saga 定义会产⽣多个实例,每个实例表⽰业务事务的⼀次执⾏。以创建⾏程为例,每个⾏程对象的创建过程都有与之对应的 Saga 实例,该实例的状态会被持久化下来。Eventuate Tram 使⽤关系型数据库来保存 Saga 实例。
Saga 定义可以看成是⼀个状态机的描述,状态机中的状态来⾃ Saga 所⼯作的领域对象,通常是聚合的根实体。状态机中的状态变迁来⾃对 Saga 参与者所提供的命令的调⽤,以及命令的回应消息。根据命令的回应结果,状态机转换到不同的状态,当状态机处于某个状态时,会调⽤与当前状态相关的参与者的命令。
编排型 Saga
编排型 Saga 没有单独的 Saga 实体来管理业务事务的流程,⽽是通过不同参与者之间的事件传递来完成。
每个参与者只需要添加相应事件的处理器,通过本地事务来完成操作即可。
处理的结果以新的事件⽅式进⾏发布,从⽽触发其他参与者的处理逻辑,推动业务事务的进展。
从实现的⾓度来说,编排型Saga只需要利⽤Eventuate Tram框架中提供的事务性消息即可,并不需要额外的⽀持。
业务事务的流程,只存在于事件的发布和处理之中。
编排型 Saga 的好处在于简单,并不需要附加的Saga实体,另外参与者之间是松散耦合的。编排型Saga的缺点在于业务事务的逻辑散落在不同的参与者中,不容易理解整个业务的流程,另外参与者可能会由于事件的发布和处理⽽产⽣循环依赖关系。
由于这样的缺点,编排型 Saga ⼀般只⽤来实现⾮常简单的业务事务,更多的时候,使⽤编制型 Saga 是更好的选择。
Saga 的隔离性问题
Saga 由⼀系列本地事务组成,并通过补偿操作来处理失败。从数据库事务的 ACID 特性来说,Saga 只具有 ACD 特性,缺少了隔离性,隔离性保证了多个事务并发执⾏的结果,与这些事务顺序执⾏时的结果保持⼀致。每个事务都相当于在各⾃隔离的空间中运⾏,互相并不影响。
Saga 并不具备隔离性,这是因为组成 Saga 的本地事务是各⾃独⽴提交的。
当⼀个 Saga 实例的某个步骤完成之后,该步骤对应的本地事务就会被提交,该事务对数据库的改动对其他本地事务是可见的。
⼀个正在运⾏ Saga 实例中的某个步骤在对数据库进⾏操作时,可以读取到另外⼀个Saga实例产⽣的部分结果,也可以覆写掉另外⼀个Saga实例已经写⼊的结果。这可能会造成数据异常。
解决 Saga 隔离性问题的⼀个常见⽅案是在应⽤层次添加锁,可以把领域对象的状态作为锁。
另外⼀种解决⽅案是从数据库中重新读取领域对象。
3.CQRS如何设计与实现
CQRS 是命令和查询的职责分离(Command Query Responsibility Segregation)对应的英⽂名称的⾸字母缩写。CQRS 中的命令指的是对数据的更新操作,⽽查询指的是对数据的读取操作,命令和查询的职责分离指的是⽤不同的模型来分别进⾏更新和读取操作。CQRS 与我们通常使⽤的更新和读取数据的⽅式并不相同。
我们通常对数据的操作⽅式是典型的 CRUD 操作,分别表⽰对记录的创建(Create)、读取(Read)、更新(Update)和删除(Delete)。在有些时候,还会加上⼀个列表(List)操作来读取满⾜条件的多个记录,组成 LCRUD 操作,CRUD 操作使⽤的是同⼀个模型。在⾯向对象的设计中,通常使⽤领域对象类来作为模型的描述,在进⾏持久化时,领域对象的实例被映射成关系型数据库中的表中的记录,或是 NoSQL 数据库中的⽂档等。这样的实现⽅式,相信很多开发⼈员都不陌⽣,也是开发中经常会⽤到的模式。很多开发框架都提供了对这种模式的⽀持,Spring Data 中的 CrudRepository 接⼜就提供了对 LCRUD 操作
的基本抽象。
单⼀模型的问题
单⼀模型要⾯对的问题是如何⽤⼀个模型来满⾜不同的更新和查询请求。当模型⽐较简单,或是模型的使⽤者⽐较少时,这并不是⼀个太⼤的问题;当模型变得复杂,或是需要满⾜很多使⽤者的不同需求时,维护这样的模型就变得很困难。
在⼀个应⽤中,总是有⼀些模型处于核⼼的地位,⽐如电⼦商务应⽤中的订单、客户和产品等模型,应⽤中的各种组件,都或多或少需要⽤到这些核⼼模型。如此多的依赖关系,导致核⼼模型的修改变得很困难,⼤部分代码在使⽤时,只需要⽤到核⼼模型的部分内容。在进⾏读取操作时,免不了要根据使⽤者的需要,对模型进⾏投影(Projection)和转换操作。投影指的是从模型中选择所需要的数据⼦集,⽽转换则是把模型转换成另外⼀种格式。在进⾏更新操作时,也需要先把客户端发送的模型转换成内部的单⼀模型,这样的模型转换会带来⼀定的性能开销。
模型转换的问题在使⽤关系型数据库时尤为明显。这是因为关系型数据库在设计时需要遵循不同的范式。规范化的结果是数据查询时可能需要进⾏多表的连接操作,影响性能。对于这个问题,通常的做法是创建⼀个单独的报表数据库来满⾜查询请求。报表数据库的表设计⽅便更好地满⾜查询需求,⽽数据则来源于业务数据库。两个数据库之间的数据同步和表模式转换,⼀般通过 ETL ⼯具来完成。这实际上是对更新和查询使⽤不同模型的做法的⼀种应⽤。
CQRS 的应⽤范围
与传统应⽤使⽤单⼀模型进⾏全部操作相⽐,CQRS 分别使⽤两个不同的模型来进⾏更新和查询操作。从⼀个模型到两个模型,所带来的复杂度的提升并不只是简单的翻倍,开发⼈员需要花费更多的时间来理解这两个模型的使⽤。只有当 CQRS 所带来的好处,超过它本⾝引⼊的复杂度时,使⽤ CQRS 技术才是有意义的。实际上,对于⼤部分应⽤来说,使⽤传统的单⼀模型的⽅式确实更好。适合于CQRS技术的应⽤主要有两类:
第⼀类应⽤的更新模型和查询模型本⾝就存在很⼤差异,第⼆类应⽤在更新和查询操作时有不同的性能要求。
使⽤事件源技术的应⽤在更新和查询时的模型是不相同的。事件源技术使⽤不同类型的事件来表⽰对状态的修改,⽽查询时则通过依次应⽤事件的修改,从⽽得到相关的结果对象。这使得事件源技术很适合与 CQRS 技术⼀块使⽤,实际上,这两者也经常被⼀块提及。
有些应⽤在更新和查询时有不同的性能需求,使⽤单⼀模型没办法满⾜这⼀性能需求,这⼀点与在算法设计时选择数据结构的思路是相似的。在修改和访问这两种操作中,不同数据结构的时间复杂度是不同的,有些应⽤的查询操作的数量远多于更新操作,因此需要对查询操作进⾏优化。
使⽤ CQRS 技术把更新和查询两种操作进⾏分离之后,就可以对它们分别进⾏针对性的优化,在运⾏时可以采⽤不同的扩展策略。⽐如,可以为查询操作添加数量很多的运⾏实例。
在微服务架构的应⽤中,由于每个微服务各⾃独⽴,我们可以只把 CQRS 技术应⽤在其中的某个微服务上。
这样可以充分利⽤CQRS技术的优势,同时避免对整个应⽤进⾏较⼤的改动。
CQRS 的设计
CQRS的设计要点是为查询和命令创建不同的模型,命令模型⽤来对数据进⾏修改,⽤来描述对数据修改的意愿,⽽查询模型则⽤来读取数据,这⾥需要把命令和事件进⾏区分。命令描述的是改变状态的期望,⽽事件则是状态改变的结果。
查询模型的设计专门为满⾜查询请求进⾏了优化,查询模型在设计时,考虑更多的是使⽤者的需求。查询模型的使⽤者通常是⽤户界⾯,根据⽤户界⾯展⽰时的要求,来设计查询模型。如果使⽤单⼀模型,需要在读取数据之后,再进⾏额外的计算来得到统计信息,这种做法的性能相对较差。如果专门为了统计信息设计相应的查询模型,那么只需要直接读取即可,并不需要额外的计算。
CQRS 的实现
实现 CQRS 的重点是更新和查询模型的实现,命令本质上是⼀种消息。后端实现中通常会使⽤消息队列或消息中间件来接收命令,接收到的命令都需要进⾏验证来保证合法性。命令的验证包括两部分:⼀部分与业务⽆关,只是检查命令是否满⾜结构上的要求,⽐如是否缺失必需的字段等;另⼀部分则与业务相关,需要根据命令执⾏时的上下⽂来确定,⽐如订单⽀付命令所处理的订单对象,是否处于合法的状态。通过验证的命令会按照接收的顺序来执⾏。执⾏顺序的错误可能造成数据不⼀致,命令在执⾏时会更新数据存储。
查询模型的设计需求来⾃使⽤者,查询模型通常不包含复杂的业务逻辑,只是作为数据的容器。这使得查询模型使⽤起来很简单。
四、API组合
1.如何设计与实现API组合
API ⽹关
外部 API 是提供给 Web 应⽤、移动客户端和第三⽅客户端来调⽤的;⽽内部 API 是提供给其他微服务来调⽤的。
外部 API 就是外部⽤户与这个⿊盒⼦交互的⽅式,这两者之间交互的桥梁,就是 API ⽹关。所有外部的 API 访问请求,都需要通过这个⽹关进⼊到后台。 API ⽹关其本质上是⼀个反向代理(Reverse Proxy)。
API 组合
在微服务架构的应⽤中,应⽤的功能被分散到多个微服务中。来⾃⼀个微服务的 API 并不能满⾜外部使⽤者的需求,因为⼀个微服务只能提供部分数据。
因此,需要⼀种⽅式来提供给使⽤者所需要的全部数据。
使⽤ API 组合,在应⽤内部创建进⾏ API 组合的服务。对客户端发送的 API 请求,该组合服务调⽤后台的多个微服务的 API,并把得到的数据进⾏整合,再返回给客户端。API 组合的好处是对微服务 API 的调⽤发⽣在系统内部,调⽤的延迟很⼩,也免去了客户端的多次调⽤。
Backend For Frontend 模式
Backend For Frontend 模式指的是为每⼀种类型的前端创建其独有的后端 API。这个 API 专门为前端设计,完全满⾜其需求,通常由该前端的团队来维护。
API 组合的实现
Spring Cloud Gateway 是 Spring 框架提供的 API ⽹关的实现,基于 Spring Boot 2、Spring WebFlux 和 Project Reactor。
Spring Cloud Gateway 中有 3 个基本的概念,分别是路由、断⾔和过滤器。
路由是⽹关的基本组成部分,由标识符、⽬的地 URI、断⾔的集合和过滤器的集合组成。
断⾔⽤来判断是否匹配 HTTP 请求,本质上是⼀个 Java 中的 Predicate 接⼜的对象,进⾏判断时的输⼊类型是 Spring 的ServerWebExchange 对象。
过滤器⽤来对 HTTP 请求和响应进⾏处理,它们都是 GatewayFilter 接⼜的对象,多个过滤器串联在⼀起,组成过滤器链。
前⼀个过滤器的输出作为下⼀个过滤器的输⼊,这⼀点与 Servlet 规范中的过滤器是相似的。
当客户端的请求发送到⽹关时,⽹关会通过路由的断⾔来判断该请求是否与某个路由相匹配。如果找到了对应的路由,请求会
由该路由的过滤器链来处理,过滤器既可以在请求发送到⽬标服务之前进⾏处理,也可以对⽬标服务返回的响应进⾏处理。
Spring Cloud Gateway 提供了两种⽅式来配置路由,⼀种⽅式是通过配置来声明,另⼀种是通过代码来完成。
Spring Cloud Gateway 提供了⼤量内置的断⾔和过滤器的⼯⼚实现。
以断⾔来说,可以通过 HTTP 请求的头、⽅法、路径、查询参数、Cookie 和主机名等来进⾏匹配;以过滤器来说,内置的过滤器⼯⼚可以对 HTTP请求的头、路径、查询参数和内容进⾏修改,也可以对 HTTP 响应的状态码、头和内容进⾏修改,还可以添加请求速率限制、⾃动重试和断路器
2.如何使用Netflix Falcor组合API
在⼤部分情况下,REST API所返回的数据结构,与使⽤者对数据的要求并不完全匹配。当API 所提供的数据多于使⽤者的需要时,处理⽅式还⽐较简单,只需要忽略多余的数据即可,但是传输多余的数据也会导致更长时间的⽹络延迟和更多的内存消耗。
如果⼀个API所提供的数据不能满⾜需求,就需要组合多个 API。Backend For Frontend模式可以解决⼀部分的问题,但仍然免不了需要根据客户端的需求,对 API 进⾏调整和维护。
造成这种问题的根源在于API的使⽤者⽆法随意地控制API返回的数据,当使⽤者的需求发⽣变化时,总是需要API的提供者⾸先做出修改,然后使⽤者再消费新版本的 API。
API 的版本化,并没有从根本上解决这个问题,只是让API的变化更加容易管理。从使⽤者的⾓度来说,如果能够根据使⽤的需要,⾃主的选择所要查询的数据,那么当使⽤的需求发⽣改变时,并不需要 API 提供者做出改变,这⽆疑可以极⼤地提升开发效率。
Netflix Falcor——数据即 API
Falcor 中所公开的是数据本⾝,以及通⽤的获取和更新数据的⽅式,具体的使⽤则完全由客户端来确定。
在 Falcor 的架构中,数据由⼀个抽象的 JSON 图来表⽰。这个 JSON 图中包含了提供者所能开放的全部数据,并以图的形式表⽰出来。这种图的表⽰形式,与数据库中的实体关系模型、⾯向对象中的对象关系图,以及领域驱动设计中的聚合的引⽤关系,在本质上都是相似的,都是把数据抽象成实体,以及实体之间的引⽤关系。这些实体及其关联关系,来⾃应⽤所在的领域,组成了应⽤的模型。
Falcor使⽤JSON来描述数据。由于JSON实际上是⼀种树形结构,⽆法直接表达图中的引⽤关系。Falcor对JSON进⾏了扩展,增加了新的基本类型来描述图相关的信息。Falcor 实际上由对 JSON 图对象进⾏操作的⼀系列协议组成。
JSON 图
JSON 图(JSON Graph)中的每个实体都有唯⼀的路径(Path),这个路径是实体唯⼀的保存路径,也是其他实体进⾏引⽤时的路径,这个路径称为该实体的⾝份路径(Identity Path)。
数据源
数据源⽤来把 JSON 图暴露给模型,每个数据源都与⼀个 JSON 图关联。模型通过执⾏ JSON 图的抽象操作来访问数据源所提供的 JSON 图。
模型
在有了数据源之后,客户端理论上可以直接使⽤数据源提供的接⼜来访问 JSON图。
不过更好的做法是通过模型作为视图与数据源之间的中介。
模型在数据源的基础上,提供了⼀些实⽤的功能,包括把 JSON 图中的数据转换成 JSON对象,在内存中缓存数据以及进⾏批量处理。
相对于数据源,模型所提供的接⼜更加易⽤。
路由器
路由器是 DataSource 接口的实现,⼀般运⾏在服务器端⽤来给模型提供数据。在微服务架构的应⽤中,路由器扮演了 API 组合的⾓⾊。
路由器由⼀系列的路由组成,每个路由匹配 JSON 图中的路径集合,对于每个路由,需要定义它所⽀持的操作,以及每个操作具体的实现。
3.如何使用GraphQL组合API
GraphQL
GraphQL 这个名称的含义是图查询语⾔(Graph Query Language),
GraphQL 是为 API 设计的查询语⾔,提供了完整的语⾔来描述 API 所提供的数据的模式(Schema)。
模式在 GraphQL 中扮演了重要的作⽤,类似于 REST API 中的 OpenAPI 规范。有了模式之后,客户端可以⽅便地查看 API 所提供的查询,以及数据的格式;
服务器可以对查询请求进⾏验证,并根据模式来对查询的执⾏进⾏优化。
根据 GraphQL的模式,客户端发送查询到服务器,服务器验证并执⾏查询,同时返回相应的结果。查询的结果完全由请求来确定,这就意味着客户端对获取的数据有完全的控制。
GraphQL 使⽤图来描述实体与实体之间的关系,还可以⾃动处理实体之间的引⽤关系。在⼀个查询中可以包含相互引⽤的多个实体。
GraphQL 使得 API 的更新变得容易。在 API 的 GraphQL模式中可以增加新的类型和字段,也可以把已有的字段声明为废弃的。
已经废弃的字段不会出现在模式的⽂档中,可以⿎励使⽤者使⽤最新的版本。
GraphQL ⾮常适⽤于微服务架构应⽤的 API 接口,可以充分利⽤已有微服务的 API。
查询和修改
GraphQL 中定义了类型和类型中的字段。最简单的查询是选择对象中的字段。如果对象中有嵌套的其他对象,可以同时选择嵌套对象中的字段。
模式和类型
GraphQL 使⽤语⾔中性的模式语⾔来描述数据的结构,每个 GraphQL 服务都通过这个模式语⾔来定义所开放的数据的类型系统。
GraphQL 规范中已经定义了⼀些内置的类型,每个服务提供者也需要创建⾃⼰的类型。
查询执⾏
当 GraphQL 的查询发送到服务器时,由服务器负责查询的执⾏,查询的执⾏结果的结构与查询本⾝的结构相匹配。查询在执⾏时需要依靠类型系统的⽀持。GraphQL 查询中的每个字段都可以看成是它类型上的⼀个函数或⽅法,该函数或⽅法会返回⼀个新的类型。
每个类型的每个字段,在服务器上都有⼀个函数与之对应,称为解析器(Resolver)。
当需要查询某个字段时,这个字段对应的解析器会被调⽤,从⽽返回下⼀个需要处理的值,这个过程会递归下去,直到解析器返回的是标量类型的值。
GraphQL 的查询过程,总是以标量值作为结束点。
如果字段本来就是对象中的属性,那么获取这些字段的解析器的实现⾮常简单,并不需要开发⼈员显式提供。⼤部分的 GraphQL 服务器的实现库,都提供了对这种解析器的⽀持。如果⼀个字段没有对应的解析器,则默认为读取对象中同样名称的属性值。