● 时隔一年的项目刨坑解惑

缘由

因为最近工作量不是很大,所以开始对以前的项目进行优化升级,比如:「公司核心系统内存泄露排查」和一个本地备份工具(本地拖回服务器的备份进行再次备份),下图所示:

IMG_1658

完成这些之后,又是一阵空虚。突然想起2021年春节后,公司做了一个xxx小车项目,当时遇到一个问题一直困扰着我。直到拿到公司一块RK3568开发板做测试,才解开这一年的疑惑(其实也应该怪自己抠门,贪便宜买了树莓派ZERO W的板子,没买带网口的树莓派4B)。

上述xxx小车项目的硬件设备使用移远EC200S-CN模块,在离线后会出现两种情况:

  • 原先与服务器建立的TCP连接未断开的情况下,又与服务器建立了另一个TCP连接(IP相同端口不同,或者IP、端口都不相同);
  • 使用原先建立的TCP连接继续通讯。

出现第1种情况的话,服务器会缓存很多未及时释放的TCP连接,为了节约TCP资源,我加了如下判断:如果设备未在300秒(5分钟)内发送心跳包,会自动断开与设备的连接。但是设备离线后重现上线,压根不知道自己被断开了连接,继续使用原先的TCP通道发送数据,结果设备以为自己发了数据服务器却不返回。所以又改为了心跳超时判断阈值为900秒(15分钟)。

简单点说:当时的解决方案就是延长心跳时长,服务器晚点断开TCP连接,防止设备离线重新上线后找不到原先的TCP通道。

硬件测试

跟同事使用开发板,做了两个测试:

  • 拔掉移动网络天线,甚至拔掉SIM卡,服务器未收到断开通知;
  • 拔掉开发板供电电源,服务器未收到断开通知;

开发板如下图:

软件测试

使用TCP调试助手模拟开发板,做了三个测试:

  • 调试助手关闭端口,模拟开发板主动断开TCP连接,毫无疑问服务器收到了断开通知;
  • 调试助手不关闭端口,直接正常关闭软件,服务器收到了断开通知(这个测试不严谨,因为软件有可能会在关闭时做事件处理);
  • 强制结束调试助手进程,服务器收到了断开通知。如下图所示:

WX20220513-102756

软硬件模拟结果完全让人摸不着头脑。在未深入了解硬件及嵌入式程序源码的情况下,这事虽然通过修改心跳阈值解决,但是具体原因不明,只能搁置。

解惑准备

昨天我拿到了一块Android/Linux开发板RK3568,关闭了WIFI,插入了网线,实物图如下: IMG_1651

板子原载系统是Android 12,在公司挂了一夜百度网盘,刷入了Debian(没装Ubuntu的原因是因为镜像5G,而Debian只有3.5G,百度网盘拖下来能快点),如下图: IMG_1653

自带的python3的版本为3.7.2,足够用。服务端使用之前的项目去掉心跳包踢除限制,再写一个简单的tcpclient.py:

import socket

client = socket.socket()
client.bind(('', 1234))
client.connect(('192.168.0.2',8899))

while True:
    msg = input(">>:").strip()
    if len(msg) == 0 :continue
    client.send(msg.encode())
    data = client.recv(1024)
    print("-> Server Response:",data.decode())

解惑开始

服务器IP:192.168.0.2,TCP Server端口:8899;
客户端IP:192.168.0.127,TCP Client端口:1234(随机端口号会建立新连接,可以用来模拟断电后上电)。

准备做3个测试:

  1. 断开网线后,开发板向服务端发送一条数据,观察多久断开连接;
  2. 断开网线后,开发板向服务端发送一条数据,过几秒后重新插入网线,观察是否会断开连接;
  3. 断开网线后,服务端和开发板不收发任何数据,观察多久断开连接;

1. 断开网线后,开发板向服务端发送一条数据,观察多久断开连接

在断开网线后,在服务器使用命令netstat -nap tcp | grep tcp4 | grep 192.168.0.127查看,TCP连接还是处于ESTABLISHED状态,说明断开网线,不会立即断开TCP连接。

WX20220513-113311

大约15分钟,开发板TCP断开连接,如下图所示: IMG_1864

提示Network is unreachable,网络无法到达。我搜了下:

stackoverflow的这个问题 聊到了TCP的重试机制:

关于超时重传次数,以下是Linux文档中的描述:

tcp_retries1:
在连接建立过程中(未激活的sock),除了上面的情况以外,内核要重试多少次后才决定放弃连接。

tcp_retries2:
在通讯过程中(已激活的sock),数据包发送失败后,内核要重试发送多少次后才决定放弃连接。

显然,我遇到的问题是tcp_retries2的情况,我查了下系统默认的重试次数:

IMG_1864

tcp_retries2 的默认值是 15,这个重试次数的耗时大约是13~30分钟,这只是一个大概值,最终耗时时间还要取决于RTO(retransmission timeout,重传超时时间)。

收获及结论:默认情况(未修改tcp_retries2)下,linux嵌入式设备与服务器心跳包的最大超时阈值应控制在10分钟左右。单片机另谈。

2. 断开网线后,开发板向服务端发送一条数据,过几秒后重新插入网线,观察是否会断开连接

在经历了第1个测试后,这个测试可以得出很肯定的答案,开发板会在一段间隔后,使用原tcp通道继续重传发送数据。超时时长间隔如下所示:

3. 断开网线后,服务端和开发板不收发任何数据,观察多久断开连接

没有做实验,继续搜索相关资料,在stackoverflow找到两篇文章:「When is a TCP connection considered idle?」和「timeout in a TCP connection with no exchange of data

这两篇文章的矛头都指向了net.ipv4.tcp_keepalive_time

在开发板运行sysctl -a | grep net.ipv4 | grep keepalive,可以看到keepalive参数配置: IMG_1870

net.ipv4.tcp_keepalive_intvl = 75
net.ipv4.tcp_keepalive_probes = 9
net.ipv4.tcp_keepalive_time = 7200

# tcp_keepalive_time=7200:表示保活时间是7200秒(2小时),也就2小时内如果没有任何连接相关的活动,则会启动保活机制;

# tcp_keepalive_intvl=75:表示每次检测间隔75秒;

# tcp_keepalive_probes=9:表示检测9次无响应,认为对方是不可达的,从而中断本次的连接。

根据上面的参数可以算出一个TCP连接最大空闲时间 = 7200+75*9=7875秒。

收获及结论:默认情况(上图配置)下,linux嵌入式设备与服务器停止收发数据后,会在大约2小时候断开连接。如果大量「设备」(这里的「设备」可能是真实设备,也有可能是黑客伪装的连接)只连接不收发数据,会给服务器造成严重负担,所以要及时踢掉僵尸设备。单片机另谈。

测试总结

默认配置情况下,linux嵌入式设备断开网线后,如果没有任何数据传输,2小时内TCP状态并不会改变,会一直维持连接状态;如果有数据传输,应按最大13分钟断开连接处理。

最后的查根溯源

最后回到单片机硬件设备上,既然是设备离线,那就是跟keepalive有关系,继续搜索资料:

找到了Quectel论坛的一份PDF文档,里面有关于设置keepalive时长的指令:

这样服务端和单片机设备都可以设置keepalive时长。如果设置相同时长,不仅可以避免长连接浪费,也可以一定程度筛选异常连接(比如检测黑客伪造TCP连接进行DDOS攻击)。

至此,一年以来被困扰的疑问已经解决。

参考资料:
「LTE Standard TCP/IP Application Note - Quectel Forums」
「TCP-聊一聊重传次数」