在目前微服务的背景下,网络异常越来越常见了,而有一些网络异常非常模糊,理解什么情况下会导致什么异常,还是有一定难度的,为此我做了大量实验,来复现各种异常场景。
先快速回顾下正常情况下TCP的交互过程与socket状态变迁,如下:
思考:如果第一个SYN包服务端没收到,会怎么样?
客户端会重发SYN包给服务端,服务端收到后会再次发SYN+ACK给客户端。
思考:如果最后一个ACK包没收到,会怎么样?
服务端会重发SYN+ACK包给客户端,客户端收到后会再次发ACK给服务端。
这里可以发现,TCP协议里面,重发都发生在没有收到ACK的场景,纯ACK确认包不会重发。
思考:如果之前三次握手时ACK丢失了,但客户端已经是ESTABLISHED状态了,调用write发数据了,会怎么样?
write发的数据包,也是带有ACK标记的,不管与之前的ACK包哪个先到,服务端都会变成ESTABLISHED状态。
而如果ACK与数据包都到不了服务端,一段时间后,服务端SYN_RECV状态的Socket会自动关闭,且不回复任何包给客户端,可以发现这种场景下,客户端认为连接成功,而服务端根本就没有连接。
思考:如果一个连接一直没有被使用(如连接池),而超过服务端最大空闲时间,服务端主动关闭了连接,会怎么样?
这时服务端会变成FIN_WAIT_2,这个状态也是有超时时间的,如果对方一直不发FIN过来,操作系统就会回收掉这个Socket,而客户端会一直是CLOSE_WAIT状态。
所以如果CLOSE_WAIT状态很多,一般是程序漏写了关闭Socket的代码。
从上面的状态变迁图,也可以推断出,绝大多数情况下,SYN_SENT、SYN_RECV、FIN_WAIT_1、LAST_ACK状态应该很少,除非网络很卡,因为这些状态只要一收到了ACK就转变成其它状态了!
ok,上面是TCP正常流程,下面以Java网络异常为例,讲讲各种异常情况!
发生异常:java.net.SocketTimeoutException: connect timed out
at java.net.PlainSocketImpl.socketConnect(Native Method)
at java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:350)
at java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:206)
at java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:188)
at java.net.SocksSocketImpl.connect(SocksSocketImpl.java:392)
at java.net.Socket.connect(Socket.java:589)
这个异常原因是,客户端connect建立连接时,服务端一直没收到SYN包,超过了设置的连接超时时间后,就会报此异常。
还可能是,服务端收到了SYN包,但SYN+ACK一直发不到客户端,也会报此异常。
发生异常:java.net.ConnectException: Connection refused (Connection refused)
at java.net.PlainSocketImpl.socketConnect(Native Method)
at java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:350)
at java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:206)
at java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:188)
at java.net.SocksSocketImpl.connect(SocksSocketImpl.java:392)
at java.net.Socket.connect(Socket.java:589)
这个异常原因是,当服务端没有程序监听某个端口时,客户端却又试图connect连接这个端口就会出现此异常,其本质是服务端回复了一个RST包。
注:RST包就是TCP协议中用来处理异常情况的,一般接收方收到RST包后,会直接回收Socket资源而不经过四次挥手过程。
发生异常:java.net.SocketTimeoutException: Read timed out
at java.net.SocketInputStream.socketRead0(Native Method)
at java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
at java.net.SocketInputStream.read(SocketInputStream.java:171)
at java.net.SocketInputStream.read(SocketInputStream.java:141)
当socket.read()读对端数据时,等待数据超时了,则会报Read timed out读取超时异常。
大多数情况下,这种异常都是服务端处理太慢导致的,可通过socket.setSoTimeout()来修改这个超时时间,注意理解这个超时时间,它不是整个读取过程时间,而是无任何数据通信的空闲时间。
一般来说,由于socket有写缓冲(send buffer),write方法是不阻塞立即返回的,但如果write大量数据(如文件上传),当send buffer用完时write方法还是会阻塞的。
不管write方法是否阻塞,数据多次重传失败,会导致异常,区别是阻塞write被异常打断,而没有阻塞write时,会在下一次write时抛异常。
对于这种情况的异常信息,不同的操作系统表现不一样,如下:
发生异常:java.net.SocketException: Connection timed out (Write failed)
at java.net.SocketOutputStream.socketWrite0(Native Method)
at java.net.SocketOutputStream.socketWrite(SocketOutputStream.java:111)
at java.net.SocketOutputStream.write(SocketOutputStream.java:143)
发生异常:java.net.SocketException: Connection reset by peer: socket write error
at java.net.SocketOutputStream.socketWrite0(Native Method)
at java.net.SocketOutputStream.socketWrite(SocketOutputStream.java:111)
at java.net.SocketOutputStream.write(SocketOutputStream.java:143)
对于重传的次数,Linux上默认15次,可通过内核参数net.ipv4.tcp_retries2配置,而Windows上默认5次,可通过注册表项TcpMaxDataRetransmissions配置。
总而言之,write超时可能导致Connection timed out (Write failed)异常或Connection reset by peer异常(Windows上)。
一般来说,如果对端机器上连接不存在了,还调用write往其发数据包,对方会回复RST包来终止连接。
注:那什么时候会出现连接不存在呢?如机器直接断电后重启,或网络包被路由到了错误的机器上等,都会使得机器上没有相应的TCP连接。
而当阻塞在write/read时,收到了对方的RST包,或先收到对方的RST包,再write/read时,就会报Connection reset异常。
如果对端机器上连接不存在了,本端连续调用write/read时,在不同操作系统上会产生不一样的异常序列,如下:
# 第一次write,调用正常,对端返回RST包
# 第二次read,抛connection reset异常:
发生异常:java.net.SocketException: Connection reset
at java.net.SocketInputStream.read(SocketInputStream.java:210)
at java.net.SocketInputStream.read(SocketInputStream.java:141)
# 第三次read,抛connection reset异常:
发生异常:java.net.SocketException: Connection reset
at java.net.SocketInputStream.read(SocketInputStream.java:210)
at java.net.SocketInputStream.read(SocketInputStream.java:141)
# 第一次write,调用正常,对端返回RST包
# 第二次write,抛connection reset异常:
发生异常:java.net.SocketException: Connection reset
at java.net.SocketOutputStream.socketWrite(SocketOutputStream.java:115)
at java.net.SocketOutputStream.write(SocketOutputStream.java:143)
# 第三次write,抛broken pipe异常:
发生异常:java.net.SocketException: Broken pipe (Write failed)
at java.net.SocketOutputStream.socketWrite0(Native Method)
at java.net.SocketOutputStream.socketWrite(SocketOutputStream.java:111)
at java.net.SocketOutputStream.write(SocketOutputStream.java:143)
# 第一次write,调用正常,对端返回RST包
# 第二次write,抛Connection reset by peer异常:
发生异常:java.net.SocketException: Connection reset by peer: socket write error
at java.net.SocketOutputStream.socketWrite0(Native Method)
at java.net.SocketOutputStream.socketWrite(SocketOutputStream.java:111)
at java.net.SocketOutputStream.write(SocketOutputStream.java:143)
# 第三次write,抛Connection reset by peer异常:
发生异常:java.net.SocketException: Connection reset by peer: socket write error
at java.net.SocketOutputStream.socketWrite0(Native Method)
at java.net.SocketOutputStream.socketWrite(SocketOutputStream.java:111)
at java.net.SocketOutputStream.write(SocketOutputStream.java:143)
总而言之,RST包会导致Connection reset异常,同时也可能导致Broken pipe异常。
如果对方调用close关闭了连接,本端再调用read或write方法读写数据会怎么样呢?
如果首次是read调用,Linux和Windows都会返回-1,表示EOF,如下:
如果首次是write调用,对端会回复RST包,如下:
而如果是连续的write/read调用,不同操作系统上表现不同,如下:
# 第一次write,调用正常,对端返回RST包
# 第二次write,抛broken pipe异常:
发生异常:java.net.SocketException: Broken pipe (Write failed)
at java.net.SocketOutputStream.socketWrite0(Native Method)
at java.net.SocketOutputStream.socketWrite(SocketOutputStream.java:111)
at java.net.SocketOutputStream.write(SocketOutputStream.java:143)
# 第三次write,抛broken pipe异常:
发生异常:java.net.SocketException: Broken pipe (Write failed)
at java.net.SocketOutputStream.socketWrite0(Native Method)
at java.net.SocketOutputStream.socketWrite(SocketOutputStream.java:111)
at java.net.SocketOutputStream.write(SocketOutputStream.java:143)
# 第四次read,返回-1
# 第一次write,调用正常,对端返回RST包
# 第二次write,抛Software caused connection abort: socket write error异常:
发生异常:java.net.SocketException: Software caused connection abort: socket write error
at java.net.SocketOutputStream.socketWrite0(Native Method)
at java.net.SocketOutputStream.socketWrite(SocketOutputStream.java:111)
at java.net.SocketOutputStream.write(SocketOutputStream.java:143)
# 第三次write,抛Software caused connection abort: socket write error异常:
发生异常:java.net.SocketException: Software caused connection abort: socket write error
at java.net.SocketOutputStream.socketWrite0(Native Method)
at java.net.SocketOutputStream.socketWrite(SocketOutputStream.java:111)
at java.net.SocketOutputStream.write(SocketOutputStream.java:143)
# 第四次read,抛Software caused connection abort: recv failed异常:
发生异常:java.net.SocketException: Software caused connection abort: recv failed
at java.net.SocketInputStream.socketRead0(Native Method)
at java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
at java.net.SocketInputStream.read(SocketInputStream.java:171)
at java.net.SocketInputStream.read(SocketInputStream.java:141)
总而言之,如果对方关闭了连接,本端还write数据,会报Broken pipe或Software caused connection abort异常。
注:如果直接Ctrl+c或kill -9杀死程序,由于只是进程死亡,Linux内核还在,内核会给对端发送FIN包以关闭连接。
上面已经看到了,绝大多数异常都是因为收到了RST包,除了端口未监听或连接不存在这两种情况会产生RST包外,还有一些特殊情况,也会导致RST包产生,如下:
如果你也想复现这些网络异常,可以了解下iptables和hping3命令,实现包丢弃或发送指定包(如RST包),如下:
# 观测22333端口数据包
sudo tcpdump -ni any port 22333
# 添加iptables规则,丢弃22333端口的数据包
sudo iptables -t filter -I INPUT -p tcp -m tcp --dport 22333 -j DROP
# 添加iptables规则,丢弃22333端口除SYN+ACK的所有ACK包
sudo iptables -t filter -I INPUT -p tcp -m tcp --dport 22333 --tcp-flags SYN,ACK ACK -j DROP
# 删除iptables规则
sudo iptables -t filter -D INPUT -p tcp -m tcp --dport 22333 --tcp-flags SYN,ACK ACK -j DROP
# 手动发RST包
# -a:源ip地址
# -s:源端口号
# -p:目标端口号
# --rst:开启RST标记位
# --win:设置tcp window大小
# --setseq:设置包seq号
sudo hping3 -a 10.243.72.157 -s 22333 -p 53824 --rst --win 0 --setseq 654041264 -c 1 10.243.211.45