0%

使用Netty构建TCP通讯中通讯错误问题的排查和详解

简介

最近上线的项目中使用Netty构建tcp服务去连接设备来收集设备运行的状态,在试运行一段时间后发现kibana中偶尔出现设备端发送了我们的数据,后端解析出错的问题,但是设备端又死活不承认他们的问题,无奈下本着做事要拿出证据的原则,这边被迫再次学习了TCP等知识来打他们的脸。

问题描述

我们和设备端定义的协议核心为传输的时候在前面带上一个2个字节的数据域来表示数据域的长度,后端收到数据再读取这个数据域的长度,再获取接下来数据载体的长度,并且将其读出,再运行一段时间后我们发现kibana中的日志经常报以下两个错误

错误:1

1
Connection reset by peer

错误:2

1
在解析协议时出错
问题分析

错误1:明显是由于连接被关闭而报出的,这个在现场是有可能的

错误2: 在解析协议时出错,服务端逻辑是应用层协议解析出错将关闭连接,所以接着日志引发错误1

那么我们排查问题的重点就放在为啥会出现解析错误。

问题解决
问题1,数据域长度问题

一开始我们我们查看代码就发现之前应用层定义的2个字节来存储数据域太少了,2 个字节是16位,在java中用short来表示,2个字节能表示2的16次方 65 536个数字,但是java中要用一半表示负数一半表示正数,所以一共最多数据域可以发送32768个字节,在将其换算成k大概为32K,也就是一次最多传输32K 的数据

问题2,TCP协议

在限制传输大小后,后端仍然报解析错误。这是排查处理粘包和拆包的代码是Netty提供的LengthFieldBasedFrameDecoder 类,这部分基本不可能出现问题,为了排查出原因复习了一下TCP协议

我们知道TCP建立连接时会发起3次握手,如图

Connection_TCP

1
2
3
1:客户端(通过执行connect函数)向服务器端发送一个SYN包,请求一个主动打开。该包携带客户端为这个连接请求而设定的随机数A作为消息序列号。
2:服务器端收到一个合法的SYN包后,把该包放入SYN队列中;回送一个SYN/ACK。ACK的确认码应为A+1,SYN/ACK包本身携带一个随机产生的序号B。
3:客户端收到SYN/ACK包后,发送一个ACK包,该包的序号被设定为A+1,而ACK的确认码则为B+1。然后客户端的connect函数成功返回。

那具体什么是ACK什么是SYN呢?我们对照一下TCP协议就知道了,我们着重看下标志位的内容就是下图保留位000后面的数据,可以这样理解,这个标志位就相当于我们写接口,这个接口有一个参数,分别为1,2,3 这3个参数分别表示不同的3种状态,我们根据这个状态执行不同的逻辑操作,这个表示位相当于这个参数,至于为什么保留000,我想应该是为了后续协议扩展,如果后续出了更多的状态码,只需要补上就好了,不需要重新定义协议


1632228600(1)

  • 来源连接端口(16位长)-识别发送连接端口
  • 目的连接端口(16位长)-识别接收连接端口
  • 序列号(seq,32位长)
    • 如果含有同步化旗标(SYN),则此为最初的序列号;第一个资料比特的序列码为本序列号加一。
    • 如果没有同步化旗标(SYN),则此为第一个资料比特的序列码。
  • 确认号(ack,32位长)—期望收到的数据的开始序列号。也即已经收到的数据的字节长度加1。
  • 资料偏移(4位长)—以4字节为单位计算出的数据段开始地址的偏移值。
  • 保留(3比特长)—须置0
  • 标志符(9比特长)
    • NS—ECN-nonce。ECN显式拥塞通知(Explicit Congestion Notification)是对TCP的扩展,定义于 RFC 3540 (2003)。ECN允许拥塞控制的端对端通知而避免丢包。ECN为一项可选功能,如果底层网络设施支持,则可能被启用ECN的两个端点使用。在ECN成功协商的情况下,ECN感知路由器可以在IP头中设置一个标记来代替丢弃数据包,以标明阻塞即将发生。数据包的接收端回应发送端的表示,降低其传输速率,就如同在往常中检测到包丢失那样。
    • CWR—Congestion Window Reduced,定义于 RFC 3168(2001)。
    • ECE—ECN-Echo有两种意思,取决于SYN标志的值,定义于 RFC 3168(2001)。
    • URG—为1表示高优先级数据包,紧急指针字段有效。
    • ACK—为1表示确认号字段有效
    • PSH—为1表示是带有PUSH标志的数据,指示接收方应该尽快将这个报文段交给应用层而不用等待缓冲区装满。
    • RST—为1表示出现严重差错。可能需要重新创建TCP连接。还可以用于拒绝非法的报文段和拒绝连接请求。
    • SYN—为1表示这是连接请求或是连接接受请求,用于创建连接和使顺序号同步
    • FIN—为1表示发送方没有数据要传输了,要求释放连接。
  • 窗口(WIN,16位长)—表示从确认号开始,本报文的发送方可以接收的字节数,即接收窗口大小。用于流量控制。
  • 校验和(Checksum,16位长)—对整个的TCP报文段,包括TCP头部和TCP数据,以16位字进行计算所得。这是一个强制性的字段。
  • 紧急指针(16位长)—本报文段中的紧急数据的最后一个字节的序号。
  • 选项字段—最多40字节。每个选项的开始是1字节的kind字段,说明选项的类型。
    • 0:选项表结束(1字节)
    • 1:无操作(1字节)用于选项字段之间的字边界对齐。
    • 2:最大报文段长度(4字节,Maximum Segment Size,MSS)通常在创建连接而设置SYN标志的数据包中指明这个选项,指明本端所能接收的最大长度的报文段。通常将MSS设置为(MTU-40)字节,携带TCP报文段的IP数据报的长度就不会超过MTU(MTU最大长度为1518字节,最短为64字节),从而避免本机发生IP分片。只能出现在同步报文段中,否则将被忽略。
    • 3:窗口扩大因子(3字节,wscale),取值0-14。用来把TCP的窗口的值左移的位数,使窗口值乘倍。只能出现在同步报文段中,否则将被忽略。这是因为现在的TCP接收数据缓冲区(接收窗口)的长度通常大于65535字节。
    • 4:sackOK—发送端支持并同意使用SACK选项。
    • 5:SACK实际工作的选项。
    • 8:时间戳(10字节,TCP Timestamps Option,TSopt)
      • 发送端的时间戳(Timestamp Value field,TSval,4字节)
      • 时间戳回显应答(Timestamp Echo Reply field,TSecr,4字节)

从上面资料中可知ACK和SYN或者是 SYN-ACK包其实就是将来TCP请求头对于的位置1而已

知道了标志位的信息我们还需要状态TCP的状态码,其实我们理解成HTTP的状态码就好了,每个状态码对应的TCP现在处于的不同状态


下表为TCP状态码列表,以S指代服务器,C指代客户端,S&C表示两者,S/C表示两者之一:[15]

  • LISTEN S

    服务器等待从任意远程TCP端口的连接请求。侦听状态。

  • SYN-SENT C

    客户在发送连接请求后等待匹配的连接请求。通过connect()函数向服务器发出一个同步(SYNC)信号后进入此状态。

  • SYN-RECEIVED S

    服务器已经收到并发送同步(SYNC)信号之后等待确认(ACK)请求。

  • ESTABLISHED S&C

    服务器与客户的连接已经打开,收到的数据可以发送给用户。数据传输步骤的正常情况。此时连接两端是平等的。这称作全连接。

  • FIN-WAIT-1 S&C

    (服务器或客户)主动关闭端调用close()函数发出FIN请求包,表示本方的数据发送全部结束,等待TCP连接另一端的ACK确认包或FIN&ACK请求包。

  • FIN-WAIT-2 S&C

    主动关闭端在FIN-WAIT-1状态下收到ACK确认包,进入等待远程TCP的连接终止请求的半关闭状态。这时可以接收数据,但不再发送数据。

  • CLOSE-WAIT S&C

    被动关闭端接到FIN后,就发出ACK以回应FIN请求,并进入等待本地用户的连接终止请求的半关闭状态。这时可以发送数据,但不再接收数据。

  • CLOSING S&C

    在发出FIN后,又收到对方发来的FIN后,进入等待对方对己方的连接终止(FIN)的确认(ACK)的状态。少见。

  • LAST-ACK S&C

    被动关闭端全部数据发送完成之后,向主动关闭端发送FIN,进入等待确认包的状态。

  • TIME-WAIT S/C

    主动关闭端接收到FIN后,就发送ACK包,等待足够时间以确保被动关闭端收到了终止请求的确认包。(按照RFC 793,一个连接可以在TIME-WAIT保证最大四分钟,即最大分段寿命(maximum segment lifetime)的2倍)

  • CLOSED S&C

    完全没有连接。


在linux中我执行了 lsof -i pid 查看我的程序,我的主服务在等待客户端连接所以其状态为LISTEN,下面状态都为ESTABLISHED说明我目前有这么多的TCP连接在同时通讯

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
COMMAND    PID USER   FD   TYPE    DEVICE SIZE/OFF NODE NAME
java 272641 root 41u IPv6 288726424 0t0 TCP *:ospf-lite (LISTEN)
java 272641 root 43u IPv6 288846370 0t0 TCP cdh1:ospf-lite->192.168.7.116:62294 (ESTABLISHED)
java 272641 root 46u IPv6 288903360 0t0 TCP cdh1:ospf-lite->192.168.7.112:62309 (ESTABLISHED)
java 272641 root 47u IPv6 288883851 0t0 TCP cdh1:ospf-lite->192.168.7.109:56469 (ESTABLISHED)
java 272641 root 48u IPv6 288846374 0t0 TCP cdh1:ospf-lite->192.168.7.105:49538 (ESTABLISHED)
java 272641 root 49u IPv6 288849436 0t0 TCP cdh1:ospf-lite->192.168.7.117:63780 (ESTABLISHED)
java 272641 root 52u IPv6 288846378 0t0 TCP cdh1:ospf-lite->192.168.7.104:62634 (ESTABLISHED)
java 272641 root 56u IPv6 289743582 0t0 TCP cdh1:ospf-lite->192.168.7.118:62854 (ESTABLISHED)
java 272641 root 62u IPv6 289434904 0t0 TCP cdh1:ospf-lite->192.168.7.106:56702 (ESTABLISHED)
java 272641 root 63u IPv6 288846379 0t0 TCP cdh1:ospf-lite->192.168.7.103:53834 (ESTABLISHED)
java 272641 root 71u IPv6 288740012 0t0 TCP cdh1:ospf-lite->192.168.7.111:55792 (ESTABLISHED)
java 272641 root 72u IPv6 291875760 0t0 TCP cdh1:ospf-lite->192.168.7.114:59756 (ESTABLISHED)
java 272641 root 76u IPv6 292145489 0t0 TCP cdh1:ospf-lite->192.168.7.102:58032 (ESTABLISHED)
java 272641 root 79u IPv6 292105805 0t0 TCP cdh1:ospf-lite->192.168.7.101:51138 (ESTABLISHED)
java 272641 root 211u IPv6 288846384 0t0 TCP cdh1:ospf-lite->192.168.7.108:65527 (ESTABLISHED)
java 272641 root 213u IPv6 288846385 0t0 TCP cdh1:ospf-lite->192.168.7.110:53628 (ESTABLISHED)
java 272641 root 216u IPv6 288849433 0t0 TCP cdh1:ospf-lite->192.168.7.107:51733 (ESTABLISHED)
java 272641 root 217u IPv6 288818776 0t0 TCP cdh1:ospf-lite->192.168.7.113:54426 (ESTABLISHED)
java 272641 root 218u IPv6 288818777 0t0 TCP cdh1:ospf-lite->192.168.7.115:54173 (ESTABLISHED)
java 272641 root 219u IPv6 288818778 0t0 TCP cdh1:ospf-lite->192.168.7.119:53698 (ESTABLISHED)

那么现在重点来了TCP如何结束通讯,且如何处理结束时当前还在缓冲区未发完的数据?如图,我们上文得知TCP发送FIN状态码代码代表结束通讯,因此我们知道当应用主动关闭连接时TCP并不会马上关闭连接,而是将缓冲区的数据发送完毕后再调用FIN标识码码去关闭连接,这样保证了主动关闭连接下数据的准确性,如果应用被强制杀掉,或者应用被关闭时没有调用close时,这时TCP通道并不会主动关闭,而是在一端调用write或者read时报Connection reset by peer错误,而这就解释了为啥会报第一个错误,时因为我在Netty中开了心跳坚持如果30秒没有从tcp通道中读取数据或者写数据的话那么就关闭这个通道,当客户端崩溃或者没有关闭通道退出时,Netty 去Write写入心跳数据时报出这个错误,因此得出此所以报错是由于客户端人员发送协议错误

img

1
2
3
4
5
6
连接终止使用了四路握手过程(或称四次握手,four-way handshake),在这个过程中连接的每一侧都独立地被终止。当一个端点要停止它这一侧的连接,就向对侧发送FIN,对侧回复ACK表示确认。因此,拆掉一侧的连接过程需要一对FIN和ACK,分别由两侧端点发出。

首先发出FIN的一侧,如果给对侧的FIN响应了ACK,那么就会超时等待2*MSL时间,然后关闭连接。在这段超时等待时间内,本地的端口不能被新连接使用;避免延时的包的到达与随后的新连接相混淆
连接可以工作在TCP半开状态。即一侧关闭了连接,不再发送数据;但另一侧没有关闭连接,仍可以发送数据。已关闭的一侧仍然应接收数据,直至对侧也关闭了连接。

也可以通过测三次握手关闭连接。主机A发出FIN,主机B回复FIN & ACK,然后主机A回复ACK.[13]
总结

在TCP由应用调用close方法去关闭TCP会主动将TCP缓冲池的数据发送完毕再发一个FIN标示位,因此主动关闭tcp并不会导致数据丢失。

*如果应用被强制杀掉,或者应用被关闭时没有调用close时,这时TCP通道并不会主动关闭,而是在一端调用write或者read时报Connection reset by peer错误 *