基于KCP的TCP&UDP多通道开源框架
一、需求分析
目前网上已经有非常多的KCP的原理机制、以及各种版本的KCP实现的相关资料。我在之前做了两篇文章的KCP相关分析,分别是原理机制和性能测试实践。
我们当前的项目是一个对实时性要求比较高的游戏,理论上,按传统实时游戏做法,TCP的性能也是够用,但为了追求更好的效果和更流畅的体验,我们决定在战斗中使用KCP作为网络层通信。
根据调研以及性能测试,总结原因如下:
- TCP在网络环境较差的时候,丢包率较高,且十分不稳定;KCP在内外网的环境中,表现都十分稳定。
- TCP的RTO延时计算不合理,会造成重传数据包时间过长;KCP对于重传发包以及RTO延时计算有着更加友好的算法设计。
- TCP是以控制网络带宽为目的而设计的协议,而当前网络环境下,带宽已经不是特别重要;KCP则是以控制流速为目的而设计。
二、初版实现
一开始,我们分析github大佬开源的Java版本实现库(https://github.com/l42111996/java-Kcp),理论上是完全够用的。并且这个版本的开源库在原版C的基础上,结合Java中Netty的基于事件驱动,以及多核CPU的利用,对KCP消息的flush策略又做了优化.
/**
* 执行flush
* flush策略
* 1,在send调用后检查缓冲区如果可以发送直接调用update得到时间并存在uktucp内
* 2,定时任务到了检查uktucp的时间和自己的定时,如果可以发送则直接发送,时间延后则重新定时,定时任务发送成功后检测缓冲区 是否触发发送时间
* 3,读事件触发后检测检测缓冲区触发写事件
*/
1.设计实现
从源码来看,作者非常熟悉Netty和KCP,对这两者又做了较好的融合。并且根据作者文档说明,这版实现在腾讯是有5款上线项目验证的。
因此我在大致摸透这套框架之后,再结合C版本的原作者对KCP的使用建议(https://github.com/skywind3000/kcp/wiki/Cooperate-With-Tcp-Server),对网络层进行了初版的改造,做了如下设计:
保留原有的TCP通道,新增一条KCP/UDP通道,在游戏逻辑层做网络切换逻辑。检测到客户端通过哪条信道发过来消息,我们就切换玩家网络为当前信道。
基于这种实现,我们在项目中可以实现如下功能:
- 利用TCP的安全可靠性,用TCP通道处理玩家在战斗服的登录,以及战斗场景的创建、进入、结算、和退出战斗场景流程
- 利用KCP的网络稳定性以及较差网络环境下的低延迟特性,用KCP/UDP通道处理玩家战斗中的状态同步消息(我们的战斗是状态同步,但实际也可以用在帧同步的项目中。这一点不重要,就理解为战斗内通信消息即可)
- 在某些UDP包不可达的网络下,战斗内可灵活切换网络通道,退回到TCP备用通道
2.问题分析
在单网络情况下的战斗内通信中,无论玩家使用TCP还是KCP/UDP通道,都是没有任何问题的。
但很快我们就发现这种模式在多网络切换下的一些弊端,一旦涉及到网络切换的一些边界情况,就可能会出现问题
如上面序列图所示:
- 在2.1操作时,客户端发现TCP网络不通,2.2的ack包以及2.3的状态同步包被阻塞在网络中,经过很久才返回
给客户端(或者也有可能客户端的操作包在发给服务端的时候,就已经被堵在路上了。图中的2.1、2.2、2.3任意一个包都有可能被堵住) - 这个时候,客户端切到KCP网络,并接着发后面的玩家操作,服务端的战斗状态可能也已经做了很多改变。
- 过了很久很久,客户端啪地收到一个服务端的状态同步包2.3,告诉你怪扣血100,但很有可能这个怪早就被打死了。
提问:客户端收到这个状态同步包,是处理还是不处理。
3.解决方案
当然了,基于这套通信架构,解决方案也是有:
- 给每个消息包加一个网络包序号,低于当前序号的包不做处理,但这种方式只能应用于状态同步游戏,状态同步是允许丢包的,只要保证玩家最终状态一致即可;但如果是帧同步游戏,就稍微有些头疼了,帧同步的游戏是绝对不允许丢包的,因为客户端会根据帧数据进行逻辑运算,而不是简单的同步状态,所以帧同步游戏一定需要一套完善的包序列管理机制。
- 给每个消息包加一个时间戳,原理和加包序号相同,也只能适用于状态同步游戏。22
- 不管是走TCP还是KCP网络,服务端对客户端的每一包都做一个回复机制,客户端需要感知到它的消息服务端有没有收到。因为应用层感知不到网络底层收到ACK包,因此需要在应用层实现一套类似ACK的机制。
这三种方案中,显然前两种实现起来更简单,但这还只是能想到了网络切换中会遇到问题,实际情况中,可能遇到的问题会更多。
既然问题出在网络切换和包序列管理上,那为什么不在统一的入口和出口来处理网络消息包呢?
三、多通道网络实现
这个分割线,代表以下内容开始进入正题了
和大佬们一番讨论之后,决定使用一种更为激进,却也是一劳永逸的方法。即以TCP和UDP为双通道网络通信,以KCP进行统一的数据包管理为模型的通信架构。
大概用了一周的时间,我基于这套开源库进行改造,实现了一套以KCP为应用层,TCP和UDP为底层通信协议的双通道网络层。这样的架构下,消息包的序列,分片,窗口大小,流量控制等,都完全交给KCP去做,而底层网络,想用什么用什么,想起几个起几个。因为网络消息的管理统一交给了KCP处理,因此它不再有网络之间切换的问题。
为了让它更灵活,更容易扩展,我把这套网络层抽象出一个开源库,修改为下层支持多通道网络,并且为了使用方便,我在原框架的接口中,把更多参数修改为可配置,既是方便自己,同时也开放给大家,让这套框架最大限度的开放KCP应用层和底层网络通信的配置。
该框架已发布release1.1版本,上传了github和maven中央仓库,大家可以根据我的说明通过maven导入使用
github:https://github.com/hjcenry/ktucp-netty
欢迎大家贡献一个小星星,开放出来既是方便自己也是方便大家,如果大家使用过程中有任何问题,可以在github中提issues,我都会解决。
四、ktucp-netty
取名简单粗暴,因为通信架构基于kcp/tcp/udp,所以干脆三合一ktucp,后缀netty代表整个框架以Netty为通信基础实现
以下内容摘自我github工程里的README,对这套框架的架构和使用,做一个简单的介绍。
基于原作者的开源项目的修改:https://github.com/l42111996/java-Kcp.git
原项目:
通信架构
应用层 <--> UDP <--> KCP
实现功能
- java版kcp基本实现
- 优化kcp的flush策略
- 基于事件驱动,利用多核性能
- 支持配置多种kcp参数
- 支持配置conv或address(ip+port)确定唯一连接
- 支持fec(降低延迟)
- 支持crc32校验
基于原项目的新增和优化:
通信架构
应用层
┌┴┐
UDP TCP ...(N个网络)
└┬┘
KCP
优化和新增
- 支持配置多个TCP/UDP底层网络服务
- 支持TCP和UDP通道切换
- 支持自定义配置底层网络的Netty参数
- 支持添加底层网络的自定义Handler
- 支持自定义编解码
- 支持切换KCP下层的网络
- 支持强制使用某一个网络发送数据
- 支持使用自定义时间服务(可以不用System.currentTimeMillis方法而使用自己系统的缓存时间系统)
五、为什么要使用多网络
根据原作者对KCP的使用建议(https://github.com/skywind3000/kcp/wiki/Cooperate-With-Tcp-Server)
实际使用中,最好是通过TCP和UDP结合的方式使用:
- 中国网络情况特殊,可能出现UDP包被防火墙拦下
- TCP网络在使用LB的情况下,两端中的一端可能出现感知不到对方断开的情况
- 可通过TCP的可靠连接作为备用线路,UDP不通的情况下可使用备用TCP
结合以上需求,这套开源库的目的就是整合TCP和UDP网络到同一套KCP机制中,甚至可以支持启动多TCP多UDP服务。
并且最大程度的开放底层Netty配置权限,用户可根据自己的需求定制化自己的网络框架
欢迎大家使用,有任何bug以及优化需求,欢迎提issue讨论
六、快速开始
好了,废话不多说了,我们直接上手看看它怎么使用吧
maven地址
<dependency>
<groupId>io.github.hjcenry</groupId>
<artifactId>ktucp-net</artifactId>
<version>1.1</version>
</dependency>
服务端
1. 创建ChannelConfig
ChannelConfig channelConfig = new ChannelConfig();
channelConfig.nodelay(true, 40, 2, true);
channelConfig.setSndWnd(512);
channelConfig.setRcvWnd(512);
channelConfig.setMtu(512);
channelConfig.setTimeoutMillis(10000);
channelConfig.setUseConvChannel(true);
// 这里可以配置大部分的参数
// ...
2. 创建KtucpListener监听网络事件
KtucpListener ktucpListener = new KtucpListener() {
@Override
public void onConnected(int netId, Uktucp uktucp) {
System.out.println("onConnected:" + uktucp);
}
@Override
public void handleReceive(Object object, Uktucp uktucp) throws Exception {
System.out.println("handleReceive:" + uktucp);
ByteBuf byteBuf = (ByteBuf) object;
// TODO read byteBuf
}
@Override
public void handleException(Throwable ex, Uktucp uktucp) {
System.out.println("handleException:" + uktucp);
ex.printStackTrace();
}
@Override
public void handleClose(Uktucp uktucp) {
System.out.println("handleClose:" + uktucp);
System.out.println("snmp:" + uktucp.getSnmp());
}
@Override
public void handleIdleTimeout(Uktucp uktucp) {
System.out.println("handleIdleTimeout:" + uktucp);
}
};
3. 创建并启动KtcupServer
KtucpServer ktucpServer = new KtucpServer();
// 默认启动一个UDP端口
ktucpServer.init(ktucpListener, channelConfig, 8888);
4. 观察日志
[main] INFO com.hjcenry.log.KtucpLog - KtucpServer Start :
===========================================================
TcpNetServer{bindPort=8888, bossGroup.num=1, ioGroup.num=8}
UdpNetServer{bindPort=8888, bossGroup.num=8, ioGroup.num=0}
===========================================================
客户端
1. 创建ChannelConfig
ChannelConfig channelConfig = new ChannelConfig();
// 客户端比服务端多一个设置convId
channelConfig.setConv(1);
channelConfig.nodelay(true, 40, 2, true);
channelConfig.setSndWnd(512);
channelConfig.setRcvWnd(512);
channelConfig.setMtu(512);
channelConfig.setTimeoutMillis(10000);
channelConfig.setUseConvChannel(true);
// 这里可以配置大部分的参数
// ...
2. 创建KtucpListener监听网络事件
KtucpListener ktucpListener = new KtucpListener() {
@Override
public void onConnected(int netId, Uktucp uktucp) {
System.out.println("onConnected:" + uktucp);
}
@Override
public void handleReceive(Object object, Uktucp uktucp) throws Exception {
System.out.println("handleReceive:" + uktucp);
ByteBuf byteBuf = (ByteBuf) object;
// TODO read byteBuf
}
@Override
public void handleException(Throwable ex, Uktucp uktucp) {
System.out.println("handleException:" + uktucp);
ex.printStackTrace();
}
@Override
public void handleClose(Uktucp uktucp) {
System.out.println("handleClose:" + uktucp);
System.out.println("snmp:" + uktucp.getSnmp());
}
@Override
public void handleIdleTimeout(Uktucp uktucp) {
System.out.println("handleIdleTimeout:" + uktucp);
}
};
3. 创建并启动KtcupClient
// 默认启动一个UDP端口
KtucpClient ktucpClient = new KtucpClient();
ktucpClient.init(ktucpListener, channelConfig, new InetSocketAddress("127.0.0.1", 8888));
4. 观察日志
[main] INFO com.hjcenry.log.KtucpLog - KtucpClient Connect :
===========================================================
TcpNetClient{connect= local:null -> remote:/127.0.0.1:8888, ioGroup.num=8}
UdpNetClient{connect= local:null -> remote:/127.0.0.1:8888, ioGroup.num=0}
===========================================================
以上是简单的示例,可快速启动ktucp服务和客户端。关于多网络的详细使用方法,可参考下面的例子3和4
七、使用注意
- 客户端对应实现:该框架仅实现了Java版本,其他版本的客户端需要根据此通信架构进行实现(单纯使用UDP通道的话,也是能和原版KCP兼容的)
- convId的唯一性:因不能校验udp的address或tcp的channel,只能依靠convId获取唯一Uktucp对象
- convId的有效性校验:需要判断convId的来源,防止伪造。因convId从消息包读取,框架底层对TCP连接的消息包做了Channel唯一性判断处理,但UDP暂时没有好的判断方法。如果有安全性需求,应用层需要自己做一个防伪检测,比如服务端给客户端分配一个token,客户端在每个消息包头把token带过来,服务端对每个包头的token做一个校验
- 处理好多网络连接管理:因底层配置较为开放,默认为KCP超时即断开所有连接,如有其他配置,请注意连接释放时机
八、使用方法以及例子
有一部分直接引用原作者的例子
九、相关资料
- https://github.com/skywind3000/kcp 原版c版本的kcp
- https://github.com/xtaci/kcp-go go版本kcp,有大量优化
- https://github.com/Backblaze/JavaReedSolomon java版本fec
- https://github.com/LMAX-Exchange/disruptor 高性能的线程间消息传递库
- https://github.com/JCTools/JCTools 高性能并发库
- https://github.com/szhnet/kcp-netty java版本的一个kcp
- https://github.com/l42111996/csharp-kcp 基于dotNetty的c#版本kcp,完美兼容
- https://github.com/l42111996/java-Kcp.git 此开源库的原版本
十、后续计划
篇幅有些过长了,剩余内容列入计划中
- github中写一份详细使用wiki文档,把所有可配参数以及调用接口以文档形势呈现
- 以这套框架为基础,实现一些网络相关例子,如rpc调用、游戏服务器等
- 有时间的话,可以考虑实现其他语言版本的客户端(或者看看大家有没有这方面的需求)
- 大家对于这套框架有没有什么更好的想法或者优化方案,欢迎提意见或加入一起开发
微信:hjcenry 欢迎交流讨论