微服务之服务间通信
单体应用向微服务拆分进化后,要解决的第一个问题就是进程间的通信问题。具体的实现方式有很多种但是通讯类型无外乎同步和异步两大类。
进程通信的本质是交换消息,而所谓的消息通常就是业务处理所需要的数据,通信过程中消息格式至关重要。消息格式的选择会对进程间通信的效率产生直接影响,同时也对通信双方的API接口规范的制定起到了决定性要求。另外在考虑消息类型时还要考虑到跨语言场景,大部分情况下一个完整的微服务系统都不会是只有一种语言构建而成的,一般大厂都会横跨Java、Python、Go、C等多种语言。
通信类型
同步
HTTP REST
RPC
好处 | 弊端 | |
REST | 它非常简单,并且大家都很熟悉 | 可能会导致可用性降低。通信过程需要客户端和服务端双方同时在线,任何一段故障通信均将失败。 |
可测试很高,可以通过浏览器或者curl命令来测试。 | 客户端必须知道服务端的具体位置(URL) | |
直接支持请求/响应方式的通信 | 只支持请求/响应方式的通信 | |
HTTP对防火墙友好。对于一些路由网关类的负载配置,能够高效灵活的支持其具体实现。 | 单个请求中获取多个资源时,对设计具有挑战性 | |
不需要中间代理,简化了系统架构 | 有时很难讲多个更新操作映射到HTTP动词。 | |
RPC | 设计具有复杂更新操作的API非常简单 | 对脚本类的语言支持不好 |
具有高效、紧凑的进程间通信机制,尤其是在交换大量消息时。 | 需要有配套的路由负载和安全策略(大部分RPC框架其实已经具备了解决模块) | |
支持在远程调用和消息传递过程中使用双向流失消息方式。 | ||
它实现了客户端和用各种语言编写服务端之间的互操作性。(跨语言) |
异步
RocketMQ
kafka
通过使用异步消息通信的方式能够很好的实现微服务的解耦,异步消息传递通常有两种模式,一种是:C端直接将消息发送给P端即可不需要得到恢复,当需要P端恢复时由于是异步通信,C端也不会阻塞,P端处理完后再根据接收到的信息向C端回复处理结果。这种方式也叫无代理模式,比较小众主要适用于一些异步通知场景。另外一种就是:使用消息代理,也就是我们常说的消息中间件MQ了,使用场景比较广泛,行业内有很多成熟方案。
消息主要由消息头和消息体构成,按照不同使用场景可以细分为文档类型、命令类型和事件类型:
结构
消息头:消息头中可以细分出标题,即名称与值对的集合,用于描述正在发送的数据的源数据。除了消息发送者提供的名称与值对之外,小系统还包含其他信息,例如发件人或消息传递基础设施生成的唯一消息ID,以及可选的返回地址,该地址指定发送回复的消息通道。
消息体:消息正文是以文本或二进制格式发送的数据。
类型
文档:仅包含数据的通用信息。接受者决定如何解释它。对命令式消息的回复是文档消息的一种使用场景。
命令:一条等同于RPC请求的消息。它指定要调用的操作及其参数。
事件:表示发送方这一端发生了重要的事件。事件通常是领域事件,表示领域对象的状态更改。
使用消息代理的一个重要好处就是发送方不需要知道接收方的网络位置,另一个好处是消息代理缓冲消息,知道接收方能够处理它。
好处 | 坏处 |
松耦合 | 潜在性能瓶颈 |
自带消息缓存机制 | 潜在单点故障 |
灵活通信 | 额外的操作复杂性 |
中间件选型:需要关注以下几点
支持的编程语言(跨语言):选择的中间件应该支持尽可能多的编程语言。
支持的消息标准:中间件是否支持多种消息标准,比如AMQP和STOMP,还是只支持专用的消息标准。
消息排序:是否能够保证消息的处理顺序
投递保障:是否有消息丢失保障机制
持久性:是否支持持久化到磁盘,并且能够在服务崩溃时有较高的数据恢复处理机制
耐久性(偏移量):接收方重新连接到中间件时,是否会收到断开连接时发送的消息。
可扩展性:扩展成本如何
延迟:端到端是否有较大延迟
竞争性接收方(并发):是否支持客户端并发接收
每种中间件都有不同的侧重点,通常消息传递的顺序性、可扩展性、投递保障是必不可少的参考标准。基于异步消息模式设计微服务架构无疑是一种很好的方法,它抽象底层消息系统的细节。使用消息机制的一个关键挑战是以原子化的方式同时完成数据库更新和发布消息,即事物的控制。一个好的解决方案是使用事务性发件箱模式,并首先将消息作为数据库事物的一部分写入数据库。然后,通过一个单独的进程轮询发布者模式或事物日志拖尾模式从数据库中检索消息,并将其发布给中间件。
通信格式
消息的格式可以分为两大类:文本和二进制。文本类消息的可读性较高、兼容性较好,适合业务系统之间传递消息,例如JSON、XML等;二进制类消息传输性能较高,可读性较和兼容性较差,适合一些Paas层的组件使用。
可读性格式 JSON、XML等
高效格式avro、protocol Buffers
使用基于文本格式消息的弊端主要是消息往往过度冗长,特别是XML。消息的每一次传递都必须反复包含除了值以外的属性名称,这样会造成额外的开销。另外一个弊端是解析文本引入的额外开销,尤其是消息较大的时候。因此,在对效率和性能敏感的场景下,优先选择二进制格式消息。
服务发现
微服务的一大特点就是客户端和服务端要有灵敏的服务发现机制。即,服务端要将自己在网络中的位置暴漏给客户端,由于微服务自带自动扩展、故障和升级的动态改变特性,客户端必须要动态、及时的感知到服务端地址变化。
我们可以进一步将服务发现抽象为服务注册信息存储数据库,该库包含一张服务实例网络位置信息存储记录表。
实际服务发现的核心思路其实就一种,那就是注册中心。至于怎么实现这个注册中心就仁者见仁智者见智了,比如可以通过一张简单的数据表来存储,服务端向数据表更新地址信息,客户端通过查询数据表后再自行实现负载请求。亦或是直接通过成熟的中间件完成,例如SpringCloud默认的Eureka或dubbo默认的zookeeper等。还可以直通通过Nginx实现,将负载部分下沉到组件,客户端不需要自助实现负载等。
当然这只是服务发现最核心的实现原理,其具体实施还需要考虑多种元素,比如心跳探活、通讯协议、缓存设计、跨语言支撑,甚至有些中间件把服务发现只作为分布式服务通信中的一个模块,与负载均衡、流量路由一并封装实现。在微服务设计阶段服务发现虽然很重要但是不能作为我们强依赖的环节,这点需要特别注意。注册信息可以在应用服务启动时初始化,也可以定期同步甚至是被动接收信息更新,但不可作为强依赖项,要解耦出来。
通信故障
在分布式系统中,当服务试图向另一个服务发送同步请求时,永远都面临着局部故障的风险。通常有三大类异常需要考虑:
超时:超时最大的潜在风险在于,很大可能由于下游系统的问题,交易持续耗时耗尽上游系统资源造成雪崩。所以在等待针对请求的响应时,一定不要做成无限阻塞,而是要设定一个超时阈值。
流量控制:流量控制包括限流和路由两类
限流:服务端要具备限流能力,这要可以有效的保护自身和下游系统。通用的限流计算方法有漏斗、令牌、计数器、滑动窗口等方法,根据不同的业务需要可以选择不同的限流计算方法。限流的实现方式也分很多种,可以做成单节点限流或分布式限流。如果是单节点限流可以通过一些第三方类库实现,比如guava的limit包;如果是分布式限流需要引入一个可以控制集中事务的组件,例如Redis、MySQL等。当然限流的维度也可以细分为接口级别、方法级别甚至是业务维度组合级别,需要根据具体的应用场景决定。
路由:路由是在处理流量不均或者AB测试、红蓝发布等场景的有效解决方案,对于系统也可以起到一定的保护。具体实现方式可以通过引入组件(比如:Nginx)或根据自身场景自研,自研成本也不会太高。
熔断:实际上就是一种快速失败的手段,在一些大促场景通常被当做兜底方案使用。熔断虽然会对业务有损但是它能保住一部分交易,不至于整个系统都无法对外提供服务。
以上三类异常场景均需要有配到的服务治理能力支撑才行,比如异常流量发现机制、自动限流、自动熔断等操作机制。同时异常处理完成后还要具备自动恢复的能力。故障的处理通常是本着快速止血、定位问题(需要有一些故障现场的日志支撑才行)、自动恢复这三个自动运维的原则设计。
总结
设计系统时是要通过同步方式还是要通过异步方式来实现通信其实从不同的角度看会有不同的答案,此处我只给我我所经历的两个项目经验。
第一个就是我们要做一个支持18W TPS的高并发系统,这个系统最大的特定就是流量大,业务连续性要求高(金融行业业务连续性L5的要求,RTO<=2分钟 & RPO≈0,系统可用性99.99%,计划外全年系统故障不得超过5分钟),同时业务场景相对简单。针对以上特征我们最后采用的是RPC同步通信方式,因为这种方式有一点常被大家忽略的好处就是可以横向扩容,对资源就可以提升并发量。我们在设计之初又特意避开所有强依赖项,整个核心交易链路理论上是可以无限扩容下去的。
另外一个项目也是一个金融跨境项目,但是这个项目对TPS要求较低,日常100峰值800即可。但这个项目业务上过于复杂,拆分后的微服务数量有50个左右,这还是没有上线前大家根据业务功能完成的拆分项,后续肯定还会增加。由于拆分服务过多、业务复杂、TPS要求较低,针对这三个特点我们最后通过MQ异步实现通信。
归纳下就是MQ异步通信会成为我们系统吞吐量的瓶颈,而且还是强依赖的这种。根据不同的场景可以按照下面列出的指标作为选型参考:(不绝对只作为参考,很多时候都是需要衡量取舍的)
同步 | 异步 | |
吞吐量高要求系统 | ✔ | |
业务复杂且多变 | ✔ | |
业务连续性高要求系统 | ✔ | |
解耦性要求高的系统 | ✔ |