Tcp/Ip协议

words: 6.8k    views:    time: 25min
tcp


20世纪60年代,美国国防部希望研究一种即使部分通信线路被破坏也能维持通信的技术,这一需求促进了分组交换网络的发展。分组交换使得多个节点之间的通信可以在节点受损时通过备用路径继续。

这一研究推动了ARPANET的建设,ARPANET是最早采用分组交换的广域网络之一,1970年代,文顿·瑟夫与罗伯特·卡恩设计了TCP/IP协议,并于1983年在ARPANET上全面部署,奠定了现代互联网的基础。1980年代,ISO推动OSI参考模型及其协议栈的标准化,但因实现复杂、市场推广不及TCP/IP等原因,未能取得广泛应用。同时TCP/IP随着互联网的私有化与万维网的兴起,逐渐成为了全球通信的事实标准。

Tcp/IP网络模型

相较于OSI提出的七层理论模型,TCP/IP实际采用的是四层模型

  • 链路层 包括物理层和数据链路层,负责在相邻节点之间传输数据

物理层将数据转换为物理信号,在传输介质(如双绞线、光纤、无线电波)上进行传输;

数据链路层将二进制数据封装成帧,识别帧边界,并通过差错检测机制(如CRC)确保帧的完整性,丢弃受损的帧。

这一层有一个重要的概念,MAC物理地址,它是设备在当前网络中的唯一标识。在以太网中,每个设备都有一个唯一的MAC地址,如果要与一个设备通信,需要先通过ARP(Address Resolution Protocol 地址解析协议)将IP地址转换为MAC地址,然后才能发送数据包;

  • 网络层 主要负责在不同网络之间路由数据,其主要内容包括IP协议和路由协议

IP是一种用于在互联网上路由数据的协议,它定义了一个包含源IP和目的IP的数据报,在网络中传输时,路由器会根据其目的IP来决定发往哪个网络;

路由协议是网络层的导航系统,用于在路由器之间动态交换路由信息,自动计算并维护路由表。当网络拓扑发生变化时(链路故障),路由协议能够自动调整,使数据包绕过故障路径继续传输。

  • 传输层 负责在主机应用之间提供可靠的数据传输,核心协议包括TCP和UDP

TCP提供的是面向连接的、可靠的、有序的(基于字节流)数据传输服务;

UDP提供的是一种无连接的、不可靠的、无序的(基于消息报文)的数据传输服务;

  • 应用层 负责为应用程序提供网络服务,它定义了用于在应用程序之间传递信息的协议,如HTTP、FTP等

连接过程

服务端对socket执行bind方法可以绑定监听端口,然后执行listen方法后就会进入LISTEN状态,等待客户端连接

  • 三次握手
客户端 服务端
发送SYN报文,等待服务端的ACK,进入SYN_SENT状态
收到SYN报文,给客户端返回(ACK+SYN)报文,等待客户端的ACK,进入SYN_RCVD状态
收到(ACK+SYN)报文,给服务端返回ACK报文,进入ESTABLISHED状态
收到ACK报文,进入ESTABLISHED状态

服务端在第一次收到SYN后,会将socket放入一个半连接队列(SYN队列),使用哈希表维护;在第三次收到ACK后,再从半连接队列中取出socket并放入全连接队列(ACCEPT队列),使用链表维护,等待执行accept()调用时取出,所以accetp并没有参与连接过去,其作用只是取出一个准备好的连接;不管是全连接还是半连接队列,都是内存中的结构,都有长度限制;

  • 全连接队列

通过 ss -lnt 可以查看全连接队列,Send-Q表示队列最大值,Recv-Q表示当前使用值,0表示队列当前为空,连接都被取出来了;

当Send-Q与Recv-Q的数值很接近,就表示队列要满了,可以通过watch -d 'netstat -s | grep overflow'来查看溢出发生过的次数;

如果全连接队列满了,服务端继续收到客户端的第三次ACK包默认会丢弃,并根据/proc/sys/net/ipv4/tcp_abort_on_overflow配置进行处理:
配置为0,在丢弃ACK后会开启一个定时器,充传(SYN+ACK)包,如果重传超过一定次数,会删除半连接队列中的对应连接;
配置为1,会直接给客户端会RST,效果上看就是连接断了,与服务端未开启端口监听的现象一样;

  • 半连接队列

半连接队列无法直接查看,统计SYN_RCVD状态的连接可以获得:netstat -nt | grep -i '127.0.0.1:8080' | grep -i 'SYN_RCVD' | wc -l

同样,通过netstat统计也可以查看半连接队列的溢出情况:watch -d 'netstat -s | grep -i "SYNs to LISTEN sockets dropped"'

SYN Flood攻击:一般半连接的存活时间很短,如果队列满了,那么大概率是被攻击了,攻击者模拟客户端疯狂发送SYN但不回复ACK,导致队列撑满;
如果/proc/sys/net/ipv4/tcp_syncookies置为1,可以绕过半连接队列,服务端收到SYN时,不会再放入队列,而是直接生成一个cookies,放在返回的(SYN+ACK)报文中,然后再收到ACK时进行验证,不通过直接丢弃。
ACK攻击:cookies虽然避免了半连接队列撑满的问题,但是其编解码都是比较耗CPU的,攻击者可以编造大量的ACK包,通过瞎编的cookies信息来耗尽服务端的CPU资源;另外由于cookies方案没有保存连接信息,所以如果(SYN+ACK)报文丢包了,也不会再重传;

断开过程

正常情况下,只要数据传输完,不管是客户端还是服务端,都可以主动发起断开连接

  • 四次挥手
主动方 被动方
数据发送完主动调用关闭,发送Fin报文,进入FIN_WAIT_1状态
收到Fin报文,立刻回应Ack报文,通知并等待应用层关闭,进入CLOSE_WAIT状态
收到对方ACK回复,等待对方的FIN报文,进入FIN_WAIT_2状态
应用层完成关闭,发送FIN报文,等待回复,进入LAST_ACK状态
收到对方的Fin报文,回应Ack,进入TIME_WAIT状态,等待2MSL时间,防止Ack丢包,对方重发FIN需要处理,以及处理掉一些由于网络原因导致的延迟数据包(MSL表示一个TCP报文在网络中存在的最长时间,超过这个时间,报文必须被丢弃),
收到Ack确认,完成连接CLOSED
等待结束,连接CLOSED

如果被动方没有数据要发送,也可以将第二次Ack和第三次Fin合并发送,变成三次挥手,这个取决于操作系统的具体实现,并不属于TCP的规定;

  • 异常情况

如果主动方一直收不到第三次Fin,连接会一直卡在FIN_WAIT_2状态,此时根据调用方式会有不同的表现行为:
调用的shutdown()并且只关闭了写,那么此时还可以读,会一直等待对方Fin数据包;
调用的close()关闭读和写,那么会等一段时间(/proc/sys/net/ipv4/tcp_fin_timeout),超时后变为TIME_WAIT状态;

如果被动方一直收不到第四次Ack,那么会尝试重发第三次Fin,默认重试8次(/proc/sys/net/ipv4/tcp_orphan_retries)

RST异常断开

正常情况下,Tcp可以进行四次挥手优雅地断开连接,但在异常情况下,双方无法正常挥手,所以就需要一个机制去强行关闭连接。RST就是用来处理这种异常情况,不管是发出还是收到标记了RST的数据包,对应的内存、端口等连接资源都会被释放,接收RST的一方一般会反馈connection reset或connection refused报错

出现RST的情况:

  • 对端端口不可用

如果服务端端口未开启,在收到数据包时内核会找不到对应的sock(操作系统维护的一个全局哈希表,里面包含所有的socket,服务执行listen时就会创建一个sock放入表中),那么就会给客户端回一个RST。

当然在回复RST之前,会首先对数据包进行校验和验证,如果不通过则直接丢弃(可能是发送过程中被篡改了,或者本就是一个伪造的包,在四层网络中,不管是哪一层,只要收到这种数据包,推荐做法都是默默丢弃)。

  • socket提前关闭

对于应用程序的退出,无论是正常还是异常,主动还是被动kill,都会发出Fin。对socket执行close()或shutdown()也会发出Fin,不过shutdown()有参数可以选择性的关闭读或写,而close会同时关闭socket的读和写通道。

当执行close()时,如果socket的接收缓冲区还有数据,那么会发RST;如果是发送缓冲区有数据,那么会等待数据发送完,再发送Fin数据包;

虽然主动方close()发完Fin,被动方在响应Ack后还可以发送数据,但主动方是不会去收这个消息的,并会返回一个RST,然后直接结束连接。最后被动方的内核协议栈收到RST后,也会关闭连接,此时应用层如果继续读取数据,会收到报错connection reset by peer,而如果尝试进行写数据,会收到Broken pipe错误。

可靠性问题

实现可靠传输最简单的方法是停止等待,发送一个数据包,然后等待回复,再继续发送下一个数据包。但是网络环境不可靠,导致每一次发送的数据包都可能会丢,如果主机A发送的数据包丢失了,那么主机B永远收不到数据进行回复,机器A就永远卡在等待。解决方法是超时重传,发完数据包后开始计时,超过时间还没收到回复就认为是发生了丢包,重新发送。但如果原先的数据包并没有丢失,只是在网络中被延迟了,重传就会导致主机B受到两个数据包,为了区分可以对数据包进行编号,这样接收方就可以根据编号判断是不是重传的数据(TCP的头部有两个32位字段,序列号和确认号,它们表示发送方数据第一个字节的编号,和接收方期待的下一包数据的第一个字节的编号)。

以上办法虽然满足了可靠传输的要求,但问题是效率太低,所以办法是连续发送数据包,这样接收方会不断收到数据,然后逐一进行确认回复。但是这样需要考虑接收方的缓冲区大小,以及数据处理能力。如果由于发送太快导致接收方无法接收,那么只是会导致频繁重传,浪费网络资源,所以要根据接收方缓冲区的情况来确定发送数据的范围,也就是滑动窗口(TCP头部有一个16位的窗口大小,表示接收方剩余的缓冲区大小,发送方可以根据这个调整自己的发送窗口)

  • 发送方根据接收方的缓冲区大小,设置自己的可发送窗口大小,处于窗口内的数据表示可发送,之外的数据不可发送;
  • 当窗口内的数据收到确认回复时,整个窗口会往前移动,直到发送完成所有的数据;

滑动窗口是想办法避免重传(重传是下下策,浪费网络资源),但如果确实发生了,TCP分段机制可以降低它的影响,也就是将大段数据包分成一小包一小包,如果发生了丢包,那么只要重传对应的小包就行。由于网络复杂,在接收端很难按顺序依次收到数据包,那么会根据序列对数据包进行乱序重排

如果每发一个送数据包,都必须有一个回复,就会导致网络中充斥着和发送数据包一样数量的回复报文,从而导致网络效率低下。解决办法是累积确认,接收方不需要逐个进行回复,而是累积到一定量的数据包之后,告诉发送方在此数据包之前的数据已全都收到。比如收到1234,只需要告诉发送方收到了4,那么发送方就知道1234都收到了(具体做法是默认等40ms,比如收到1之后等40ms,如果期间收到了2,那么再等40ms看能不能收到3,如果等不到3就立即回复2的Ack);

累积确认可以提高网络效率,但是如果遇到丢包情况,比如接收方收到了123567,编号4的数据包丢失了,那么是否就回一个3,然后将567丢掉呢。此时应该告诉发送方只需要重传4即可,所以还需要选择确认SACK机制来配合(TCP头部的选项字段,可以设置已经收到的报文段,每个报文段通过两个边界来确定)

粘包拆包问题

数据链路层受物理限制,传输能力必然有限,将其一次能够传输的最大数据称为MTU,并要求网络层(IP层)对发送的超长数据进行分片;

  • MTU:Max Transmit Unit最大传输单元,是数据链路层提供给网络层的一次传输数据最大值,一般是1500字节。如果数据包不超过1500字节,那么一个IP包就可以完成发送,如果超过了,就需要分片进行发送,分片后的IP Header ID相同;

  • MSS:Max Segment Size最大分段大小,是TCP用来限制应用层最大的发送字节数。假设MTU=1500,那么MSS=1500 - 20(IP头) - 20(TCP头)=1460,如果有2000字节需要发送,就需要分成两段才可以完成发送;

TCP不只是会对数据进行分段,也可能进行数据包组装的操作,目的是为了节约网络I/O资源,如果前后两次TCP发的数据都远小于MSS,可以进行合并发送,具体办法是Nagle算法,通过延迟发送小数据包,然后将多个小数据包合并发送;

  • 如果包长度达到MSS就立即发送,否则等待下一个包到来;如果下一个包到来后两个包的总长度超过MSS,再进行拆分发送;
  • 等待超时(一般200ms),虽然包长度没有达到MSS,但迟迟等不来下一个包,那么也进行发送;

Nagle算法考虑的是节约网络资源,但是可能导致延迟变大,现在一般网络环境都比较好,所以建议会选择将它关闭,也就是将TCP_NODELAY置为1;

所以TCP很可能将数据包进行分段或者合并发送,再加上TCP是基于二进制字节流发送的,就无法知道消息的起始和结束,也就是粘包拆包现象。不过应用层协议可以自己解决,办法一般是使用特殊标志来作为消息边界,以及消息头中加入消息长度信息。

根因还是字节流发送,就算关闭Nagle,消息到了TCP缓存还是会合并导致粘包。而基于数据报的UDP,无论应用层要发送怎样的数据,都是照样发送,如果数据包太长需要分片,那也是IP层的事情(与UDP不同,TCP的分段操作会尽量避免IP层继续分片,当然如果网络链路中有其它设备MTU更小,还是可能导致分片)。IP层虽然进行分片,但是在对端会自己将分片组装完成再交给传输层,上层收到的永远是完整的报文。

Keepalive机制

Keepalive是TCP协议内置的探测机制,用于检测长时间未活动的连接是否仍然存活。目的是检测并清理无效连接,防止资源长期占用。

相关参数可以通过/proc/sys/net/ipv4/文件调整

  • tcp_keepalive_time:连接空闲多久后开始发送探测包,默认7200秒;
  • tcp_keepalive_intvl:探测包之间的间隔时间,默认75秒;
  • tcp_keepalive_probes:探测失败前最多发送的探测包数量,默认9次;

但是Keepalive功能必须由应用程序显式设置SO_KEEPALIVE选项开启,如果仅调整上面三个参数是无效的

1
2
3
4
5
6
7
// 客户端
Socket socket = new Socket();
socket.setKeepAlive(true); 

// 服务端
ServerSocket serverSocket = new ServerSocket(port);
serverSocket.setKeepAlive(true);

可以通过netstat确认

1
2
3
4
5
6
7
8
9
10
11
12
13
netstat -4npo | grep -i established | head
tcp        0      0 172.18.120.55:2379      172.18.120.56:52104     ESTABLISHED 8072/etcd            keepalive (11.02/0/0)
tcp        0      0 172.18.120.55:2379      172.18.20.153:48184     ESTABLISHED 8072/etcd            keepalive (0.52/0/0)
tcp        0      0 172.18.120.55:2379      172.18.120.55:49988     ESTABLISHED 8072/etcd            keepalive (5.13/0/0)
tcp        0      0 172.18.120.55:2379      172.18.120.56:52176     ESTABLISHED 8072/etcd            keepalive (24.84/0/0)
tcp        0      0 172.18.120.55:51590     172.18.20.153:2379      ESTABLISHED 8804/kube-apiserver  keepalive (1.42/0/0)
tcp        0      0 172.18.120.55:49878     172.18.120.55:2379      ESTABLISHED 8804/kube-apiserver  keepalive (8.01/0/0)
tcp        0      0 172.18.120.55:49564     172.18.20.153:2379      ESTABLISHED 8804/kube-apiserver  keepalive (12.87/0/0)
tcp        0      0 172.18.120.55:2379      172.18.120.56:50060     ESTABLISHED 8072/etcd            keepalive (5.77/0/0)
tcp        0      0 172.18.120.55:2379      172.18.120.55:48138     ESTABLISHED 8072/etcd            keepalive (19.08/0/0)
tcp        0      0 172.18.120.55:37618     172.18.20.153:2379      ESTABLISHED 8804/kube-apiserver  keepalive (14.60/0/0)

keepalive (11.02/0/0)表示当前TCP连接已启用Keepalive:(距离下次发送的剩余时间/失败的探测包计数/累积重传次数)
  • 理解HTTP Keepalive

相对TCP的Keepalive是决定是否进行存活探测,并关闭无效连接;HTTP(Nginx、Redis、数据库等其它各种应用层协议都一样)的Keepalive是决定是否复用TCP连接,也就是消息发送结束后是否立即发送Fin。如果不是立即发Fin,而是等待一段时间超时后再发Fin,也就是我们常说的长连接了,只要在发送Fin之前,就可以连续发送应用层的消息;

一次网页访问的过程

  1. 解析URL,一般是:http://服务域名/资源路径;

  2. DNS解析,也就是查询服务器域名对应的IP地址,一般是先看操作系统缓存,没有在看hosts文件,然后再找本地dns服务器,根服务器;

  3. 建立TCP连接,应用调用Socket库,委托操作系统建立TCP连接;

  4. 数据包封装,操作系统封装数据,添加TCP和IP头部(源IP和目的IP),并根据路由配置,决定从哪个网卡出去(route -n可以查看当前系统的路由表);

应用程序调用write或send系统调用开始发包时,数据包会从用户缓冲区复制到TCP发送缓冲区(TCP Send Buffer),详细过程参考下图

  1. 链路层发送,如果目标IP不在当前网络(交换机二层网络),主机会通过ARP广播查询网关的MAC地址,也就是路由器的网口;

交换机工作在数据链路层,根据MAC地址转发帧,不会修改数据内容。其内部维护了一张MAC地址表,记录了端口号与MAC地址的对应关系,当节点A主机发消息从1号口进来,地址表就会记录一条A和1的关系,如果A长时间没有发消息,这条记录也会过期被删除;

  1. 路由器转发,路由器收到数据包,根据目标IP查询路由表,决定出口和下一跳,可能经过多跳路由,最终到达目标网络的路由器;

路由器工作在网络层,其每个端口都有一个MAC地址和IP地址,可以根据IP地址转发数据包,并重新封装(主要是修改目的MAC地址)。内部维护了一张路由表,用来作为数据包转发依据,如果没有匹配的记录,并且没有配置默认路由,那么会丢弃数据包,并通过ICMP消息告知发送方。(交换机在转发时如果MAC地址表没有对应记录,处理方式是进行广播,由于其网络规模小,也不用担心造成网络拥塞问题)。

  1. 到达目标主机,到达目标网络后,再通过ARP协议找到对应主机的MAC地址,进入其协议栈,再进行数据包解封,最终到达目标应用程序;

数据包到达网卡后会触发中断irq通知CPU读取数据包,高性能网络场景中数据包量大,频繁中断会影响性能,NAPI机制可以通过轮询poll批量处理数据包,轮询数量可以通过net.core.netdev_budget配置,详细过程示意如下

可能的丢包原因

了解数据发送的过程后,可以梳理一下其中可能丢包的环节

  • 连接丢包

连接过程中有两个队列,半连接队列和全连接队列,如果队列满了,那么新来的包就会被丢弃,具体现象就是连接失败;

  • 流量控制丢包

应用层需要发送网络数据包的应用有很多,如果不加控制的话,网卡会吃不消。所以需要流量控制(qdisc),让数据按一定的规则排队依次处理,但排队的队列有长度限制,当数据过快而队列长度又不够时,就容易出现丢包。

ifconfig命令可以查看丢包情况,TX和RX分别代表了发送和接收情况

ifconfig
1
2
3
4
5
6
7
8
ens33: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
inet 192.168.141.74 netmask 255.255.255.0 broadcast 192.168.141.255
inet6 fe80::20c:29ff:fe8d:a98d prefixlen 64 scopeid 0x20<link>
ether 00:0c:29:8d:a9:8d txqueuelen 1000 (Ethernet)
RX packets 50436 bytes 53859719 (53.8 MB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 12372 bytes 1083579 (1.0 MB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

tc命令可以进一步判断是不是流控导致的丢包

tc -s qdisc show dev ens33
1
2
3
4
5
qdisc fq_codel 0: root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5.0ms interval 100.0ms memory_limit 32Mb ecn 
Sent 1005621 bytes 12447 pkt (dropped 0, overlimits 0 requeues 2)
backlog 0b 0p requeues 2
maxpacket 54 drop_overlimit 0 new_flow_count 1 ecn_mark 0
new_flows_len 0 old_flows_len 0
  • 网卡丢包

网卡和驱动丢包也很常见,比如网线质量差、接触不良、网卡性能等原因,或者由于ring buffer的限制,上面ifconfig中overruns记录的就是由于ring buffer长度不足导致的溢出次数

查看网卡配置,如果要修改ring buffer长度,可以执行:ethtool -G ens33 rx 4096 tx 4096

ethtool -g ens33
1
2
3
4
5
6
7
8
9
10
11
Ring parameters for ens33:
Pre-set maximums:
RX: 4096
RX Mini: 0
RX Jumbo: 0
TX: 4096
Current hardware settings:
RX: 256
RX Mini: 0
RX Jumbo: 0
TX: 256
  • 接收缓冲区丢包

使用Tcp socket进行网络通信时,内核会分配一个发送缓冲区和一个接收缓冲区

查看发送缓冲区大小

sysctl net.ipv4.tcp_rmem
1
net.ipv4.tcp_rmem = 4096	131072	6291456

查看接收缓冲区大小

sysctl net.ipv4.tcp_wmem
1
net.ipv4.tcp_rmem = 4096	131072	6291456

对于发送缓冲区,如果满了会阻塞调用,或立即返回一个错误信号让应用层等下再重试。而如果接收缓冲区满了,那么Tcp的窗口会变为0,会通知发送方停止发送数据,但发送方在收到通知前已经发送的数据可能就丢包了

  • 网络过程丢包

在发送端和接收端之间,存在各种路由器、交换机还有光缆等设备,发生丢包是很正常的,而且作为发送方并没有办法去处理,但是可以通过mtr命令查看是在哪一个节点发生的丢包

mtr -r baidu.com
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Start: 2026-03-29T00:23:46+0000
HOST: java Loss% Snt Last Avg Best Wrst StDev
1.|-- 192.168.141.1 0.0% 10 0.3 0.3 0.1 0.4 0.1
2.|-- 192.168.1.1 0.0% 10 7.5 29.4 2.4 108.2 38.4
3.|-- ??? 100.0 10 0.0 0.0 0.0 0.0 0.0
4.|-- ??? 100.0 10 0.0 0.0 0.0 0.0 0.0
5.|-- ??? 100.0 10 0.0 0.0 0.0 0.0 0.0
6.|-- ??? 100.0 10 0.0 0.0 0.0 0.0 0.0
7.|-- 202.97.126.37 90.0% 10 22.5 22.5 22.5 22.5 0.0
8.|-- ??? 100.0 10 0.0 0.0 0.0 0.0 0.0
9.|-- ??? 100.0 10 0.0 0.0 0.0 0.0 0.0
10.|-- ??? 100.0 10 0.0 0.0 0.0 0.0 0.0
11.|-- 219.158.11.90 50.0% 10 141.1 72.5 28.4 141.1 59.5
12.|-- 110.242.66.186 0.0% 10 28.3 44.0 26.4 108.4 27.0

解释一下为什么最后一跳丢包0%,前面的节点却丢包100%

IP数据包有个TTL属性,表示数据包能允许的最大跳数,每经过一跳,TTL计数减1,如过计数为0,那么收到这个数据包的节点必须丢弃,不可以再转发下一跳,并且应该向源端发送一个ICMP Time Exceeded消息(有的节点可能设置不进行icmp响应,就会静默丢弃);

mtr的办法就是逐步提高数据包的TTL跳数,直到能够跳到目标节点,TTL刚好为0。这样源端就能知道要经过多少跳可以到达目标,对于中间的节点,如果正常回复icmp就不会丢包,而如果限制了icmp回复频率或者干脆不回复,可能就会出现类似丢包的现象。

可以指定使用TCP协议进行探测,但是mtr在使用TCP探测时,每轮会递增源端口,而ECMP网络多路径负载均衡会根据五元组进行路径规划,会导致每轮探测包所走的路径都不一样(目的就是为了遍历所有的路径),最后mtr把这些不同路径的信息一起统计,可能只显示最优路径的信息。

1
mtr -r -c 100 --tcp -P 80 baidu.com


参考: