跳转至

服务器开发中网络数据分析与故障排查经验漫谈

一、 操作系统提供的网络接口

为了能更好的排查网络通信问题,我们需要熟悉操作系统提供的以下网络接口函数,列表如下:

接口函数名称 接口函数描述 接口函数签名
socket 创建套接字 int socket(int domain, int type, int protocol);
connect 连接一个服务器地址 int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
send 发送数据 ssize_t send(int sockfd, const void *buf, size_t len, int flags);
recv 收取数据 ssize_t recv(int sockfd, void *buf, size_t len, int flags);
accept 接收连接 int accept4(int sockfd, struct sockaddr addr, socklen_t addrlen, int flags);
shutdown 关闭收发链路 int shutdown(int sockfd, int how);
close 关闭套接字 int close(int fd);
setsockopt 设置套接字选项 int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);

注意:这里以 bekeley 提供的标准为例,不包括特定操作系统上特有的接口函数(如 Windows 平台的 WSASend,linux 的 accept4),也不包括实际与网络数据来往不相关的函数(如 select、linux 的 epoll),这里只讨论与 tcp 相关的接口函数,像与 udp 相关的函数 sendto/recvfrom 等函数与此类似。

下面讨论一下以上函数的一些使用注意事项:

1 以上函数如果调用出错后,返回值均为 -1;但是返回值是 -1,不一定代表出错,这还得根据对应的套接字模式(阻塞与非阻塞模式)。

2 默认使用的 socket 函数创建的套接字是阻塞模式的,可以调用相关接口函数将其设置为非阻塞模式(Windows 平台可以使用 ioctlsocket 函数,linux 平台可以使用 fcntl 函数,具体设置方法可以参考 这里。)。阻塞模式和非阻塞模式的套接字,对服务器的连接服务器和网络数据的收发行为影响很大。详情如下:

阻塞模式 下,connect 函数如果不能立刻连上服务器,会导致执行流阻塞在那里一会儿,直到 connect 连接成功或失败或网络超时;而非阻塞模式下,无论是否连接成功 connect 将立即返回,此时如果未连接成功,返回值将是 -1,错误码是 EINPROGRESS,表示连接操作仍然在进行中。Linux 平台后续可以通过使用 select/poll 等函数检测该 socket 是否可写来判断连接是否成功。

阻塞套接字模式 下,send 函数如果由于对端 tcp 窗口太小,不足以将全部数据发送出去,将阻塞执行流,直到出错或超时或者全部发送出去为止;同理 recv 函数如果当前协议栈系统缓冲区中无数据可读,也会阻塞执行流,直到出错或者超时或者读取到数据。send 和 recv 函数的超时时间可以参考下文关于常用 socket 选项的介绍。

非阻塞套接字模式 下,如果由于对端 tcp 窗口太小,不足以将数据发出去,它将立刻返回,不会阻塞执行流,此时返回值为 -1,错误码是 EAGAIN 或 EWOULDBLOCK,表示当前数据发不出去,希望你下次再试。但是返回值如果是 -1,也可能是真正的出错了,也可能得到错误码 EINTR,表示被 linux 信号中断了,这点需要注意一下。recv 函数与 send 函数情形一样。

3 send 函数虽然名称叫“send”,但是其并不是将数据发送到网络上去,只是将数据从应用层缓冲区中拷贝到协议栈内核缓冲区中,具体什么时候发送到网络上去,与协议栈本身行为有关系(socket 选项 nagle 算法与这个有关系,下文介绍常见套接字选项时会介绍),这点需要特别注意,所以即使 send 函数返回一个大于 0 的值 n,也不能表明已经有 n 个字节发送到网络上去了。同样的道理,recv 函数也不是从网络上收取数据,只是从协议栈内核缓冲区拷贝数据至应用层缓冲区,并不是真正地从网络上收数据,所以,调用 recv 时,操作系统的协议栈已经将数据从网络上收到自己的内核缓冲区中了,recv 仅仅是一次数据拷贝操作而已。

4 由于套接字实现是收发全双工的,收和发通道相互独立,不会相互影响,shutdown 函数是用来选择关闭 socket 收发通道中某一路(当然,也可以两路都关闭),其 how 参数取值一般有三个:SHUT_RD/SHUT_WR/SHUT_RDWR,SHUT_RD 表示关闭收消息链路,即该套接字不能再收取数据,同理 SHUT_WR 表示关闭套接字发消息链路,但是这里有个问题,有时候我们需要等待缓冲区中数据发送完后再关闭连接怎么办?这里就要用到套接字选项 LINGER,关于这个选项请参考下文常见的套接字选项介绍。最后,SHUT_RDWR 同时关闭收消息链路和发消息链路。通过上面的分析,我们得出结论,shutdown 函数并不会要求操作系统底层回收套接字等资源,真正会回收资源是 close 函数,这个函数会要求操作系统回收相关套接字资源,并释放对 ip 地址与端口号二元组的占用,但是由于 tcp 四次挥手最后一个阶段有个 TIME_WAIT 状态(关于这个状态下文介绍 tcp 三次握手和四次回收时会详细介绍),导致与该 socket 相关的端口号资源不会被立即释放,有时候为了达到释放端口用来复用,我们会设置套接字选项 SOL_REUSEPORT(关于这个选项,下文会介绍)。综合起来,我们关闭一个套接字,一般会先调用 shutdown 函数再调用 close 函数,这就是所谓的优雅关闭:

5 常见的套接字选项

严格意义上说套接字选项是有不同层级的(level),如 socket 级别、TCP 级别、IP 级别,这里我们不区分具体的级别。

  • SO_SNDTIMEO 与 SO_RCVTIMEO

这两个选项用于设置阻塞模式下套接字,SO_SNDTIMEO 用于在 send 数据由于对端 tcp 窗口太小,发不出去而最大的阻塞时长;SO_RCVTIMEO 用于 recv 函数因接受缓冲区无数据而阻塞的最大阻塞时长。如果你需要获取它们的默认值,请使用 getsockopt 函数。

  • TCP_NODELAY

操作系统底层协议栈默认有这样一个机制,为了减少网络通信次数,会将 send 等函数提交给 tcp 协议栈的多个小的数据包合并成一个大的数据包,最后再一次性发出去,也就是说,如果你调用 send 函数往内核协议栈缓冲区拷贝了一个数据,这个数据也许不会马上发到网络上去,而是要等到协议栈缓冲区积累到一定量的数据后才会一次性发出去,我们把这种机制叫做 nagle 算法。默认打开了这个机制,有时候我们希望关闭这种机制,让 send 的数据能够立刻发出去,我们可以选择关闭这个算法,这就可以通过设置套接字选项 TCP_NODELAY,即关闭 nagle 算法。

  • SO_LINGER

linger 这个单词本身的意思,是“暂停、逗留”。这个选项的用处是用于解决,当需要关闭套接字时,协议栈发送缓冲区中尚有未发送出去的数据,等待这些数据发完的最长等待时间。

  • SO_REUSEADDR/SO_REUSEPORT

一个端口,尤其是作为服务器端端口在四次挥手的最后一步,有一个为 TIME_WAIT 的状态,这个状态一般持续 2MSL(MSL,maximum segment life, 最大生存周期,RFC 上建议是 2 分钟)。这个状态存在原因如下:1. 保证发出去的 ack 能被送达(超时会重发 ack)2. 让迟来的报文有足够的时间被丢弃,反过来说,如果不存在这个状态,那么可以立刻复用这个地址和端口号,那么可能会收到老的连接迟来的数据,这显然是不好的。为了立即回收复用端口号,我们可以通过开启套接字 SO_REUSEADDR/SO_REUSEPORT。

  • SO_KEEPALIVE

默认情况下,当一个连接长时间没有数据来往,会被系统防火墙之类的服务关闭。为了避免这种现象,尤其是一些需要长连接的应用场景下,我们需要使用心跳包机制,即定时从两端定时发一点数据,这种行为叫做“保活”。而 tcp 协议栈本身也提供了这种机制,那就是设置套接字 SO_KEEPALIVE 选项,开启这个选项后,tcp 协议栈会定时发送心跳包探针,但是这个默认时间比较长(2 个小时),我们可以继续通过相关选项改变这个默认值。

二、常用的网络故障排查工具

1.ping

ping 命令可用于测试网络是否连通。

2.telnet

命令使用格式:

telnet  ip或域名 port

例如:

telnet 120.55.94.78 8888

telnet www.baidu.com 80

结合 ping 和 telnet 命令我们就可以判断一个服务器地址上的某个端口号是否可以对外提供服务。

由于我们使用的开发机器以 windows 居多,默认情况下,windows 系统的 telnet 命令是没有打开的,我们可以在【控制面板】- 【程序】- 【程序和功能】- 【打开或关闭 Windows 功能】中打开 telnet 功能。

3.host 命令

host 命令可以解析域名得到对应的 ip 地址。例如,我们要得到 www.baidu.com 这个域名的 ip 地址,可以输入:

得到 www.google.com 的 ip 地址可以输入:

4 .netstat 命令

常见的选项有:

-a (all) 显示所有选项,netstat 默认不显示 LISTEN 相关

-t (tcp) 仅显示 tcp 相关选项

-u (udp) 仅显示 udp 相关选项

-n 拒绝显示别名,能显示数字的全部转化成数字。(重要)

-l 仅列出有在 Listen (监听) 的服務状态

-p 显示建立相关链接的程序名 (macOS 中表示协议 -p protocol)

-r 显示路由信息,路由表

-e 显示扩展信息,例如 uid 等

-s 按各个协议进行统计 (重要)

-c 每隔一个固定时间,执行该 netstat 命令。

5. lsof 命令

lsof,即 list opened filedescriptor,即列出当前操作系统中打开的所有文件描述符,socket 也是一种 file descriptor,常见的选项是:

-i 列出系统打开的 socket fd

-P 不要显示端口号别名

-n 不要显示 ip 地址别名(如 localhost 会用 127.0.0.1 来代替)

+c w 程序列名称最大可以显示到 w 个字符。

常见的选项组合为 lsof –i –Pn:

可以看到列出了当前侦听的 socket,和连接 socket 的 tcp 状态。

6.pstack

严格意义上来说,这个不算网络排查故障和调试命令,但是我们可以利用这个命令来查看某个进程的线程数量和线程调用堆栈是否运行正常。指令使用格式:

pstack pid

即,pstack 进程号,如:

7.nc 命令

即 netcat 命令,这个工具在排查网络故障时非常有用,因而被业绩称为网络界的“瑞士军刀”。常见的用法如下:

  • 模拟服务器端在指定 ip 地址和端口号上侦听
nc –l 0.0.0.0 8888
  • 模拟客户端连接到指定 ip 地址和端口号
nc 0.0.0.0 8888

我们知道客户端连接服务器一般都是操作系统随机分配一个可用的端口号连接到服务器上去,这个指令甚至可以指定使用哪个端口号连接,如:

nc –p 12345 127.0.0.1 8888

客户端使用端口 12345 去连接服务器 127.0.0.1::8888。

  • 使用 nc 命令发消息和发文件

客户端

服务器

8 .tcpdump

这个是 linux 系统自带的抓包工具,功能非常强大,默认需要开启 root 权限才能使用。

其常见的选项有:

-i 指定网卡

-X –XX 打印十六进制的网络数据包

-n –nn 不显示 ip 地址和端口的别名

-S 以绝对值显示包的 ISN 号(包序列号)

常用的过滤条件有如下形式:

tcpdump –i any ‘port 8888’
tcpdump –i any ‘tcp port 8888’
tcpdump –i any ‘tcp src port 8888’
tcpdump –i any ‘tcp src port 8888 and udp dst port 9999’
tcpdump -i any 'src host 127.0.0.1 and tcp src port 12345' -XX -nn -vv

关于 tcpdump 命令接下来将会以对 tcp 三次握手和四次挥手的包数据进行抓包来分析。

三、 tcp 三次握手和四次挥手过程解析

熟练地掌握 tcp 三次握手和四次挥手过程的每一个细节是我们排查网络问题的基础。

下面我们来通过 tcpdump 抓包能实战一下三次握手的过程,假设我的服务器端的地址是 127.0.0.0.1 : 12345,使用 nc 命令创建一个服务器程序并在这个地址上进行侦听:

nc –v -l 127.0.0.0.112345

然后在客户端机器上开启 tcpdump 工具:

然后在客户端使用 nc 命令创建一个客户端去连接服务器:

我们抓到的包如下:

图片看不清,可以放大来看。上面我们需要注意的是:

三次握手过程是客户端先给服务器发送一个 SYN,然后服务器应答一个 SYN+ACK,应答的序列号是递增 1 的,表示应答哪个请求,即从 4004096087 递增到 4004096088,接着客户端再应答一个 ACK。这个时候,我们发现发包序列号和应答序列号都变成 1 了,这是 tcpdump 使用相对序号,我们加上 -S 选项后就变成绝对序列号了。

这是正常的 tcp 三次握手,假如我们连接的服务器 ip 地址存在,但监听端口号并不存在,我们看下 tcpdump 抓包结果:

这个时候客户端发送 SYN,服务器应答 ACK+RST:

这个应答包会导致客户端的 connect 连接失败。

还有一种情况就是客户端访问一个很遥远的 ip,或者网络繁忙,服务器对客户端发送的网络 SYN 报文没有应答,会出现什么情况呢?

我们先将防火墙的已有规则都清理掉: iptables -F

然后给防火墙的 INPUT 链上增加一个规则,丢弃本地网卡 lo(也就是 127.0.0.1 这个回环地址)上的所有 SYN 包。

接着,我们看到 tcpdump 抓到的数据包如下:

连接不上,一共重试了 5 次,重试的时间间隔是 1 秒,2 秒,4 秒,8 秒,16 秒,最后返回失败。这个重试次数在/proc/sys/net/ipv4/tcp_syn_retries 内核参数中设置,默认为 6。

四次挥手与三次握手基本上类似,这里就不贴出 tcpdump 抓包的详情了。实际的网络开发中,尤其是高 QPS 的服务器程序,可能在在服务器程序所在的系统上留下大量非 ESTABLISHED 的中间状态,如 CLOSE_WAIT/TIME_WAIT,我们可以使用以下指令来统计这些状态信息:

netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'

得到结果可能类似:

让我们再贴一张 tcp 三次握手和四次挥手更清晰的图吧。

下面看下一般比较关心的三种 TCP 状态

  • SYN_RECV

服务端收到建立连接的 SYN 没有收到 ACK 包的时候处在 SYN_RECV 状态。有两个相关系统配置:

1 net.ipv4.tcp_synack_retries,整形,默认值是 5

对于远端的连接请求 SYN,内核会发送 SYN + ACK 数据报,以确认收到上一个 SYN 连接请求包。这是三次握手机制的第二个步骤。这里决定内核在放弃连接之前所送出的 SYN+ACK 数目。不应该大于 255,默认值是 5,对应于 180 秒左右时间。通常我们不对这个值进行修改,因为我们希望 TCP 连接不要因为偶尔的丢包而无法建立。

2 net.ipv4.tcp_syncookies

一般服务器都会设置 net.ipv4.tcp_syncookies=1 来防止 SYN Flood 攻击。假设一个用户向服务器发送了 SYN 报文后突然死机或掉线,那么服务器在发出 SYN+ACK 应答报文后是无法收到客户端的 ACK 报文的(第三次握手无法完成),这种情况下服务器端一般会重试(再次发送 SYN+ACK 给客户端)并等待一段时间后丢弃这个未完成的连接,这段时间的长度我们称为 SYN Timeout,一般来说这个时间是分钟的数量级(大约为 30 秒 -2 分钟)。这些处在 SYNC_RECV 的 TCP 连接称为半连接,并存储在内核的半连接队列中,在内核收到对端发送的 ack 包时会查找半连接队列,并将符合的 requst_sock 信息存储到完成三次握手的连接的队列中,然后删除此半连接。大量 SYNC_RECV 的 TCP 连接会导致半连接队列溢出,这样后续的连接建立请求会被内核直接丢弃,这就是 SYN Flood 攻击。能够有效防范 SYN Flood 攻击的手段之一,就是 SYN Cookie。SYN Cookie 原理由 D. J. Bernstain 和 Eric Schenk 发明。SYN Cookie 是对 TCP 服务器端的三次握手协议作一些修改,专门用来防范 SYN Flood 攻击的一种手段。它的原理是,在 TCP 服务器收到 SYN 包并返回 SYN+ACK 包时,不分配一个专门的数据区,而是根据这个 SYN 包计算出一个 cookie 值。在收到 ACK 包时,TCP 服务器在根据那个 cookie 值检查这个 TCP ACK 包的合法性。如果合法,再分配专门的数据区进行处理未来的 TCP 连接。观测服务上 SYN_RECV 连接个数为:7314,对于一个高并发连接的通讯服务器,这个数字比较正常。

  • CLOSE_WAIT

发起 TCP 连接关闭的一方称为 client,被动关闭的一方称为 server。被动关闭的 server 收到 FIN 后,但未发出 ACK 的 TCP 状态是 CLOSE_WAIT。出现这种状况一般都是由于 server 端代码的问题,如果你的服务器上出现大量 CLOSE_WAIT,应该要考虑检查代码。

  • TIME_WAIT

根据三次握手断开连接规定,发起 socket 主动关闭的一方 socket 将进入 TIME_WAIT 状态。TIME_WAIT 状态将持续 2MSL。TIME_WAIT 状态下的 socket 不能被回收使用。 具体现象是对于一个处理大量短连接的服务器,如果是由服务器主动关闭客户端的连接,将导致服务器端存在大量的处于 TIME_WAIT 状态的 socket, 甚至比处于 Established 状态下的 socket 多的多,严重影响服务器的处理能力,甚至耗尽可用的 socket,停止服务。TIME_WAIT 是 TCP 协议用以保证被重新分配的 socket 不会受到之前残留的延迟重发报文影响的机制,是必要的逻辑保证。和 TIME_WAIT 状态有关的系统参数有一般由 3 个,本机设置如下:

net.ipv4.tcp_tw_recycle = 1

net.ipv4.tcp_tw_reuse = 1

net.ipv4.tcp_fin_timeout = 30

net.ipv4.tcp_fin_timeout,默认60s,减小fin_timeout,减少TIME_WAIT连接数量。

net.ipv4.tcp_tw_reuse = 1表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭;

net.ipv4.tcp_tw_recycle = 1表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭。

我们这里总结一下这些与 tcp 状态的选项:

  • net.ipv4.tcp_syncookies=1 表示开启 SYN Cookies。当出现 SYN 等待队列溢出时,启用 cookie 来处理,可防范少量的 SYN 攻击。默认为 0,表示关闭。
  • net.ipv4.tcp_tw_reuse=1 表示开启重用。允许将 TIME-WAIT 套接字重新用于新的 TCP 连接。默认为 0,表示关闭。
  • net.ipv4.tcp_tw_recycle=1 表示开启 TCP 连接中 TIME-WAIT 套接字的快速回收。默认为 0,表示关闭。**
  • net.ipv4.tcp_fin_timeout=30 表示如果套接字由本端要求关闭,这个参数决定了它保持在 FIN-WAIT-2 状态的时间。
  • net.ipv4.tcp_keepalive_time=1200 表示当 keepalive 启用时,TCP 发送 keepalive 消息的频度。默认是 2 小时,这里改为 20 分钟。
  • net.ipv4.ip_local_port_range=1024 65000 表示向外连接的端口范围。默认值很小:32768~61000,改为 1024~65000。
  • net.ipv4.tcp_max_syn_backlog=8192 表示 SYN 队列的长度,默认为 1024,加大队列长度为 8192,可以容纳更多等待连接的网络连接数。
  • net.ipv4.tcp_max_tw_buckets=5000 表示系统同时保持 TIME_WAIT 套接字的最大数量,如果超过这个数 字,TIME_WAIT 套接字将立刻被清除并打印警告信息。默认为 180000,改为 5000。

注意

上文中用红色字体标识出来的两个参数:

net.ipv4.tcp_tw_recycle

net.ipv4.tcp_tw_reuse

在实际 linux 内核参数调优时并不建议开启。至于原因,我会单独用一篇文章来介绍。

四、 关于跨系统与跨语言之间的网络通信连通问题

如何在 Java 语言中去解析 C++ 的网络数据包,如何在 C++ 中解析 Java 的网络数据包,对于很多人来说是一件很困难的事情,所以只能变着法子使用第三方的库。其实使用 tcpdump 工具可以很容易解决与分析。

首先,我们需要明确字节序列这样一个概念,即我们说的大端编码 (big endian) 和小端编码 (little endian),x86 和 x64 系列的 cpu 使用小端编码,而数据在网络上传输,以及 Java 语言中,使用的是大端编码。那么这是什么意思呢?

我们举个例子,看一个 x64 机器上的 32 位数值在内存中的存储方式:

i 在内存中的地址序列是 0x003CF7C4~ 0x003CF7C8,值为 40 e2 01 00。

十六进制 0001e240 正好等于 10 进制 123456,也就是说小端编码中权重高的的字节值存储在内存地址高(地址值较大)的位置,权重值低的字节值存储在内存地址低(地址值较小)的位置,也就是所谓的高高低低。

相反,大端编码的规则应该是高低低高,也就是说权值高字节存储在内存地址低的位置,权值低的字节存储在内存地址高的位置。

所以,如果我们一个 C++ 程序的 int32 值 123456 不作转换地传给 Java 程序,那么 Java 按照大端编码的形式读出来的值是:十六进制 40E20100 = 十进制 1088553216。

所以,我们要么在发送方将数据转换成网络字节序(大端编码),要么在接收端再进行转换。

下面看一下如果 C++ 端传送一个如下数据结构,Java 端该如何解析(由于 Java 中是没有指针的,也无法操作内存地址,导致很多人无从下手),下面利用 tcpdump 来解决这个问题的思路。

我们客户端发送的数据包:

其结构体定义如下:

利用 tcpdump 抓到的包如下:

放大一点:

我们白色标识出来就是我们收到的数据包。这里我想说明两点:

  • 如果我们知道发送端发送的字节流,再比照接收端收到的字节流,我们就能检测数据包的完整性,或者利用这个来排查一些问题;
  • 对于 Java 程序只要按照这个顺序,先利用 java.net.Socket 的输出流 java.io.DataOutputStream 对象 readByte、readInt32、readInt32、readBytes、readBytes 方法依次读出一个 char、int32、int32、16 个字节的字节数组、63 个字节数组即可,为了还原像 int32 这样的整形值,我们需要做一些小端编码向大端编码的转换。

参考资料:

  • 《TCP/IP 详解卷一:协议》
  • 《TCP/IP 详解卷二:实现》
  • 游双《Linux 高性能服务器编程》
  • https://man.cx/?page=iptables(8)
  • https://vincent.bernat.im/en/blog/2014-tcp-time-wait-state-linux
  • https://blog.csdn.net/chinalinuxzend/article/details/1792184
  • https://www.zhihu.com/question/29212769
  • https://blog.csdn.net/launch_225/article/details/9211731
  • https://www.cnblogs.com/splenday/articles/7668589.html
  • http://man.linuxde.net/ss
  • http://www.cnxct.com/coping-with-the-tcp-time_wait-state-on-busy-linux-servers-in-chinese-and-dont-enable-tcp_tw_recycle/
  • https://www.cnblogs.com/xkus/p/7463135.html