利用 kcp 加速跨地区 grpc 请求

背景

目前维护的长链消息推送系统,拥有一个中心节点和上百个边沿节点。需要将消息从中心节点推送到所有边沿节点。为了保障用户就近接入,这些边沿节点分布在全国(世界)多个地区。

在从中心节点推送中我们发现到边沿节点的速度非常不稳定,特别是晚高峰的时候 99 分位耗时抖动严重。

“KCP” 是基于 UDP 协议上实现的一套可靠传输协议,其主要特点是面向流速、降低高丢包场景的延迟。早年间多用来“翻墙”,如知名的 kcptun,v2ray 也内建了 KCP 支持。

中心向边缘推送的场景、晚高峰的抖动跟 KCP 的应用场景非常类似。遂决定尝试让 grpc 底层走 kcp 来传输数据以达到 kcp 宣称的低延迟。

正文

kcptun 是利用 kcp协议来实现的远程端口映射,通过对其源代码的阅读发现其核心是 kcp-go 来完成 KCP 连接的建立和管理,得益于 go 良好的 interface 设计,可以几乎 0 成本地将 grpc 改造支持 KCP 协议传输。

我们支持 grpc server 启动分成三个部分:

1.listener,即 net.Listener interface,通常是通过 net.Listen(“tcp”,”127.0.0.1:9000″) 获得的。

2.grpc server,即通过 grpc.NewServer() 创建的 grpc server 对象。

3. service implement,即 proto 中定义的 service 的实现对象,最终会通过 pb.RegisterXXServer 将 pb 定义和实现进行绑定。

而要替换成 KCP 协议只会涉及到 listener 的替换,即提供一个实现了net.Listerner 接口的对象,而这个在 kcp-go 已经实现了。

因此我们要做的只是调用 kcp-go 中的 kcp.ListenWithOptions() 来创建自己的 Listerner,最后通过 server.Serve(listener) 即可启动支持 KCP 协议访问的 grpc server。其本质是 grpc->http2-> kcp->udp 这样的协议结构。

同时 grpc 可以同时绑定多个 listerner 进行 serve,因此你既可以监听 TCP ,也可以通过监听基于 UDP 实现的 KCP,即一个端口上实现两种协议的访问。

对于客户端,我们可以通过实现自己的 Dialer 来实现以 KCP 协议连接 grpc server。grpc 提供了 grpc.DialOption 来自定义连接行为,只要 Dial 之后获得兼容 net.Conn 的对象即可。因此通过在 grpc.Dial 中使用 grpc.WithContextDialer 来自定义我们的连接方式。在具体实现中使用 kcp.DialWithOptions 来完成 KCP 连接的创建。

得益于 kcp-go 良好的设计实现,我们只需要了很少几行代码即完成了 grpc 对 kcp 的支持。最终实现的效果如下:

服务端通过一个端口监听 TCP/UDP

客户端可以根据配置选择以 TCP 连接或者 KCP 协议连接服务端

问题:

1.kcp-go 本身没有连接状态和超时机制,在 server 端重启后,客户端无法感知到连接变化,此时发生消息会一直阻塞,因此需要通过配置 grpc 协议层面的心跳来维持连接,当心跳失败后触发自动重连,即使用 grpc.WithKeepaliveParams 设置。通过测试发现此问题是由于 kcp-go v5 版本客户端实现导致,kcp-go v4 无此问题,具体原因还在调查中。

2.多网卡问题:在使用UDP 协议监听 0.0.0.0:9999 这样的地址时,如果是多网卡的场景,当客户端向网卡 2 发送消息,服务端在回传时会出现向 网卡 1 回包的情况。这在同一个局域网不会出现问题,但如果跨越多层网络则通过网卡 1 的回包无法到达客户端(因为链路上 nat 都是只认网卡2 的)。这个问题只能通过在监听时分别监听每个网卡地址(IP)来解决。参考文章(双网卡UDP通信问题

结果

最终通过 kcp 完成海外节点消息推送的优化,耗时曲线趋于平稳,基本保持在一个 RTT 时间。

以上过程代码已整理到 gitbub: https://github.com/Lynnworld/grpc-kcp-transport