选择合适的进程间通信机制是一个重要的架构决策,它会影响应用的可用性,甚至与事务管理相互影响。
概述交互方式
首先考虑交互方式有助于你专注于需求,避免陷入细节。
一对一一对多同步模式请求/响应无异步模式异步请求/响应 单向通知发布/订阅 发布/异步响应
一对一: 每个客户端请求由一个服务实例处理
一对多: 每个客户端请求由多个服务实例处理
单向通知: 客户端的请求发送到服务端,并不期望服务端做出任何响应
发布/订阅方式: 客户端发布通知消息,被零个或多个感兴趣的服务订阅
发布/异步响应方式: 客户端发布请求消息,等待从感兴趣的服务发回的响应
在微服务中定义api
服务的api是服务与其客户端之间的契约,它由客户端结构可以调用的方法、服务发布的事件组成。
挑战:
没有一个简单的编程语言结构来构造和定义服务的api。若使用不兼容的api部署新版本的服务,虽然在编译阶段不会出现错误,但会出现运行时故障。
首先编写接口定义,然后与客户端开发人员一起查看这些接口的定义。只有在反复迭代几轮api定义后,才可以具体服务实现编程。这种预先设计有助于你构建满足客户端需求的服务。
api的演化
挑战:
1、不能够强行要求客户端和服务端api版本保持一致
2、你一般采用滚动升级的方式更新服务,因此一个服务的旧版本和新版本肯定会共存
语义化版本控制
它是一组规则,用于指定如何使用版本号,并且以正确的方式递增版本号,版本号由三部分组成,必须按如下方式递增版本号:
major:当你对api进行不兼容的更改时
minor:当你对api进行向后兼容的增强时
patch:当你进行向后兼容的错误修复时
你可以在实现rest api或消息机制的服务时,包含版本号
进行次要且向后兼容的改变
理性情况下应该只进行向后兼容的更改:
添加可选属性
向响应添加属性
添加新操作
服务应该为缺少的请求属性提供默认值,客户端应忽略任何额外的响应属性,这样老版本的客户端能直接只用更新的服务
进行主要且不向后兼容的改变
此时必须在一段时间内同时支持新旧版本的api
假如使用rest,可以在url中嵌入主要版本号,或者使用http的内容协商机制,在mime类型中包含版本号。
实现api的服务适配器将包含在旧版本与新版本之间进行转换的逻辑,如api gateway几乎会使用版本化的api
消息的格式
考虑到以后会扩展到其他语言,我们不应该使用类似java序列化这样跟语言强相关的消息格式
基于文本的消息格式
如json和xml,可读性高,自描述的。但往往过度冗长,解析开销过大。
二进制消息格式
对效率和性能敏感的场景下,比较适用。常见的如protocol buffers和avro,它们提供了强类型定义的idl,编译器会自动根据其格式生成序列化和反序列化的代码,因此你不得不采用api优先的方法来进行服务设计。
基于同步远程过程调用模式的通信
客户端的业务逻辑调用由rpi代理适配器类实现的接口,rpi代理类向服务发出请求,rpi服务器适配器类通过调用服务的业务逻辑来处理请求
使用rest
rest是使用http协议的进程间通信机制
其关键概念是资源,它通常表示单个业务对象。rest使用http动词操作资源,使用url引用这些资源。
rest成熟度模型
level 0:只是向服务端点发起http post请求,进行服务调用
level 1:引入了资源的概念
level 2:使用http动词执行操作
level 3:基于hateoas原则设计,基本思想是由get请求返回的资源信息中包含链接,这些链接能够执行该资源允许的操作
最流行的rest idl是open api的规范,他是从swagger开源项目发展而来的。
一个请求中获取多个资源的挑战
rest资源通常以业务对象为导向,设计rest api时常见问题是如何使客户端能够在单个请求中检索多个相关对象。纯rest api要求客户端发出多个请求,更复杂的情况时需要更多往返并遭受过多延迟,其中一个解决方案是api允许客户端在获取资源时检索相关其他资源,如果情况更复杂耗时,则使用graphql和falcor等替代技术。
把操作映射为http动词的挑战
如何将在业务对象上执行的操作映射到http动词。但很难将多个更新操作映射到http动词,且更新可能不是幂等的,但这却是使用put的要求。
一种解决方案是定义用于更新资源的特定方面的子资源,还有就是将动词指定为url的查询参数。但这不是很符合restful的要求。
rest的好处和弊端
好处:
简单熟悉
可使用浏览器扩展或curl来测试api
直接支持请求/响应方式通信
http对防火墙友好
不需要中间代理,简化系统架构
弊端:
只支持请求/响应方式通信
没有代理缓冲消息,可能导致可用性降低
客户端必须知道服务实例的位置
在单个请求中获取多个资源具有挑战性
有时很难将多个更新操作映射到http动词
使用grpc
由于http仅提供有限数量的动词,设计支持多个更新操作的rest api不总是很容易,grpc可以避免此问题。它是一种跨语言客户端和服务端的框架,基于二进制消息,你可以基于protocol buffer的idl定义grpc api,能够保持在向后兼容的同时进行变更。
grpc除简单的请求/响应rpc外,还支持流式rpc。
好处:
便于设计具有复杂更新操作的api
具有高效紧凑的进程间通信机制,尤其在交换大量信息时
支持双向流式消息方式
实现了客户端和用各种语言编写的服务端间的互操作性
弊端:
需要更多工作
旧式防火墙也许不支持http/2
也是一种同步通信机制,存在局部故障的问题
使用断路器模式处理局部故障
服务端可能因为故障等无法在有限时间内对客户端请求做出响应,客户端等待响应被阻塞,这可能会在其他客户端甚至使用服务的第三方应用之间传导,导致服务中断。
解决方案:
1、开发可靠的远程过程调用代理,包括:
网络超时机制
限制客户端向服务器发出的请求数量
断路器模式:在连续失败次数超过指定阀值后一段时间内,这个代理会立即拒绝其他调用,稍后重试,若成功则解除断路器
2、从服务失效故障中恢复
服务只是向其客户端返回错误
返回备用值
使用服务发现
服务实例具有动态分配的网络位置,由于自动扩展、故障和升级,服务实例会动态更改,因此客户端代码必须使用服务发现
什么是服务发现
服务发现的关键组件是服务注册表
两种方式实现服务发现:
服务及其客户直接与服务注册表交互
通过部署基础设施来处理服务发现
应用层服务发现模式
它是两种模式的组合
自注册模式:服务实例向服务注册表注册自己
客户端发现模式:客户端从服务注册表检索可用服务实例列表,并在它们之间进行负载均衡
例子:如euraka,高可用的服务注册表;euraka java客户端;ribbon,支持eureka客户端的复杂http客户端
好处:可以处理多平台部署的问题
弊端:需要为使用的每种编程语言提供服务发现库。开发者需要负责设置、管理服务注册表,分散一定精力。
平台层服务发现模式
它是两种模式的组合:
第三方注册模式:由第三方负责处理注册,而不是服务本身向服务注册表注册自己
服务端发现模式:客户端不需要查询服务注册表,而是向dns名称发出请求,请求被解析到路由器,路由器查询服务注册表对请求进行负载均衡。
例子:docker和kubernetes
好处:服务发现的所有方面完全由部署平台处理
弊端:仅限于支持使用该平台部署的服务
基于异步消息模式的通信
客户端使用异步消息调用服务
消息传递
消息由消息头部和消息主体组成 类型:
文档 仅包含数据的通用消息
命令 一条等同于rpc请求的消息
事件 表示发送方这一端发生了重要事件
关于消息通道
发送方中的业务逻辑调用发送端接口,该接口由消息发送方适配器实现。消息发送方通过消息通道向接收方发送消息。消息通道是消息传递基础设施的抽象。调用接收方的消息处理程序适配器来处理消息。它调用接收方业务逻辑实现的接收端端口。
类型:
点对点通道:向正在从通道读取的一个消费者传递消息
发布-订阅通道:将一条消息发给所有订阅的接收方
使用消息机制实现交互方式
足够灵活,支持上面描述的所有交互方式
实现请求/响应和异步请求/响应
消息机制本质上是异步的,因此只提供异步请求/响应,但客户端可能会阻塞,直到收到回复。
通过在请求消息中包含回复通道和消息标识符来实现异步请求/响应。接收方处理消息将回复发送到指定的回复通道,回复消息包含与消息标志符具有相同值的相关性id,用以匹配验证。
实现单向通知
实现发布/订阅
客户端将消息发布到由多个接收方读取的发布/订阅通道,对特定领域对象的事件感兴趣的服务只需订阅相应的通道。
实现发布/异步响应
它把发布/订阅和请求/响应两种方式的元素组合在一起
客户端发布一条消息,在头部指定回复通道,该通道也是发布-订阅通道。消费者将包含相关性id的回复消息写入回复通道,客户端通过相关性id来收集响应
为基于消息机制的服务api创建api规范
不像rest,没有广泛采用的标准来记录通道和类型,需要自己定义。服务的异步api一般由消息通道和命令、回复和事件消息类型组成
记录异步操作
请求/异步响应式api
单向通知式api
记录事件发布
服务可使用发布/订阅的方式对外发布事件
使用消息代理
无代理消息
无代理架构中,服务可以直接交换消息,如zeromq
好处:
允许更轻的网络流量和更低的延迟
消除了消息代理可能会成为性能瓶颈或单点故障的可能性
具有较低的操作复杂性
弊端:
服务需要了解彼此的位置
导致可用性降低,发送方和接收方必须同时在线
实现例如确保消息能够成功投递这些复杂功能时挑战性更大
基于代理的消息
如activemq,kafka 好处:
发送方不需要知道接收方的网络位置
消息代理缓冲消息,直到接收方能够处理它们
选择消息代理考虑因素:
支持的编程语言
支持的消息标准
消息排序
投递保证
持久性:保存到磁盘且能在代理崩溃时恢复
耐久性:若接收方重新连接到消息代理,是否会收到断开连接时发送的消息
可扩展性
延迟
竞争性接收方:在多线程多实例同时处理消息的情况下,确保消息仅被处理一次,且按照应有的顺序来处理
使用消息代理实现消息通道:
每个消息代理都用自己与众不同的概念来实现消息通道,如kafka使用主题实现点对点通道和发布-订阅通道,rabbitmq使用交换+队列实现点对点通道,使用组播式交换和每客户端队列实现发布-订阅通道
好处:
松耦合:客户端不需要感知服务实例的位置
消息缓存:发送方和接受方不要求一定同时在线
灵活的通信:支持前面所述的所有交互方式
明确的进程间通信:与rpc相比,程序员不会陷入类似“本地调用”的那种“太平盛世”的感觉
弊端:
潜在的性能瓶颈,不过可以横向扩展
潜在的单点故障,不过现代消息代理大部分是高可用的
额外的操作复杂性
处理并发和消息顺序
如何在保留消息顺序的同时,横向扩展多个接收方的实例
采用分片通道方案,如将orderid作为分片键,特定订单的每个事件都发布到同一个分片,该消息也由同一个接收方实例读取
1、分片通道由两个或多个分片组成,分片的行为类似于通道
2、发送方在消息头部指定分片键,消息代理使用分片键将消息分配给分片
3、消息代理将接收方的多个实例组合在一起。并将它们视为相同的逻辑接收方,如kafka中的消费者组。消息代理将每个分片分配给单个接收器。
处理重复消息
正常情况下,保证传递的消息代理只会传递一次消息。但故障可能导致消息被多次传递。
两种方法处理重复消息:
编写幂等消息处理器:
幂等指这个应用被相同输入参数多次重复调用时,也不会产生额外的效果,但要保证消息代理在重新传递消息时保持相同顺序。
跟踪消息并丢弃重复消息:
简单的解决方案是消息接收方使用message id跟踪它已处理的消息并丢弃任何重复项
事务性消息
数据库更新和消息发送都必须在事务中进行,否则系统可能处于不一致状态。
使用数据库表作为消息队列
通过事务性发件箱模式,即将事件或消息保存在数据库的outbox表中,将其作为数据库事务的一部分发布。
将消息从数据库移动到消息代理的两种方法:
通过轮询模式发布事件 轮询数据库中的发件箱,将消息发送给消息代理,它在小规模下运行良好,但经常轮询数据库可能会导致数据库性能下降
使用事务日志拖尾模式发布事件 应用提交到数据库的更新对应着数据库事务日志中的一个条目。事务日志挖掘器可以读取事务日志,将跟消息有关的记录发送给消息代理。
其挑战在于需要一些开发努力,现有框架有debezium,eventuate tram等。
消息相关的类库和框架
直接使用消息代理客户端库的弊端:
客户端库将发布消息的业务逻辑耦合到消息代理api
客户端库是非常底层的,需要常编写重复类似的代码
不支持更高级别的交互
更好的方法是使用更高级别的库或框架,如eventuate tram
使用异步消息提高可用性同步消息会降低可用性
如rest,当服务必须从另一个服务获取信息后才能返回它客户端的调用,就会导致可用性问题。每增加一个额外的服务,会更进一步降低可用性。
要最大化一个系统的可用性,就应该最小化系统的同步操作量
消除同步交互
方法:
使用异步交互模式:
客户端和服务端使用消息通道发送消息来实现异步通信。这种架构很有弹性,消息代理会一直缓存消息,直到服务端接收并处理消息。但服务很多情况采用同步通信协议的外部api,需要对请求立即作出响应。
复制数据:
服务维护一个数据副本,这些数据是服务在处理请求时需要使用的,数据的源头会在数据发生变化时发出消息,服务订阅这些消息来确保数据副本的实时更新。
弊端:
数据量巨大时效率低下
没有从根本上解决服务如何更新其他服务所拥有的数据这个问题
先响应,后处理
如order service,它在不调用任何其他服务的情况下创建订单,然后通过与其他服务交换信息来异步验证新创建的order
优点:即使其他服务中断, order service仍然会创建订单响应客户
弊端:为了使客户端知道订单是否已成功创建,需要定期轮询或者向客户端发送通知。
java达人
(长按或扫码识别)
感谢你的阅读,下面是一个抽奖链接按钮,10月20日晚上开奖,共5个红包,写文不易,感谢大家支持。
感谢大家一直以来的阅读、在看和转发,点我参与抽奖!点我参与抽奖!