现象
设备通过http接口下载文件,下载失败。
客户设备在自己搭载的环境下载不成功,下载进度到26%左右就失败
测试侧可以概率性复现出此场景。
- 部分设备会出现问题
- 小固件不会出现问题
注:下载文件的逻辑是从本地文件读取,通过文件流传输出去,很简单的代码。
定位问题
在日志上寻找出相关错误信息
从日志上可以看出,堆栈跟踪抛出了java.net.SocketTimeout异常,通过对异常信息的字面理解,可以看出是连接超时导致的异常。
根据堆栈信息可以看出其是在做writeBytes 时出现的超时异常,出现了写入超时。 定位接口以及相关方法
https://xxx/storage/download?id=a115a0b4fd3e4dd0b2983ee616f8c4be
通过浏览器直接访问即可下载,由此判断接口无问题。
在单独定位接口无误后,可以判断出现 超时(SocketTimeoutException) 的场景只能和 设备交互的环境中排查。
但是具体为什么会出现写超时的场景,回忆以往处理的bug经历,应该没遇到过。
由于这个属于这次的版本才出现的bug,需要先验证下别人是否有修改过相关的代码,毕竟此问题一看就不是个几个小时内能排查出来的bug。
-
查找相关代码历史修改记录:没内容
-
询问同事:询问了测试以及同事有没有相关的问题/技术支持。并没有。
由于此问题比较难见,并且能提供的相关信息较少,即使把单纯的报错去网上查找个半天啥也查不到。
目前来看只能通过抓包复现问题
抓包复现
通过和设备的测试同事千辛万苦复现出了错误场景,并且导出了出现此问题的时间段抓出的包。
分析包
注:
- HTTP请求后,两台计算机交互的request和response,会由TCP操作。其信息传递会比单纯的传递request和response要复杂。通过TCP建立的通讯往往需要计算机之间多次的交换信息才能完成一次request或response。
- RST:RST表示复位,TCP中用来异常的关闭连接。发送RST包关闭连接时,不需要等缓冲区的包都发出去(不像FIN包),直接就丢弃缓存区的包发送RST包。而接收端收到RST包后,也不必发送ACK包来确认。
所以如抓包的上图所示,在服务端传输固件数据给终端时:是不正常的接收,返回消息。而是到处出现TCP window full信息
以及最终能看到服务端(10.200.110.51)下发了 RST。
从这里看出来,是服务端导致出了一些问题,由我们服务端主动关闭链路。
通过对整个包分析,又看到了另一个奇怪的内容
可以看出终端在我们固件下载一半后,主动向我们发了一堆rst。
同时服务端日志中打印了
从这里看出来,上图可以看出设备主动关闭了连接。此问题是来源于设备:为何会主动关闭链路?
不过在目前看来,这是一个新的报错,
1.出问题的是jav.net.SocketTimeoutException:连接超时
2.此次报错是IOException:Brokenpipe:主动断开
分析相关的资料
此次需要捋清几个问题
- 设备下载固件的逻辑(排查的优先级最高)
通过和设备几个逻辑层的开发讨论后,得到的结论:
部分设备机型(除了现在现在较新的)因为硬件内存限制的原因,会边安装边下载。
在下载包的时候,如果是在检测到不是可以升级的版本等问题会断开下载。
- 什么样的场景会出现TCP window full/zero window(抓包中出现的问题)
网上资料中:
TCP window full, 是指的发送端发送的数据已经达到的接受窗口的上限。 继而停止发送,等待新的接收窗口的通告。
TCP中获得Zero窗口的原因是因为接收器的recv缓冲区没有空间。 有很多种方法可以实现。此问题的一个常见原因是当您通过LAN或其他相对较快的网络连接发送时,一台计算机明显快于另一台计算机。作为一个极端的例子,假设你有一台3Ghz计算机通过千兆以太网尽快发送到运行1Ghz cpu的另一台机器。由于发送方的发送速度比接收方能够读取的速度快得多,因此接收方的recv缓冲区将填满,导致TCP堆栈向发送方通告零窗口
ZeroWindow, 当发送方的发送速度大于接收方的处理速度,接收方的缓冲塞满后,就会告诉发送方当前窗口size=0,请停止发送,发送方此时停止发送数据。
结论
最终总结出的结论是:服务器的发送速度 快于终端 收的速度
对于经验而言,根据个人经验,控制服务器 request/response 速率的地方有主要几处:
-
nginx:如配置缓存区大小,配置传输速度等
-
带宽:TCP拥塞协议,滑动窗口会根据当前带宽,动态控制窗口的大小
-
TCP缓冲区大小:服务器可以设置默认socket缓冲区大小
解决
排查nginx,询问运维是否修改过配置
通过层层过滤查找/询问历史修改记录,这里得知此次版本新加上了这几个配置。
proxy_buffering off;
proxy_buffer_size 128k;
proxy_buffers 100 128k;
所以找到了这次出问题的根因,proxy_buffering被关闭了,tomcat线程发送的信息不会通过nginx的缓冲区转发,而是直接秒传给终端。
让测试去除这些参数后已经不会再出现。
终端为什么会发RST /服务端日志打印的IOException:Brokenpipe异常含义 (抓包中出现的问题)
由设备下载固件的逻辑,回归日志寻找设备当时下载的文件,发现版本不匹配,所以下载了一半断开了连接。
IOException:Brokenpipe
某个客户端突然中止了进程导致了该错误
终端断开了连接,所以打印了该异常
服务端为什么会发RST/服务端日志jav.net.SocketTimeoutException异常含义 (此次问题异常的问题)
剩下最后一个问题,虽然根据TCP window full 已经定位出问题所在了。但是并没有定位出服务器异常的真正问题
jav.net.SocketTimeoutException在网页上查询的资料很少(StackOverflow,百度等),基本出问题的不在服务端,都在客户端调用http请求时才出现。
通过网上资料查询找不到时,阅读源码才是最有效的方法
阅读源码
回到日志输出错误的堆栈
可以看到虽然出现了ClientAbortException,但是并没有抛出我们要的SocketTimeoutException异常。
证明异常在doWrite里面抛出。
进入doWrite后,发现他有几百个实现,并且实现里面还有很多个接口。很难找到具体是在那个类具体实现。
用debug的方式,一层一层进去看,或者可以直接在异常上面打断点找到上级调用。
最后找到是这个实现类。
通过最后的解读,可以看到其异常是由writeTimeout控制的
其值的引用和赋值便是:
我们配置的connect-timeout便是这个writeTimeout的值。
从上放的源码中可以得出,我们Tomcat线程和设备8秒未进行通信(或者什么场景),主动关闭了链路(发送RST),抛出了SocketTimeoutException。
回到抓包中的场景,TCP window full/zero window。
分析相关资料:
TCP坚持定时器(blog.csdn.net/zhuaimiao1/…)
当零窗口出现后,如何继续数据的传输呢。
假设:接收方窗口更新为非0后,发送ACK给发送方,更新window的size,是一种可行的方式。
但是如果这个ACK丢掉了(TCP不会给ACK回复ACK),那么发送方和接收方就会处于一种尴尬的局面,一个等着发,一个等着收,直到超时。
此时TCP中加入了坚持定时器的概念,由发送方主动创建,持续不断地询问接收方窗口是否更新,这个询问被称为窗口探测(window probes)。
坚持状态与重传超时之间一个不同的特点就是TCP从不放弃发送窗口探查。
这些探查每隔60秒发送一次,这个过程将持续到窗口打开或者应用进程使用的连接被终止。
所以这个问题中,TCP坚持定时器没有时间限制,意味着这个TCP连接会永远保持下去。
但是对于tomcat中,创建的其长连接,无根据坚持定时器的逻辑 ,只是根据connectionTime的设置值,而关闭了链路。
模拟异常场景
编写代码,使用socket的方式,开启keepalive 下载http请求,并且在读取文件流的时候休眠
public static void main(String[] args)
throws Exception {
String destLoc = "E:/test";
InetAddress addr = InetAddress.getByName("10.200.110.171");
String ip = addr.getHostAddress();
int bias = 0;
String encoding = "utf-8";
Socket socket = new Socket(ip, 9989);
InputStream is = socket.getInputStream();
OutputStream os = socket.getOutputStream();
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(os, encoding));
writer = new BufferedWriter(new OutputStreamWriter(os, encoding));
writer.write("GET " + "/storage/download?id=b4f34e4119c9dcc7e19e3de48ad" + " HTTP/1.1\r\n");
writer.write("Host: " + "10.110.110.110" + "\r\n");
writer.write("Connection: keep-alive\r\n");
writer.write("\r\n");
writer.flush();
is.skip(bias+2);
String fname = "123";
FileOutputStream fos = new FileOutputStream(destLoc + fname );
byte[] buf = new byte[1024];
int len;
while((len=is.read(buf)) != -1) {
Thread.sleep(1000000000);
fos.write(buf, 0, len);
}
fos.close();
}
复现:
所以得到结论:
Tomcat对 TCP坚持定时器没有做处理逻辑,只根据自身的connectionTimout决定是否关闭链路。