什么是RPC

  • RPC(Remote Procedure Call)远程过程调用,通俗的讲就是一个节点请求另一个节点提供的服务。
  • RPC 强调的是远程过程调用,与之对应的是本地过程调用,函数调用就是最简单的本地过程调用。

为什么?

为什么会有RPC?需要将一个服务变成另一个节点提供的服务,而不是去使用本地类似函数一样的调用呢?这其实是分布式系统应用到的最基本的行为。

最简单的本地过程调用

1
2
3
4
5
6
7
8
9
func add(a, b int) int {
	total := a + b
	return total
}

func main() {
	total := add(1, 2)
	fmt.Println(total)
}

上面代码是一个函数调用,也是一个最简单的本地过程调用,它的调用过程如下:

  1. 每个函数调用的时候会初始化一个栈,在调用之前会将传递的 1 和 2 两个值压入 add 函数所在的栈中;
  2. 压入栈之后会有个执行函数过程,分别从栈中取出 a 和 b 的值,也就是 1 和 2;
  3. 然后做加法运算,把值给 total, 然后将 total 压入函数栈中;
  4. 最后 return 的时候会从栈底的元素弹出,将值赋给全局变量 total ,函数里面的 total 是个局部变量,函数调用完,函数里面的 total 就没有了。

上面这个本地函数的调用完成是在当前节点的编译器上完成的。如果将上面调用变成远程调用,过程就不会像上面步骤那么简单了。

为什么

如果将 add 函数放在另外一个远程服务器上, 然后去调用这个函数,把结果从另一台服务器返回给本地的服务器,这个过程就是一个远程过程调用。如果要做到这点,会面临什么问题呢?

了解了临问题也就知道了这些问题其实也就是RPC要解决的问题。

远程调用中的问题

在远程调用时,我们需要执行的函数体是在远程的机器上的,也就是说,add 函数是在另一个远程机器上执行。这会带来以下几个问题:

  • Call ID
  • 序列化和反序列化
  • 网络传输

1. Call ID

比如现在将 add1 放到另外一台服务器上,可以看成是本地的一个进程想要调用远程的一个服务器上的 add1 函数,远程机器上可能不止一个函数,可能有 add2、add3······

问题1:如果 Server A 向 Server B 发送一个网络请求来调用 add1 函数,那远程服务器 Server B 怎么知道调用的是 add1 函数,而不是 add2 函数呢?

就像一个客户到商店买东西,店家老板怎么知道客户要买什么呢?

很简单,这需要客户将自己的需求说出来,把要买的东西告诉老板是要买烟还是买酒。

回到 rpc 的问题,思路就是,要解决B如何知道A调用的是add1,而不是其他函数,就需要让A告诉B调用的是add1。

我们给每个函数一个 ID, 当想调用 add1 的时候,告诉远程服务器调用的是 ID 为 1 的函数,远程服务器就知道调用的是 add1 函数,并将结果返回。也就是服务双方需要沟通好调用的是哪个函数,在调用时就能够准确定位。

在本地调用中,函数体是直接通过函数指针来指定的,我们调用add,编译器就自动帮我们调用它相应的函数指针。但是在远程调用中,函数指针是不行的,因为两个进程的地址空间是完全不一样的,就好比修仙宇宙中两个人都不在同一个修仙大陆中一样。

所以,在RPC中,所有的函数都必须有自己的一个ID。这个ID在所有进程中都是唯一确定的。客户端在做远程过程调用时,必须附上这个ID。然后我们还需要在客户端和服务端分别维护一个 {函数 <–> Call ID} 的对应表。两者的表不一定需要完全相同,但相同的函数对应的Call ID必须相同。当客户端需要进行远程调用时,它就查一下这个表,找出相应的Call ID,然后把它传给服务端,服务端也通过查表,来确定客户端需要调用的函数,然后执行相应函数的代码。

比如主流的解决方案就是通过维护同一份 Proto 定义来协商唯一的函数调用。

2. 序列化和反序列化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import "fmt"

func Add(a, b int) int {
	total := a + b
	return total
}

func main() {
	total := Add(1, 2)
	fmt.Println(total)
}

比如上面有一个 Add 函数,现在想把Add函数变成一个远程的函数调用,也就意味着需要把Add函数放到远程服务器上去运行,分布式系统也存在类似的场景:

比如电商系统有一段逻辑,这个逻辑需要扣减库存 reduce,但是这个库存服务是在一个独立的系统中,原本库存服务写在本地,但是后面发现库存系统非常复杂,就单独做了一个服务,这个服务也可能放在了另外一台服务器上运行。

问题2:将一个函数放到远程服务器上来调用,调用过程中客户端怎么把参数值传给远程的函数呢?

在本地调用中,我们只需要把参数压到栈里,然后让函数自己去栈里读就行。但是在远程过程调用时,客户端跟服务端是不同的进程,不能通过内存来传递参数。甚至有时候客户端和服务端使用的都不是同一种语言(比如服务端用C++,客户端用Java或者Python)。

这时候就需要客户端把参数先转成一个字节流,传给服务端后,再把字节流转成自己能读取的格式。这个过程叫序列化和反序列化。同理,从服务端返回的值也需要经过序列化反序列化的过程。

3. 网络传输

远程调用往往用在网络上,客户端和服务端是通过网络连接的。所有的数据都需要通过网络传输,因此就需要有一个网络传输层。网络传输层需要把Call ID和序列化后的参数字节流传给服务端,然后再把序列化后的调用结果传回客户端。只要能完成这两者的,都可以作为传输层使用。因此,它所使用的协议其实是不限的,能完成传输就行。尽管大部分RPC框架都使用TCP协议,但其实UDP也可以,而gRPC干脆就用了HTTP2。Java的Netty也属于这层的东西。

解决了上面三个机制,就能实现RPC了,具体过程如下: client端解决的问题:

  1. 将这个调用映射为Call ID。这里假设用最简单的字符串当Call ID的方法
  2. 将Call ID,a和b序列化。可以直接将它们的值以二进制形式打包
  3. 把2中得到的数据包发送给ServerAddr,这需要使用网络传输层
  4. 等待服务器返回结果
  5. 如果服务器调用成功,那么就将结果反序列化,并赋给total

server端解决的问题

  1. 在本地维护一个Call ID到函数指针的映射call_id_map,可以用dict完成
  2. 等待请求,包括多线程的并发处理能力
  3. 得到一个请求后,将其数据包反序列化,得到Call ID
  4. 通过在call_id_map中查找,得到相应的函数指针
  5. 将a和rb反序列化后,在本地调用add函数,得到结果
  6. 将结果序列化后通过网络返回给Client

在上面的整个流程中,估计有部分同学看到了熟悉的计算机网络的流程和web服务器的定义。 所以要实现一个RPC框架,其实只需要按以上流程实现就基本完成了。 其中: Call ID映射可以直接使用函数字符串,也可以使用整数ID。映射表一般就是一个哈希表。 序列化反序列化可以自己写,也可以使用Protobuf或者FlatBuffers之类的。 网络传输库可以自己写socket,或者用asio,ZeroMQ,Netty之类。 实际上真正的开发过程中,除了上面的基本功能以外还需要更多的细节:网络错误、流量控制、超时和重试等。 最后提一个问题: 如何将远程的这些过程写出本地函数调用的感觉来?