[大型网站系统与Java中间件实践]--分布式服务框架(RPC)

来源:互联网 发布:苹果硬盘彻底删除数据 编辑:程序博客网 时间:2024/06/04 19:15

本文涉及到的内容:
(1)架构中引入服务框架的概念
(2)服务框架的设计与实现
(3)服务框架在实战中的优化
(4)为服务框架护航的服务治理

其实这一篇博客就是介绍分布式服务框架的设计与实现原理,当然只是一些原理的介绍,具体的实现还得我们自己去开发。本文思路来源于《大型网站系统与Java中间件实践》一书。

1. 架构中引入服务框架的概念

在最初的网站中,业务都是直接访问底层的服务,比如数据库、缓存、分布式文件系统、搜索引擎等。如下图所示:
这里写图片描述

随着业务的发展,应用变得逐渐复杂,我们可以把庞大的应用拆分细化,但是任然存在问题:数据库连接数的压力,系统之间存在重复的代码。根据功能拆分应用后结构如下图:
这里写图片描述

这样并不能解决实际上业务继续发展时存在的问题,于是提出了服务化的架构:
这里写图片描述

这就是服务化的方案,在应用和底层数据库、缓存等资源之间加了一个服务层,真正的服务可能是多层的,服务之间也可能是互相调用的。这就需要对服务进行管理,这时候就涉及到服务的治理了。

服务化带来的好处很多:架构更加清晰了,应用变成了服务,由专门的团队开发维护,提高代码质量,不存在重复的代码了。服务化的管理也就落到了服务治理上了,典型的就是Dubbo作为服务提供者和调用这,使用Zookeeper作为服务的注册中心来治理服务。

2. 服务框架的设计与实现

1. 应用从集中式走向分布式遇到的问题

在应用还没有服务化之前,应用都是通过本地方法的调用来使用其余组件时,但是Web应用走向有服务化的结构后,本地调用就会变成了远程调用(也就是RPC),这时候应该关注的就是易用性与性能损失。

我们通过下面的一张图来看看服务框架需要解决的问题:
这里写图片描述

从图中可知原本单机的方法本地调用变成远程调用之后会涉及到好几个步骤:
(1)接口调用,并将接口信息封装成对象;

(2)寻址路由:也就是让调用方法确认哪个机器上的上的实例被调用。

(3)消息编码成二进制数据在网络传输,以及对消息的解码处理。

(4)网络通信,这里涉及到Socket,常用的就是基于NIO的netty

(5)实例定位:指在被调用机器上找到对应的实例来进行方法调用。

2. 通过示例来看服务框架的原型

其实上一节的描述并不完整,只包含了从一端到另外一端的调用过程。具体来说,应该是每个结点既可以是调用端也可以是服务提供端。也就是说调用端和服务端应该是对称的。

下面我们以加法运算为例,单机下直接执行本地加法运算就行,那么在RPC下是怎么实现的呢?也就是怎么把本地方法变成远程调用服务。

首先就是把加法的接口抽象出来,然后把实现独立出来:

package com.netty.rpc.servicebean;/** * 计算器定义接口 */public interface Calculate {    //两数相加    int add(int a, int b);}

实现类:

package com.netty.rpc.servicebean;/** * 计算器定义接口实现 */public class CalculateImpl implements Calculate {    //两数相加    public int add(int a, int b) {        return a + b;    }}

下面我们先从调用端看:
我们要重新实现Calculate这个接口中的add()方法:伪代码如下:

public int add(int a, int b){    //1. 获取可用服务的地址列表(路由选择)    //其实就是根据服务名称获取可提供服务的机器地址列表    List<string> list = getAvaiableServiceAddresses("com.netty.rpc.servicebean.Calculate.add");    //2. 通过服务治理注册中心,确定要调用的目标机器的地址    string address = chooseTarget(list);    //3. 建立连接    Socket s = new Socket(address);    //4. 请求序列化编码    byte[] request = getRequest(a, b);    //5. 发送请求    s.getOutputStream().write(request);    //6. 接收结果    byte[] response = new byte[10240];    s.getOutputStream().read(response);    //7. 解析结果    int result = getResult(response);    //8. 返回结果    return result;}

在上面的第一二步中,其实有几种解决方案:
(1)通过负载均衡,那么第一步返回的就是负载均衡的地址和端口,并且第二步会直接返回这个地址和端口

(2)如果是通过 名称服务 来处理,第一步返回的就是可用的服务的地址列表,注意这里是通过服务名来查找的,所以服务名一般都是类的全限定名+方法名。 然后通过第二步选择其中一个机器作为请求的目标。这里调用者与服务者之间是直接连接的,不过是在连接之前要去注册中心获取可用的机器。

(3)三四五步就是建立连接,编码请求,并发送请求了。这一步可以使用基于NIO的netty实现。

(4)后面就是获取调用结果并返回的。

再看看服务提供端:
先给出伪逻辑代码:
这里写图片描述

服务端必定有一个真正实现加法运算的类,也就是CalculateImpl类。

服务端其实也就是一个事件循环监听器,有请求来了之后就处理:
(1)解码消息获取request对象

(2)根据request中的信息,包含版本号,方法名,服务名等获取服务对象

(3)直接调用服务对象的方法获取结果。这里一般利用反射实现的。

(4)将结果封装到response,返回客户端。

到这里一个简单的RPC实例就说完了。也可以说一个简单的RPC服务框架就搭好了,当然实际情况肯定比这个复杂得多。

下面我们分别分析调用端和服务提供端的具体实现。

3. 服务框架中服务调用端的设计与实现(客户端)

服务调用端也叫做客户端,根据前一节的实例分析,我们总结一下客户端需要经过哪些环节,然后分别对每个环节进行设计实现:
这里写图片描述

下面分析集群环境下怎么实现:

1)寻址路由方案的选择

(1)在调用端和服务提供端中间放置 负载均衡 服务器,其实也就是一个代理。这里的负载均衡可以是硬件负载均衡也可以是LVS等软件负载均衡服务器,逻辑图如下所示:
这里写图片描述

(2)调用端和服务提供端之间通过直连的方式。
这种方式下调用者和提供者直接建立连接,并引入了一个服务注册查找中心的服务。逻辑图如下:
这里写图片描述

服务注册查找中心对于调用者来说只是提供可用的服务提供者列表。出于效率考虑,实际上也并不是每次每次调用远程服务的时候都会去注册查找中心查找可用地址,而是把地址缓存在调用者本地,当有变化时服务注册查找中心主动发起通知,告诉调用者,可用的服务提供者列表发生了变化,让调用者重新发起查询动作。

当获取到可提供服务的地址列表后,就是选择一个地址去请求服务。这里的选择路由机制就可以参考负载均衡的实现,使用随机、轮询、权重等方式。

调用端对路由选择的考虑点有很多,为了考虑服务提供端集群的压力均衡,还有方案:
(1)基于接口、方法、参数的路由,可以把路由选择的粒度足够小到参数的划分上。

(2)调用端对流量控制的处理:保证系统的稳定性,控制调用端到服务提供者的请求的流量。

2)编解码与网络通信的选择

关于这部分,可以直接使用基于NIO的netty框架,这也是目前使用最多的解决方案。

编解码部分,使用Java序列化有很多缺点:不支持异构系统、效率低,编码后字节占用存储大。

这里还有一个问题,就是关于消息发送的形式, 是采用XML?JSON?还是Java的Object对象。

这一部分可以参考我关于netty相关的博客。

3)客户端的多种异步服务调用方式。

使用NIO可以完成连接复用以及对调用者的同步调用支持,c除此之外还有几种别的调用方式值得参考:

(1)oneway方式
oneway是一种只管发送请求而不关心结果的方式,在NIO下使用oneway的话,会比同步调用简单很多,如下图所示:
这里写图片描述

把发送的数据放入数据队列中就可以继续后面的任务了。IO线程也只用从数据队列中读出数据然后通过Socket发送出去就好了,Oneway不关心对方是否接收到了数据,也不关心对方收到数据之后最什么或则有什么返回。属于不保证可靠送达的通知。

(2)Callback回调方式
这种方式下请求发送方发送请求之后会继续执行自己的操作,等对方有响应时进行一个回调。设计图如下:

这里写图片描述

上图分析:
(1)调用者首先设置了回调对象,然后将数据写入数据队列之后就可以做自己的事了。

(2)后面的IO线程也是一样,从数据队列中取出数据,建立Socket然后发送给服务端。

(3)当服务提供者返回之后,IO线程会通知回调对象,这时候就执行回调方法。

(4)如果需要支持超时,同样通过定时任务来处理,如果超时服务提供者还没有返回,这时候也执行回调方法,通知超时没有结果。

(5)这里需要注意的是,回调函数的执行最好是在单独的线程中,不要放在IO线程中执行,防止回调函数的执行时间长等问题影响了IO线程。

(3)Future方式
这种实现的结构如下图:

这里写图片描述

同样是在数据入队前设置Future对象,接着就在线程中发送数据到服务提供者。等到获取到服务提供者的响应之后,就通过Future来获取通信结果并直接控制超时。

(4)可靠异步方式(消息队列中间件)
可靠异步要保证异步请求能够在远程被执行,一般通过消息中间件来完成这个保障。

上面四种异步通信方式中:
(1)Oneway是单向的通知;
(2)Callback回调是一种被动的方式,Callback的执行不是在原请求线程中。
(3)Future是一种能够主动控制超时、获取结果的方式,并且它的执行仍然在原请求线程中。
(4)可靠异步方式能保证异步请求在远程被执行。

4)异步服务调用方式–Future方式对远程服务调用的优化。

在实际的生产环境中可能出现一个请求中调用多个远程服务的情况,比如下图所示:
这里写图片描述

上面的一个请求需要3个远程服务调用,每个的时间损耗也给出了,此外请求者自己还有20ms的数据处理时间。下图给出了具体运行流程的显示:

这里写图片描述

上面存在大量的等待时间,服务B等A结束,C等B结束。我们是不是可以改变一下线程处理方式,如下:

这里写图片描述

我们按照顺序将A,B,C依次发送给服务端,发过去后并不等待,而是等将A,B,C一起发送出去。再一起等待A,B,C的结果。然后进行数据处理,这样时间就从100ms变成了60ms,提升十分明显,当前这是有个前提的,就是A,B,C的调用结果之间没有依赖性。

4. 服务框架中服务提供端的设计与实现(服务端)

先用一个图显示一下服务提供端的具体工作:

这里写图片描述

反序列化–》定位服务–》执行方法生成方法–》将结果response序列化–》返回客户端。

(1)暴露远程服务给调用者

服务端的工作有两个:
(1)将本地提供的服务注册到 服务注册查找中心(zookeeper);

(2)根据进来的请求定位服务并执行。

下面给出一个服务提供端的配置示例:

这里写图片描述

这个和请求调用端的bean的配置非常类似,但是也有区别:
(1)服务提供端使用的是ProviderBean对象,而客户端使用的是ConsumerBean对象。

(2)服务端指定了一个target属性,表明要具体执行服务的bean,在下面的bean中也定义了。但是ProviderBean并不执行具体的服务,只是起到调用端代码存根的作用。

(3)ProviderBean的职能是:服务需要注册到服务注册查找中心之后才能被服务调用者发现,所以ProviderBean需要将自己所代表的服务注册到服务注册查找中心。

(2)服务端对请求处理的流程

先用一个图示一下:

这里写图片描述

上面的图中涉及几个问题:
(1)网络通信层中,对于已经建立连接的通道,IO线程会进行通信的处理(这里一般都是多个IO线程),也就是处理读写。基于Reactor线程模型中,通过一个或则多个Acceptor接收器来处理通道连接。使用后端的IO线程池来处理数据的读写以及序列化与反序列化。

(2)定位服务一般就是在IO线程中处理。

(3)调用服务一般不建议在IO线程中,防止方法调用 处理时间较长,导致挂死IO线程。

(4)调用服务使用一个单独的线程池处理(线程池隔离)。当然也可以根据接口名、方法、参数,使用多个线程池隔离,降低隔离的粒度。具体的如下图:

这里写图片描述

5. 服务升级

我们的服务接口总存在升级的可能性,这时候怎么处理呢?
(1)接口不变,完善代码,这种就比较简单了,调用者来说基本不需要变化。

(2)需要修改原有的接口:
1)需要在接口中增加方法,对于调用者来说基本不需要变化。

2)需要修改接口中某些方法的参数列表:这种就比较复杂了,最好是通过版本号来解决,因为如果对方法修改了,所有调用者都要同步修改,会导致很大的麻烦。

6. 服务治理

服务治理就是在系统采用服务化框架后为服务化保驾护航的功能。

(1)管理服务

(2)查看服务

具体的可以看看zookeeper 的功能。

自此服务框架的介绍就结束了,关于一个RPC的实践,可以参考:
http://blog.csdn.net/u010853261/article/category/6709191
这个分类下的博客

有了服务框架,原本集中在一个应用中的功能就可以分布成不同的服务提供者和服务调用者,通过Spring来管理这些服务。

2 0