事情的起因是我的一个同学让我帮他看一个问题,当时的描述是: 服务器在请求较多时,出现不响应的情况。
现场环境是
- os: CentOS Linux release 7.6.1810 (Core)
- web server: undertow v2.0.20.Final
- springBootVersion: 2.1.5.RELEASE
- 一个内网穿透工具 frp
网络拓扑大概是 client->内网穿透frp->httpServer 的样子
前置条件就是这样。
尝试看网络状态和线程状态
netstat
首先 netstat na|grep port 看了下网络状态,并用awk统计了下连接的状态:
CLOSE_WAIT 613
ESTABLISHED 53
TIME_WAIT 17
netstat result like :
[root@localhost backend]# netstat -nalt |grep 8077
tcp6 0 0 :::8077 :::* LISTEN
tcp6 1 174696 192.168.2.195:8077 172.16.1.10:49588 CLOSE_WAIT
tcp6 1 188280 192.168.2.195:8077 172.16.1.10:49576 CLOSE_WAIT
...
jstatck
underTow 的http处理线程总共32个 且都处于下面的状态 :
"XNIO-1 task-32" #69 prio=5 os_prio=0 tid=0x0000000001c8e800 nid=0x1e10 runnable [0x00007f0a20dc1000]
java.lang.Thread.State: RUNNABLE
at sun.nio.ch.PollArrayWrapper.poll0(Native Method)
at sun.nio.ch.PollArrayWrapper.poll(PollArrayWrapper.java:115)
at sun.nio.ch.PollSelectorImpl.doSelect(PollSelectorImpl.java:87)
at sun.nio.ch.SelectorImpl.lockAndDoSelect(SelectorImpl.java:86)
- locked <0x00000006cf91e858> (a sun.nio.ch.Util$3)
- locked <0x00000006cf91e848> (a java.util.Collections$UnmodifiableSet)
- locked <0x00000006cf91e5f0> (a sun.nio.ch.PollSelectorImpl)
at sun.nio.ch.SelectorImpl.select(SelectorImpl.java:97)
at sun.nio.ch.SelectorImpl.select(SelectorImpl.java:101)
at org.xnio.nio.SelectorUtils.await(SelectorUtils.java:46)
at org.xnio.nio.NioSocketConduit.awaitWritable(NioSocketConduit.java:263)
at org.xnio.conduits.AbstractSinkConduit.awaitWritable(AbstractSinkConduit.java:66)
at io.undertow.conduits.ChunkedStreamSinkConduit.awaitWritable(ChunkedStreamSinkConduit.java:379)
at org.xnio.conduits.ConduitStreamSinkChannel.awaitWritable(ConduitStreamSinkChannel.java:134)
at io.undertow.channels.DetachableStreamSinkChannel.awaitWritable(DetachableStreamSinkChannel.java:87)
at io.undertow.server.HttpServerExchange$WriteDispatchChannel.awaitWritable(HttpServerExchange.java:2039)
at io.undertow.servlet.spec.ServletOutputStreamImpl.writeBufferBlocking(ServletOutputStreamImpl.java:577)
at io.undertow.servlet.spec.ServletOutputStreamImpl.write(ServletOutputStreamImpl.java:150)
at org.springframework.security.web.util.OnCommittedResponseWrapper$SaveContextServletOutputStream.write(OnCommittedResponseWrapper.java:639)
at org.springframework.util.StreamUtils.copy(StreamUtils.java:143)
at com.berry.oss.service.impl.ObjectServiceImpl.handlerResponse(ObjectServiceImpl.java:732)
...
至此,我们看到了两个疑点,
- 为什么有这么多CLOSE_WAIT状态的连接
- 为什么所有的处理线程都在等待写的方法上
CLOSE_WAIT
以前在cjh的产线问题中见过TIME_WAIT 太多的情况,CLOSE_WAIT 太多的情况我没见过,所以又回顾了一遍TCP的握手挥手,如图:
所以 CLOSE_WAIT 状态实际上是客户端发送了挥手的FIN之后的服务器的状态,此时客户端已经完成自己的 write操作,只会read服务端发来的数据,而等服务端发送完了,就会发自己的FIN包。
查了些资料之后,看到很多人CLOSE_WAIT的原因是因为 服务端有非常耗时的操作,导致会话超时,客户端发FIN,而服务端线程已经阻塞在操作上,导致服务端没法发FIN包。
所以我沿着jstack的线程栈看了下相关的方法,大致如下:
@GetMapping("/hello")
public void hello(HttpServletResponse response) throws IOException {
String path="C:\\Users\\Administrator\\Desktop\\over.mp4";
FileInputStream fileInputStream=new FileInputStream(path);
OutputStream outputStream=response.getOutputStream();
response.setContentType("video/mp4");
int byteCount = 0;
byte[] buffer = new byte[4096];
int bytesRead = -1;
while ((bytesRead = fileInputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
byteCount += bytesRead;
}
outputStream.flush();
}
只是一个http发送大文件视频的服务,没有什么耗时操作。所以到这我已经无计可施了。
tcpdump 抓包
因为判断问题出在tcp上,所以我们简单看了看tcpdump的抓包资料,然后决定抓包看下网络上到底发生了什么。抓到的大概信息如下:
可以看到在客户端发了 FIN之后,疑点:
- 很明显的服务端和客户端一直在互相发ACK包,
- 客户端的ack包里 win一直是0
所以win 代表什么意思,
tcp窗口
通过查看tcp的格式:
我们发现: 窗口大小 字段占了16位,指明TCP接收方缓冲区的长度,以字节为单位,最大长度是65535字节,0指明发送方应停止发送,因为接收方的TCP的缓冲区已满,
问题总结
所以主要的问题是这个网络代理frp 有bug, 或者是部署这个服务的机器太垃圾,导致服务器一直阻塞在写方法上无法继续。
java socket的关闭操作对应的tcp行为
这部分我觉得是一个很重要的,且以前都没有被我注意过的点,当然我也没什么机会做socket编程,查看了之后总结起来如下:
- close() 方法会关闭读写操作,已经在内核send-q的数据会尝试发送完,之后再发FIN包。对端接收FIN之后,read会读到-1 。 如果设置了setSoLinger()配置,那么在尝试发送send-q数据时如果超时,则会发RST包直接关闭连接。如果对端write,则已经close()的端会发RST包,在对端第二次write时会报错SocketException。
- shutdownOutput() 会发FIN包,且停止写操作 ,但还可以读
- shutdownInput() 停止读操作,对端的所有数据包都会ACK。
参考:
https://blog.csdn.net/zlfing/...
http2
至此已经定位了这个CLOSE_WAIT的问题所在,但我手贱又测了几次,发现了一个很奇怪的现象:我在服务端tcpdump,自己浏览器做client,视频点开之后直接关闭页面,一直抓不到FIN包,且连接也一直ESTABLISHED,服务器也已经报错java.nio.channels.ClosedChannelException且停止数据传输, 这让我很疑惑,如果连接一直存在,那是什么停止了服务端的传输。
在尝试了无数次之后,终于发现了一个疑点:
00:52:57.745946 IP 116.237.229.239.64575 > izuf6buyhgwtrvp2bv981yz.8077: Flags [P.], seq 1400:1442, ack 2399686, win 513, length 42
每次在我关闭页面之后,抓包总会抓到客户端发的一个42长度的数据报。P代表立刻上送到上层应用,无需等待。
所以看起来服务端停止传输数据是由于应用的行为导致,而并不由TCP控制,漫无目的的测了很久后终于 在浏览器的控制台,看到了协议是h2,代表了使用的是http2协议。
关于http2的详解:
https://blog.wangriyu.wang/20...
关于http2 网上已经很多资料,我只简单记录下关键点:
http2 多路复用
http1.1 已经出现了tcp连接的复用(keep-alived) ,但是一个http的状态总归还是由tcp的打开和关闭来掌管,且同一时刻tcp上只能存在一个http连接,即使使用了管道化技术,同时可以发很多个http请求,但服务器依旧是FIFO的策略进行处理并按顺序返回。
而在http2中客户端和服务器只需要一个tcp连接,每一个http被称作一个流,帧被当作一个http报文的单位,每一个帧会标明自己属于哪个流,且帧会分类型,来控制流的状态:
- HEADERS: 报头帧 (type=0x1),用来打开一个流或者携带一个首部块片段
- DATA: 数据帧 (type=0x0),装填主体信息,可以用一个或多个 DATA 帧来返回一个请求的响应主体
- PRIORITY: 优先级帧 (type=0x2),指定发送者建议的流优先级,可以在任何流状态下发送 PRIORITY 帧,包括空闲 (idle) 和关闭 (closed) 的流
- RST_STREAM: 流终止帧 (type=0x3),用来请求取消一个流,或者表示发生了一个错误,payload 带有一个 32 位无符号整数的错误码 (Error Codes),不能在处于空闲 (idle) 状态的流上发送 RST_STREAM 帧
- SETTINGS: 设置帧 (type=0x4),设置此 连接 的参数,作用于整个连接
- PUSH_PROMISE: 推送帧 (type=0x5),服务端推送,客户端可以返回一个 RST_STREAM 帧来选择拒绝推送的流
- PING: PING 帧 (type=0x6),判断一个空闲的连接是否仍然可用,也可以测量最小往返时间 (RTT)
- GOAWAY: GOWAY 帧 (type=0x7),用于发起关闭连接的请求,或者警示严重错误。GOAWAY 会停止接收新流,并且关闭连接前会处理完先前建立的流
- WINDOW_UPDATE: 窗口更新帧 (type=0x8),用于执行流量控制功能,可以作用在单独某个流上 (指定具体 Stream Identifier) 也可以作用整个连接 (Stream Identifier 为 0x0),只有 DATA 帧受流量控制影响。初始化流量窗口后,发送多少负载,流量窗口就减少多少,如果流量窗口不足就无法发送,WINDOW_UPDATE 帧可以增加流量窗口大小
- CONTINUATION: 延续帧 (type=0x9),用于继续传送首部块片段序列,见 首部的压缩与解压缩
如何从http升级到http2
这是我比较好奇的地方,服务端客户端究竟在哪里协商升级到http2。
参考:
https://imququ.com/post/proto...
简单来说就是 Google 在 SPDY 协议中开发了一个名为 NPN(Next Protocol Negotiation,下一代协议协商)的 TLS 扩展,在这个扩展中会进行协议选择,很幸运的是我在本地wireshark报文中找到了他:
Extension: application_layer_protocol_negotiation (len=14)
Type: application_layer_protocol_negotiation (16)
Length: 14
ALPN Extension Length: 12
ALPN Protocol
ALPN string length: 2
ALPN Next Protocol: h2
ALPN string length: 8
ALPN Next Protocol: http/1.1
可以看到 客户端传了 http1.1 h2给服务端 ,而服务端的握手返回:
Extension: application_layer_protocol_negotiation (len=5)
Type: application_layer_protocol_negotiation (16)
Length: 5
ALPN Extension Length: 3
ALPN Protocol
ALPN string length: 2
ALPN Next Protocol: h2
这就是http2的协商握手过程。
wireshark抓http2的包
我还需要确认一件事,就是 之前说的42长度的数据包到底是什么样子,但因为http2必须基于https,导致wireshark无法看到这个包内容,搜了下资料操作如下:
https://zhuanlan.zhihu.com/p/...
最终看到这个42长度的包 是:
HyperText Transfer Protocol 2
Stream: RST_STREAM, Stream ID: 3, Length 4
Length: 4
Type: RST_STREAM (3)
Flags: 0x00
0... .... .... .... .... .... .... .... = Reserved: 0x0
.000 0000 0000 0000 0000 0000 0000 0011 = Stream Identifier: 3
Error: CANCEL (8)
这个帧的状态是 RST_STREAM ,他会通知应用层停止这个流。
至此所有的疑惑都解除了。
最后总结
当我在解决问题的时候,才会明白自己到底有多菜。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。