作者:辰宇
本文主要介绍私有协议2.0,也即XRPC的背景、总体设计、相关技术实现细节和性能测试结果。
私有协议作为解决 PolarDB-X 中计算节点和存储节点复杂通信需求的技术手段,在 PolarDB-X 2.0 公共云版上线初期就作为重要的功能一起发布了。同时在PolarDB-X开源版中,也作为唯一的和后端存储节点的通信链路,在数据库请求主链路中起着至关重要的作用。 然而随着 PolarDB-X 的发展,存储节点 5.7 & 8.0 的兼容问题,国产化 ARM 平台的适配等需求接踵而至,私有协议基于 MySQL X plugin 的网络框架设计逐渐变得力不从心,因此对存储节点上私有协议服务端的代码重构就势在必行,XRPC即私有协议2.0应运而生。
难以解决的局限性
私有协议最初的设计在前文已有说明,其旨在解决计算节点和存储节点连接数爆炸的问题。通过连接会话解耦,将传统的 MySQL 会话机制优化为类 RPC 机制,通过会话 ID 实现在同一个通信信道上传输多个会话。由于当时对于快速上线的需求,开发相对困难的存储节点端,使用了相对成熟完整的 MySQL X plugin 进行扩展改造,基于其网络处理调度框架进行消息扩展,完成了私有协议 server 端的开发。其架构如下图所示:


该网络执行架构成功协助解决了 PolarDB-X 所遇到的后端连接爆炸问题,同时基于 protobuf 消息的扩展,也实现了很多计算节点和存储节点的高级交互功能,帮助 PolarDB-X 从传统的中间件模式迈入了完整分布式数据库的行列。 诚然,这个框架也是存在一定的历史局限性的,由于 MySQL X plugin 是以 one thread per connection 为理念设计的,每个处理session都绑定了一个执行线程,同时请求消息接收是由额外的线程处理,并分发到对应的工作会话线程上。这种处理模式也带来一定的性能损耗,特别是在高并发小请求的 TP 场景下,大量线程消息传递和调度本身对系统的压力也是很大的。如下图所示,task queue pop 占据了大量的 CPU 时间:

其次 MySQL 中的 socket 处理模型比较简单,基本上是采用 non-block socket + ppoll 的方式进行等待控制,且单个线程只等待一个 socket,这种设计在超大规模集群中,性能下降和资源占用都非常可观,亟需多路复用的 IO 模型来解决这种超大规模连接及请求的处理。
全新设计的网络框架
为了解决上面遇到的问题,我们决定对网络处理框架进行全部重新设计,并引入线程池模型,一步到位完成连接、会话、执行线程的全部解耦。首先我们调研了现有的一些高性能网络和异步执行框架。
高性能网络&执行框架调研
gRPC

grpc-client-server-polling-engine-usage gRPC是个标准的多个 epoll complete queue 模型。对 listen socket,如果支持 SO_REUSEPORT,开多个分别绑定到每个 epoll 上,如果不支持,开一个,挂在所有 epoll 上。gRPC不建内部线程,用户线程上去等(实际上间接监听,见后文 epoll 模型中描述),accept新连接后,socket fd 随机挂到一个 complete queue 上。

一个客户端的请求会选择一个 channel 中的 socket,作为请求的 TCP,然后绑定到一个 complete queue 上进行处理。

epoll-polling-engine gRPC 的 epoll 模型中,每个 complete queue 对应实现一个 epoll 的 fd set,同一个 fd 可能会注册到不同的 epoll set 中(complete queue),用户的线程通过调用特定的函数,会对 epoll 进行 poll 等待,在实现中使用 poll 去监听自身的一个 fd(上图的 ev_fd)和 epoll 的 fd(上图的 epoll fd1),因为多个线程去监听 epoll fd,不确定哪个线程会完成 epoll 中注册的事件处理,当实际处理的事务完成后,通过 signal ev_fd 来唤醒真正想等待对应事件的线程(个人理解为 client 模式中,等待请求处理的线程被其他线程把任务完成后,被唤醒得知等待的事件完成)。
gRPC 的这种设计也存在一定缺陷即惊群。标准 epoll_wait() 在多线程等待时,如果有一个事件触发,只会唤醒一个线程,而 gRPC 模型中,由于线程等待在 [ev_fd,epoll_fd] 上,同时是拿 poll 去监听的,一旦任意在 epoll 中的 fd 事件唤醒,会导致所有 poll 在这个 complete queue 上的线程都被唤醒。而且 fd 可能被绑在多个 complete queue 上,影响会更大。这种情况主要出现在 server 模式,因为 listen fd 会绑到每个 complete queue 上,accept 时候会触发,此外多个线程处理一个 complete queue 也会在一个 fd 变成 readable 时候引起惊群。
gRPC 给出的解决方案是,搞个新的 epoll set,命名为 polling island,保证等待的 fd 只存在于一个 polling island,避免因为 fd 存在于多个 complete queue 而导致的多个 queue 上等待的惊群(这里 polling island 的聚合算法细节忽略,本质上会生成一个大的 epoll set,有相同 fd 的 complete queue 实质上会等到一个 polling island 上)。其次为了避免 poll 在 [ev_fd,epoll_fd] 上带来的惊群,改进为 psi_wait 到 epoll 上,同 signal 唤醒对应的等待线程。
由于 gRPC 需要考虑指定等待事件线程的唤醒,以及多线程可能 poll 在同一个 complete queue 上的情况,采用了这种分2层 poll 的模型(改进后的 psi_wait 变成单层模型)。在 server 模型下由于服务线程对等,不存在等待特定客户端返回,可以直接退化成多线程 epoll_wait 形式,效率会更高。在 client 模型下,psi_wait 的模型值得借鉴(低并发下,指定等待线程被唤醒处理事件的概率高,少一层 notify,额外代价低)。
libuv
nodejs 的事件框架,和libevenet和libev类似,单线程的 epoll 模型,所有非阻塞任务都由回调函数完成,阻塞的都会注册到 epoll 中。对于网络 server 服务,一般是在这个线程里面处理返回写入数据,或者分发出来给任务队列做,但数据写回还是要依赖那个处理事件的单线程写。
常见的多线程的使用方法是开多个 instance,类似 gRPC 方式,用 SO_REUSEPORT 开多个 listen socket,在每个 epoll 上监听,每个 epoll 上单线程处理,也可以单个 epoll 专门 listen,accept 的 socket 分发到其他 epoll 上处理。
由于一个 epoll 只有一个线程,数据结构线程不安全,事件队列直接交互信息需要额外同步措施。而且如果存在连接热点(某个连接上请求特别多且计算特别重),对应的事件队列处理线程就会在同样 epoll 上的其他消息响应变慢。而其他队列上的线程也不能分担任务。
Percona
Percona 中实现了 Thread_pool_connection_handler,替代原生 MySQL 网络处理模型。
具体实现为多个线程池 instance,每个池子里面单独调度。每个线程池一个 epoll,在 connection handler 收到请求的时候,将连接注册到 epoll 中。epoll 使用 edge triggered,one shot 模式,仅在连接建立或者 idle 状态再注册进来。线程池第一次工作时候,会选出一个线程作为 listener,该 listener 负责 epoll_wait,在收到请求后,如果高优先级队列为空,会自己也参与到请求处理中,然后让出 listener 角色。
综上,还是比较标准的多实例、单个线程进行 epoll_wait 再进行任务队列分发的模型,针对 listener 线程是否参与数据读取和处理进行了特殊优化(在高优先级队列为空时,参与请求处理,减少低并发下调度导致的 rt)。队列非空情况下,仅对 epoll 事件压任务队列,并不实际 recv,提升网络响应效率。
虽然这个线程池模型设计考虑非常多,也对各种情况进行针对性优化。但在高并发且较差网络环境下,由于 listener 并没有实际处理收包就将任务委派给 worker,worker 需要在对应 socket 上做 recv 到一个完整请求,大部分情况没有问题,但如果请求比较大或者网络比较差,这里会有较长的 io wait,同时线程池的等待扩张机制,也会导致线程池快速扩张。
单线程做 epoll_wait 再分发是比较标准的模型,正如代码注释中写的,listener 线程不干活只分发是个比较糟糕的思路,因为多了一层数据和唤醒 worker 流程,既然 gRPC 中就采用多线程等待 epoll 的方法,这种纯 server 场景更加适合无状态的多 server thread 做,只要保证至少有一个线程等在 epoll_wait 上即可,同时也不要 signal 唤醒对应等待线程,因为 server 本来就不存在这种线程。
多线程事件驱动框架(XRPC)
基于上述的调研结果,我们针对 PolarDB-X 私有协议的需求,设计了全新的基于epoll的多线程事件驱动网络执行框架,内部命名为 XRPC。整体架构如下图所示:

其具备以下特性:
- 主体实现在plugin/polarx_rpc中,网络、调度框架与mysql基本独立,提供最大兼容性,便于移植(5.7 or 8.0, X86 or ARM)
- 全新的基于多线程epoll异步事件驱动框架,包含网络、任务队列、timer等基本组件
- 统一工作线程逻辑设计,所有线程对等、无状态,自动根据任务分配负载
- 动态线程池设计
- 轻载下,线程完成 epoll 事件触发,收包解码,请求处理返回的全流程,减少上下文切换,提供最佳响应;
- 重载下,线程池通过任务队列处理请求,减少线程调度,提供最大化吞吐量。
- 多 epoll instance 设计,最大化发挥多核 numa 架构性能
未完待续




