服务调用:除了常用的同步服务调用之外,分布式服务框架还需要支持其他几种形式的服务调用,下面将详细介绍。
一、常见误区
因惯性思维,很多人会将传统MVC架构或者RPC框架的做法带入到分布式服务框架的架构设计中,其中有些思想存在误区,或者已过时,它们会破坏分布式服务架构的架构品质,下面将纠正这些误区。
1.1、NIO 就是异步服务:实际上,通信框架基于 NIO实现,并不意味着服务框架就支持异步服务调用,两者本质上不是同一个层面的事情。在分布式服务框架中,引入NIO带来的好处是显而易见的,各种I/O对比如表1-1所示:
同步阻塞I/O(BIO) | 伪异步I/O | 非阻塞I/O(NIO) | 异步I/O(AIO) | |
客户端个数:I/O线程 | 1:1 | M:N(其中M可以大于N) | M:1(1个I/O线程处理多个客户端连接) | M:0(不需要启动额外的I/O线程,被动回调) |
I/O类型(阻塞) | 阻塞(I/O) | 阻塞(I/O) | 非阻塞(I/O) | 非阻塞(I/O) |
I/O类型(同步) | 同步(I/O) | 同步(I/O) | 同步(I/O)(I/O多路复用) | 异步(I/O) |
API使用难度 | 简单 | 简单 | 非常复杂 | 复杂 |
调试难度 | 简单 | 简单 | 复杂 | 复杂 |
可靠性 | 非常差 | 差 | 高 | 高 |
表1-1 几种I/O模型的功能和特性对比
引入NIO 的优点归纳如下:
【1】所有的 I/O操作都是非阻塞的,避免有限的 I/O线程因为网络、对方处理慢等原因被阻塞。
【2】多路复用的 Reactor线程模式:基于 Linux的 epoll和 Selector,一个 I/O线程可以并行处理成百上千条链路,解决了传统同步I/O通信线程膨胀的问题。
NIO只解决了通信层面的异步问题,跟服务调用的异步没有必然关系,也就是说,即便采用 BIO通信,依然可以实现异步服务调用,只不过通信效率和可靠性比较差而已。
下面对异步服务调用和通信框架的关系进行说明,如图1-1所示:
图1-1 服务调用和通信框架的关系
用户发起远程调用之后,经历了层层业务逻辑处理、消息编码,最总序列化后的消息会被放入到通信框架的消息队列中。业务线程可以选择同步等待、也可以选择直接返回,通过消息队列的方式实现业务层和通信层的分离是比较成熟、典型的做法,现代的RPC框架或者Web服务器很少直接使用业务线程进行网络读写。通过图1-1可以看出,采用 BIO还是 NIO对上层的业务是不可见的,双方的汇聚点是消息队列,在Java实现中它通常就是个 Queue。业务线程将消息放入到发送队列中,可以选择主动等待或者立即返回,跟通信系统是否是NIO没有任何关系。
1.2、服务调用模式:【1】OneWay模式: 只有请求,没有应答,例如通知消息;
【2】请求-应答模式:一请求,一应答的模式,这种模式最常见。
OneWay 模式:服务调用由于不需要返回应答,因此很容易被设计为异步的:消费者发起远程服务调用之后,立即返回,不需要同步阻塞等待应答。
对于请求-应答模式:一般的观点都认为消费者必须要等待服务端响应,拿到结果后才能返回,否则结果从哪里取?即便业务线程不阻塞,没有获取到结果流程还是无法继续执行下去。
从逻辑上看,上述观点没有问题。但是实际中,同步阻塞等待应答并非是唯一的技术选择,我们也可以利用 Java的 Future-Listener 机制来实现异步服务调用。从业务角度看,它的效果与同步等待等价,但是从技术层面看,却是个很大的进步,他可以保证业务线程在不同步阻塞的情况下实现同步等待的效果,服务执行效率更高。
1.3、异步服务调用性能更高:通常在实验室环境中测试,由于网络时延小、模拟业务又通常比较简单,所以异步服务调用并不一定性能更高,但是在生产环境中,异步服务调用往往性能更高、可靠性也更好。主要原因是网络环境相对恶劣,真时的服务调用耗时更多等,这种恶劣的运行环境正好能够发挥异步服务调用的优势。
二、服务调用方式
服务框架支持多种形式的服务调用,本节将对这集中服务调用的原理和设计进行讲解。
2.1、同步服务调用:它的工作原理如下:客户端发起远程服务调用请求,用户线程完成消息序列化之后将消息投递到通信框架,然后同步阻塞,等待通信框架发送请求并接受应答之后,唤醒同步等待的用户线程,用户线程获取到应答之后返回。工作原理如图1-2所示:
图1-2 同步服务调用
【1】消费者调用服务端发布的接口,接口调用由分布式服务框架包装成动态代理,发起远程服务调用。
【2】消费者线程调用通信框架的消息发送接口之后,直接或者间接调用wait()方法,同步阻塞等待应答。
【3】通信框架的 I/O线程通过网络将请求消息发送给服务端。
【4】服务端返回应答消息给消费者,由通信框架负责应答消息的反序列化。
【5】I/O线程获取到应答消息之后,根据消息上下文找到之前同步阻塞的业务线程,notify()阻塞的业务线程,返回应答给消费者,完成服务调用。
为了防止服务端长时间不返回应答消息导致客户端用户线程挂死,用户线程等待的时候需要设置超时时间,这个超时时间与服务端或者客户端配置的超时时间对应。
2.2、异步服务调用:基于 JDK 的 Future机制,可以非常方便地实现异步服务调用。
JDK 原生的 Future主要用于异步操作,它代表了异步执行的结果,用户可以通过调用它的get方法获取结果。如果当前操作没有执行完,get 操作将阻塞调用线程。在实际项目中,往往会扩展 JDK 的 Future,提供 Future-Listener机制,它支持主动获取和被动异步回调通知两种模式,适用于不同的业务场景。
以 Netty 的 Future 接口定义为例,新增了监听器管理接口,监听器主要用于异步通知回调。异步服务调用工作原理如图1-3所示:
图1-3 异步服务调用工作原理
【1】消费者调用服务端发布的接口,接口调用由分布式服务框架包装成动态代理,发起远程服务调用。
【2】通信框架异步发送请求消息,如果没有发生 I/O异常,返回。
【3】请求消息发送成功之后,I/O 线程构造 Future对象,设置到 RPC上下文中。
【4】用户线程通过 RPC 上下文获取 Future对象。
【5】构造 Listener对象,将其添加到 Future中,用于服务端应答异步回调通知。
【6】用户线程返回,不阻塞等待应答。
【7】服务端返回应答消息,通信框架负责反序列化。
【8】I/O 线程将应答消息,设置到 Future对象的操作结果中。
【9】Future 对象扫描注册的监听器列表,循环调用监听器的 operationComplete方法,将结果通知给监听器,监听器获取到结果之后,继续后续业务逻辑的执行,异步服务调用结束。
需要指出的是,还有另外一种异步服务调用形式,就是不添加 Listener,用户连接发起 N次服务调用,然后依次从 RPC上下文中获取 Future对象,最终再主动 get结果,业务线程阻塞,相比于老的同步服务调用,它的阻塞时间更短,其工作原理如图1-4:
图1-4 异步服务调用主动get结果原理图
异步服务调用的代码实例如下:
xxxService1.xxxMethod(Req);
Future f1 = RpcContext.getContext().getFuture();
xxxService2.xxxMethod(Req);
Future f2 = RpcContext.getContex().getFuture();
Object xxResult1 = f1.get(3000);
Object xxResult2 = f2.get(3000);
假如 xxxService1 和 xxxService2 发布成异步服务,则调用 xxxMethod方法之后当前业务线程不阻塞,立即返回null,用户不能直接使用它的返回值,而是通过当前线程上下文RpcContext获取异步操作结果Future。获取到Future之后继续发起其他异步服务调用,然后获取另一个Future....最后,通过Future的get方法集中获取结果。无论是多少Future,采用此种方法用户线程最长阻塞时间为耗时最长的Future,即T = Max(t(future*));如果是同步调用,用户线程阻塞时间T = t(future1) + t(future2)+... ...+t(futureN)。异步服务调用相比于同步服务调用有两个优点:
【1】化串行为并行,提升服务调用效率,减少业务线程阻塞时间。
【2】化同步为异步,避免业务线程阻塞。
异步服务调用效果如图1-5所示:
图1-5 异步服务调用场景
采用异步服务调用模式,最后调用三个服务异步操作结果 Future的 get方法同步等待应答,他的总执行时间T=Max(T1,T2,T3),相对于同步服务调用,性能提升效果非常明显。第二种基于 Future-Listener的纯异步服务调用,它的代码示例如下:
xxxService1.xxxMethod(Req);
Future f1 = RpcContext.getContext().getFuture();
Listener l = new xxxListener();
f1.addListener(1);
......后续代码省略
基于 Future-Listener的异步服务调用相比于 Future-get模式更好,但是实际使用中有一定的局限性。
2.3、并行服务调用:在大多数业务应用中,服务总是被串行地调用和执行,例如A调用B服务,B服务调用C服务,最后形成一个串行服务调用链:A-->B服务-->C服务-->......。串行服务调用比较简单,但是一些业务场景中,需要采用并行服务调用来降低E2E的时延。
【1】多个服务之间逻辑上不存在互相依赖关系,执行先后顺序没有严格的要求,逻辑上可以被并行执行。
【2】长流程业务,调用多个业务,对时延比较敏感,其中有部分服务逻辑上无上下文关联,可以并行调用。
并行服务调用的目标主要有两个:
【1】降低业务 E2E时延;
【2】提升这个系统的吞吐量;
要解决串行调用效率低的问题,有两个解决对策:
【1】异步服务调用;
【2】并行服务调用;
并行服务调用的原理:一次同时发起多个服务调用,先做流程的Fork,在利用Future等主动等待获取结果,进行结果汇聚Join。实现并行服务调用的集中技术方案:
【1】JDK7 的 Fork/Join,可以实现子任务的并行执行和结果汇聚;
【2】BPM 的 Paraller Gateway(并行网关);
【3】批量串行服务调用;
JDK7 的 Fork/Join 底层会开启多个线程来分解任务,在服务框架中使用会导致依赖线程上下文传递的变量丢失、线程膨胀不可控等问题,因此在并行服务调用时不适合使用 JDK 的 Fork/Join并行执行框架。BPM 流程引擎支持并行流程(子流程)调用,它的执行示意图如图1-6所示:
图1-6 BPM Parallerl GateWay 工作流程
Paraller Gateway(并行网管)能在一个流程里用来对并发建模。在一个流程模型里引入并发最直接的网关就是并行网管(Parallel Gateway),它允许 Fork执行多个路径,或者Join多个执行的到达路径。并行网管的功能基于即将到达的和即将离开的流程顺序流。
【1】Fork:所有即将离开的顺序流将将以并行方式,为每个顺序流程建立一个并发执行器。
【2】Join:所有的并发执行达到并行网关,在网关里面等待直到每个来到的顺序流的执行到达,条件满足后流程继续通过合并网关。
从技术上看,不同的 BPM流程引擎具体实现细节也不同,但大多数都支持:通过创建子线程的方式实现并行调用、通过批量调用的方式实现伪异步并行调用。对于服务框架而言,BPM Parallel Gateway的功能可以满足需求,但是为了并行服务调用引入BPM流程引擎显然是得不偿失,我们可以参考 Parallel Gateway的伪异步并行调用来实现服务框架的并行服务调用。下面对批量串行服务调用实现并行服务调用的原理进行说明,如图1-7所示:
图1-7 批量服务调用原理图
【1】服务框架提供批量服务调用接口供消费者使用,他的定义样例如下:parallelService.invoke(serviceName[], methodName[],args[]);
【2】平台的并行服务调用器创建并行 Future,缓存批量服务调用上下文信息;
【3】并行服务调用器循环调用普通的 Invoker,通过循环的方式执行单个服务调用,获取到单个服务的Future之后设置到Parallel Future中;
【4】返回 Parallel Future给消费者;
【5】普通 Invoker调用通信框架的消息发送接口,发送远程服务调用;
【6】服务端返回应答,通信框架对报文做反序列化,转换成业务对象更新Parallel Future的结果列表;
【7】消费者调用 Parallel Future的get(timeout)方法,同步阻塞,等待所有结果都返回;
【8】Parallel Future通过对结果集进行判断,看所有服务调用是否都已经完成(包括成功、失败和异常);
【9】所有批量服务调用结果都已经返回,Notify消费者线程,消费者获取到结果列表,完成批量服务调用,流程继续执行;
通过批量服务调用+Future机制,我们实现了并行服务调用,而且没有创建新的线程,用户不用担心依赖线程上下文的功能出异常。该方案唯一的缺点就是用户需要调用平台提供的并行服务调用接口,这个会导致API层面的依赖,对于努力构建零依赖的服务框架而言不是最优的选择。但是零依赖事实是不存在的,即便100%XML配置也是一种配置依赖,所以在设计过程中要能够识别并抓主要矛盾点,做到有所舍,否则设计工作将步履维艰。
2.4、泛化调用:泛化调用通常包含两种模式:泛化引用和泛化实现。泛化引用主要用于客户端没有API接口及数据模型的场景,参数及返回值中的所有 POJO均用 Map表示,通常用于框架继承,比如实现一个通用的服务测试框架。泛化实现主要用于服务端没有API接口及数据模型的场景,参数及返回值中的所有POJO均用Map表示,通常用于框架集成,比如实现一个通用的远程服务Mock框架。泛化调用的设计要点如下:
【1】分布式服务框架提供泛化接口,供服务提供者实现和消费者引用,它的参数定义如下:
public interface GenService{
Object invoke (String methodName, String[] paramTypes, Object[] args);
}
【2】消费者引用泛化接口,则直接将请求参数转换成Map,应答消息也自动转换成Map。
【3】服务提供者如果使用泛化实现发布服务,则自动将请求参数转换成Map,调用GenService的泛化实现类,应答消息自动包装成Map返回。
泛化调用由于比较灵活,没有服务契约,在实际项目中慎用,它通常用于测试集成、系统上线之后的回声测试等。
总结:服务框架往往支持多种形式的调用,我们在设计服务调用时,需要充分考虑用户的使用习惯以及业务面临的主要挑战,在矛盾中做出平衡和取舍,这是一个优秀架构师的基本功。
----如果喜欢,点个 红心♡ 支持以下,谢谢----