Distributed System | RPC 模块设计与实现

RPC 是分布式系统中不可缺少的一部分。之前接触过几种 RPC 组件,这里就总结一下常见 RPC 模块的设计思想和实现。最后我们来设计一个可以方便进行 RPC 调用的 RPC 模块。

RPC 模块设计需要考虑的问题

RPC 模块将网络通信的过程封装成了方法调用的过程。从使用者的角度来看,在调用端进行RPC调用,就像进行本地函数调用一样;而在背后,RPC 模块会将先调用端的函数名称、参数等调用信息序列化,其中序列化的方式有很多种,比如 Java 原生序列化、JSON、Protobuf 等。接着 RPC 模块会将序列化后的消息通过某种协议(如 TCP, AMQP 等)发送到被调用端,被调用端在收到消息以后会对其解码,还原成调用信息,然后在本地进行方法调用,然后把调用结果发送回调用端,这样一次 RPC 调用过程就完成了。在这个过程中,我们要考虑到一些问题:

  • 设计成什么样的调用模型?
  • 调用信息通过什么样的方式序列化?通过哪种协议传输?性能如何?可靠性如何?
  • 分布式系统中最关注的问题:出现 failure 如何应对?如何容错?
  • 更为全面的服务治理功能(如服务发现、负载均衡等)

我们一点一点来思考。第一点是设计成什么样的调用模型。常见的几种模型:

  • 服务代理。即实现一个服务接口,被调用端实现此服务接口,实现对应的方法逻辑,并写好 RPC 调用信息接收部分;调用端通过 RPC 模块获取一个服务代理实例,这个服务代理实例继承了服务接口并封装了相应的远程调用逻辑(包括消息的编码、解码、传输等)。调用端通过这个服务代理实例进行 RPC 调用。像 Vert.x Service Proxy 和 gRPC 都是这种模型。这样的 RPC 模块需要具备生成服务代理类的功能
  • 直接调用,即设计特定的 API 用于 RPC 调用。比如 Golang 的 rpc 包,里面的 Client 就提供了一个 Call 方法用于任意RPC调用,调用者需要传入方法名称、参数以及返回值指针(异步模式下传入 callback handler)

我更倾向于选择服务代理这种模型,因为服务代理这种模型在进行 RPC 调用的时候非常方便,但是需要 RPC 模块生成服务代理类(静态或动态生成),实现起来可能会麻烦些;当然 Go 的 rpc 包封装的也比较好,调用也比较方便,考虑到 Go 的类型系统,这已经不错了。。。

RPC 调用耗时会包含通信耗时和本地调用耗时。当网络状况不好的时候,RPC 调用可能会很长时间才能得到结果。对传统的同步 RPC 模式来说,这期间会阻塞调用者的调用线程。当需要进行大量 RPC 调用的时候,这种阻塞就伤不起了。这时候,异步 RPC 模式就派上用场了。我们可以对传统 RPC 模式稍加改造,把服务接口设计成异步模式的,即每个方法都要绑定一个回调函数,或利用 Future-Promise 模型返回一个 Future,或者直接上 reactive 模式(未来的趋势)。设计成异步、响应式模式以后,整个架构的灵活性就能得到很大的提升。

第二点是调用信息的序列化/反序列化以及传输。序列化主要分为文本(如 JSON, XML 等)和二进制(如 Thrift, Protocol 等)两种,不同的序列化策略性能不同,因此我们应该尽量选择性能高,同时便于开发的序列化策略。在大型项目中我们常用 Protobuf,性能比较好,支持多语言,但是需要单独定义 .proto 文件(可以利用 protostuff);有的时候我们会选择 JSON,尽管效率不是很高但是方便,比如 Vert.x Service Proxy 就选择了 JSON 格式(底层依赖 Event Bus)。另一点就是传输协议的选择。通常情况下我们会选择 TCP 协议(以及各种基于 TCP 的应用层协议,如 HTTP/2)进行通信,当然用基于 AMQP 协议的消息队列也可以,两者都比较可靠。

这里还需提一点:如何高效地并发处理 request / response,这依赖于通信模块的实现。拿 Java 来说,基于 Netty NIO 或者 Java NIO / AIO 的 I/O 多路复用都可以很好地并发处理请求;而像 Go RPC 则是来一个 request 就创建一个 Goroutine 并在其中处理请求(Goroutine 作为轻量级用户态线程,创建性能消耗小)。

最后一点也是最重要的一点:实现容错,这也是分布式系统设计要考虑的一个核心。想象一下一次 RPC 调用过程中可能产生的各种 failure:

  • 网络拥塞
  • 丢包,通信异常
  • 服务提供端挂了,调用端得不到 response

一种简单的应对方式是不断地超时重传,即 at least once 模式。调用端设置一个超时定时器,若一定时间内没有收到response就继续发送调用请求,直到收到response或请求次数达到阈值。这种模式会发送重复请求,因此只适用于幂等性的操作,即执行多次结果相同的操作,比如读取操作。当然服务提供端也可以实现对应的逻辑来检查重复的请求。

更符合我们期望的容错方案是 at most once 模式。at most once 模式要求服务提供端检查重复请求,如果检查到当前请求是重复请求则返回之前的调用结果。服务提供端需要缓存之前的调用结果。这里面有几点需要考虑:

  • 如何实现重传和重复请求检测?是依靠协议(如TCP的超时重传)还是自己实现?

如果自己实现的话:

  • 如何检查重复请求?我们可以给每个请求生成一个独一无二的标识符(xid),并且在重传请求的时候使用相同的xid进行重传。用伪代码可以表示为:
1
2
3
4
5
6
7
if (seen(xid)) {
result = oldResult;
} else {
result = call(...);
oldResult = result;
setCurrentId(xid);
}
  • 如何保证xid是独一无二的?可以考虑使用UUID或者不同seed下的随机数。
  • 服务请求端需要在一个合适的时间丢弃掉保存的之前缓存的调用结果。
  • 当某个RPC调用过程还正在执行时,如何应对另外的重复请求?这种情况可以设置一个flag用于标识是否正在执行。
  • 如果服务调用端挂了并且重启怎么办?如果服务调用端将xid和调用结果缓存在内存中,那么保存的信息就丢失了。因此我们可以考虑将缓存信息定时写入硬盘,或者写入 replication server 中,当然这些情况就比较复杂了,涉及到高可用和一致性的问题。

由此可见,虽然 RPC 模块看似比较简单,但是设计的时候要考虑的问题还是非常多的。尤其是在保证性能的基础上又要保证可靠性,还要保证开发者的易用性,这就需要细致地思考了。

常见RPC模块实现

这里我来简单总结一下用过的常见的几个 RPC 模块的使用及实现思路。

Go RPC

Golang 的 rpc 包使用了 Go 自己的 gob 协议作为序列化协议(通过encoding/gob模块内的Encoder/Decoder进行编码和解码),而传输协议可以直接使用TCP(Dial方法)或者使用HTTP(DialHTTP)方法。开发者需要在服务端定义struct并且实现各种方法,然后将struct注册到服务端。需要进行RPC调用的时候,我们就可以在调用端通过Call方法(同步)或者Go方法(异步)进行调用。同步模式下调用结果即为reply指针所指的对象,而异步模式则会在调用结果准备就绪后通知绑定的channel并执行处理。

在rpc包的实现中(net/rpc/server.go),每个注册的服务类都被封装成了一个service结构体,而其中的每个方法则被封装成了一个methodType结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type methodType struct {
sync.Mutex // protects counters
method reflect.Method
ArgType reflect.Type
ReplyType reflect.Type
numCalls uint
}
type service struct {
name string // name of service
rcvr reflect.Value // receiver of methods for the service
typ reflect.Type // type of the receiver
method map[string]*methodType // registered methods
}

每个服务端都被封装成了一个Server结构体,其中的serviceMap存储着各个服务类的元数据:

1
2
3
4
5
6
7
8
type Server struct {
mu sync.RWMutex // protects the serviceMap
serviceMap map[string]*service
reqLock sync.Mutex // protects freeReq
freeReq *Request
respLock sync.Mutex // protects freeResp
freeResp *Response
}

RPC Server 处理调用请求的默认路径是 /_goRPC_。当请求到达时,Go就会调用Server结构体实现的ServeHTTP方法,经ServeConn方法传入gob codec预处理以后最终在ServeCodec方法内处理请求并进行调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func (server *Server) ServeCodec(codec ServerCodec) {
sending := new(sync.Mutex)
for {
service, mtype, req, argv, replyv, keepReading, err := server.readRequest(codec)
if err != nil {
if debugLog && err != io.EOF {
log.Println("rpc:", err)
}
if !keepReading {
break
}
// send a response if we actually managed to read a header.
if req != nil {
server.sendResponse(sending, req, invalidRequest, codec, err.Error())
server.freeRequest(req)
}
continue
}
go service.call(server, sending, mtype, req, argv, replyv, codec)
}
codec.Close()
}

如果成功读取请求数据,那么接下来 RPC Server 就会新建一个 Goroutine 用来在本地执行方法,并向调用端返回 response:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (s *service) call(server *Server, sending *sync.Mutex, mtype *methodType, req *Request, argv, replyv reflect.Value, codec ServerCodec) {
mtype.Lock()
mtype.numCalls++
mtype.Unlock()
function := mtype.method.Func
// Invoke the method, providing a new value for the reply.
returnValues := function.Call([]reflect.Value{s.rcvr, argv, replyv})
// The return value for the method is an error.
errInter := returnValues[0].Interface()
errmsg := ""
if errInter != nil {
errmsg = errInter.(error).Error()
}
server.sendResponse(sending, req, replyv.Interface(), codec, errmsg)
server.freeRequest(req)
}

在执行调用的过程中应该注意并发问题,防止资源争用,修改数据时需要对数据加锁;至于方法的执行就是利用了Go的反射机制。调用完以后,RPC Server 接着调用sendResponse方法发送response,其中写入response的时候同样需要加锁,防止资源争用。

gRPC

gRPC 是 Google 开源的一个通用的 RPC 框架,支持 C, Java 和 Go 等语言。既然是 Google 出品,序列化协议必然用 protobuf 啦(毕竟高效),传输协议使用 HTTP/2,更为通用,性能也还可以。开发时需要在 .proto 文件里定义数据类型以及服务接口,然后配上 protoc 的 gRPC 插件就能够自动生成各个语言的服务接口和代理类。

Vert.x Service Proxy

Vert.x Service Proxy 是 Vert.x 的一个异步 RPC 组件,支持通过各种 JVM 语言(Java, Scala, JS, JRuby, Groovy等)进行 RPC 调用。使用 Vert.x Service Proxy 时我们只需要按照异步开发模式编写服务接口,加上相应的注解,Vert.x Service Proxy 就会自动生成相应的服务代理类和服务调用处理类。Vert.x Service Proxy 底层借助 Event Bus 进行通信,调用时将调用消息包装成 JSON 数据然后通过 Event Bus 传输到服务端,得到结果后再返回给调用端。Vert.x 的一大特性就是异步、响应式编程,因此 Vert.x Service Proxy 的 RPC 模型为异步 RPC,用起来非常方便。几个异步过程可以通过各种组合子串成一串,妥妥的 reactive programming 的风格~

更多的关于 Vert.x Service Proxy 的实现原理的内容可以看这一篇:Vert.x 技术内幕 | 异步 RPC 实现原理

PS: 我经常吐槽 Vert.x Service Proxy 这个名字,因为光看名字很多人不知道它可以用来实现 RPC,导致了很多人以为 Vert.x 不能做 RPC,应该改名叫 Vert.x Async RPC 比较合适。。。当然它还有很大的改进空间,主要是被 Vert.x Event Bus 的性能和可靠性给拖累了。。。

文章目录
  1. 1. RPC 模块设计需要考虑的问题
  2. 2. 常见RPC模块实现
    1. 2.1. Go RPC
    2. 2.2. gRPC
    3. 2.3. Vert.x Service Proxy