断线重连与重登陆

背景

先设想以下几种常见的会导致移动设备断线的场景:

  • 手机4G切换到了Wi-Fi
  • 使用手机时由于移动切换了基站
  • 内网路由器的NAT映射表刷新了

诸如此类的情景,其实本质上都可以归纳为一个问题:你正在使用的设备的外网IP地址突然变了。对TCP协议而言,其标识连接的方式是通过一个包含源IP地址,源IP端口,目的IP地址,目的IP端口的4元组来实现的。如果我们在应用程序中只根据TCP的连接来区分用户,那么在出现上述情况时必然会产生的一个问题就是来自同一个用户新老连接的会产生冲突。如果放任不处理,用户就会出现明明网络是好的,但是自己却与服务器断开了连接的情况。这无疑是用户体验极差的,甚至会产生一种这样的印象:这破应用服务器怎么这么差。比较好的做法是自动帮同一个用户把老的连接关掉,数据传输移植到新的连接上,用户可能会感受到轻微的延迟/卡顿,但是不会出现直接断线的情况。这个问题Google称之为Connection Migration即连接迁移。

进一步分析

解决这个问题首先需要了解一下IP地址的改变会产生什么影响,前面提到过TCP是以IP地址+端口号为标识来区分不同连接的数据的。更深入一点分析,当准备建立一条TCP连接的时候,本质上是客户端发送一个特殊的包给服务端,然后服务端根据这个特殊的包的信息可以知道是来自xx地址xx端口的一个客户端请求建立连接,一旦接受,在服务端这里就会生成一个专门为这个客户端服务的一条连接,这之后双方就可以通过这条连接来通讯了,绝大部分使用TCP的应用都会把这条网络连接进行层层的封装来达到给上层调用进行收发数据的目的。当一方的IP地址发生改变,显而易见,这条TCP连接就失效了,服务端无法再接收到任何消息,客户端发送出去的消息也杳无音讯。不难想到如果不使用这种IP地址+端口的四元组作为连接的唯一标识不是就可以无视IP地址的变动了吗?答案确实如此,如果你使用的是TCP协议,那么你可以参考MPTCP( https://en.wikipedia.org/wiki/Multipath_TCP )的实现,不过需要注意的是MPTCP没有办法完全解决上述问题(如手机仅有4G且切换了基站),MPTCP本质上是提前建立多条平行的连接,然后利用多条连接来最大程度减小某种渠道连接失败带来的影响。但是如果你使用的是UDP协议,可操作性就很多了。由于UDP本身是没有连接概念的,大部分使用UDP协议的应用都会封装出一套类似TCP连接相关接口的,并且通过读UDP包头的一些数据或者干脆还是用源的IP地址+端口来区分来源,所以完全可以在包头添加一个自己管理的16/32/64位的ID作为连接的唯一标识,这样就完全不依赖IP地址来区分数据来源了,自然也就不会被IP地址变动所影响。

更具体的一些细节

说起来容易做起来难,真正尝试在UDP网路库里面加这个特性的时候,倒也遇到了一些问题和产生了一些思考。

  • IP变动的感知:尽管我们不再依赖IP地址+端口来区分来源了,但是我们还是需要知晓同一个connection id的IP变动这一事情的,所以对于数据包的来源IP地址和端口还是要记录一下,并且最好有显式的通知给上层。
  • 旧连接状态的迁移:使用UDP构建网络库的时候由于服务端需要自己在底层分包,所以即使IP切换了,也不会对数据的接受产生影响,在服务端的角度看上去就是收到了一个数据包,它的唯一标识connection id是一个已知的,只不过对应的IP地址改变了。那么在服务端只需要将这个数据包继续抛给上层对应的connection id的连接处理就行了。而在客户端,IP地址切换可能导致你调用的socket相关API报错(也可能没有,视你使用的底层库而定)这时候处理好报错,恢复正常发送流程也就可以了。这里需要注意一点,抽象分层做的好的话,UDP网络库里面一些有状态的层如可靠重传层,加密层等这些应该是和底层真正用来发送数据的socket层相互分开的,所以本质上IP地址的变化不会对连接状态产生太大影响。
    如果使用的是TCP协议,则需要提前记录下来两端的收发数据状态,重连建立新的连接的时候有一个重新握手的流程,让两端交换一下旧连接没有收发完的数据。
  • 连接唯一标识(ID)的管理:理论上来说为了防止碰撞最好让ID尽可能的长(如64位),但是如果实在不放心客户端自动随机生成的话,可以考虑使用服务端生成。因为这样的话就可以保证唯一,但是坏处就是建立连接的步骤和耗时会变多。对于一些并发量比较高比较看中延时的且以短连接为主网页应用,可能这样不是非常适合。但是以长连接为主的游戏应用,其实不是特别在意建立连接时多耗一些时间。
  • 预防恶意攻击:这点也是绝大部分网络库最头疼的一点,或者说在网络库里我觉得其实根本没有最好的处理办法。以最无脑的DDoS攻击为例,在你网络库还没有开始调用系统调用读取数据的时候,其实已经就被攻击了,因为人家攻击的根本就是不是你这个维度的东西。分布式的DDos完全可以在带宽,CPU资源这层对服务端进行攻击。那么再退一步说,对于一些伪装包、篡改包的攻击,还是可以进行防御的。数据包中加上签名就可以在绝大部分情况下防止中间人篡改数据。防止中间人读取数据的话也可以加上加密。对于那种强行获取到了别人的connection id,然后开始伪装成别人的发包,可以再建立连接的时候使用DH秘钥交换之类的算法,这样可以得到一个只有双方知道的秘钥,再用这个秘钥加密就可以保证中间人无法伪装成别人发包。如果客户端被完全破解了,或者整个网路库的代码被破解了,攻击者可以完全按照合法的客户端登录流程一步步发送合法的数据包给服务端这种情况,目前我也没有想到特别好的办法。。。但是其实换个角度想,这种其实对于网络层来说已经是无法辨别真伪的数据包了,只有在业务逻辑层加上限制,才能检测到是否非法(如果这个伪装的客户端没有触发任何限制呢?那就只能当做正常用户了)。

有感知&无感知的断线重连

一般来说,我们通常会把应用层和网络传输层分开,中间用接口的方式实现解耦。因此上层应用只需要看到一些send/recv/connect的接口就可以了,对于底下网络层是怎样实现数据传输的并则不需要关心。如果我们在网络层内部实现一些自动重连的机制,就可以对上层不暴露断线这件事情。当底层的断线发生时,上层应用仍然可以正常的使用下层提供的send/recv接口,只是可能会感受到微弱的延时而已(当然这取决于你重连的耗时),这种情况下由于应用层是无感知的,所以我们可以称之为无感知(对上层应用而言)的断线重连。

但是网络传输层也不是万能的,如果遇到了底层的重连无法恢复的情况,这时候就只能把断线这件事情告诉上层应用了。一般来说,这时候上层应用可以返回登录界面让用户重新登录。如果想做的用户体验好一点,重新登陆以后可以自动帮用户恢复到上次断线前的状态,诸如重连回到上次的房间,位置,进行中的游戏等。

实现一个底层的断线重连

虽然上层应用处理断线重连时可能会根据业务的不同而有不用的变化,但是在底下的网络传输层内部,需要做的事情缺十分相似,建立好新的连接,将双方未送达的数据恢复。考虑到无论是使用TCP协议还是自己定义的可靠UDP类协议,都存在断线的可能性,因此,我们可以考虑在连接之上再封装一层session层,session层可以感知到下层持有连接的断线情况(通过心跳等方式实现感知),并且在断线发生时立即重新建立连接。此外,对上层应用暴露出来的send/recv接口并不是直接与内核的socket相关api通讯,而是在session内部稍稍缓存。一来这样在session尝试重连的时候,上层应用仍然可以继续调用send/recv接口。二来为了保证数据的完整性,在session内部也可以记录收发过的数据包,这样重新建立好最底层的连接之后可以继续收发之前被截断的数据。

一个简单的session实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Session
{
private Connection conn; //真正的底层连接,可被替换
private SendRecvState state; //记录收发数据状态的类
private Task keepAliveLoop; //检测连接状态的task

public void Connect()
{
//do connect
}

public void Send(byte[] data)
{
//do send
}

public byte[] Recv()
{
//do recv
}

private bool reconnect()
{
//do reconnect
}
}

一点总结

断线重连的解决方案,说白了就是用新的连接替换已经失效的老的连接。在这个替换过程中,对原来连接的一些读写的状态需要恢复,从而保证上层应用的数据的完整性。另外在使用一些网络协议的时候,如何检测到断线也是一个很重要的问题,以TCP为例(RFC里及其难用的TCP-Keep—Alive就算了),如果用户不主动收发数据,是不知道这条连接是否还健在的,所以一个定时的心跳检测也是必须的。所以一个完整的断线重连流程总结起来就是:侦测断线–>重新连接–>恢复状态

Author

John Doe

Posted on

2018-12-20

Updated on

2021-02-07

Licensed under

You need to set install_url to use ShareThis. Please set it in _config.yml.

Comments

You forgot to set the shortname for Disqus. Please set it in _config.yml.