深入理解 TCP 三次握手
导图
索引
[toc]
基本握手流程
图为三次握手基本流程:
客户端发送 同步序列号 SYN 至服务器
服务器接收到后回复确认 ACK,并附带自己的同步序列化 SYN
客户端确认服务器的消息回复 ACK
深入探讨细节
TCP 连接状态共有哪些?如何在 Linux 系统中查看 TCP 状态?
由图可知,TCP 状态共包含以下11个,其中连接状态有 9 个
CLOSED:连接处于关闭状态
LISTEN:端口处于监听状态
SYN_SENT:客户端发送第一个 SYN 同步序列号后,自动进入 SYN_SENT 状态
SYN_RECV:服务端收到来自客户端的SYN后,进入 SYN_RECV 状态
ESTABLISHED:C/S 两端三次握手完成,已同步双方序列号,连接成功建立后进入 ESTABLISHED 状态
FIN_WAIT_1:主动关闭方发送FIN报文后,表示不再发送数据,此后进入 FIN_WAIT_1 状态,等待被动方的ACK确认报文
CLOSE_WAIT:CLOSE_WAIT 顾名思义,等待进程调用 close 函数关闭连接。被动关闭方接收到 FIN 报文,自动回复 ACK 确认报文,表示确认知晓你方发送通道关闭。此时如果有未发送完成的数据会继续发送给主动关闭方,如果没有数据需要发送了,则可能将 ACK 确认报文以及 FIN 报文一同发送给主动关闭方
CLOSING:仅出现在某些特殊情况,例如双方同时发送FIN报文,随后在等待 ACK 报文的过程中,都等来了 对方的FIN 报文,在此情况,连接会进入到 CLOSING 状态,它替代了 FIN_WAIT2 状态,此时内核会自动回复 ACK 确认对方发送通道的关闭,同时等待对方确认己方FIN返回ACK报文,如果产生丢包或超时,也会适时重发 FIN 报文的情况下最终关闭
LAST_ACK:被动关闭方发送FIN后,等待主动方返回 ACK 来确认连接关闭
FIN_WAIT_2:主动关闭方收到被动方的 ACK 后进入 FIN_WAIT2 状态
TIMEWAIT:主动关闭方收到被动方发来的 FIN 报文时,主动方回复 ACK,表示确认对方的发送通道已经关闭,连接随之进入 TIME_WAIT 状态,等待 60 秒后关闭连接,进入最终 CLOSED 状态
可以通过 ss -s
命令获取系统连接状态汇总,不过由于命令返回的瞬时数据,无法呈现这一指标的趋势变化,所以通常需要结合监控系统来进行更全面的观察分析
$ ss -s
Total: 346 (kernel 392)
TCP: 53 (estab 25, closed 12, orphaned 0, synrecv 0, timewait 2/0), ports 0
Transport Total IP IPv6
* 392 - -
RAW 0 0 0
UDP 6 4 2
TCP 41 15 26
INET 47 19 28
FRAG 0 0 0
Grafana 看板
为什么是三次握手?不是两次、四次?
简单来说,主要有四个方面的原因:
- 三次握手才能保证双方具有接收发送的能力 😄
- 三次握手才可以阻止重复历史连接的初始化 😀
- 三次握手才可以同步双方的初始序列号 😘
- 三次握手才可以避免资源浪费(SYN+ACK)👏
原因一:三次握手才能保证双方具有接收发送的能力 😄
第一次握手,客户端发送SYN,如果报文成功发出,并被服务器端接收到。此时表明客户端发送功能OK,服务器端接受功能OK~
第二次握手,服务器端发送SYN+ACK,如果报文成功发出,并被客户端接收到,此时证明客户端发送功能OK,服务器端收发功能都OK~
第三次握手,客户端发送ACK,如果报文成功发出,并被服务器端接收到。此时证明客户端及服务器两端收发功能都OK~
原因二:三次握手才可以阻止重复历史连接的初始化 😀
RFC 793 所指出 TCP 连接使用三次握手的首要原因
The principle reason for the three-way handshake is to prevent old duplicate connection initiations from causing confusion
简单翻译下,就是为了防止旧的重复连接初始化造成混乱。
那再思考下,什么叫旧的连接?以及为什么旧的连接会造成初始化混乱?
网络世界,错综复杂,数据包在无数网络线路中流转,可能会遇到各种各样的问题,有时先发送的数据包,会如我们所期待的那样,先到达目标主机。可有时并非如此,由于网络拥堵等乱七八糟的原因,会使得旧的数据包,先到达目标主机,那么这种情况下 TCP 三次握手是如何工作的呢?
试想下,客户端连续发送了多次 SYN 建立连接的报文,由于网络产生短时拥堵,导致其中两个 SYN 发生错序:
- 一个 旧 SYN 报文 90 比 最新的 SYN 100 报文早到达了服务端 😱
- 服务器端本身无法辨别 SYN 新旧,所以自动回复 SYN + ACK(300,90+1)
- 客户端收到后,根据自身的上下文,判断此为历史连接(序列号过期或超时)
- 随后客户端发送 RST 报文给服务端,表示中止该连接(不排除有建立连接的可能性,但此类连接通常为无效连接,空占资源)
随后,待到服务端接收到有效 SYN 连接,才会开始进行正常的三次握手流程
原因三:三次握手才可以同步双方的初始序列号 😘
初始序列号 ISN,为什么我们需要让 C/S 同步序列号?序列号存在意义是什么呢 ?只有解开这些问题,我们才能更好的理解TCP 工作原理。
在TCP 协议中。通信双方都必须维护一个「序列号」(共两个序列号), 序列号是可靠传输的一个关键因素,它的主要作用包括以下几点:
- 接收方通过序列号,可以去除重复的数据(服务端接收到X数据报文,但回复ACK时确认报文丢失,客户端超时重传发送X数据报文,服务端通过序列号识别出X为已接收数据报文)、
- 接收方可以根据数据包的序列号按序接收
- 发送方可以标识发送出去的数据包中, 哪些是已经被对方收到的
原因四:三次握手才可以避免资源浪费(SYN+ACK)👏
上面已经已经解答了为什么握手不是两次,原因很简单,二次握手不能很好的处理的由于网络延迟导致的历史连接问题。那么既然不能是两次,那为什么不可以是四次呢?
这个问题,回答起来很简单!
四次握手其实也能够可靠的同步双方的初始化序号,但由于第二步和第三步可以优化成一步,那么我们何必多发送一次报文呢?所以这就是 TCP「三次握手」的原因。
为什么客户端和服务端的初始序列号 ISN 是不相同的?
- 为了通信双方能够根据序号,将不属于本连接的报文段丢弃
- 为了安全性,防止黑客伪造的相同序列号的 TCP 报文被对方接收
初始序列号 ISN 是如何生成的?
起始 ISN 是基于时钟生成的,每 4 毫秒 + 1,转一圈要 4.55 个小时。
RFC1948 中提出了一个较好的初始化序列号 ISN 随机生成算法。
ISN = M + F (localhost, localport, remotehost, remoteport)
- M 是一个计时器,这个计时器每隔 4 毫秒加 1。
- F 是一个 Hash 算法,根据源 IP、目的 IP、源端口、目的端口生成一个随机数值。要保证
Hash 算法不能被外部轻易推算得出,用 MD5 算法是一个比较好的选择。
三次握手中有哪些队列?分别有什么作用?
- SYN 半连接队列:SYN 半连接队列是用来维护未完成握手的连接信息,当这个队列溢出后,服务器将无法再建立新连接。
- ACCEPT 全连接队列:服务器端接收到三次握手的最后一个ACK确认报文,此时C/S双方收发功能都正常且已同步序列号。内核会把连接从 SYN 半连接队列中移出,然后移入 accept 队列,等待进程调用 accept 函数时把连接取出来。如果进程不能及时地调用 accept 函数,就会造成 accept 队列溢出,最终导致建立好的 TCP 连接被丢弃。
当流量上升、节点负载增加、或遇到网络攻击时,这两个队列很有可能会导致无法与客户端建立连接,具体问题下面会详细分析和讨论
握手性能优化
客户端优化
SYN 报文重传次数
net.ipv4.tcp_syn_retries
客户端发送 SYN 开启了三次握手,等待服务器回复的 ACK 报文,如果客户端迟迟没有收到 ACK,客户端会重发 SYN,重试的次数由 tcp_syn_retries
参数控制,默认是 6 次:
net.ipv4.tcp_syn_retries = 6
等待超过 1 秒中未收到 ack,那么进入重试流程,按照参数配置,它会重试 6 次,这 6 次重试会以 2、4、8、16、32、64 秒为间隔进行重试,最后一次重试,系统会等待 64 秒,如果仍然没有收到来自客户端返回的 ACK,将会终止终止三次握手。
所以,总耗时是 1+2+4+8+16+32+64=127
秒,超过 2 分钟。
可以根据网络的稳定性,以及服务器的繁忙程度修改重试次数,比如内网中通讯时,就可以适当调低重试次
数,尽快把错误暴露给应用程序。
net.ipv4.tcp_syn_retries = 3
服务端优化
syn_backlog 半连接队列
三次握手的服务端优化,我们先看第一个常见容易发生问题的点 tcp_syn_backlog 半连接队列溢出,既然说到溢出,那么就不得不思考以下几个问题
- 什么是半连接队列溢出?
- 为什么半连接队列会溢出?
- 半连接队列溢出会导致什么问题?
- 如何分析当前系统是否存在溢出?
- 发生半连接队列溢出了,我该怎么办?
OK,这些我们一个个来讨论
什么是半连接队列溢出?半连接队列溢出会导致什么问题?
首先,我们知道,当服务器收到来自客户端的 SYN 报文后,会立刻回复 SYN+ACK 报文,以此确认客户端的序列号,并发送自己的序列号。
此时,对于这个还未真正完成建立的新连接(SYN_RCV
),服务器会通过一个 SYN 半连接队列 来维护,而当半连接队列大量出现未完成握手的连接,数量超过 tcp_max_syn_backlog <默认 128>
后,队列发生溢出,服务器将无法再建立新连接。
现在,我们已经知道了问题 1、3 的答案
为什么半连接队列会溢出?
接着来看第二个问题,为什么半连接队列会溢出?
首先,在 低流量负载、网络质量好的情况下,半连接队列是基本不可能出现的队列溢出,反之,当流量增大 或 网络发生抖动时,服务端无法在短时间内向大量的客户端发送 sync/ack
报文,那么就会导致半连接队列会溢出
除此外,还有一种场景,那就是低流量、网络质量也不错时,也出现了半连接队列溢出,那么九成以上的概率说明系统遇到了 syn flood
洪水攻击
SYN Flood 洪水攻击的大致原理如下:
- 客户端构造大量随机源 IP 的 SYN 包,向服务端发送 TCP 连接建立请求
- 服务器收到包后,会向源 IP 发送 SYN+ACK 报文,并等待三次握手的最后一次 ACK 报文
- 当服务端超时未收到 客户端的 ack 后,会按照
tcp_synack_retries
参数配置的次数进行重试
毫无疑问,服务端的重试已没有意义的,就这样,大量的恶意的半连接占满 syn_backlog
队列,导致正常的连接无法建立,影响服务器对外提供服务
如何分析当前系统是否存在溢出?
答案是 netstat -s
如下所示,通过 netstat -s | grep "SYNs to LISTEN"
命令可以获得由于半连接队列已满,而引发的失败次数的统计结果
$ netstat -s|grep "SYNs to LISTEN"
79704 SYNs to LISTEN sockets dropped
补充说明一下,还可以通过 /proc/net/netstat
获取半连接、全连接队列溢出数
$ netstat -s|egrep -i "socket(s)? (overflowed|dropped)"
23789 times the listen queue of a socket overflowed
23808 SYNs to LISTEN sockets dropped
$ cat /proc/net/netstat | awk '/TcpExt/ { print $20,$21}'
ListenOverflows ListenDrops
23789 23808
该值为 SYN 半连接队列溢出导致连接被丢弃的个数累加值,如果数值在持续增加,则说明系统存在半连接队列溢出的问题
发生半连接队列溢出了,我该怎么办?
两个思路
- 调大队列,让队列能容纳更多的半连接
- 减少
tcp_synack_retries
重试次数,避免网络资源浪费 - 配置使用 syncookies,让队列溢出不再影响正常连接的建立
我们分别看下这两个思路如何实践
思路一:调大队列,让队列能容纳更多的半连接
半连接队列长度受 tcp_max_syn_backlog
、somaxconn
参数控制,我们可以通过增大这两个参数调大队列,这里的 somaxconn
参数配置影响 半连接、全连接两个队列的长度上限,所以需要一并设置
具体方式如下:
# 临时设置
$ sysctl -w net.ipv4.tcp_max_syn_backlog=2048
$ sysctl -w net.core.somaxconn=2048
# 永久设置
$ vim /etc/sysctl.conf
net.ipv4.tcp_max_syn_backlog=2048
net.core.somaxconn=2048
思路二:减少 tcp_synack_retries
重试次数
如果服务器没有收到 ACK,就会一直重发 SYN+ACK 报文,与客户端重发 SYN 类似,它的重试会经历 1、2、4、8、16 秒,最后一次重试后等待 32 秒,若仍然没有收到 ACK,才会关闭连接,故共需要等待 63 秒
当网络繁忙、不稳定时,可以适当的调大重发次数,提高连接的成功率
如果遇到 SYN Flood,则可以调小重试次数,从而避免网络资源的浪费
通过调整 tcp_synack_retries
参数可以修改重发次数,默认值为 5,可以按需调整为 3
或 6
$ sysctl -w net.ipv4.tcp_synack_retries=3
$ vim /etc/sysctl.conf
net.ipv4.tcp_synack_retries=3
思路三:启用 syncookies 功能,让队列溢出不再影响正常连接的建立
SYN 半连接队列已满,并不代表一定非得丢弃客户端连接,我们还可以配置开启 syncookies 功能,这样就可以降低 半连接队列被打满 导致无法与客户端建立连接,甚至我们还可以做到不使用 SYN 队列的情况下成功建立连接,这取决 tcp_syncookies
参数的配置
首先,先看一下 syncookies 工作原理如下:
- 客户端发送 syn 报文
- 服务端接收到来自客户端的 syn 报文
- 服务器判断自身是否开启
tcp_syncookies
以及syn_backlog
是否溢出决定后面的选择tcp_syncookies
参数为 0,说明系统没有开启该功能,则丢弃该连接tcp_syncookies
参数为 1,说明功能已配置开启,此时如果syn_backlog
队列发生溢出,那么服务端计算生成一个 cookie 值,进入流程 4tcp_syncookies
参数为 2,说明功能已配置开启,服务端不会判断syn_backlog
队列是否发生溢出,直接计算生成一个 cookie 值,进入流程 4
- 将计算出的 cookie 值,放在即将发出的 SYN/ACK 报文中
- 客户端收到 SYN/ACK 报文,从中拿到 cookie,回复 ACK 报文时携带该 cookie
- 服务端从 ack 报文取出 cookie 进行校验,如果合法,就认为连接建立成功
- 服务器将连接直接放入 accept 队列
基于 syncookies 功能,我们可以在一定程度上解决 syn 洪水攻击带来的问题,不过 syncookies 仅用于应对 SYN 泛洪攻击,且使用这种方式建立的连接,许多 TCP 特性都无法使用,所以通常来说建议配置 1
,即仅在队列满时再启用。
# 临时设置
$ sysctl -w net.ipv4.tcp_syncookies=1
# 永久设置
$ vim /etc/sysctl.conf
net.ipv4.tcp_syncookies=1
accept 全连接队列
按照同样的的流程,去思考以下几个问题
- 什么是全连接队列溢出?
- 为什么全连接队列会溢出?
- 全连接队列溢出会导致什么问题?
- 如何分析当前系统是否存在全连接队列溢出?
- 发生全连接队列溢出了,我该怎么办?
什么是全连接队列溢出?
在三次握手的最后一步,服务器收到客户端的 ACK,则代表连接建立成功。
什么是全连接队列溢出?
此时,内核会把连接从 SYN 半连接队列中移至 accept 队列,等待进程调用 accept 函数把连接取出来。如果进程不能及时地调用 accept 函数,就会造成 accept 队列溢出,最终导致建立好的 TCP 连接被丢弃。全连接队列溢出会导致什么问题?
这一点和半连接队列一致,队列满了就丢弃连接
为什么全连接队列会溢出?
导致 全连接队列溢出的原因一般都是因为进程调用 accept()
太慢了,大量连接堆积在队列中,没有被应用及时拿走,那么什么时候 accept()
会很慢呢,大致有几个原因
- CPU 繁忙:CPU 繁忙自然会导致 进程可用的时间片减少,对应的 accept 速度也就满了
- IO 繁忙:IO 繁忙就会导致出现大量 IO WAIT,大量 IO WAIT 也会占用时间片,影响进程去 accept 连接
如何分析当前系统是否存在全连接队列溢出?
上面提到过 netstat -s
,这里的 overflowed
就是全连接队列溢出数的累加值
$ netstat -s|egrep -i "socket(s)? (overflowed|dropped)"
23789 times the listen queue of a socket overflowed
23808 SYNs to LISTEN sockets dropped
$ cat /proc/net/netstat | awk '/TcpExt/ { print $20,$21}'
ListenOverflows ListenDrops
23789 23808
由于这个值是累加值,很难看出趋势,所以一般会结合监控系统,在 Grafana 看板上观察分析
除了 netstat
,还可以使用 ss -lnt
$ ss -lnt
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 129 128 *:80 *:*
注意看 Recv-Q
与 Send-Q
两列,在不同状态下,它们的含义不同
- LISTEN 状态
- Send-Q 表示 accept 全连接队列最大限制大小,最大 128
- Recv-Q 表示 accept 全连接队列当前已用大小,已用 129,说明已经发生了溢出
- ESTABLISHED 状态
- Send-Q:发送数据包的 buffer
- Recv-Q:接受数据包的 buffer
通过上面两种方式,都可以分析我们系统当前是否存在溢出
发生全连接队列溢出了,我该怎么办?
在说处理全连接队列溢出思路之前,先补充说明一个点,丢弃连接只是 Linux 的默认行为
除此外,我们可以选择向客户端发送 RST 复位报文,通知客户端由于accept队列溢出连接建立失败,打开这一功能需要将 tcp_abort_on_overflow
参数设置为 1
net.ipv4.tcp_abort_on_overflow = 1
但是,通常情况下,应当把 tcp_abort_on_overflow 设置为 0,因为这样更有利于应对突发流量
为什么这么说,我给你举个例子:
当出现流量增大、服务端负载飙高时,虽然服务器短时间无法快速的从队列里 accept 连接,导致其他客户端发送过来的 ACK 被丢弃,但是,此时客户端的连接状态已经进入 ESTABLISHED
对于客户端而言,它就在建立好的连接上不停地发送请求,只要服务端没有为请求回复 ACK,请求就会被多次重发
这时,如果服务器上的进程只是短暂的繁忙,导致 accept 队列被堆满,那么当服务器过了繁忙期,accept 队列有空位时,服务器再次接收到来自客户端的重试请求报文,由于报文内含有 ACK,仍然会触发服务器端成功建立连接。
所以,tcp_abort_on_overflow 设为 0 可以提高连接建立的成功率,除非你非常肯定 accept 队列会长期溢出时,才能设置为 1 以尽快通知客户端。
OK,接下来,我们来看比较常用的处理思路,处理全连接队列溢出,主要就两个思路
- 增加队列长度
- 减少重试次数
先来看看如何调整 accept 队列的长度
首先,配置系统 accept 队列最大上限,net.core.somaxconn
$ sysctl -w net.core.somaxconn=2048
$ vim /etc/sysctl.conf
net.core.somaxconn=2048
修改之后,应用大概率是不会即时生效的,如下所示
通过 ss -ltnp|awk '{print $3,$NF}'
获取处于 Listen 状态的端口监听队列(全连接队列)
$ ss -ltnp|awk '{print $3,$NF}'
Send-Q Address:Port
128 users:(("alertmanager",4638,5))
128 users:(("alertmanager",4747,5))
128 users:(("redis-server",27587,4))
128 users:(("nginx",14433,6),("nginx",12003,6))
128 users:(("nginx",23374,6),("nginx",23373,6))
...
接下来,我们还需要调整的应用(服务、容器)进行设置
不同服务配置 backlog 的方式都不尽相同,以 Nginx 为例,配置服务(应用)listen
函数的 backlog
参数
server {
listen 80 default backlog=2048;
server_name localhost;
#access_log logs/host.access.log main;
location / {
root /opt/nginx/html;
index index.html index.htm;
}
}
更改参数配置后,需要重启服务,因为 SYN 半连接队列和 accept 队列都是在 listen() 时初始化的。
# 重启生效前队列长度
$ ss -ltnp|awk '{print $3,$NF}'|grep nginx
128 users:(("nginx",14433,6),("nginx",12003,6))
# 重启服务
$ ./nginx/sbin/nginx -s reload
# 监听队列由 128 变更为1024
$ ss -ltnp | awk '{print $3,$NF}'|grep nginx
2048 users:(("nginx",18903,6),("nginx",12003,6))
对于运行在容器环境的应用来说,我们需要在 docke run
启动时添加 --sysctl net.core.somaxconn=2048
为容器环境设置 全连接队列上限,然后在应用配置文件内再次设置 listen 上限
$ docker run -ti --sysctl net.core.somaxconn=2048 --rm ubuntu /bin/bash
root@9e850908ddb7:/# sysctl net.core.somaxconn
net.core.somaxconn = 2048
对于 Kubernetes 平台的 Pod 而言,可以使用 securityContext
设置 ,然后在应用配置文件内再次设置 listen 上限
apiVersion: v1
kind: Pod
metadata:
name: sysctl-example
spec:
securityContext:
sysctls:
- name: net.core.somaxconn
value: "8096"
TFO 技术
TFO是什么?
TFO(TCP fast open) 是对计算机网络中(TCP)连接的一种简化握手手续的拓展,用于提高客户端服务端间连接的打开速度。
TFO(TCP fast open) 是 TCP 协议的实验性更新 experimental update,它允许服务器和客户端在连接建立握手阶段交换数据,从而使应用节省了一个 RTT 的时延。
但是 TFO 在某些环境存在一些问题,因此协议要求 TCP 实现必须默认禁止TFO,如果想要使用TFO功能,则需要在服务端程序显式配置启用。
TFO 解决了什么问题?
减少三次握手时间消耗,据 Google 统计,三次握手消耗时间占 HTTP 请求完成总时间 10% 到 30% 之间。
TFO 是如何实现的?
接下来,具体看下 TFO 工作流程,它把通讯分为两个阶段:
首先,在使用 TFO 之前,客户端需要通过一次普通三次握手来获取 FOC(Fast Open Cookie)
- 1.客户端发送一个带有 Fast Open 选项的 SYN 包,同时携带一个空的 cookie 域来请求一个 cookie
- 2.服务端使用秘钥生成一个 cookie,然后通过 SYN-ACK 包的 Fast Open 选项来返回给客户端
- 3.客户端缓存这个 cookie 以备将来使用 TFO 建立连接的时候使用
拥有 TOC 执行 TFO
- 1.客户端发送一个带有数据的 SYN 包,同时在 Fast Open 选项中携带之前通过正常连接获取的 cookie
- 2.服务端使用密钥验证 TOC 有效性,如果验证通过,服务端会返回 SYN-ACK 报文,连接建立成功,然后服务端把接收到的数据传递给应用层。如果 TOC 是无效的,服务端会丢掉SYN包中的数据,同时返回一个SYN-ACK包来确认SYN包中的序列号
- 3.如果 TOC 有效,在连接完成之前,服务端可以给客户端发送响应数据,携带的数据量受到 TCP 拥塞控制的限制(RFC5681)。
- 4.客户端发送 ACK 包来确认服务端的 SYN 和数据,如果客户端 SYN 包中的数据没有被服务器确认,客户端会在这个ACK包中重传对应的数据
- 5.剩下的连接处理就类似正常的 TCP 连接了,客户端一旦获取到 FOC,可以重复 Fast Open 直到 TOC 过期。
TFO 代码实战
配置服务端开启 tfo 功能
$ sysctl -w net.ipv4.tcp_fastopen=3
nginx 服务端
server {
# 启用 fastopen,并限制尚未完成三方握手的连接队列的最大长度
listen 8080 fastopen=500;
server_name localhost;
access_log logs/access.log main;
real_ip_header X-Real-IP;
location /{
root html;
}
}
修改后重启应用配置
$ ./nginx -t
$ ./nginx -s reload
tcp_fast_open_client.py
#!/usr/bin/env python
import sys
import socket
TCP_FASTOPEN = 0x20000000
dest_name = sys.argv[1]
port = int(sys.argv[2])
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.sendto("GET / HTTP/1.0\r\n\r\n".encode(), TCP_FASTOPEN, (dest_name, port))
data = s.recv(1024)
s.close()
print(data.decode())
抓包实战
正常握手阶段
服务端:开始抓包
$ tcpdump tcp and port 8080 -w tfo_nginx_test.pcap
客户端:执行客户端测试脚本
# 首次执行
$ python3 head_client.py 123.206.25.230 8080
...
# 等待几秒后(尽量避免报文乱序),再次执行
$ python3 head_client.py 123.206.25.230 8080
...
下载抓包文件,导入 wireshark 进行分析,追踪第一个 tcp 流(首次正常三次握手)
由图可见,首次正常三次握手的 SYN 报文中,TCP OPTIONS 附加了 TOC 请求。
服务端返回SYN,ACK,同时生成并回传了长度12位TOC(1a078126fcc7ea1c)
客户端将 ACK 与 PSH 合并成一个报文,以此减少发送报文
服务端确认请求包,并返回响应头以及响应正文,最后客户端发送FIN断开连接。PS:不过不清楚这里为什么是两次就断开了?
快速打开阶段
客户端:可以看到,由于上次握手已经获取到 TOC,现在首个 SYN 包(1)就是发送 GET 请求(TCP OPTION 附带 TOC)
服务端:密钥验证 TOC 有效性,验证通过后回复SYN,ACK(2),连接建立成功,Nginx 进程处理处理客户端请求返回响应头(3)及正文(4)
最终断开连接,不过断开这里还是有点迷~ 正常的 客户端先 FIN、服务端回 ACK,结果客户端直接给了个 RST…
TFO 存在的问题
并非所有设备/应用都支持
TFO的IPv4支持在3.6(客户端)和3.7(服务端)版本中被合并进Linux内核。IPv6服务器的TFO支持被合并进入3.16版本。
数据包途径某些中间件(防火墙,NAT)可能会引起问题,因为这是对旧协议的相对较新的更改
如果客户端需要使用 SYN 发送的数据很大(>~1400字节),则 TCP Fast open 不会优化任何内容
第一个 SYN 数据包可能会重复,因此应用程序需要进行特殊处理。
在 TFO 下随着 SYN 发送的数据有可能重复递交到应用层。例如在 IP 层不可靠传输的情况下,发送端的一个 SYN 包被传输成了两个 SYN 包,而在接收端,接收到第一个 SYN 包后,接收端把随 SYN 的数据传递到应用层。
随后,客户端发起关闭 TCP 连接的操作,服务端由于不会进入 TIME_WAIT 保护状态,可能会收到延迟的重复包,此时可能导致将附带数据的 SYN 报文再次传向应用层。因此如果应用层不能忍受这种包重复,则不能开启 TFO 特性。
TFO 应用场景
TFO 的核心原理是当拥有 TOC 后,下次建立连接时,客户端在第一个 SYN 报文中携带请求数据,以此减少一个 RTT。可此时服务器的 SYN 报文还没有发给客户端,客户端能否与自己成功建立连接,服务器并不确定。
于是,此时服务器需要去假定连接已经建立成功,并把请求交付给进程去处理,所以换言之服务器必须提前就能够信任这个客户端。
看到这,基本就能猜出这技术的适用场景了:
- 相对较新的客户端和服务器
- 通过网络连接,少量或没有中间设备(至少是支持TFO的中间设备)
- 不关注”用户追踪问题”(Cookie标识用户)