1

事情的起因是我的一个同学让我帮他看一个问题,当时的描述是: 服务器在请求较多时,出现不响应的情况。
现场环境是

  • 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)
...

至此,我们看到了两个疑点,

  1. 为什么有这么多CLOSE_WAIT状态的连接
  2. 为什么所有的处理线程都在等待写的方法上

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之后,疑点:

  1. 很明显的服务端和客户端一直在互相发ACK包,
  2. 客户端的ack包里 win一直是0

所以win 代表什么意思,

tcp窗口

通过查看tcp的格式:


我们发现: 窗口大小 字段占了16位,指明TCP接收方缓冲区的长度,以字节为单位,最大长度是65535字节,0指明发送方应停止发送,因为接收方的TCP的缓冲区已满,

问题总结

所以主要的问题是这个网络代理frp 有bug, 或者是部署这个服务的机器太垃圾,导致服务器一直阻塞在写方法上无法继续。

java socket的关闭操作对应的tcp行为

这部分我觉得是一个很重要的,且以前都没有被我注意过的点,当然我也没什么机会做socket编程,查看了之后总结起来如下:

  1. close() 方法会关闭读写操作,已经在内核send-q的数据会尝试发送完,之后再发FIN包。对端接收FIN之后,read会读到-1 。 如果设置了setSoLinger()配置,那么在尝试发送send-q数据时如果超时,则会发RST包直接关闭连接。如果对端write,则已经close()的端会发RST包,在对端第二次write时会报错SocketException。
  2. shutdownOutput() 会发FIN包,且停止写操作 ,但还可以读
  3. 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 ,他会通知应用层停止这个流。
至此所有的疑惑都解除了。

最后总结

当我在解决问题的时候,才会明白自己到底有多菜。


没有感情的杀手
321 声望7 粉丝

搬砖工程师