一、gRPC介绍
gRPC 是在 HTTP/2 之上实现的 RPC 框架,HTTP/2 是第 7 层(应用层)协议,它运行在 TCP(第 4 层 - 传输层)协议之上,相比于传统的 REST/JSON 机制有诸多的优点:
- 基于 HTTP/2 之上的二进制协议(Protobuf 序列化机制);
- 一个连接上可以多路复用,并发处理多个请求和响应;
- 多种语言的类库实现;
- 服务定义文件和自动代码生成(.proto 文件和 Protobuf 编译工具)。
此外,gRPC 还提供了很多扩展点,用于对框架进行功能定制和扩展,例如,通过开放负载均衡接口可以无缝的与第三方组件进行集成对接(Zookeeper、域名解析服务、SLB 服务等)。
二、gRPC服务调用原理
一个完整的 RPC 调用流程示例如下:
RPC 请求消息发送流程
gRPC 默认基于 Netty HTTP/2 + PB 进行 RPC 调用,请求消息发送流程如下所示:
RPC 响应接收和处理流程
gRPC 客户端响应消息的接收入口是 NettyClientHandler,它的处理流程如下所示:
并行调用和异步调用
要解决串行调用效率低的问题,有两个解决对策:
- 并行服务调用,一次 I/O 操作,可以发起批量调用,然后同步等待响应;
- 异步服务调用,在同一个业务线程中异步执行多个服务调用,不阻塞业务线程。
采用并行服务调用的伪代码示例:
ParallelFuture future = ParallelService.invoke(serviceName [], methodName[], args []);
List<Object> results = future.get(timeout);// 同步阻塞式获取批量服务调用的响应列表
并行服务调用的一种实现策略如下所示:
异步服务调用的工作原理如下:
异步服务调用相比于同步服务调用有两个优点:
- 化串行为并行,提升服务调用效率,减少业务线程阻塞时间。
- 化同步为异步,避免业务线程阻塞。
基于 Future-Listener 的纯异步服务调用代码示例如下:
xxxService1.xxxMethod(Req);
Future f1 = RpcContext.getContext().getFuture();
Listener l = new xxxListener();
f1.addListener(l);
class xxxListener{
public void operationComplete(F future)
{ // 判断是否执行成功,执行后续业务流程}
}
理解误区
1、异步服务就是异步吗?
实际上,通信框架基于 NIO 实现,并不意味着服务框架就支持异步服务调用了,两者本质上不是同一个层面的事情。在 RPC/ 微服务框架中,引入 NIO 带来的好处是显而易见的:
- 所有的 I/O 操作都是非阻塞的,避免有限的 I/O 线程因为网络、对方处理慢等原因被阻塞;
- 多路复用的 Reactor 线程模型:基于 Linux 的 epoll 和 Selector,一个 I/O 线程可以并行处理成百上千条链路,解决了传统同步 I/O 通信线程膨胀的问题。
NIO 只解决了通信层面的异步问题,跟服务调用的异步没有必然关系,也就是说,即便采用传统的 BIO 通信,依然可以实现异步服务调用,只不过通信效率和可靠性比较差而已。
对异步服务调用和通信框架的关系进行说明:
用户发起远程服务调用之后,经历层层业务逻辑处理、消息编码,最终序列化后的消息会被放入到通信框架的消息队列中。业务线程可以选择同步等待、也可以选择直接返回,通过消息队列的方式实现业务层和通信层的分离是比较成熟、典型的做法,目前主流的 RPC 框架或者 Web 服务器很少直接使用业务线程进行网络读写。
通过上图可以看出,采用 NIO 还是 BIO 对上层的业务是不可见的,双方的汇聚点就是消息队列,在 Java 实现中它通常就是个 Queue。业务线程将消息放入到发送队列中,可以选择主动等待或者立即返回,跟通信框架是否是 NIO 没有任何关系。因此不能认为 I/O 异步就代表服务调用也是异步的。
2、异步服务调用性能肯定更高吗?
对于 I/O 密集型,资源不是瓶颈,大部分时间都在同步等应答的场景,异步服务调用会带来巨大的吞吐量提升,资源使用率也可以提高,更加充分的利用硬件资源提升性能。
另外,对于时延不稳定的接口,例如依赖第三方服务的响应速度、数据库操作类等,通常异步服务调用也会带来性能提升。
但是,如果接口调用时延本身都非常小(例如毫秒级),内存计算型,不依赖第三方服务,内部也没有 I/O 操作,则异步服务调用并不会提升性能。能否提升性能,主要取决于业务的应用场景。
普通 RPC 调用
普通的 RPC 调用提供了三种实现方式:
- 同步阻塞式服务调用,通常实现类是 xxxBlockingStub(基于 proto 定义生成)。
- 异步非阻塞调用,基于 Future-Listener 机制,通常实现类是 xxxFutureStub。
- 异步非阻塞调用,基于 Reactive 的响应式编程模式,通常实现类是 xxxStub。
Streaming 模式服务调用
gRPC 服务调用支持同步和异步方式,同时也支持普通的 RPC 和 streaming 模式,可以最大程度满足业务的需求。
对于 streaming 模式,可以充分利用 HTTP/2.0 协议的多路复用功能,实现在一条 HTTP 链路上并行双向传输数据,有效的解决了 HTTP/1.X 的数据单向传输问题,在大幅减少 HTTP 连接的情况下,充分利用单条链路的性能,可以媲美传统的 RPC 私有长连接协议:更少的链路、更高的性能:
gRPC 的网络 I/O 通信基于 Netty 构建,服务调用底层统一使用异步方式,同步调用是在异步的基础上做了上层封装。因此,gRPC 的异步化是比较彻底的,对于提升 I/O 密集型业务的吞吐量和可靠性有很大的帮助。
三、gRPC线程模型
影响 RPC 框架性能的三个核心要素如下:
- I/O 模型:用什么样的通道将数据发送给对方,BIO、NIO 或者 AIO,IO 模型在很大程度上决定了框架的性能;
- 协议:采用什么样的通信协议,Rest+ JSON 或者基于 TCP 的私有二进制协议,协议的选择不同,性能模型也不同,相比于公有协议,内部私有二进制协议的性能通常可以被设计的更优;
- 线程:数据报如何读取?读取之后的编解码在哪个线程进行,编解码后的消息如何派发,通信线程模型的不同,对性能的影响也非常大。
gRPC 线程模型
消息的序列化和反序列化均由 gRPC 线程负责,而没有在 Netty 的 Handler 中做 CodeC,原因如下:Netty4 优化了线程模型,所有业务 Handler 都由 Netty 的 I/O 线程负责,通过串行化的方式消除锁竞争,原理如下所示:
如果大量的 Handler 都在 Netty I/O 线程中执行,一旦某些 Handler 执行比较耗时,则可能会反向影响 I/O 操作的执行,像序列化和反序列化操作,都是 CPU 密集型操作,更适合在业务应用线程池中执行,提升并发处理能力。因此,gRPC 并没有在 I/O 线程中做消息的序列化和反序列化。
改进点思考
1、时间可控的接口调用直接在 I/O 线程上处理
gRPC 采用的是网络 I/O 线程和业务调用线程分离的策略,大部分场景下该策略是最优的。但是,对于那些接口逻辑非常简单,执行时间很短,不需要与外部网元交互、访问数据库和磁盘,也不需要等待其它资源的,则建议接口调用直接在 Netty /O 线程中执行,不需要再投递到后端的服务线程池。避免线程上下文切换,同时也消除了线程并发问题。
例如提供配置项或者接口,系统默认将消息投递到后端服务调度线程,但是也支持短路策略,直接在 Netty 的 NioEventLoop 中执行消息的序列化和反序列化、以及服务接口调用。
2、减少锁竞争
当前 gRPC 的线程切换策略如下:
优化之后的 gRPC 线程切换策略:
通过线程绑定技术(例如采用一致性 hash 做映射), 将 Netty 的 I/O 线程与后端的服务调度线程做绑定,1 个 I/O 线程绑定一个或者多个服务调用线程,降低锁竞争,提升性能。
四、gRPC 安全性设计
RPC 调用安全主要涉及如下三点:
- 个人 / 企业敏感数据加密:例如针对个人的账号、密码、手机号等敏感信息进行加密传输,打印接口日志时需要做数据模糊化处理等,不能明文打印;
- 对调用方的身份认证:调用来源是否合法,是否有访问某个资源的权限,防止越权访问;
- 数据防篡改和完整性:通过对请求参数、消息头和消息体做签名,防止请求消息在传输过程中被非法篡改。
敏感数据加密传输
1. 基于 SSL/TLS 的通道加密
2. 针对敏感数据的单独加密
有些 RPC 调用并不涉及敏感数据的传输,或者敏感字段占比较低,为了最大程度的提升吞吐量,降低调用时延,通常会采用 HTTP/TCP + 敏感字段单独加密的方式,既保障了敏感信息的传输安全,同时也降低了采用 SSL/TLS 加密通道带来的性能损耗,对于 JDK 原生的 SSL 类库,这种性能提升尤其明显。
它的工作原理如下所示:
通常使用 Handler 拦截机制,对请求和响应消息进行统一拦截,根据注解或者加解密标识对敏感字段进行加解密,这样可以避免侵入业务。
认证和鉴权
1. 身份认证
2. 权限管控
在 RPC 调用领域比较流行的是基于 OAuth2.0 的权限认证机制,它的工作原理如下:
数据完整性和一致性
利用消息摘要可以保障数据的完整性和一致性,它的特点如下:
- 单向 Hash 算法,从明文到密文的不可逆过程,即只能加密而不能解密;
- 无论消息大小,经过消息摘要算法加密之后得到的密文长度都是固定的;
- 输入相同,则输出一定相同。
目前常用的消息摘要算法是 SHA-1、MD5 和 MAC,MD5 可产生一个 128 位的散列值。 SHA-1 则是以 MD5 为原型设计的安全散列算法,可产生一个 160 位的散列值,安全性更高一些。MAC 除了能够保证消息的完整性,还能够保证来源的真实性。