TCP

链路层

链路层报文结构

MTU (Maximum transmission unit) 是什么? is the largest size packet or frame.

链路层报文结构 MTU

使用命令 netstat -in 查看各个网口的 MTU 最大传输单元大小。

网络层

IP报文

UDP

UDP 头

TCP

TCP 头

从连接到建立的时序图

常用选项

  • SO_REUSEADDR: 强制使用处在 TIME_WAIT 状态的 socket 地址
  • SO_RCVBUF: TCP 接受缓冲区,最小值 256 字节,修改内核 /proc/sys/net/ipv4/tcp_rmem 来取消最小值限制
  • SO_SNDBUF: TCP 发送缓冲区,最小值 2048 字节,修改内核 /proc/sys/net/ipv4/tcp_wmem 来取消最小值限制
  • SO_RCVLOWAT: 接收缓冲区低水位标记,被 I/O 复用系统判断 socket 是否可读,默认 1 字节
  • SO_SNDLOWAT: 发送缓冲区低水位标记,默认 1 字节
  • SO_LINGER: 关闭 TCP 时,是否发送残留数据

TTL

它是数据包可经过的最多路由器总数。 TTL 初始值由源主机设置后,数据包在传输过程中每经过一个路由器 TTL 值则减 1 ,当该字段为 0 时,数据包被丢弃,并发送 ICMP 报文通知源主机,以防止源主机无休止地发送报文。这里扩展说一下 ICMP ( Internet Control Message Protocol ),它是检测传输网络是否通畅、主机是否可达、路由是否可用等网络运行状态的协议。 ICMP 虽然并不传输用户数据,但是对评估网络健康状态非常重要,经常使用的 ping 、 tracert 命令就是基于 ICMP 检测网络状态的有力工具。

发送缓存和接受缓存

发送进程和接受进程可能以不同的速度写入数据和读取数据,因此 TCP 需要使用缓存来存储数据。缓存还被 TCP 用来进行流量控制和差错控制。

IP 层作为 TCP 的服务提供者,它必须以分组 (in packets) 为单位发送数据,而不是按照字节流来发送。在运输层,TCP 把若干个字节组成一个分组 (TCP groups a number of bytes together into a packet called a segment),称为 报文段 (segment)。TCP 给每个报文段添加一个首部(用于控制),然后再把这个 报文段 (segment) 交付给 IP 层传输。这些 报文段 (segment)被封装成 IP 数据报 (IP datagram) 后发送出去。这些 报文段 (segment) 在接受时,有可能会失序、丢失,或受到损伤和重传,所有这些都是由 TCP 来处理的,而接受进程并不知道 TCP 的这些活动。

这些报文段 (segment)并不一定长度相同

面向连接

面向的是虚连接,而不是物理连接,每一个 IP 数据报 (IP datagram) 可以走不同的路径到达终点。

TCP 特点

  • 编号系统 Numbering System
  • 流量控制
  • 差错控制
  • 拥塞控制

报文段 (segment) 首部中有两个叫做序号 (sequence number)确认号 (acknowledgment number) 的字段。这两个字段都指的是字节 (byte number) 的编号,而不是 报文段 (segment) 的编号。

TCP 把在一个连接中要发送的所有数据字节都编上号,它随机选择一个位于 0 ~ (2^32 -1) 之间的一个数字作为第一个字节的编号。当字节都被编上号以后,TCP 就给每一个要发送的报文段指派一个序号 (sequence number)

报文段中确认字段的值定义了某一方期望接受的下一个字节的编号,确认号是累积的

报文段 Segment


最大报文段长度(MSS: Maximum segment size) 表示 TCP 传往另一端的最大块数据的长度。当一个连接建立时,连接的双方都要通告各自的 MSS。对于一个以太网, MSS 值可达 1460 字节。

TCP 连接

TCP 协议在运输层和终点之间建立了一条虚路径。同属于一个报文的所有报文段都沿着这条虚路径发送 (All of the segments belonging to a message are then sent over this virtual path)。

三次握手

  • SYN: Synchronize Sequence Number
  • ACK: Acknowledgement
  • FIN: Finish

三次握手

  • 客户端发送第一个报文段 (SYN 报文段),其不携带任何数据,但是它要消耗一个序号。

  • 服务器发送第二个报文段 (SYN + ACK 报文段),其同样不携带任何数据,但是也要消耗一个序号。

  • 客户端发送第三个报文段 (ACK 报文段),请注意,这个报文段的序号和 SYN 报文段使用的序号一样,也就是说,它不消耗任何序号。在某些实现中,连接阶段的第三个报文段可以携带客户端的第一个数据块,在这种情况下,第三个报文段必须有一个新的序号来表示数据中的第一个字节的编号。

TCP 中使用的连接建立过程很容易碰到一个严重的安全问题,称为 SYN 洪泛攻击 (SYN flooding attack)。当一个或多个恶意的攻击者向某台服务器发送大量的 SYN 报文段,并通过伪造报文段中的源 IP 地址来假装每一个报文段来自不同的客户时,这个问题就发生了。服务器认为这些客户发来了主动打开请求,于是就分配必要的资源,如创建传送控制块 (TCP) 表,并设置一些计时器。然后 TCP 的服务器向这些假冒的客户发送 SYN + ACK 报文段,而这些报文段都丢失了。但是,在服务器等待握手的第三步的这段时间里,大量的资源被占用而没有利用。如果在很短的时间内,SYN 报文段的数量很大,服务器最终会因资源耗尽而不能接受来自合法客户的连接请求。这种 SYN 洪泛攻击属于一组称为 拒绝服务攻击 (denial of service attack) 的安全攻击,即攻击者用大量的服务请求垄断了一个系统,使这个系统因超载而拒绝为合法的请求提供服务。

TCP 的一些实现采取了一些策略减轻 SYN 攻击的影响:

  • 强制限制在指定时间内的连接请求次数
  • 把来自不希望的源地址的数据包过滤掉
  • SCTP: 使用 Cookie,做到推迟资源的分配,直至服务器能够证实连接请求来自合法的 IP 地址

数据传送

数据可以双向传送,并且在同一个报文段中也可以携带确认,确认是随数据捎带过来的。举例,客户使用两个报文段发送了2000字节的数据,然后服务器使用一个报文段发送了2000字节的数据。前三个报文段既带有数据又带有确认,但最后一个报文段只有确认而没有数据。

  • 客户发送第一个报文段

  • 客户发送第二个报文段

  • 服务器发送第一个报文段

  • 客户返回最后一个报文段

客户发送的数据报文段具有置 1 的 PSH (推送) 标志,因此服务器 TCP 知道要在收到这些数据后尽可能快地 (不必等待更多数据的到来)把它们交付给服务器进程。而从服务器发送来的报文段则没有把推送标志置 1.

通过发送一个 URG (紧急) 位置 1 的报文段,可以发送紧急字节。TCP 紧急模式只是发送方的应用程序对某一部分字节流做了标记,要求接收方的应用程序特殊对待,并不是优先处理,也不是加速数据服务。

连接终止

目前,大多数 TCP 实现允许在连接终止时有两种选择:三次挥手具有半关闭选项的四次挥手

三次挥手
  • 客户发送一个把 FIN 位置置为 1 的报文段,这个报文段也可以包含客户发送的最后一块数据,如果不包含,那么它只消耗一个序号。

  • 服务器返回一个 FIN + ACK 报文段,这个报文段同样也可以包含来自服务器的最后一块数据。如果它不携带数据,那么它只消耗一个序号。

  • 客户发送最后一个 ACK 报文段,不携带数据,也不消耗序号。

半关闭

连接的一方停止接收数据,但是仍然发送数据,这称之为半关闭 (half-close)

建立一个连接需要三次握手,而终止一个连接要经过 4 次握手。这由 TCP 的半关闭(half-close)造成的。既然一个 TCP 连接是全双工(即数据在两个方向上能同时传递),因此每个方向必须单独地进行关闭。这原则就是当一方完成它的数据发送任务后就能发送一个 FIN 来终止这个方向连接。收到一个 FIN 只意味着在这一方向上没有数据流动。一个 TCP 连接在收到一个 FIN 后仍能发送数据。

连接复位

某一端的 TCP 可能会拒绝一个连接请求,也可能异常终止一条在用的连接,或者可能要终止一条空闲的连接,所有这些都是通过 RST (复位) 标志来完成的。

连接建立和半关闭终止

MSL 是一个报文段被丢弃之前在因特网中能够生存的最大时间。TCP 报文段是封装在生存时间 (TTL) 受限的 IP 数据报中。当 IP 数据报被丢弃时,封装在其中的 TCP 报文段也就丢失了。MSL 的常用数值是 30 ~ 60 秒。有两个理由使得我们需要 TIME-WAIT 状态和 2MSL 计时器:

  • 如果最后一个 ACK 报文段丢失了,那么服务器 TCP 以为是它的 FIN 丢失了,因而重传它。如果客户已进入 CLOSED 状态,并在 2MSL 计时器超时之前就关闭了这条连接,那么客户就永远收不到这个重传的 FIN 报文段,因而服务器也就永远收不到最后的 ACK。服务器无法关闭这条连接。2MSL 计时器可以使客户等待足够长的时间,使得在 ACK 丢失 (一个 MSL) 的情况下,可以等到下一个 FIN 的到来 (另一个 MSL)。如果在 TIME-WAIT 状态中有一个新的 FIN 到达了,客户就发送一个新的 ACK,并重新启动这个 2MSL 计时器。
  • 某个连接中的重复报文段可能会出现在下一个连接中。客户和服务器关闭连接,经过短暂时间后,它们又打开了一个新的使用相同 Socket 地址,那么前一个连接的重复报文段有可能会到达新连接中。为了避免这个问题,TCP 规定这种情况必须经过 2MSL 时间之后才能出现。

关于 TIME-WAIT 还有弄清楚三点:

(1) 通常,只有一端 — 主动关闭 (发送第一条 FIN ) 的那一端会进入 TIME-WAIT 状态
(2) TIME-WAIT 一般是 0.5 ~ 2 分钟
(3) 如果连接处于 TIME-WAIT 时有分组到达,就重启 2MSL 的定时器

SO_LINGER 选项不建议使用,这会暗杀 TIME-WAIT 状态,强壮的应用程序永远都不应该干扰 TIME-WAIT 状态 —- 这是 TCP 可靠机制的重要组成部分。


TIME_WAIT 太多怎么解决?

修改 /etc/sysctl.conf

1
2
3
4
net.ipv4.tcp_tw_reuse = 1 #表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭;
net.ipv4.tcp_tw_recycle = 1 #表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭。net.ipv4.tcp_timestamps 开启时,net.ipv4.tcp_tw_recycle开启才能生效,。
net.ipv4.tcp_timestamps = 1 #表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭。
net.ipv4.tcp_fin_timeout = 2 #用来设置保持在FIN_WAIT_2状态的时间

保存后 sysctl -p 生效

为什么需要 TIME_WAIT

在 TIME_WAIT 等待的 2MSL 是报文在网络上生存的最长时间,超过阈值便将报文丢弃。一般来说, MSL 大于 TTL 衰减至 0 的时间。在 RFC793 中规定 MSL 为 2 分钟。但是在当前的高速网络中, 2 分钟的等待时间会造成资源的极大浪费,在高并发服务器上通常会使用更小的值。既然 TIME_WAIT 貌似是百害而无 利的,为何不直接关闭,进入 CLOSED 状态呢?原因有如下几点。

第一,确认被动关闭方能够顺利进入 CLOSED 状态。 B 机器收不到来自 A 的最后一个 ACK 的话,那么 B 机器就无法进入 CLOSED 状态。
第二,防止失效请求。这样做是为了防止己失效连接的请求数据包与正常连接的请求数据包混淆而发生异常。

TIME_WAIT 过多

TIME_WAIT 是挥手四次断开连接的尾声,如果此状态连接过多,则可以通过优化服务器参数得到解决。如果不是对方连接的异常,一般不会出现连接无法关闭的情况。

CLOSE_WAIT 过多

CLOSE WAIT 过多很可能是程序自身的问题,比如在对方关闭连接后,程序没有检测到,或者忘记自己关闭连接,没有释放流等等。

三次挥手

连接建立和终止阶段更为常见的是使用三次挥手:

拒绝连接

服务器发送 RST + ACK 报文段后,客户端进入 CLOSED 状态:

TCP 调参

高并发服务器建议调小 TCP 协议的 time_wait 超时时间。操作系统默认 240 秒后,才会关闭处于 time_wait 状态的连接,在高并发访问下,服务器端会因为处于 time_wait 的连接数太多,可能无法建立新的连接,所以需要在服务器上调小此等待值。在 linux 服务器上请通过变更 /etc/sysctl.conf 文件去修改该缺省值 ( 秒 ):

1
net.ipv4.tcp_fin_timeout = 30

调大服务器所支持的最大文件句柄数 (File Descriptor ,简写为 fd) 。主流操作系统的设计是将 TCP / UDP 连接采用与文件一样的方式去管理,即一个连接对应于一个 fd 。主流的 linux 服务器默认所支持最大 fd 数量为 1024,当并发连接数很大时很容易因为 fd 不足而出现 “ open too many files ” 错误,导致新的连接无法建立。 建议将 linux 服务器所支持的最大句柄数调高数倍 ( 与服务器的内存数量相关 ) 。

通过使用 ulimit -n 查看单个进程可以打开文件句柄的数量。通过如下命令

1
lsof -n | awk '{print $2}' | sort | uniq -c | sort -nr | more

查看句柄数量(左侧)与进程号(右侧)的对应关系:

句柄数量和进程的对应

TCP 中的窗口

TCP 为每个方向的数据传送各使用两个窗口 (发送窗口和接受窗口),也就是说总共有四个窗口。

发送窗口:

接受窗口:

通常接受窗口的大小可以这样计算:

1
rwnd = 缓存大小 - 正在等待被拉取的字节数

流量控制 Flow Control

流量控制平衡了生产者生产数据的速度和消费者消耗数据的速度。为了实现流量控制,TCP 强制发送方和接收方不断调整它们的窗口大小,即使双方的缓存大小在连接建立时被固定了下来。流量控制的一个例子:

糊涂窗口综合症 Silly Window Syndrome: 在滑动窗口的操作中可能出现一个严重的问题,这就是发送应用程序产生数据的速度很慢,或者接受应用程序消耗数据的速度很慢,或者两者都有。不管是哪一种情况,都会使得发送数据的报文段很小,这就会降低运行的效率。例如 TCP 发送的报文段只包含 1 个字节的数据,那么意味着我们为此多发送了 40 字节 (20 字节的 IP 首部和 20 字节的 TCP 首部) 的数据,再算上数据链路层和物理层的额外开销后,这种低效率的程度就更加严重了。解决这种问题的方法是防止一个字节一个字节的发送数据。

  • Nagle 算法: 在发送了第一个报文段后 (哪怕只有 1 字节),发送 TCP 在输出缓存中累积数据并等待,直至收到接受 TCP 发来的确认,或者已累积了足够的数据可以装成最大长度的报文段,此时就可以发送这个报文段了。 关闭算法: Socket.setTcpNoDelay()

如果是接收方接受数据的速度太慢,它在收到数据后将其存储在缓存中。现在缓存满了,接收方通知的窗口值为 0,这表示发送方必须立即停止发送数据。对于这种情况,有两种解决办法:

  • Clark 算法: 只要有数据到达就发送确认,但在缓存中有足够大的空间放入最大长度的报文段之前,或者至少有一半的缓存空间为空之前,一直都宣布窗口大小为零。
  • 推迟确认: 接收方在对收到的报文段进行确认之前一直等待,直至输入缓存 (incoming buffer) 有足够的空间为止,减少通信量,但有可能迫使发送方重传未被确认的报文段。目前 TCP 的定义是推迟确认不能超过 500 ms。

差错控制 Error Control

  • 检测和重传受到损伤的报文段
  • 重传丢失的报文段 - 差错控制机制的核心
  • 保存失序到达的报文段直至缺失的报文段到齐
  • 检测和丢弃重复的报文段

TCP 通过使用三个简单的工具来完成其差错控制: 校验和、确认以及超时

ACK 报文段不消耗序号,也不需要被确认。

正常情况:

报文段丢失:

快重传 - 具有更大数值的 RTO:

下一个确认自动纠正丢失确认带来的影响:

丢失的确认被重传的报文段纠正:

拥塞控制 Congestion Control

除了接收方的 rwnd 之外,网络的发送速度是决定发送方窗口大小的第二个实体。

真正的窗口大小: minimum(rwnd, cwnd)

TCP 处理拥塞的一般策略是基于三个阶段: 慢开始、拥塞避免和拥塞检测。

  • 慢开始: 指数增长: 拥塞窗口大小从 1 个最大报文段的长度 (MSS) 开始。每当一个报文段被确认,拥塞窗口就增大一个 MSS。慢开始算法开始很慢,但按指数规律增大。发送方密切关注一个称为 ssthresh (慢开始门限) 的变量。

  • 拥塞避免: 加法增大: 当拥塞窗口大小达到慢开始的门限时,慢开始阶段就停止,而加法增大阶段就开始了。每当一整个 “窗口” 中的报文段都被确认后,拥塞窗口大小才增加 1.一个 “窗口” 就是指在一个 RTT 期间传输的报文段的数量。换言之,这个增长是基于 RTT 的,而不是基于到达的 ACK 的数量。

  • 拥塞检测: 乘法减小: 让发送方能够猜测到拥塞已发生的唯一现象就是它需要重传一个报文段。之所以需要重传是为了恢复一个遗失的分组,而这个分组假设是因为某个路由器有太多的输入分组而不得不丢弃,所以才被丢弃掉的,也就是说路由器或者网络已变得超载或者拥塞了。重传可以发生在以下两种情况之一:当 RTO 计时器超时,或者是当收到了三个重复的 ACK 时。不管哪一种情况,门限值都要下降到一半 (乘法减小)。大多数 TCP 实现有以下两种反应:
  1. 如果是计时器超时,那么出现拥塞的可能性就很大。TCP 会:
    • 设置门限值为当前窗口大小的一半
    • cwnd 重置为 1
    • 再次从慢开始阶段开始
  2. 如果是收到三个 ACK,出现拥塞的可能性就很小。TCP 会启动快重传和快恢复:
    • 设置门限值为当前窗口大小的一半
    • cwnd 设置为门限值
    • 启动拥塞避免阶段

拥塞举例:


[以下内容摘抄自《TCP/IP 卷一》] 为什么要慢开始:当发送方和接收方处于同一个局域网时,这种方式(发送方一开始便向网络发送多个报文段,直至达到接收方通告的窗口大小为止)是可以的。但是如果在发送方和接收方之间存在多个路由器和速率较慢的链路时,就有可能出现一些问题。一些中间路由器必须缓存分组,并有可能耗尽存储器的空间。

现在, TCP 需要支持一种被称为 “慢启动 (slow start)” 的算法。该算法通过观察到新分组进入网络的速率应该与另一端返回确认的速率相同而进行工作。

慢启动为发送方的 TCP 增加了另一个窗口:拥塞窗口 (congestion window),记为 cwnd。当与另一个网络的主机建立 TCP 连接时,拥塞窗口被初始化为 1 个报文段(即另一端通告的报文段大小)。每收到一个 ACK,拥塞窗口就增加一个报文段(cwnd 以字节为单位,但是慢启动以报文段大小为单位进行增加)。发送方取拥塞窗口与通告窗口中的最小值作为发送上限。拥塞窗口是发送方使用的流量控制,而通告窗口则是接收方使用的流量控制。

发送方开始时发送一个报文段,然后等待 ACK。当收到该 ACK 时,拥塞窗口从1增加为2,即可以发送两个报文段。当收到这两个报文段的 ACK 时,拥塞窗口就增加为 4。这是一种指数增加的关系。

在某些点上可能达到了互联网的容量,于是中间路由器开始丢弃分组。这就通知发送方它的拥塞窗口开得过大。拥塞避免算法是一种处理丢失分组的方法。拥塞避免算法要求每次收到一个确认时将 cwnd 增加 1/cwnd。与慢启动的指数增加比起来,这是一种加性增长 (additive increase)。我们希望在一个往返时间内最多为 cwnd 增加 1 个报文。

TCP 的计时器

为了能够顺利地进行 TCP 的操作,大多数的 TCP 实现至少使用 4 个计时器:

  • 重传计时器: 重传丢失的报文段
  • 持续计时器: 在连接的一方需要发送数据但对方已通告窗口大小为 0 时,就需要设置 TCP 的坚持定时器。如果一个确认丢失了,则双方就有可能因为等待对方而使连接终止:接收方等待接收数据(因为它已经向发送方通告了一个非 0 的窗口),而发送方在等待允许它继续发送数据的窗口更新。为防止这种死锁情况的发生,发送方使用一个坚持定时器 (persist timer) 来周期性地向接收方查询,以便发现窗口是否已增大。这些从发送方发出的报文段称为窗口探查 ( window probe )。
  • 保活计时器: 防止两个 TCP 之间长时间空闲。超时通常设置为两个小时。如果服务器过了两个小时还没有收到客户端的任何消息,它就发送一个探测报文段。若连续发送了 10 个探测报文段 (每隔 75 秒一个) 还没有收到响应,他就假定客户端出现了故障,并终止这个连接。
  • TIME-WAIT 计时器: 连接终止期间使用

TCP Package

典型 TCP 实现:

TCP 粘包/拆包问题

TCP 是个 “流” 协议,所谓流,就是没有界限的一串数据,就像河里的流水,它们是连成一片的,其间并没有分界线。发生粘包/拆包的原因:

  • 应用程序 write 写入的字节大小大于套接口发送缓冲区的大小
  • 进行 MSS 大小的 TCP 分段
  • 以太网帧的 payload 大于 MTU 进行 IP 分片

TCP 粘包/拆包问题原因

业界主流解决方案:

  • 消息定长,每个报文固定长度 200 字节,不够补空格
  • 包尾增加回车换行符进行分割,例如 FTP 协议
  • 将消息分为消息头和消息体,消息头中包含表示消息总长度 (或者消息体长度) 的字段,通常设计思路为消息头的第一个字段用 int32 来表示消息的总长度
  • 更复杂的应用层协议
1
2
3
4
5
public void channelRead() {
byte[] req = new byte[buf.readableBytes()];
buf.readBytes(req);
String body = new String(req, "UTF-8");
}

Netty 默认提供多种解码器用于处理半包问题:

1
2
socketChannel.pipeline().addLast(new LineBasedFrameDecoder(1024));
socketChannel.pipeline().addLast(new StringDecoder());

常用 TCP 选项

摘自 memcached.c 源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
setsockopt(sfd, SOL_SOCKET, SO_REUSEADDR, (void *)&flags, sizeof(flags));
if (IS_UDP(transport)) {
maximize_sndbuf(sfd);
} else {
error = setsockopt(sfd, SOL_SOCKET, SO_KEEPALIVE, (void *)&flags, sizeof(flags));
if (error != 0)
perror("setsockopt");

error = setsockopt(sfd, SOL_SOCKET, SO_LINGER, (void *)&ling, sizeof(ling));
if (error != 0)
perror("setsockopt");

error = setsockopt(sfd, IPPROTO_TCP, TCP_NODELAY, (void *)&flags, sizeof(flags));
if (error != 0)
perror("setsockopt");
}
  • SO_REUSEADDR:
  • SO_KEEPALIVE:
  • SO_LINGER:
  • TCP_NODELAY:

SO_REUSEADDR vs SO_REUSEPORT

不使用 SO_REUSE_ADDR:

1
2
绑定 socketA: 0.0.0.0:21
绑定 socketB: 192.168.0.1:21 将会失败,因为 0.0.0.0 包含 192.168.0.1

使用 SO_REUSE_ADDR:

1
绑定 socketB: 192.168.0.1:21 将会成功,因为 0.0.0.0 并不完全等于 192.168.0.1

上述结果与 socketA 和 socketB 的绑定先后顺序无关

SO_REUSEADDR_VS_SO_REUSEPORT

MTU

以太网是 1500 字节,光纤是 4000 字节

我们为什么一定需要三次握手,而不是两次?

双方使用 Sequence number 来跟踪它们已经发送的信息。然而序列号并不是由 0 开始的,而是由 ISN (Initial Sequence Number), 即一个随机生成的数字。The three-way handshake is necessary because both parties need to synchronize their segment sequence numbers used during their transmission.

1
2
3
4
Alice ---> Bob    SYNchronize with my Initial Sequence Number of X
Alice <--- Bob I received your syn, I ACKnowledge that I am ready for [X+1]
Alice <--- Bob SYNchronize with my Initial Sequence Number of Y
Alice ---> Bob I received your syn, I ACKnowledge that I am ready for [Y+1]

在这个过程中,产生了四个事件:

  1. Alice picks an ISN and SYNchronizes it with Bob.
  2. Bob ACKnowledges the ISN.
  3. Bob picks an ISN and SYNchronizes it with Alice.
  4. Alice ACKnowledges the ISN.

在实际中,第 2 个事件和第 3 个事件可以在放到一个包中,所以三次握手就够了:

1
2
3
Bob <--- Alice         SYN
Bob ---> Alice SYN ACK
Bob <--- Alice ACK

两次的话,只能保证一方建立一个 ISN,另一方 Acknowledge 它。但是另一方却无法发送任何数据。

ISN 不能从 0 开始因为: TCP sequence prediction attack The attacker hopes to correctly guess the sequence number to be used by the sending host. If they can do this, they will be able to send counterfeit(伪造) packets to the receiving host which will seem to originate from the sending host, even though the counterfeit(伪造) packets may in fact originate from some third host controlled by the attacker.

《码出高效》的解释

  • 信息对等: 双方机器互相确认可以收发报文
  • 防止请求超时导致创建脏连接:

如果是三次握手 , 则 B 机器收到连接请求后,同样会向 A 机器确认同意创建连接,但因为 A 机器不是 SYN_SENT 状态,所以会直接丢弃, B 机器由于长时间没有收到确认信息 (两次握手无需确认,没有这一步),最终超时导致连接创建失败,因而不会出现脏连接。

TCP 中间人攻击

服务器端在 accept 一个文件描述符之后怎么提高安全性?

Internet Protocol Suite

VPN 工作在数据链路层。

scp 卡死

  • 从抓包中可以明显知道 scp 之所以卡死是因为丢包了,客户端一直在重传,图中绿框
  • 图中篮框显示时间间隔,时间都是花在在丢包重传等待的过程
  • 奇怪的问题是图中橙色框中看到的,网络这时候是联通的,客户端跟服务端在这个会话中依然有些包能顺利到达(Keep-Alive包)
  • 同时注意到重传的包长是1442,包比较大了,看了一下tcp建立连接的时候MSS是1500,应该没有问题
  • 查看了scp的两个容器的网卡mtu都是1500,正常

scp 传输的时候实际路由大概是这样的:

1
容器A---> 宿主机1 ---> ……中间的路由设备 …… ---> 宿主机2 ---> 容器B
  • 前面提过其它容器 scp 同一个文件到容器 B 没问题,所以我认为中间的路由设备没问题,问题出在两台宿主机上
  • 在宿主机 1 上抓包发现抓不到丢失的那个长度为 1442 的包,也就是问题出在了 容器A—> 宿主机1 上

查看宿主机1的 dmesg 看到了这样一些信息

1
2
2016-08-08T08:15:27.125951+00:00 server kernel: openvswitch: ens2f0.627: dropped over-mtu packet: 1428 > 1400
2016-08-08T08:15:27.536517+00:00 server kernel: openvswitch: ens2f0.627: dropped over-mtu packet: 1428 > 1400

到这里问题已经很明确了 openvswitch 收到了 一个 1428 大小的包因为比 mtu1400 要大,所以扔掉了,接着查看宿主机 1 的网卡 mtu 设置果然是 1400,悲催,马上修改 mtu 到 1500,问题解决。


  • Q: 传输的包超过 MTU 后表现出来的症状?
  • A: 卡死,比如 scp 的时候不动了,或者其他更复杂操作的时候不动了,卡死的状态。
  • Q: 为什么我的 MTU1500,但是抓包看到有个包 2700,没有卡死?
  • A: 有些网卡有拆包的能力,具体可以 Google:LSO、TSO,这样可以减轻 CPU 拆包的压力,节省CPU资源。

关于TCP 半连接队列和全连接队列

问题描述:

JAVAclientserver,使用 socket 通信。server 使用 NIO

  1. 间歇性的出现 clientserver 建立连接三次握手已经完成,但 serverselector 没有响应到这连接
  2. 出问题的时间点,会同时有很多连接出现这个问题。
  3. selector 没有销毁重建,一直用的都是一个。
  4. 程序刚启动的时候必会出现一些,之后会间歇性出现。

通过 ss -s 去看队列的溢出统计数据:

1
667399 times the listen queue of a socket overflowed

反复看了几次之后发现这个 overflowed 一直在增加,那么可以明确的是 server 上全连接队列一定溢出了。

接着查看溢出后,OS怎么处理:

1
2
# cat /proc/sys/net/ipv4/tcp_abort_on_overflow
0

tcp_abort_on_overflow0 表示如果三次握手第三步的时候全连接队列满了那么 server 扔掉 client 发过来的 ack(在 server 端认为连接还没建立起来)

为了证明客户端应用代码的异常跟全连接队列满有关系,我先把 tcp_abort_on_overflow 修改成 1,1 表示第三步的时候如果全连接队列满了, server 发送一个 reset 包给 client,表示废掉这个握手过程和这个连接(本来在 server 端这个连接就还没建立起来)。

接着测试然后在客户端异常中可以看到很多 connection reset by peer 的错误,到此证明客户端错误是这个原因导致的。


The maximum queue length for incoming connection indications (a request to connect) is set to 50. If a connection indication arrives when the queue is full, the connection is refused.

Apparently your ServerSocket never accepts any connections, just listens. You must either call accept() and start handling the connection or increase the backlog queue size:

1
new ServerSocket(port, 100)

简单来说 TCP 三次握手后有个 accept 队列,进到这个队列才能从 Listen 变成 accept,默认 backlog 值是 50,很容易就满了。满了之后握手第三步的时候server就忽略了 client 发过来的 ack 包(隔一段时间 server 重发握手第二步的 syn+ack 包给 client),如果这个连接一直排不上队就异常了。

如上图所示,这里有两个队列: syns queue(半连接队列); accept queue(全连接队列)

三次握手中,在第一步 server 收到 clientsyn后,把相关信息放到半连接队列中,同时回复 syn+ackclient(第二步);

比如 syn floods 攻击就是针对半连接队列的,攻击方不停地建连接,但是建连接的时候只做第一步,第二步中攻击方收到 serversyn+ack 后故意扔掉什么也不做,导致 server 上这个队列满其它正常请求无法进来。

第三步的时候 server 收到 clientack ,如果这时全连接队列没满,那么从半连接队列拿出相关信息放入到全连接队列中,否则按 tcp_abort_on_overflow 指示的执行。

这时如果全连接队列满了并且 tcp_abort_on_overflow0 的话,server 过一段时间再次发送 syn+ackclient(也就是重新走握手的第二步),如果 client 超时等待比较短,就很容易异常了。

在我们的 osretry 第二步的默认次数是 2 (centos默认是5次):

1
net.ipv4.tcp_synack_retries = 2

如果TCP连接队列溢出,有哪些指标可以看呢?

上述解决过程有点绕,那么下次再出现类似问题有什么更快更明确的手段来确认这个问题呢?

1
netstat -s
1
2
3
[root@server ~]#  netstat -s | egrep "listen|LISTEN" 
667399 times the listen queue of a socket overflowed
667399 SYNs to LISTEN sockets ignored

比如上面看到的 667399 times ,表示全连接队列溢出的次数,隔几秒钟执行下,如果这个数字一直在增加的话肯定全连接队列偶尔满了。

ss 命令:

1
2
3
[root@server ~]# ss -lnt
Recv-Q Send-Q Local Address:Port Peer Address:Port
0 50 *:3306 *:*

上面看到的第二列 Send-Q 表示第三列的 listen 端口上的全连接队列最大为 50,第一列 Recv-Q 为全连接队列当前使用了多少。

全连接队列的大小取决于:min(backlog, somaxconn) . backlog是在socket创建的时候传入的,somaxconn是一个os级别的系统参数

半连接队列的大小取决于:max(64, /proc/sys/net/ipv4/tcp_max_syn_backlog)。 不同版本的os会有些差异

TCP 的安全性

安全性是指相对安全:

  • 握手机制
  • sequence number

参考

推荐文章