你好,我是胜辉。
在前面预习篇的两节课里,我们一起回顾和学习了网络分层模型与排查工具,也初步学习了一下抓包分析技术。相信现在的你,已经比刚开始的时候多了不少底气了。那么从今天开始,我们就要正式进入TCP这本大部头,而首先要攻破的,就是握手和挥手。
TCP的三次握手非常有名,我们工作中也时常能用到,所以这块知识的实用性是很强的。更不用说,技术面试里面,无论是什么岗位,似乎只要是技术岗,都可能会问到TCP握手。可见,它跟操作系统基础、编程基础等类似,同属于计算机技术的底座之一。
握手,说简单也简单,不就是三次握手嘛。说复杂也复杂,别看只是三次握手,中间还是有不少学问的,有些看似复杂的问题,也能用握手的技术来解决。不信你就跟我看这几个案例。
TCP连接都是用TCP协议沟通的吗?
看到这个小标题,可能你都觉得奇怪了:TCP连接不用TCP协议沟通还用什么呢?
确实,一般来说TCP连接是标准的TCP三次握手完成的:
- 客户端发送SYN;
- 服务端收到SYN后,回复SYN+ACK;
- 客户端收到SYN+ACK后,回复ACK。
这里面SYN会在两端各发送一次,表示“我准备好了,可以开始连接了”。ACK也是两端各发送了一次,表示“我知道你准备好了,我们开始通信吧”。
那既然是4个报文,为什么是三次发送呢?显然,服务端的SYN和ACK是合并在一起发送的,就节省了一次发送。这个在英文里叫Piggybacking,就是背着走,搭顺风车的意思。
如果服务端不想接受这次握手,它会怎么做呢?可能会出现这么几种情况:
- 不搭理这次连接,就当什么都没收到,什么都没发生。这种行为,也可以说是“装聋作哑”。
- 给予回复,明确拒绝。相当于有人伸手过来想握手,你一巴掌拍掉,真的是非常刚了。
第一种情况,因为服务端做了“静默丢包”,也就是虽然收到了SYN,但是它直接丢弃了,也不给客户端回复任何消息。这也导致了一个问题,就是客户端无法分清楚这个SYN到底是下面哪种情况:
- 在网络上丢失了,服务端收不到,自然不会有回复;
- 对端收到了但没回,就是刚才说的“静默丢包”;
- 对端收到了也回了,但这个回包在网络中丢了。

你看,就这么简单的一个SYN,还能引申出三种状况出来。感觉什么东西一沾上网络,就要变成麻烦事啊。所以,跟我们在[第1讲]里学过的一样:设计网络协议真的不简单。
那么,从客户端的角度,对于SYN包发出去之后迟迟没有回应的情况,它的策略是做重试,而且不止一次。那会重试几次呢?重试多久呢?这个问题,一下子还不太好回答。不过,有tcpdump帮忙,我们可以搞清楚重试的问题,也可以搞清楚“TCP连接是否都用TCP协议沟通”的问题。
动手实验
你可以借助iptables和tcpdump做个实验,来验证这件事。你需要一台测试用的服务端,安装Ubuntu等Linux类系统,然后用你的笔记本作为客户端发起测试。这里我也放了一个视频,展示了这个实验过程,你可以结合着对照来看。
注意:在这个视频中,我是直接在tcpdump窗口里解读抓包结果的,而在下面我们是用Wireshark来解读,思路其实是一样的,只是操作方式略有不同,正好你可以都学习一下。
第一步,在服务端,执行下面的这条命令,让iptables静默丢弃掉发往自己80端口的数据包:
1 | iptables -I INPUT -p tcp --dport 80 -j DROP |
第二步,在客户端启动tcpdump抓包:
1 | sudo tcpdump -i any -w telnet-80.pcap port 80 |
第三步,从客户端发起一次telnet:
1 | telnet 服务端IP 80 |
这个时候,这个telnet会挂起:

大约一两分钟后才会失败退出,你随后就会明白背后发生了什么。
这时,你可以把客户端的tcpdump停掉了(按下Ctrl+C)。然后用Wireshark打开这个抓包文件,看看里面是什么:

telnet挂起的原因就在这里:握手请求一直没成功。客户端一共有7个SYN包发出,或者说,除了第一次SYN,后续还有6次重试。客户端当然也不是“傻子”,这么多次都失败,就放弃了连接尝试,把失败的消息传递给了用户空间程序,然后就是telnet退出。
这里有个信息很值得我们关注。第二列是数据包之间的时间间隔,也就是1秒,2秒,4.2秒,8.2秒,16.1秒,33秒,每个间隔是上一个的两倍左右。到第6次重试失败后,客户端就彻底放弃了。
显然,这里的翻倍时间,就是“指数退避”(Exponential backoff)原则的体现。这里的时间不是精确的整秒,因为指数退避原则本身就不建议在精确的整秒做重试,最好是有所浮动,这样可以让重试成功的机会变得更大一些。
这里实际上也是一个知识点了:TCP握手没响应的话,操作系统会做重试。在Linux中,这个设置是由内核参数net.ipv4.tcp_syn_retries控制的,默认值为6,也就是我们前面刚观察到的现象。以下就是我的Ubuntu 20.04测试机的配置:
1 | $ sudo sysctl net.ipv4.tcp_syn_retries |
还有另外好几个有关TCP重试的设置值,也都可以调整。更全面的内容呢,你可以直接man tcp,查看tcp的内核手册的信息。比如下面就是对于tcp_syn_retries的解释:
1 | tcp_syn_retries (integer; default: 5; since Linux 2.2) |
既然静默丢包会引起客户端空等待的问题,那我们直接拒绝,应该就能解决这个问题了吧?
正好,iptables的规则动作有好几种,前面我们用DROP,那这次我们用REJECT,这应该能让客户端立刻退出了。执行下面的这条命令,让iptables拒绝发到80端口的数据包:
1 | iptables -I INPUT -p tcp --dport 80 -j REJECT |
跟前面的实验一样,我们在客户端发起telnet 服务端IP 80。果然,telnet立刻退出,显示:
1 | $ telnet 47.94.129.219 80 |
可见,连接请求确实被拒绝了。我在telnet同时也抓了包,我们来看一下抓包文件:

奇怪,抓包文件里并没有期望的TCP RST?是我们抓包命令没写对吗?下面是这条命令,你已经初步学过tcpdump抓包命令了,看看有没有什么问题?
1 | sudo tcpdump -i any -w telnet-80-reject.pcap host 47.94.129.219 and port 80 |
命令语法没问题,要不然命令都无法执行。那过滤条件呢?指定了远端IP和端口,这是很常见的用法,应该也没什么问题。
但是,这里隐藏了一个假设的前提,也就是我们认为,这次握手的所有过程都是通过这个80端口进行的。但事实上呢?我们稍微改一下抓包条件,只保留远端IP,去掉端口的限制:
1 | sudo tcpdump -i any -w telnet-80-reject.pcap host 47.94.129.219 |
然后再来看看,我们抓到的报文是怎样的:

很意外,居然对端回复了一个ICMP消息:Destination unreachable (Port unreachable)。这还不是最意外的,我们选中这个报文,进一步看它的详情,可能会更惊讶:

原来,这个ICMP消息不仅通过type=3表示,这是一个“端口不可达”的错误消息,而且在它的payload里面,还携带了完整的TCP握手包的信息。而这个握手包,可是客户端发过来的。
补充一下:如果我们回头再检查一下前面生成的iptables规则,它是这样的:
1 | -A INPUT -p tcp -m tcp --dport 80 -j REJECT --reject-with icmp-port-unreachable |
原来,它自动补上了–reject-with icmp-port-unreachable,也就是说确实用ICMP消息做了回复。当然,你还可以把这个动作定义为–reject-with tcp-reset,那样的话就符合我们一开始的期望了。
事实上,无论是收到TCP RST还是ICMP port unreachable消息,客户端的connect()调用都是返回ECONNREFUSED,这就是telnet都报“connection refused”的深层次原因。
所以,这个握手失败的情况终于搞清楚了,它是这么发生的:

TCP握手拒绝这个事,竟然可以是ICMP报文来达成的。“握手过程用TCP协议做沟通”,看起来这么理所当然的事情居然也会反转,你是不是也有点自我怀疑了:是不是其他网络知识,也未必是我自己认为的那样呢?
这个知识点,其实是几年前我在处理一个客户的TCP连接问题时遇到的。剧情么,前面已经给你“演”过一遍了。当时我也深感TCP的水太深,快没过脖子了,甚至有点喘不过气来……从此以后,我再也不敢小看任何知识点,同时也领教了tcpdump和Wireshark在网络分析方面的威力。有了这两个大杀器的帮助,我的网络水平提高很快。这个经验我也分享给你,相信你也一定能从中受益。
Windows服务器加域报RPC service unavailable?
虽然tcpdump + Wireshark的组合威力强大,但用起来总是会稍微花点时间。**有没有不用抓包分析,也能做排查TCP连接问题的方法呢?**这样也好快一点啊。接下来这个例子,就是这样的。
我们eBay也有不少Windows服务器,这些机器都由Active Directory(简称AD)管理。有一次,我们有一台Windows服务器加入AD失败,相关同事已经排查了好久,一直没找到原因。操作过程就是最普通的加域动作:

然后,一开始显示加域成功,但是过一两分钟后,又会来个“回马枪”,冒出来一个The RPC server is unavailable的报错:

在Windows的体系里面,这个报错大体意思是连不上RPC服务器。同事检查过RPC服务端并没有问题,然后其他Windows客户端加域呢,也都正常,唯独这台就不行。
单独一台机器加不了域,本身也不是特别大的麻烦,但是同事还是想找一下根因,于是就让我帮忙。很幸运,当时我只用了大概十分钟就找到了原因(这里我有点不谦虚了,我对你扔过来的鸡蛋和番茄表示接受)。
这倒不是我对Windows多么精通,主要是正确的排查思路帮助了我。给你分享一下我当时的思路:
- 既然报错是RPC unavailable,那可能意味着有一个RPC服务没有得到响应。
- 没有得到服务端的响应,那多半是跟网络有关系,特别是跟端口的连通性有关系。
- 要知道,RPC使用的是动态端口,每次连接都可能连接到不同的服务端口。所以,我也没办法预先知道是具体哪几个端口,如果我知道的话,直接找防火墙团队去把那几个服务端口打开就好了,但这个做不到。这一点也是同事卡了许久的原因之一,他也不知道如何找到这些“动态会变的RPC端口”。
- 要找到实时在用的动态RPC端口,最方便的方法就是运行netstat命令。无论连接是处在什么状态,比如是在传输数据的ESTABLISHED状态、新近关闭端口的TIME_WAIT状态,都可以用netstat命令看到。
- 我运行了netstat,在当时的命令输出中,我注意到有一个 SYN_SENT状态的连接,它要连的就是服务端的一个高端口。
那么,这个SYN_SENT状态究竟说明了什么呢?

SYN_SENT是TCP的11个状态之一。要理解SYN_SENT的含义,我们首先要把整个TCP状态机的机制搞清楚。关于TCP状态机,目前流传比较广的是下面这张图。我没有考证过这张图的出处,不过在Stevens的《UNIX网络编程:套接字联网API》里就有这张图,很有可能最早就是来自于Stevens:

这张图浓缩了TCP状态转换的所有知识点,确实值得反复研读。不过,我鸡蛋里挑个骨头:这张图也有个小小的问题,就是对于初学者来说,它并不容易理解。
比如,多年前我自己在学习TCP的时候,就一直没有彻底看懂这张图。好笑的是,我经常假装自己看懂了,还拿这张图跟别人侃侃而谈,而对方还被我唬住了呢。所以你也要学会了:当大家都不是很懂的时候,你对自己的话越相信,你就越有说服力哦。
好了,当然是跟你开个玩笑,做学问还是要严谨。那么,这张图的难点在哪呢?我觉得主要是视角不固定,一会是发送方,一会是接收方,对初学者来说很容易混淆。实际上,在Stevens的这本书中,还有另外一张图,我认为更加清晰明了,也是我想推荐给你的:

在上面这张图里,无论是客户端还是服务端,我们从上往下看,它要经历的各个TCP状态,都展示得十分清楚。我把这个过程解读如下:

后续的过程,不用我继续解读,你也会看得很清楚了:分别沿着左边和右边的垂直线从上往下看,就经历了客户端和服务端的TCP生命周期里的各种状态,这个过程中,视角保持一致。你觉得是否比前面那张转换图,更加容易理解呢?
看懂了这张图,你应该就明白了:SYN_SENT这个状态,意味着当时这个连接请求(SYN包),已经从这台Windows服务器发出,试图跟远端的AD域控制器进行连接。但由于对端迟迟没有回应SYN+ACK报文,那么客户端这个连接的状态,就只能“停留”在SYN_SENT状态,无法转化为ESTABLISHED状态。
等到达了SYN timeout时间后,Windows操作系统会放弃这次连接,而这个SYN_SENT状态的连接也会消失不见。所以,前面提到的“实时”两字,也是很关键的。如果不是在问题发生时运行netstat,哪怕是过了几分钟再去运行netstat,错过了这个SYN_SENT,我也不能发现这个失败的TCP连接企图,也就无法定位到真正的原因了。

然后我们拿着这个端口去找防火墙团队,对方检查了配置,发现这个端口确实是禁止的。在开通后,问题就解决了。
所以说,真的不要小看任何知识点和小工具,你掌握以后,完全可以起到关键性的作用(对了,排查防火墙也时常是我们工作的痛点,我在第5和第6讲会专门讲解这方面的排查技巧,敬请期待)。
这里还有一个技术点我想给你展开一下。我们在前面已经讨论过了SYN重试的问题,显然,这次Windows的SYN_SENT的背后,我们相信,应该也是有数次的SYN重试的情况。同时,因为我观察到,这个SYN_SENT停留了大约有十几二十秒,所以我判断应该也有指数退避的存在,所以这个状态才保留了那么长时间。
也就是说,无论是Linux还是Windows,都实现了类似的TCP握手方面的容错手段。还是那句话:设计网络不容易。理解了设计者的初心,很多问题就不会那么模糊了,可能你一下子就能看清。
发送的数据还能超过接收窗口?
最后一个案例表面上并不直接跟握手相关,但背地里就……不剧透了,看剧情。
前段时间,有个朋友找到我咨询一个问题。他们最近处理了一个Redis相关的技术问题,让他们既开心又“闹心”。开心的是整体分析是正确的,问题也得以解决;“闹心”的是,唯独有个技术点好像无法自圆其说,所以想让我看看到底是怎么回事。
这个问题是:Redis服务告诉客户端它的接收窗口是190字节,但是客户端居然会发送308字节,大大超出了接收窗口。下图是他们用Wireshark打开抓包文件后的界面:

我一开始也懵了:难道TCP的深水又到我脖子这儿了?在我多年的抓包分析经历中,数据超过接收窗口的情况,好像还没有遇到过,这次算是TCP准备再次让我“开开眼”吗?
不过我很快又稳定了下来,因为我想到了一个朋友他们没有注意到的细节。在说到TCP窗口的时候,一般都会提到一个很重要的概念:Window Scale。这是因为,TCP最初是七八十年代的产物,1981年9月定稿的RFC793才第一次正式确定了TCP的标准。当时的网络带宽还处于“石器时代”,机器的带宽只有现在的百分之一,那么TCP接收窗口自然也没必要很大,2个字节长度代表的65535字节的窗口足矣。
但是后来网络带宽越来越大,65535字节的窗口慢慢就不够用了,于是设计者们又想出了一个巧妙的办法。原先的Window字段还是保持不变,在TCP扩展部分也就是TCP Options里面,增加一个Window Scale的字段,它表示原始Window值的左移位数,最高可以左移14位。
如果你还没有完全忘记计算机课的基本知识,那么应该明白这是一个非常大的提升了(扩大了2的14次方,即16384倍)。16384乘以65535,这个数字就是1G字节,也就是说,一个启用了Window Scale特性的TCP连接,最大的接收窗口可以达到1GB。可以说,这个数字至今都是够用的。
说了这么多,我们用Wireshark来看看它究竟长啥样。找一个包含了SYN报文的抓包文件,选中SYN报文,在Wireshark窗口中部找到TCP的部分,展开Options就能看到了:

我们逐一理解下。
- Kind:这个值是3,每个TCP Option都有自己的编号,3代表这是Window Scale类型。
- Length:3字节,含Kind、Length(自己)、Shift count。
- Shift count:6,也就是我们最为关心的窗口将要被左移的位数,2的6次方就是64。
小小提醒:SYN包里的Window是不会被Scale放大的,只有握手后的报文才会。
当然,TCP的窗口也是TCP知识体系里一块挺大的分支领域,我会在当前这个“实战一”模块的传输效率部分,也就是第9~11讲里,详细讲解这方面的知识,帮你把这块的东西真正搞透。
回到握手。既然Window Scale这么有用,那每个TCP报文应该都是带上这个信息的吧,因为它在TCP头部里面嘛,而每个TCP报文都有头部的,不是吗?
你要这样想就错了。事实上,Window Scale只出现在TCP握手里面。你再想想就明白了:这个是“控制面”的信息,说一次让双方明白就够了,每次都说,不光显得“话痨”,也很浪费带宽啊。一般传输过程中的报文,完全不需要再浪费这3个字节来传送一个已经同步过的信息。所以,握手之后的TCP报文里面,是不带Window Scale的。
比如,我们来看一个抓取到握手阶段的抓包文件。下图是客户端在数据传输阶段发送的报文,它是一个TLS Client Hello报文。

可见,原始窗口502字节,放大128倍后就是64256字节了。
说到这里,想必你已经明白了:我朋友这次的疑惑,其实就是缺少TCP握手包造成的。要知道,Wireshark也一样要依赖握手包,才能了解到这次连接用的Window Scale值,然后才好在原始Window值的基础上,对Window值进行左移(放大),得出真正的窗口值。于是,因为这次他们的抓包没有抓取到握手报文,所以Wireshark里看到的窗口,就是190字节,而不是190字节的某个倍数了!
当时通信的另一端当然知道这个信息,所以它发送308字节一点都不意外,因为这个值根本就没超出接收窗口。
那么,**是不是没有抓取到握手包的话,Wireshark里读取到的Window就一定不对呢?**大部分时候是这样的。不过,还有一部分老系统的TCP栈并没有启用Window Scale,那么抓包文件中有没有握手包都没关系,只要看基本Window就好了。
说到这里,你对TCP握手的印象,是不是又有改变呢?它简单,也丰富;它靠谱,也调皮。你只有真的读懂它,才不会被它牵着鼻子走。而读懂它的方法是什么呢?
就是多读些TCP理论,就是多做些抓包分析,就是多处理些案例,更是多走走,多看看。只要有心,你总有机会可以学会,可以成长。
小结
作为这个模块的第一课,这次我们围绕TCP握手展开了几个有趣的案例,并从中梳理了以下知识点:
- 客户端发起的连接请求可能因为各种原因没有回复,这时客户端会做重试。一般在Linux里,重试次数默认是6次,内核参数是net.ipv4.tcp_syn_retries。重试间隔遵循了指数退避原则。
- 服务端拒绝TCP握手,除了用TCP RST,另外一种方式是通过ICMP Destination unreachable(Port unreachable)消息。从客户端应用程序看,这两种回复都属于“对端拒绝”,所以应用表面看不出区别,但我们在抓包的时候要注意,如果单纯抓取服务端口的报文,就会漏过这个ICMP消息,可能对排查不利。
- 对于连通性相关的问题,除了用tcpdump+Wireshark这个黄金组合,我们还可以在理解TCP握手原理的基础上,使用小工具(比如netstat)来排查。特别是对于RPC服务场景,在问题发生时及时执行netstat -ant,找到SYN_SENT状态的连接,这个很可能是突破口。
- 我们也学习了如何在Wireshark中查看Window Scale。握手包中的Window Scale信息十分重要,这会帮助我们知道正确的接收窗口。在分析抓包文件时,要注意是否连接的握手包被抓取到,没有握手包,这个Window值一般就不准。
可以说,**应用都靠连接,连接都靠握手。**掌握好了握手,你的TCP就算入门了。学完这节课之后,你有没有觉得,今天的你比昨天的你,要强一些了呢?加油!后面更多的知识在等你来发现。
思考题
最后,还是按照惯例,还是给你留几道思考题:
- 在Linux中,还有一个内核参数也是关于握手的,net.ipv4.tcp_synack_retries。你知道这个参数是用来做什么的吗?
- 如果握手双方,一方支持Window Scale,一方不支持,那么在这个连接里,Window Scale最终会被启用吗?你可以参考RFC1323,给出你的解答。
欢迎在留言区分享你的答案,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
扩展知识:聊聊几个常见误区
很多时候,我们的成长不仅是由于学到了正确的知识,更是由于纠正了“错误的认知”。下面列几个常见误区,你看看自己有没有“中招”。
UDP也有握手?
有些同学会有这个误解,可能是跟nc这个命令有关。我们来看一个TCP端口22的测试:
1 | victor@victorebpf:~$ nc -v -w 2 47.94.129.219 22 |
同一时间的tcpdump抓包,显示这个TCP经历了成功的握手和挥手:
1 | $ sudo tcpdump -i any host 47.94.129.219 |
如果我们用nc测试 UDP 22端口,看看会发生什么。注意,UDP 22是没有服务在监听的。但是nc一样告诉我们succeeded!这似乎在告诉我们,这个UDP 22端口确实是在监听的:
1 | $ nc -v -w 2 47.94.129.219 22 |
同一时间的抓包,显示客户端发送了4个UDP报文,但服务端没有任何回复:
1 | 11:59:05.605556 IP victorebpf.54145 > 47.94.129.219.22: UDP, length 1 |
从表象上看,nc告诉我们:这个跟UDP 22端口的“连接”是成功的,这是nc的Bug吗?可能并不算是。原因就在于,UDP本身不是面向连接的,所以没有一个确定的UDP协议层面的“答复”。这种答复,需要由调用UDP的应用程序自己去实现。
那为什么在这里,nc还是要告诉我们成功呢?可能只是因为对端没有回复ICMP port unreachable。nc的逻辑是:
- 对于UDP来说,除非明确拒绝,否则可视为“连通”;
- 对TCP来说,除非明确接受,否则视为“不连通”。
所以,当你下次用nc探测UDP端口,不通的结果是可信的,而能通(succeeded)的结果并不准确,只能作为参考。
一台机器最多65535个TCP连接?
这也是很常见的误区了。我还是小白的时候,也曾经深信不疑。当时读到一篇讨论服务器可以承受多少TCP连接(就是C10k问题)的文章时,还觉得奇怪,不是端口范围只有0~65535吗?为什么还会有几十万上百万连接呢?
这就是没有意识到,连接是四元组(咱们在[第一节课]讲到过),并不是单纯的源端口或者目的端口。那么多个数相乘,这个乘积当然可以远远超过65535了。先不谈论海量级网站的场景,就算我们维护一台Web服务器,假如当前有10万台客户端连着你,平均每个客户端跟你有6个连接(这很常见),那么就是60万个连接了,是不是也早就超过6万了?
当然,在限定场景下,一个客户端(假设只有一个出口IP)和一个服务端(假设也只有一个IP和一个服务端口),那么确实只能最多发起6万多个连接。但你自己也已经明白,这跟前面的误解,已经是两回事了。
不能同时发起握手?
如果两端同时发送了SYN给对方,也就是双方都收到了一个SYN,那么接下来,它们会进入什么状态呢?你可能觉得这应该不行。
其实,通信双方还真的可以同时向对方发送SYN,也能建立起连接。你可以参考这节课里我提到的TCP状态转换图。在Richard Stevens的《TCP/IP详解(第一卷)》里,也提到了这个知识点,参考下图:

当然,这种情况是很罕见的,你可以参考一下,也丰富一下你对TCP握手的理解。