分布式架构基石:RPC 理论篇
Posted java2021
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了分布式架构基石:RPC 理论篇相关的知识,希望对你有一定的参考价值。
诞生背景
RPC(Remote Procedure Call),中文意思是远程过程调用。
给 RPC 下定义是很难的。有人说 RPC 是一种像调用本地函数一样调用远程服务器上的函数的思想。也有人认为 RPC 是一种概念,而不是某项特定的技术实现或者是某种协议。 都有道理,但也都不能完整描述 RPC。在了解 RPC 到底是什么之前,先来看看它的诞生背景。
服务端服务架构的演进
RPC 变成主流技术名词出现在我们视野中大概是 5、6 年前,而使它成为热门技术名词的主要原因就是业界盛行的微服务架构。那么就要先聊聊服务端应用架构。
目前主流的服务端应用架构有 3 种,分别是单体架构(Monolith)、微服务架构(MicroService)和无服务架构(ServerLess)。
这里简单介绍一下单体架构和微服务架构。
单体架构简单理解就是所有代码和功能打成一个包进行测试并部署。运行在单个线程内。而微服务架构是一种单体架构在部署方式上的升级,它运行在多个线程内。
很多人接触微服务架构大概是从 Martin Fowler 的 microservices 开始的。所以很多人误认为 Martin Fowler 就是微服务的提出者。但事实上不是 Martin Fowler,那篇文章是 2014 年写的。微服务第一次公之于众应该是 Fred George 在 2012 年 3 月的 Agile India 分享的 Micro (u)Services Architecture -small, short lived services rather than SOA。
微服务架构主要解决了单体架构的 3 个问题。
-
部署方式,单体架构修改任意一个功能都需要全量编译、测试、部署,微服务则不需要。 -
可扩展性,微服务架构具有更高的可扩展性和足够高的技术自由度。 -
业务解耦,微服务具有更好的解耦性。单体架构在做负载均衡时容易出现模块的负载不均,浪费资源,微服务则不会。
随着互联网的发展,越来越多的应用无法扛住来自用户的海量请求,纷纷转向微服务架构,以获取更方便的横向扩展。
不过微服务架构在解决单体架构问题的同时,自身也产生了大量的问题。这些问题需要引入很多额外的概念或技术来解决,比如 DDD 和 DevOps 等等。
因为微服务要进行服务拆分,每一个服务可能都运行在不同的服务器中的不同的线程中。所以实施微服务首先就要面对进程间通信的问题。
早些年盛行的 SOA 架构和微服务架构有些类似,但 SOA 具有中心化思想,在扩展性上存在瓶颈。所以目前流行的微服务倡导去中心化,去掉了 ESB 的概念,直接点对点通信,而实现点对点通信的技术就是 RPC。
RPC 和分布式
实际上 RPC 诞生的时间远早于微服务,最早要解决的问题是分布式运算。起源于 80 年代初期,已经有大概 40 年的历史了。世界上第一个 RPC 框架由施乐 PaloAlto 研究中心基于 Cedar 语言研发的 Lupine 框架。
当时施乐公司对 RPC 的定义是:
实际上我们现在谈及的 RPC 和最初的定义是有所差异的,但总的方向仍没有变。
由于微服务架构也属于分布式架构的一种,所以微服务架构是分布式架构的子集。
总结来说,RPC 是构建分布式系统的基石。
RPC 流程
RPC 整体过程如下图所示:
在整个 RPC 的流程中,有两个角色,分别是调用方(Client)和提供方(Server)。
一个服务调用另一个服务,它就是调用方。如果这个服务同时提供给别的服务调用,那么它也是提供方。也就是说一个服务在一个完整的系统中,可能既是调用方也是提供方。但在一次 RPC 过程中,一个服务只能扮演一个角色。
RPC 的实现为了让整个调用过程舒服一些,在代码层面一般都是非常简单的,只需要指定提供方的 IP、MethodName、Parameter 就可以像调用本地函数一样调用远程服务的函数。
由于双方处于不同的线程甚至可能是不同的机器中,所以会经过网络进行调用。
微服务架构中,每个服务启动时都会在服务注册中心进行注册。在发起 RPC 时,会使用在服务注册中心定时获取的 IP 列表中对应的 IP 找到真正的服务提供者,然后对参数进行序列化。再将序列化后的数据进行二进制编码,因为网络中传递的数据始终都是以二进制的形式存在的。
提供方在接收到网络请求后,进行相反的操作,先对二进制解码,然后对参数反序列化,最后找到对应的方法,通过反射机制进行调用方法。将调用方法的返回值再进行序列化、二进制编码、传输回调用方。调用方接收到网络请求后再用相同的方式获取到可使用的返回数据。
工作原理
协议
不同于 TCP 协议、UDP 协议这类网络传输协议,RPC 和 HTTP 协议一样,都属于应用层协议。
协议定义
调用方和提供方的数据交互是以数据流的方式,最终得到一个完整的数据包。提供方还需要知道从数据包什么位置读取参数,这就需要对协议有一个明确的定义。
私有 RPC 协议
在应用层有比较通用的 HTTP 协议,但 HTTP 协议的数据包比较大,包含了很多无用信息和换行符、回车符等,不适合对性能要求很高的 RPC 场景,所以通常我们会自己设计一套私有协议。
常见的做法是定长二进制协议头和可扩展变长协议体。
协议头(Header)是公共部分,与业务无关,会包含协议标识、消息 ID、消息类型、协议版本、序列化方式等数据。
协议体(Payload)是可扩展部分,包含业务信息,会包含方法名、参数值等数据。
序列化
序列化是指将程序中的对象转换为可传输的二进制数据。
在进行序列化时,需要考虑跨语言和特定语言两种情况。
跨语言
如果多个服务是使用不同语言编写的,那么设计 RPC 时还需要考虑跨语言的问题。
比如 A 服务使用 Java、B 服务使用 Golang 编写,使用 Java 的 Class 和 Golang 的 Struct 创建的对象自然无法兼容。
一个完整的 RPC 解决方案包含 RPC 框架和数据交换格式。
比较常见的跨语言 RPC 方案有 gRPC、Thrift 和 Avro。
其中 gRPC 使用 protobuf 格式,Thrift 使用 Thrift IDL 格式,Avro 使用 JSON 格式。
在 RPC 概念诞生早期,最流行的数据交换格式是 JSON 和 XML。但 XML 存在体积过大、解析性能差;JSON 存在表达能力弱等缺点。所以出现了 protobuf 和 Thrift IDL 等方案,兼顾体积和表达能力,解决了前两者的痛点。
数据交换格式还有新的方案来提高性能,比如基于 protobuf 的 protostar。
目前国内比较流行的是 gRPC。
特定语言
如果多个服务使用相同的编程语言开发,就可以使用特定的序列化框架来解决这个问题。
比如只支持 Java 的 Hessian(Hessian 也有其他语言的实现)、只支持 JVM 的 Finagle、只支持 Golang 的 rpcx 等。
这类框架有两个优点。
-
更高的性能。 -
更低的开发成本。
服务注册与发现
在最简单的 RPC 中,只有调用方和提供方两个角色。
但是在成熟的 RPC 体系中,还存在一个叫做注册中心(Register)的角色。
在介绍注册中心之前先思考一个问题。
如果在发起 RPC 时将提供方的 IP 硬编码到代码中会存在什么问题?
答案是调用方和提供方的耦合性很高。提供方的 IP 一旦发生变化,调用方也要跟着变化。
如果服务数量较少,比如只有两个服务,是感受不到这个问题的存在。IP 硬编码的方式似乎更高效、更便捷。但服务数量达到数十、数百、甚至上千个的时候,IP 硬编码的方式几乎完全不可行。
这时如果有一个容器负责维护服务提供方的 IP,调用方每隔一段时间同步一次 RPC 服务的 IP 列表,发起 RPC 时使用这个 IP 列表中对应的 IP 来获取服务提供方的真实 IP。这样可以实现调用方和提供方的解耦。
这个容器就是注册中心。
在提供方服务启动时,将自身的 IP 注册到注册中心。
调用方通过请求注册中心得到提供方的 IP 后,再发起 RPC。
计算机领域有句话完美的阐述了这种现象:计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。
从上面的描述可以看到,注册中心的概念其实类似于 DNS 的。但很少会有人用 DNS 作为注册中心。原因是 DNS 有几个特点不适合作为注册中心。
-
DNS 是人工注册。 -
DNS 有缓存,实时性无法达到秒级的要求。 -
注册中心还需要自动注册与发现、健康检查机制,这些功能 DNS 都没有。
比较成熟的开源解决方案有 Zookeeper、Eureka、Consul、Etcd 和 nacos 等。
目前国内比较流行的是 Etcd 和 Consul。
健康检查
调用方在注册中心获取到提供方的 IP 后,对提供方发起 RPC。但我们没办法保证每个提供方服务节点的状态始终都是健康的,比如有一些提供方服务节点 CPU 达到了峰值、负载过高、响应过慢、经常超时。这时可以把这个服务节点标记为健康状态,降低对它的流量。如果这个服务节点恢复正常处理速度,再把它设置为健康状态。
提供方和注册中心保持一种心跳机制,如果心跳停止,那么就会认为提供方宕机,健康状态标记为死亡。调用方不再调用该服务。不过注册中心还会使用探活尝试恢复这个服务节点。
负载均衡
当一个服务节点无法支撑现有访问量时,可以部署多个服务节点,组成集群,然后通过负载均衡(Load Balance)将请求分发给这个集群下的每个服务节点。从而实现利用多个服务节点分担请求压力的目的。
RPC 的负载均衡和 Web 服务的负载均衡不同,RPC 的负载均衡是在代码层面来实现的。RPC 调用方会和注册中心下发的所有服务节点建立一个长连接,每次发起 RPC 时,都会通过负载均衡算法自主选择一个服务节点。
常用的负载均衡算法有两类,静态均衡算法和动态均衡算法。
静态负载均衡算法
轮询算法
通过记录一个全局的值试图做到请求转移的绝对均衡。但是要考虑并发问题,需要使用锁机制来保证全局值的读写,所以存在吞吐量瓶颈。
随机算法
根据后端服务器列表的大小值随机选择其中一台进行访问。优点是实现简单,缺点是不适用于服务器承载能力不一致的情况。吞吐量越大,越接近轮询法的效果。
源地址哈希算法
加权轮询算法/轮训权重算法
由于实际情况中每台服务器的配置是不同的,所以抗压能力也不同。无论使用轮询法还是随机法,都要面临这个问题。这时更好的方法是根据服务器的真实硬件配置和抗压能力的不同,给每台服务器设置一个权重。在此基础上使用轮询法进行分配。
加权随机算法/随机权重算法
与加权轮询法类似。
动态负载均衡算法
静态负载均衡算法中,最常用的是加权法。因为往往服务器节点的配置都不相同。
可是当服务器节点达到上百或是上千的量级时,手动设置权重就不可取了,一种更加方便的做法是在健康检查时动态获取提供方当前的状态,进行动态负载均衡。
最小连接数算法
最小连接数是动态获取当前每台服务器的连接数,找到最小的那台服务器节点进行调用。
最快响应速度算法
根据最近的请求和响应时间延迟来衡量机器目前的负载情况,找到响应时间最短的那一台服务器进行调用。
除了最小连接数和最快响应速度两种算法以外,还可以进行更细粒度的计算,比如动态计算 CPU、内存的状态等。
常见负载均衡算法的实现
这篇文章不是主要讲解负载均衡的,但负载均衡作为 RPC 的一个重要组成部分,还是有了解和掌握的必要。下面使用 Go 语言实现几种简单的负载均衡算法。
模拟 3 台服务器。
// main_test.go
package loadbalance
type Balanceable struct {
Name string
Weight int
connection int
}
var server1 = Balanceable{Name: "1", Weight: 1}
var server2 = Balanceable{Name: "2", Weight: 2}
var server3 = Balanceable{Name: "3", Weight: 3}
var servers = []*Balanceable{
&server1,
&server2,
&server3,
}
func (server *Balanceable) getActive() int {
return server.connection
}
func (server *Balanceable) conn() {
server.connection++
}
复制代码
随机数算法
随机算法的实现非常简单,性能也非常不错。因为它不需要考虑并发问题。
package loadbalance
import (
"fmt"
"math/rand"
"testing"
"time"
)
func random(servers []*Balanceable) *Balanceable {
rand.Seed(time.Now().UnixNano())
idx := rand.Intn(len(servers))
return servers[idx]
}
func TestRandom(t *testing.T) {
counter := map[string]int{"1": 0, "2": 0, "3": 0}
for i := 0; i < 100; i++ {
server := random(servers)
counter[server.Name]++
}
fmt.Println("counter:", counter)
}
复制代码
运行测试:
go test -timeout 30s -run ^TestRandom$ load_balance -count=1 -v
复制代码
5 次测试结果:
counter: map[1:35 2:34 3:31]
counter: map[1:31 2:31 3:38]
counter: map[1:35 2:34 3:31]
counter: map[1:41 2:25 3:34]
counter: map[1:30 2:36 3:34]
复制代码
可以看到随机数算法的每次结果都不同,都是有所偏差的,但这个偏差不会太大,都几乎接近于 33。
轮询算法
轮询法需要维护一个计数器,并对该计数器加锁,保证对其操作的原子性。
package loadbalance
import (
"fmt"
"sync"
"testing"
)
var pos = 0
var m sync.RWMutex
func roundRobin(servers []*Balanceable) *Balanceable {
m.Lock()
defer m.Unlock()
if pos >= len(servers) {
pos = 0
}
ret := servers[pos]
pos++
return ret
}
func TestRoundRobin(t *testing.T) {
counter := map[string]int{"1": 0, "2": 0, "3": 0}
wg := sync.WaitGroup{}
wg.Add(100)
for i := 0; i < 100; i++ {
go func(i int) {
server := roundRobin(servers)
m.Lock()
defer m.Unlock()
counter[server.Name]++
wg.Done()
}(i)
}
wg.Wait()
fmt.Println("counter:", counter)
}
复制代码
运行测试:
go test -timeout 30s -run ^TestRoundRobin$ load_balance -count=1 -v
复制代码
5 次测试结果:
counter: map[1:34 2:33 3:33]
counter: map[1:34 2:33 3:33]
counter: map[1:34 2:33 3:33]
counter: map[1:34 2:33 3:33]
counter: map[1:34 2:33 3:33]
复制代码
轮询算法是通过记录值来分配,具有可预测性,所以每次的结果都是确定的。
随机权重算法
package loadbalance
import (
"fmt"
"math/rand"
"testing"
"time"
)
func weightedRandom(servers []*Balanceable) *Balanceable {
max := 1
min := servers[0].Weight
for _, server := range servers {
w := server.Weight
max += w
if min > w {
min = w
}
}
rand.Seed(time.Now().UnixNano())
idx := rand.Intn(max-min) + min
tmp := 0
for _, server := range servers {
tmp += server.Weight
if tmp >= idx {
return server
}
}
return servers[0]
}
func TestWeightedRandom(t *testing.T) {
counter := map[string]int{"1": 0, "2": 0, "3": 0}
for i := 0; i < 100; i++ {
server := weightedRandom(servers)
counter[server.Name]++
}
fmt.Println("counter:", counter)
}
复制代码
运行测试:
go test -timeout 30s -run ^TestWeightedRandom$ load_balance -count=1 -v
复制代码
5 次测试结果:
counter: map[1:19 2:34 3:47]
counter: map[1:14 2:29 3:57]
counter: map[1:22 2:26 3:52]
counter: map[1:16 2:37 3:47]
counter: map[1:15 2:30 3:55]
复制代码
随机权重算法的结果也是不确定的,但都非常贴近 1:2:3 的比例。
最小连接数算法
package loadbalance
import (
"fmt"
"math/rand"
"sync"
"testing"
"time"
)
func leastConnections(servers []*Balanceable) *Balanceable {
rand.Seed(time.Now().UnixNano())
ret := servers[0]
for _, server := range servers {
if server.getActive() <= ret.getActive() {
ret = server
}
}
return ret
}
func TestLeastConnections(t *testing.T) {
counter := map[string]int{"1": 0, "2": 0, "3": 0}
wg := sync.WaitGroup{}
wg.Add(100)
for i := 0; i < 100; i++ {
go func() {
server := leastConnections(servers)
counter[server.Name]++
server.conn()
wg.Done()
}()
}
wg.Wait()
fmt.Println("counter:", counter)
}
复制代码
运行测试:
go test -timeout 30s -run ^TestLeastConnections$ load_balance -count=1
复制代码
5 次测试结果:
counter: map[1:33 2:33 3:34]
counter: map[1:33 2:33 3:34]
counter: map[1:33 2:33 3:34]
counter: map[1:33 2:33 3:34]
counter: map[1:33 2:33 3:34]
复制代码
最小连接数算法和轮询算法类似,结果非常稳定。
熔断限流
RPC 的主要应用场景是分布式系统,高并发是分布式系统经常面临的问题,RPC 同样会面临高并发的场景。
为了保证服务的稳定性和高可用性,我们需要让服务具有自我保护的功能,防止服务器出现 CPU、内存等资源被占满的情况。
熔断限流可以放在 RPC 框架中,也可以作为独立的组件。
限流
限流是一种常见的服务自我保护方式。当服务提供方接收到的流量超过限流阀值时,就不会继续执行业务逻辑,而是返回一个限流异常。
限流可以放在 RPC 框架中实现,也可以在业务系统中实现。但最好放在 RPC 框架中实现,能让业务开发人员聚焦业务本身的的架构,才是优秀的架构。
限流的功能可以放到调用方,也可以放到提供方,甚至双方可以同时限流。
在做限流时,首先要识别调用来源。要对应用级别进行维度划分,可以是 IP 级别的维度。这样可以针对不同的调用方做流量策略。比如某一个 IP 的调用方超过一定的 QPS 就拒绝处理。
限流算法
常见的限流算法有计数器算法(Counter)、滑动窗口算法(Sliding Window)、漏桶算法(Leaky Bucket)和令牌桶算法(Token Bucket)等。
计数器算法
计数器算法的优点是实现简单。
所有的限流算法都是把时间分为 N 个时间窗口。比如按分钟为单位进行划分,每分钟都是一个时间窗口。在每个时间窗口内处理最多 X 个请求。当请求数超过 X 时,不再请求。如果进入下一个时间窗口,请求数将会重制。
下面是使用 Go 语言实现的计数器算法。
模拟服务入口。
// main_test.go
package limiting
import "fmt"
type Server struct {
limit func() bool
}
func (s *Server) api() {
pass := s.limit()
if pass {
fmt.Println("noraml")
} else {
fmt.Println("overload")
}
}
复制代码
计数器算法实现。
package limiting
import (
"testing"
"time"
)
func Counter(interval int, limit int) func() bool {
startTime := makeTimestamp()
requestCount := 0
grant := func() bool {
if makeTimestamp() < startTime+int64(interval) {
requestCount++
return requestCount <= limit
}
startTime = makeTimestamp()
requestCount = 1
return true
}
return grant
}
func makeTimestamp() int64 {
return time.Now().UnixNano() / int64(time.Millisecond)
}
func TestCounter(t *testing.T) {
limit := Counter(100, 3)
server := Server{limit}
for i := 0; i < 5; i++ {
go func() {
server.api()
}()
}
time.Sleep(100 * time.Millisecond)
for i := 0; i < 5; i++ {
go func() {
server.api()
}()
}
time.Sleep(1 * time.Second)
}
复制代码
测试结果:
noraml
noraml
noraml
overload
overload
noraml
noraml
noraml
overload
overload
复制代码
该算法会有一个问题,就是在时间窗口的临界点时容易被超出限制。如果被恶意的调用方得知了时间窗口的临界点,就可以在 1 秒内发送 2 倍的最大请求数,可能会压垮服务器。所以不建议在生产环境下使用计数器算法。
滑动窗口算法
滑动窗口是将时间轴划分为 N 个格子,每过一段时间,滑动一个格子,每个格子都有自己独立的计数器,这样就弥补了计数器算法存在的隐患。
将格子划分的越细,滑动窗口就越平滑,限流就会越精准。
可以参考这个实现。
漏桶算法
漏桶算法的原理是把请求比作是水,服务比做漏桶。水先流入漏桶里,并以限定的速度向外滴水,当水来得过猛而滴水速度不够快时就会导致水直接溢出,即拒绝服务。
在某些场景下,除了允许平均处理效率外,还要支持某种程度的突发处理。
楼桶算法的问题在于无法支持突发处理,此时的解决方案是令牌桶算法。
令牌桶算法
令牌桶算法的原理是系统以恒定的速度生成令牌,并将令牌放置到令牌桶中,令牌桶有一个容量,当容量已满时,继续向桶内添加令牌会被丢弃。当一个请求进入时,先去令牌桶内获取令牌,如果获取成功,则继续处理。如果令牌桶内没有令牌,则拒绝该请求。
go 语言中内置了 time/rate 包,该限流器就是基于令牌桶算法实现的。
可以参考 time/rate 的实现。
单节点限流和集群限流
限流的做法主要有两种,单节点限流和集群限流。
单节点限流:平滑。可以控制单节点流量,因为是在内存中处理,所以性能极好,缺点是集群扩展时无法控制调用方的请求流量。
集群限流:集中式 Redis。可以控制集群流量,但会有 Synchronized、Race Condition、延迟等问题,会出现性能瓶颈。
限流的本质是为了防止当前服务被流量高峰压垮。
常见的限流方案有 Guava RateLimiter 和 sentinel、Redis 等。
熔断
分布式系统中的调用链路通常是非常长的。当一个下游服务出现超时,如果没有合理的处理,就可能导致整条调用链路出现问题,无法正常使用。所以单靠服务提供方的限流还是不够的,还需要调用方自己处理因服务提供方出现问题而导致调用方的这个问题。这种处理手段通常就是熔断。
熔断机制是比较简单的,它是一个状态机,具有打开、关闭和半打开三种状态。正常情况下,熔断器是关闭的;当某个服务提供方出现异常时,记录收集异常信息,当这个信息符合熔断条件时,熔断器将状态变为打开,这时再发往该服务的请求会被熔断器拦截并进入失败逻辑;当熔断器打开一段时间后,会自动转换为半打开状态,允许一个请求进入服务端,如果该次请求能够正常处理,熔断器会关闭,否则继续转换为打开。
熔断的本质就是为了防止当前服务被下游服务拖垮。
这里再次体现了那句至理名言,计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。
常见的熔断方案有 Hystrix 和 Resilience4j 等。
安全认证
服务间的 RPC 通常是在同一个内网之内,而不会暴漏在公网下,所以相对是很安全的。
在服务整体的体量较小的情况下,考虑 RPC 安全认证似乎是个多余的环节。因为所有的调用方和提供方之间都是需要相互沟通的。
但是服务整体体量变大后,一个 RPC 可能会被 N 个业务线的服务使用,调用量会变大,宕机概率会增加。为了更方便的排查故障,需要知道调用方是谁。
RPC 安全认证实现思路和一个 Web Server 的用户安全认证是非常相似的。
首先需要注册、登陆这两个基本流程。
一个 RPC 可以被哪些服务调用,调用方首先需要去和提供方注册,提供方审批通过,调用方就是合法的用户。因为 RPC 的注册只提供一个 IP 信息,所以后续的调用过程实际上只需要附带 IP 信息即可,相当于免登陆。
服务端通过校验这个 IP 是否合法来决定是否继续处理请求。
在认证的基础上还可以做授权。
添加了授权后,粒度上也可以继续细化,比如细化到方法级,授权调用方可以调用哪些方法。这个和 RBAC 模型很相似了,具体还要看业务需求。
上面介绍了实现思路。安全认证的具体实现是和 RPC 的具体实现相关的。像基于 HTTP 实现的 RPC,可以使用 TLS 或者在 Header 中添加 Token。其他的方式可以使用一些通用解决方案,非对称加密算法私钥、OAuth 等。
增加安全认证的环节,唯一的问题就是增加了额外的性能损耗。
RPC 与其他技术的异同
RPC、LPC 和 IPC
与 RPC 相对应的,还有一种 LPC(Local Process Call),本地过程调用。LPC 是指在同一台计算机中的两个进程之间共享内存空间来完成相互调用。RPC 和 LPC 基本一致,不同的是它的调用双方是在两台计算机中的进程,其中多了网络的概念。
RPC 和 LPC 都属于 IPC(Inter-Process Communication)。wikipedia 对 IPC 的定义如下:
进程间通信(IPC,Inter-Process Communication),指至少两个进程或线程间传送数据或信号的一些技术或方法。进程是计算机系统分配资源的最小单位(严格说来是线程)。每个进程都有自己的一部分独立的系统资源,彼此是隔离的。为了能使不同的进程互相访问资源并进行协调工作,才有了进程间通信。通常,使用进程间通信的两个应用可以被分为客户端和服务器(见主从式架构),客户端进程请求数据,服务端响应客户端的数据请求。有一些应用本身既是服务器又是客户端,这在分布式计算中,时常可以见到。这些进程可以运行在同一计算机上或网络连接的不同计算机上。IPC 对微内核和 nano 内核的设计过程非常重要。微内核减少了内核提供的功能数量。然后通过 IPC 与服务器通信获得这些功能,与普通的宏内核相比,IPC 的数量大幅增加[1]
我们可以把 RPC 看作是一种特殊的 IPC。
RPC、HTTP 和 MQ
在分布式系统中,通信方式除了 RPC 还有 HTTP 和 MQ。
三者的区别主要是特性的不同,根据不同的应用场景可以选择不同的技术方案。
MQ 最大的作用是削峰填谷,所以天生异步,适合做应用解耦。分布式系统的通信中大多数情况下都是需要同步调用,所以 MQ 不适合分布式系统中服务间通信的场景。
RPC 和 HTTP 都支持同步和异步,所以主流的分布式系统通信解决方案是 RPC 和 HTTP 两种。
RPC 往往并不会面向用户,调用双方都知道彼此的存在,所以性能相比于语义话和可读性而言更为重要。RPC 的性能在大多数情况下都会比 HTTP 更强,所以 RPC 会成为分布式系统通信的首先。
RPC 和 RESTful
RESTful 是目前最主流的远程服务接口设计风格,它主要应用于 HTTP 的接口设计上。
RPC 和 REST 的区别有很多,主要是设计理念上的不同。RPC 是面向过程的设计,REST 是面向资源的设计。
除此之外,在具体的技术实现上也有很多不同。
耦合性
RPC 的耦合性较强,通常调用方和提供方需要使用同一套 RPC 框架。REST 则不需要,任何客户端都可以调用。
消息协议
RPC 通常使用非常紧凑的二进制消息协议,如 protobuf 和 thritf。REST 则使用松散的文本消息协议,如 JSON 和 XML。
通讯协议
RPC 最主流的实现方式是 TCP,REST 最主流的实现方式是 HTTP 和 HTTP2。
性能
由于 RPC 使用了二进制的消息协议和 TCP 作为通讯协议,性能会比 REST 高很多。这也是 RPC 会成为分布式系统中通信的主流解决方案的重要原因之一。
扫码关注 一起进步
以上是关于分布式架构基石:RPC 理论篇的主要内容,如果未能解决你的问题,请参考以下文章