深入浅出 RPC 框架 | 青训营笔记

169 阅读10分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第5篇笔记

RPC 的基本概念

什么是RPC

RPC(Remote Procedure Call Protocol)远程过程调用协议。一个通俗的描述是:客户端在不知道调用细节的情况下,调用存在于远程计算机上的某个对象,就像调用本地应用程序中的对象一样。

比较正式的描述是:一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的协议。

那么我们至少从这样的描述中挖掘出几个要点:

  • RPC是协议:既然是协议就只是一套规范,那么就需要有人遵循这套规范来进行实现。目前典型的RPC实现包括:Dubbo、Thrift、GRPC、Hetty等。
  • 网络协议网络IO模型对其透明:既然RPC的客户端认为自己是在调用本地对象。那么传输层使用的是TCP/UDP还是HTTP协议,又或者是一些其他的网络协议它就不需要关心了。
  • 信息格式对其透明:我们知道在本地应用程序中,对于某个对象的调用需要传递一些参数,并且会返回一个调用结果。至于被调用的对象内部是如何使用这些参数,并计算出处理结果的,调用方是不需要关心的。那么对于远程调用来说,这些参数会以某种信息格式传递给网络上的另外一台计算机,这个信息格式是怎样构成的,调用方是不需要关心的。
  • 应该有跨语言能力:为什么这样说呢?因为调用方实际上也不清楚远程服务器的应用程序是使用什么语言运行的。那么对于调用方来说,无论服务器方使用的是什么语言,本次调用都应该成功,并且返回值也应该按照调用方程序语言所能理解的形式进行描述。

为什么要用RPC

其实这是应用开发到一定的阶段的强烈需求驱动的。如果我们开发简单的单一应用,逻辑简单、用户不多、流量不大,那我们用不着。当我们的系统访问量增大、业务增多时,我们会发现一台单机运行此系统已经无法承受。此时,我们可以将业务拆分成几个互不关联的应用分别部署在各自机器上,以划清逻辑并减小压力。此时,我们也可以不需要RPC,因为应用之间是互不关联的。

当我们的业务越来越多、应用也越来越多时,自然的,我们会发现有些功能已经不能简单划分开来或者划分不出来。此时,可以将公共业务逻辑抽离出来,将之组成独立的服务Service应用 。而原有的、新增的应用都可以与那些独立的Service应用 交互,以此来完成完整的业务功能。

所以此时,我们急需一种高效的应用程序之间的通讯手段来完成这种需求。

其实描述的场景也是服务化 、微服务和分布式系统架构的基础场景。即RPC框架就是实现以上结构的有力方式。

常用的RPC框架

  • Thrift:thrift是一个软件框架,用来进行可扩展且跨语言的服务的开发。它结合了功能强大的软件堆栈和代码生成引擎,以构建在 C++, Java, Python, PHP, Ruby, Erlang, Perl, Haskell, C#, Cocoa, JavaScript, Node.js, Smalltalk, and OCaml 这些编程语言间无缝结合的、高效的服务。
  • gRPC:一开始由 google 开发,是一款语言中立、平台中立、开源的远程过程调用(RPC)系统。
  • Dubbo:Dubbo是一个分布式服务框架,以及SOA治理方案。其功能主要包括:高性能NIO通讯及多协议集成,服务动态寻址与路由,软负载均衡与容错,依赖分析与降级等。Dubbo是阿里巴巴内部的SOA服务化治理方案的核心框架,Dubbo自2011年开源后,已被许多非阿里系公司使用。
  • Spring Cloud:Spring Cloud由众多子项目组成,如Spring Cloud Config、Spring Cloud Netflix、Spring Cloud Consul 等,提供了搭建分布式系统及微服务常用的工具,如配置管理、服务发现、断路器、智能路由、微代理、控制总线、一次性token、全局锁、选主、分布式会话和集群状态等,满足了构建微服务所需的所有解决方案。Spring Cloud基于Spring Boot, 使得开发部署极其简单。

RPC的概念模型:User、User-Stub、RPC-Runtime、Server-Stub、Server

  • IDL(Interface Definition Language) 文件

    • Thrift
    • Protobuf
  • 生成代码
  • 编解码(序列化/反序列化)
  • 通信协议

    • 应用层协议
  • 网络通信

    • IO 网络模型

      • blocking IO
      • unblocking IO
      • IO multiplexing
      • signal driven IO
      • asynchronous IO
    • 传输层协议

      • TCP
      • UDP

RPC调用流程

要让网络通信细节对使用者透明,我们需要对通信细节进行封装,我们先看下一个RPC调用的流程涉及到哪些通信细节:

  1. 服务消费方(client)调用以本地调用方式调用服务;
  2. client stub接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体;
  3. client stub找到服务地址,并将消息发送到服务端;
  4. server stub收到消息后进行解码;
  5. server stub根据解码结果调用本地的服务;
  6. 本地服务执行并将结果返回给server stub;
  7. server stub将返回结果打包成消息并发送至消费方;
  8. client stub接收到消息,并进行解码;
  9. 服务消费方得到最终结果。

RPC的目标就是要2~8这些步骤都封装起来,让用户对这些细节透明。

RPC的好处

  • 单一职责,有利于分工协作和运维开发
  • 可扩展性强,资源使用率更优
  • 故障隔离,服务的整体可靠性更高

相比本地函数调用,RPC调用需要解决的问题

  • 函数映射
  • 数据转换成字节流
  • 网络传输

RPC 带来的问题将由 RPC 框架来解决

  • 服务宕机如何感知?
  • 遇到网络异常应该如何应对?
  • 请求量暴增怎么处理?

RPC 框架分层设计

img

编解码层

确定消息数据结构
  • 数据格式:

    • 语言特定格式 :例如 java.io.Serializable

    • 文本格式 :例如 JSON、XML、CSV 等

    • 二进制编码

      • TLV 编码:Thrift 使用 TLV 编码
      • Varint 编码:Protobuf 使用 Varint 编码
  • 选项:

    • 兼容性:支持自动增加新的字段,而不影响老的服务,这将提高系统的灵活度
    • 通用型:支持跨平台、跨语言
    • 性能:从空间和时间两个维度来考虑,也就是编码后数据大小和编码耗费时长

    img

序列化

一旦确定了消息的数据结构后,下一步就是要考虑序列化与反序列化了。

什么是序列化?序列化就是将数据结构或对象转换成二进制串的过程,也就是编码的过程。

什么是反序列化?将在序列化过程中所生成的二进制串转换成数据结构或者对象的过程。

为什么需要序列化?转换为二进制串后才好进行网络传输嘛!

为什么需要反序列化?将二进制转换为对象才好进行后续处理!

现如今序列化的方案越来越多,每种序列化方案都有优点和缺点,它们在设计之初有自己独特的应用场景,那到底选择哪种呢?从RPC的角度上看,主要看三点:

  • 通用性:比如是否能支持Map等复杂的数据结构;
  • 性能:包括时间复杂度和空间复杂度,由于RPC框架将会被公司几乎所有服务使用,如果序列化上能节约一点时间,对整个公司的收益都将非常可观,同理如果序列化上能节约一点内存,网络带宽也能省下不少;
  • 可扩展性:对互联网公司而言,业务变化飞快,如果序列化协议具有良好的可扩展性,支持自动增加新的业务字段,而不影响老的服务,这将大大提供系统的灵活度。

目前互联网公司广泛使用Protobuf、Thrift、Avro等成熟的序列化解决方案来搭建RPC框架,这些都是久经考验的解决方案。

如何发布自己的服务

Java常用zookeeper,Go常用ETCD,服务端进行注册和心跳,客户端获取机器列表,比如zookeeper:

传输协议层

  • 消息切分

    • 特殊结束符
    • 变长协议:length+body
  • 协议构造

    • 以 Thrift 的 THeader 协议为例

      img

      • LENGTH字段 32bits,包括数据包剩余部分的字节大小,不包含 LENGTH 自身长度
      • HEADER MAGIC 字段16bits,值为:0x1000,用于标识 协议版本信息,协议解析的时候可以快速校验
      • FLAGS 字段 16bits,为预留字段,暂未使用,默认值为 0x0000
      • SEQUENCE NUMBER字段 32bits,表示数据包的 seqId,可用于多路复用,最好确保单个连接内递增
      • HEADER SIZE 字段 16bits,等于头部长度字节数/4,头部长度计算从第14个字节开始计算,一直到 PAYLOAD 前(备注:header 的最大长度为 64K)
      • PROTOCOL ID字段 uint8 编码,取值有: - ProtocolIDBinary = 0 - ProtocolIDCompact = 2
      • NUM TRANSFORMS 字段 uint8 编码,表示 TRANSFORM 个数
      • TRANSFORM ID 字段 uint8 编码,表示压缩方式 zlib or snappy
      • INFO ID字段 uint8 编码,具体取值参考下文,用于传递一些定制的 meta 信息
      • PAYLOAD 消息内容
    • 协议解析

      img

网络通信层

img

  • 网络库

    • 提供易用API

      封装底层 Socket API 连接管理和事件分发功能

    • 协议支持

      tcp、udp 和uds等优雅退出、异常处理等

    • 性能

      应用层 buffer 减少copy高性能定时器、对象池等

  • 核心指标

    • 吞吐高
    • 延迟低

  • 阻塞 IO 下,耗费一个线程去阻塞在 read(fd) 去等待用足够多的数据可读并返回。
  • 非阻塞 IO 下,不停对所有 fds 轮询 read(fd) ,如果读取到 n <= 0 则下一个循环继续轮询。

第一种方式浪费线程(会占用内存和上下文切换开销),第二种方式浪费 CPU 做大量无效工作。而基于 IO 多路复用系统调用实现的 Poll 的意义在于将可读/可写状态通知和实际文件操作分开,并支持多个文件描述符通过一个系统调用监听以提升性能。

网络库的核心功能就是去同时监听大量的文件描述符的状态变化(通过操作系统调用),并对于不同状态变更,高效,安全地进行对应的文件操作。

小结

  • RPC 框架主要核心有三层:编解码层、协议层和网络通信层
  • 二进制编解码的实现原理和选型要点
  • 协议的一般构造,以及框架协议解析的基本流程
  • 网络库的基本架构,以及选型时要考察的核心指标

RPC 框架的核心指标

稳定性

  • 保障策略

    • 熔断:保护调用方,防止被调用的服务出现问题而影响到整个链路
    • 限流:保护被调用方.防止大流量把服务压垮
    • 超时控制:避免浪费资源在不可用节点上

img

从某种程度上讲超时、限流和熔断也是一种服务降级的手段 。

  • 请求成功率

    • 负载均衡

    • 重试

  • 长尾请求

    • BackupRequest 备份请求

    img

易用性

  • 开箱即用

    • 合理的默认参数选项、丰富的文档
  • 周边工具

    • 生成代码工具、脚手架工具

扩展性

  • Middleware:middleware 会被构造成一个有序调用链逐个执行,比如服务发现、路由、负载均衡、超时控制等
  • Option:作为初始化参数
  • 核心层是支持扩展的:编解码、协议、网络传输层
  • 代码生成工具也支持插件扩展

Middleware中间件执行流程

观测性

  • Log 日志

  • Metric 监控

  • Tracing 链式跟踪

  • 内置观测性服务

    • 当前环境变量
    • 配置参数
    • 缓存信息
    • 内置 pprof 服务用于排查问题

高性能

  • 目标

    • 高吞吐
    • 低延迟
  • 场景

    • 单机多机
    • 单连接多连接
    • 单/多client单/多server
    • 不同大小的请求包
    • 不同请求类型:例如 pingpong、streaming等
  • 手段

    • 连接池和多路复用:复用连接,减少频繁建联带来的开销
    • 高性能编解码协议:Thrift、Protobuf、Flatbuffer 和 Cap'n Proto 等
    • 高性能网络库:Netpoll 和 Netty 等

小结

  • 框架通过中间件来注入各种服务治理策略,保障服务的稳定性
  • 通过提供合理的默认配置和方便的命令行工具可以提升框架的易用性
  • 框架应当提供丰富的扩展点,例如核心的传输层和协议层
  • 观测性除了传统的Log、Metric和 Tracing之外,内置状态暴露服务也很有必要
  • 性能可以从多个层面去优化,例如选择高性能的编解码协议和网络库

\