平时我们用 socket()、bind()、listen()、accept()、connect() 这套 API 写服务器,总觉得"网络连接"是理所当然的:客户端一个 connect(),服务器一个 accept(),两边就"通"了。可你有没有想过,"连接"到底是在哪一刻真正建立的?是你调用 accept() 的那一刻,还是在更早的、你根本看不见的某个角落?
答案会让很多人意外:TCP 连接在服务器还没调用 accept() 之前就已经建好了。 三次握手是内核协议栈替我们完成的,accept() 只是"把已经建好的连接从队列里取出来"。这中间挡着一道几乎所有生产事故都会撞上的墙——两个队列:半连接队列和全连接队列。
这节课我们把它彻底讲透。你将会:
- 看清 TCP 三次握手里三个报文的真实模样(SYN、SYN-ACK、ACK,以及各自的序号/确认序号规律);
- 搞懂内核为"等待被 accept 的连接"维护的两条队列,以及
listen()的第二个参数backlog到底限的是什么; - 亲手用
tcpdump抓一次包,亲眼"看见"三次握手和四次挥手; - 最后理解 SYN 洪水为什么能打垮服务器,队列满时内核是丢包、重置还是拒绝。
我强烈建议你读的时候打开一台 Linux 虚拟机,跟着下面每个命令敲一遍。网络这堂课,纸上谈兵一百遍,不如抓一次包记得深。
你应该有的知识准备
在动手之前,先确认几件事你心里有数:
- 套接字(socket):操作系统给"网络通信端点"的抽象。
socket()创建、bind()绑定地址、listen()监听、accept()接受、connect()发起连接、close()关闭。我们在文章开头会用到这组文件描述符(fd,File Descriptor,内核给打开文件分配的编号,socket 也是一种 fd)。 - TCP 是一个"面向连接的、可靠的、基于字节流"的协议:面向连接,意味着通信前要先建立连接;可靠,意味着有确认重传机制;基于字节流,意味着收发数据像流水一样,没有消息边界。
- 端口(port):一台主机上用 16 位数字标识具体服务的编号。
127.0.0.1:9090就表示"本机回环地址上的 9090 端口"。 - 一个 TCP 连接通过四元组唯一确定:源 IP、源端口、目的 IP、目的端口。这也是为什么你看到
netstat里会有形如127.0.0.1:48172 → 127.0.0.1:9090这样的"一对地址"。
读不懂上面的没关系,我们边讲边用,遇到术语都会解释。现在开始。
三次握手:一生只握三次手
TCP 建立连接的过程叫三次握手(Three-Way Handshake)。说白了三句话:客户端先打招呼"我要连你";服务器回一句"收到,我也同意";客户端再回一句"好,那就这么定了"。双方确认彼此都能收、都能发,连接就建立了。
先放一张时序图,我们后面所有内容都围着它转:
客户端 Client 服务器 Server
│ │
│ ① SYN seq=X │
│ ───────────────────────────────────────> │ 客户端进入 SYN_SENT
│ │ 服务器进入 SYN_RECV
│ ② SYN+ACK seq=Y ack=X+1 │
│ <─────────────────────────────────────── │
│ 客户端进入 ESTABLISHED │
│ ③ ACK seq=X+1 ack=Y+1 │
│ ───────────────────────────────────────> │ 服务器进入 ESTABLISHED
│ │
│ (到这里,连接才算真正建立) │
三个报文的关键点我给你拆开:
- ① SYN:
SYN是 Synchronize Sequence Numbers(同步序号)的缩写。客户端向服务器发起第一次握手,包里的标志位只带SYN,同时携带一个初始序号(ISN,Initial Sequence Number),记作seq=X。X不是一个写死的数,而是内核随机生成的 32 位序号,用来给之后的数据字节排队编号。发起方此刻的状态是SYN_SENT(客户端已发出 SYN)。 - ② SYN-ACK:服务器收到 SYN 后,如果愿意建立连接,就回一个同时带了
SYN和ACK两个标志的报文,这就是SYN-ACK。它的含义是"我收到你的序号了,现在轮到我同步序号"。因此它做了两件事:自己随机生成一个初始序号seq=Y,同时把对方的序号确认掉,ack=X+1。这里的 "+1" 大有讲究——TCP 认为 SYN 标志本身占掉了一个序号,所以对方发来seq=X,它确认的应该是X+1(即"你下一个该发的字节序号是 X+1")。服务器此刻的状态是SYN_RECV(服务器已收到 SYN 并回应,但还在等最后的确认)。 - ③ ACK:客户端收到 SYN-ACK 后,回一个只带
ACK标志的纯确认报文,seq=X+1(因为 SYN 占了一号,从 X+1 继续),ack=Y+1(把服务器的初始序号 Y 也占掉一号确认掉)。这一个"ACK"就是第三次握手,它本身不再需要被确认了——这也是"第三次握手"和前两次的本质区别:前两次的 SYN 各占一个序号,而这个 ACK 只是纯确认,不占新序号。客户端在发出这一步后就进入ESTABLISHED;服务器收到后也进入ESTABLISHED,此时整个连接才算真正建立。
关于三次握手,有句话值得刻进脑子,也是后面理解队列的关键:服务器端在自己还没到 ESTABLISHED 之前,就已经收到并接受了客户端的 SYN。 也就是说,服务器"答应建立连接"这个动作(回 SYN-ACK),发生在握手完成之前。这中间跨越的时间(服务器发 SYN-ACK 到收到客户端 ACK)是一条非常容易被攻击和利用的"缺口",我们后面的 SYN 洪水小节会回到这一点。
顺带记住三次握手在报文里最常见的三个标志组合:[S](纯 SYN)、[S.](SYN+ACK)、[.](纯 ACK)。这个带点不带点的写法,正是稍后用 tcpdump 抓包时你会亲眼看到的样子。
为什么非要三次,两次不行吗
很多人问过:握手为什么是三次,服务器回完 SYN-ACK 直接当建立了行不行?答案是不行,因为你没法确认"服务器收到了客户端的 ACK"。
想象两次握手的场景:客户端发 SYN,服务器回 SYN-ACK 后就直接认为连接建立、开始发数据。可是如果这个 SYN-ACK 在半路丢了,客户端没收到,它以为还没建立,服务器却以为建好了——两边状态就不一致了,这是灾难性的。三次握手中的那个"③ ACK"就是给服务器一个证据:"你的 SYN-ACK 我确实收到了。" 没有这第三条腿,服务器永远无法确定"对方到底还活着没有"。所以三次,恰恰是最少能保证"双方都确认对方收到自己同步信息"的次数。
思考题 1: 假如只有两次握手(客户端 SYN→服务器 SYN-ACK 后就算建立),为什么"丢包"就会立刻导致状态错乱?请用自己的话解释。
参考答案
因为"两次握手"里,服务器从来没有得到"我发给客户端的 SYN-ACK 被收到了"的确认。只要 SYN-ACK 在网络上丢失,就会出现:服务器认为自己已经建立连接,开始在发数据甚至维护这个连接;而客户端压根不知道这回事,还停在"我没连上"的状态。两边成为两个世界。更糟的是,如果这是攻击者伪造的重复 SYN,服务器会为每一个伪造 SYN 都分配资源、发 SYN-ACK 浪费带宽。三次握手里的最后那个 ACK,就是给服务器一个"对方真的活着、真的收到了"的确定性证据,同时它不占序号、不承载负担,代价极低。
双队列:内核如何管理"还在排队、还没被 accept"的连接
现在进入这节课的核心。三次握手是由内核协议栈完成的,不需要应用程序(比如你写的服务器进程)参与。应用程序只管三件事:listen() 告诉内核"我开始监听",accept() 从内核取连接,close() 关闭。于是问题就来了:从客户端发 SYN,到服务器最终 accept(),这中间一串连接是怎么"寄存"的?
答案是:内核为每个监听套接字(listen socket)维护了两条队列。先看一张拓扑示意图,把两个队列的位置摆清楚:
内核协议栈(监听 127.0.0.1:9090 的 socket,状态 LISTEN)
┌─────────────────────────────────────────────────────────┐
│ │
│ ① 半连接队列(SYN 队列 / request queue) │
│ 存放:收到了 SYN、还没完成三次握手的连接 │
│ 连接状态:SYN_RECV │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ SYN_RECV│ │ SYN_RECV│ │ │ ... │
│ └────────┘ └────────┘ └────────┘ │
│ │
│ ② 全连接队列(accept 队列 / accept queue) │
│ 存放:已经完成三次握手、等待应用层 accept 取走的连接 │
│ 连接状态:ESTABLISHED │
│ ┌────────┐ ┌────────┐ │
│ │ESTAB │ │ESTAB │ ... │
│ └────────┘ └────────┘ │
└─────────────────────────────────────────────────────────┘
│
应用层 accept() 一次只从"全连接队列"弹出一个连接
半连接队列(也叫 SYN 队列、未完成连接队列)——用来保存"处于半连接状态"的请求。所谓半连接,就是三次握手还没走完的连接。服务器收到一个 SYN,把它塞进半连接队列(正因如此,也会写成 req/request queue),然后回 SYN-ACK。此时这个连接的状态是 SYN_RECV。它要在队列里一直等到客户端回 ACK、完成第三次握手,才会被挪走。整个 SYN_RECV 就是半连接队列里那种"欠了最后一脚"的状态。它的长度不由 listen() 的第二个参数直接决定,而是由内核参数 tcp_max_syn_backlog 管(后面讲)。
全连接队列(也叫 accept 队列、已完成连接队列)——用来保存"已经完全建立(ESTABLISHED)、但应用层还没调用 accept() 取走"的连接。三次握手的③ ACK 一到,内核就把连接从半连接队列挪进全连接队列,此时连接状态是 ESTABLISHED。注意,这里的"ESTABLISHED"是内核层面的状态,和你的进程有没有 accept() 无关——哪怕你程序里压根没有 accept(),只要握手完成,这条连接一样是 ESTABLISHED,只是静静躺在队列里等你取。全连接队列的长度,正是这节课的主角:由 listen() 的第二个参数决定。
一句话区分两条队列:半连接队列装"没握完手"的,全连接队列装"握完手但没人领"的。 连接在两条队列之间的是单向迁移:半连接 → 全连接,一旦握完手就从半连接队列里删掉、追加到全连接队列尾部。
accept() 只从全连接队列"弹出"
特别的,要澄清一个高频误解:accept() 永远取不到"还在三次握手过程中的连接"。 它只是简单地从全连接队列头部弹出一个已完成连接的 socket 返回给你。如果你的服务端代码里 accept() 调用得很慢,或者干脆不调用,那么全连接队列里就会堆满 ESTABLISHED 的连接。半连接队列里的东西,accept() 看都不看——因为那些连接还不算数。
这就引出了"你写的 accept() 太慢会出事"的根源:全连接队列是有限的。 一旦它满了,内核连三次握手都不让你完成了。这正是下面实验要验证的东西。
listen 的第二个参数 backlog:全连接队列到底能装几个
listen(int sockfd, int backlog) 这个系统调用,第二个参数就是大名鼎鼎的 backlog(译为"积压、待办")。在 Linux 2.2 之前,它曾一度被用来限制"队列里所有未接受连接"的总和;但从 Linux 2.2 开始语义变了,它只限制"全连接队列"(已完成握手、等待 accept 的连接)的长度。现在绝大多数现代内核都是这个语义。
那 backlog 能无限大吗?不能。内核还有一个全局硬顶,叫 somaxconn,全名 socket max connections(套接字最大连接数)。它定义在 /proc/sys/net/core/somaxconn 里,默认值在较老的内核(Linux 5.4 之前)是 128,在较新的内核(Linux 5.4 及之后)是 4096。两者关系是:
全连接队列长度 = min(backlog, somaxconn)
也就是取两者较小值。你 listen() 传的 backlog 超过 somaxconn 时,会被内核静默地截断成 somaxconn——这是一个经典的"你以为设了 65535,其实只用得上 128"的坑。而且代码里你可能传给了使用它的框架(比如 Nginx 的 listen 指令、Python 的 listen()),框架只是把你的值原样转发给系统调用,真正生效仍受 somaxconn 约束。
一个实验:backlog 设 2,服务器死活不 accept
光讲理论没感觉,我们直接复现课件里的经典实验。做法很"反人类"却极能说明问题:服务器 listen() 的 backlog 设成 2,并且进入死循环不调用 accept()。 然后启动一群客户端去连它。
我们用的是之前封装好的 TcpSocket,服务器长这样(注释里讲清每一步):
// test_server.cc —— 服务器:只监听,不收"货"
#include "tcp_socket.hpp"
int main(int argc, char* argv[]) {
if (argc != 3) {
printf("Usage ./test_server [ip] [port]\n");
return 1; // 用法不对就直接退出
}
TcpSocket sock;
bool ret = sock.Bind(argv[1], atoi(argv[2])); // 绑定 IP 和端口
if (!ret) {
return 1; // 绑定失败退出
}
ret = sock.Listen(2); // 重点:backlog=2,全连接队列上限设得很小
if (!ret) {
return 1; // 监听失败退出
}
// 故意的:客户端连接进来后,服务器不调用 accept
while (1) {
sleep(1); // 让服务器一直"装死",绝不取走任何连接
}
return 0;
}注意 sock.Listen(2) 这里传的 backlog=2,是实验成败的关键。客户端这边很朴素,就是连上之后也挂住不动:
// test_client.cc —— 客户端:只负责连接
#include "tcp_socket.hpp"
int main(int argc, char* argv[]) {
if (argc != 3) {
printf("Usage ./test_client [ip] [port]\n");
return 1;
}
TcpSocket sock;
bool ret = sock.Connect(argv[1], atoi(argv[2]));
if (ret) {
printf("connect ok\n"); // connect 成功
} else {
printf("connect failed\n"); // connect 失败
}
while (1) {
sleep(1); // 连接建立后也挂住不动,不让它被回收
}
return 0;
}现在依次启动 1 个服务器、4 个客户端,然后用 netstat 看服务器的连接状态:
# 查看当前所有 TCP 连接的监听与连接状态
netstat -antpnetstat(network statistics)是查看网络连接、路由、接口统计的经典命令。-a 列出所有连接,-n 不做域名反解(用数字地址),-t 只看 TCP,-p 显示是哪个进程占用。
结果会看到这样一幕:
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 3 0 0.0.0.0:9090 0.0.0.0:* LISTEN 9084/./test_server
tcp 0 0 127.0.0.1:9090 127.0.0.1:48178 SYN_RECV -
tcp 0 0 127.0.0.1:9090 127.0.0.1:48176 ESTABLISHED -
tcp 0 0 127.0.0.1:48178 127.0.0.1:9090 ESTABLISHED 9140/./test_client
tcp 0 0 127.0.0.1:48174 127.0.0.1:9090 ESTABLISHED 9087/./test_client
tcp 0 0 127.0.0.1:48176 127.0.0.1:9090 ESTABLISHED 9086/./test_client
tcp 0 0 127.0.0.1:48172 127.0.0.1:9090 ESTABLISHED 9088/./test_client
tcp 0 0 127.0.0.1:9090 127.0.0.1:48174 ESTABLISHED -
tcp 0 0 127.0.0.1:9090 127.0.0.1:48172 ESTABLISHED -
逐行读一遍,你会发现一个诡异的"不对称":
- 服务器端
0.0.0.0:9090处于LISTEN(监听)状态,这行是test_server进程本体。 - 四个客户端(48172、48174、48176、48178)在各自的一行里全都是
ESTABLISHED——从客户端角度,四台"客户端进程"全都连接成功了! - 但服务器端对应
48172、48174、48176的三个连接是ESTABLISHED,摆在服务器自己的"账本"(全连接队列)里(这几行-表示不属于某个进程,正是"还在队列里没人领"的写照); - 而服务器端
127.0.0.1:9090 → 127.0.0.1:48178这一个连接,状态是SYN_RECV——它卡在"半连接"的位置,没有进入 ESTABLISHED。
你发现了吗:第 4 个客户端那边自信地打印了 connect ok,服务器这边却压根没跟它完成握手。这就是今天最重要的一个现象:全连接队列满了之后,第 4 个连接就无法再"进入 ESTABLISHED"了。
为什么第 4 个连接卡在 SYN_RECV,而不是 ESTABLISHED
把时间线在脑子里过一遍,你来判断哪一步出了问题。
服务器 backlog=2,全连接队列容量是有限的(这个实验里测得是 backlog + 1 = 3,底下一小节细说)。前三个客户端依次连上来:每个都经历完整的 SYN → SYN-ACK → ACK,三次握手完成后被内核放进全连接队列。因为服务器从不 accept(),它们就永远堵在队列里。到第四个客户端发 SYN 时,全连接队列已经满了。
于是关键的一步发生了:当第四个客户端完成第三次握手、把最后一个 ACK 发给服务器时,内核发现"全连接队列已经满了,没地方放这个新完成的连接",于是悄悄把这个 ACK 丢掉了(默认行为,tcp_abort_on_overflow=0)。 丢了这个 ACK,服务器这半边就永远等不到第三次握手——它的连接只好僵死在 SYN_RECV(半连接队列里)。
而客户端这边呢?它发出 ACK 的瞬间就 connect() 成功、状态变 ESTABLISHED 了,压根不知道自己的 ACK 被服务器扔进了垃圾桶。于是出现了那个"客户端 ESTABLISHED、服务器端 SYN_RECV"的诡异不对称。
(如果你把 tcp_abort_on_overflow 设成 1,行为会变成:队列满时服务器直接回一个 RST(Reset,重置)报文给客户端,客户端就会立刻 connect failed。默认的 0 是"静默丢弃",更隐蔽,客户端还以为连上了。这个参数我们放到队列溢出小节再展开。)
思考题 2: 实验里客户端明明 connect 成功了,为什么服务器却没有这个连接?请用"双队列 + 第三次握手 ACK 被丢弃"来解释。
参考答案
connect() 成功只代表客户端完成了自己的三次握手动作(它发出 ACK 后就进入 ESTABLISHED),并不代表服务端接受了它。服务器的全连接队列已经装满(前三个完成握手的连接因为没人 accept 一直堵在里面),当第四个客户端的第三次握手 ACK 到达时,内核因为全连接队列满而把这个 ACK 丢弃,服务器端的连接因此永远停在半连接队列的 SYN_RECV 状态,无法迁移到全连接队列、也就无法变成服务器的 ESTABLISHED。这就是"客户端自认为连上了、服务器却没有这条连接"的原因,也是"队列满→握手无法完成"的直接后果。
全连接队列长度,为什么是 backlog + 1
课件实验给出的结论是:全连接队列的长度 = listen() 的第二个参数 + 1。 我们背靠本实验验证一下:backlog=2,结果放得下 3 个 ESTABLISHED(48172、48174、48176),第 4 个(48178)放不下——容量恰好是 2 + 1 = 3。
这个 "+1" 来自内核实现全连接队列时的一次经典"宽一档":内核用一个 sk_acceptq_is_full() 之类的判断,实际判定全连接队列满不满用的是"当前队列元素数 > 上限",也就是队列能容纳的其实比它报出来的 max_ack_backlog 略微多出一格,体现到上层就是我们看到的 +1。需要说明的是,这个 +1 与具体内核版本、是否开启 syncookies 都有细微出入,但你只需要记住稳定的结论:backlog 决定(并限制了)全连接队列能容纳的已完成连接数,它就是你在生产里要调的"容纳量"。 别把"恰好等于 backlog"当成死规则,把"backlog 越大,全连接队列越能装"当成方向就对了。
如果你想在生产里实际看某个监听端口当前 / 最大能排多少,用 ss 更直观:
# ss 是 netstat 的现代替代品,看监听套接字的队列状况
ss -lnt-l 只显示监听(LISTEN)状态,-n 用数字端口,-t 只看 TCP。Recv-Q 列表示当前排队等待 accept 的已完成连接数,Send-Q 列表示全连接队列的最大容量(即 min(backlog, somaxconn))。当 Recv-Q 长期逼近 Send-Q,就说明你的程序 accept() 的速度跟不上了。
思考题 3: 你写了一个高性能服务器,listen() 传了 backlog=100000,但实际压测时发现全连接队列最大只有 4096。为什么?
参考答案
因为 listen() 的 backlog 会被内核参数 net.core.somaxconn 封顶,最终生效的是 min(backlog, somaxconn)。如果你的内核版本是 Linux 5.4 之前的,somaxconn 默认只有 128;较新内核默认 4096。你传的 100000 超过了 somaxconn=4096,于是被静默截断成 4096。想真正的容量突破,必须同时提高两块:sysctl net.core.somaxconn=65535(或写 /proc/sys/net/core/somaxconn),并且应用层 listen() 也传一个不小于它的值。单改一边是常见误区。
tcpdump:给三次握手拍一套"现场录像"
前面我们是通过"状态表格"(netstat)间接推断三次握手的。现在换上更硬核的工具——tcpdump,直接把线上飞来飞去的报文抓下来看。它是 Linux 下一款重量级的网络分析工具(network sniffer),本质上是一个跑在用户态的程序,通过 libpcap 库把内核网卡驱动的数据包复制出来给你看。配合 -w 还能把原始报文记录成文件,之后用 -r 回放,或者导入 Wireshark 做更友好的图形化分析。
安装 tcpdump
大多数发行版都预装了 tcpdump,没有的话装一下。Debian / Ubuntu 系:
sudo apt-get update # 先刷新本地软件源索引,装任何包前习惯性先 update
sudo apt-get install tcpdump # 安装 tcpdump 抓包工具Red Hat / CentOS / Fedora 系:
sudo yum install tcpdump # 用 yum(有些新系统是 dnf)安装 tcpdumpsudo 是用 root 权限执行。抓包需要读网卡,普通用户通常没权限,所以抓包命令多半要加 sudo。这是新手第一次报"请求权限失败"的常见原因。
常见用法逐个拆
抓包本质上就一句话:tcpdump 条件表达式。用 -i 指定网卡,用各种"过滤器"圈定要抓的报文,剩下的就是等。下面把最常用的几种一次讲透。
1. 抓所有接口上的 TCP 报文:
sudo tcpdump -i any tcp
# │ │ │ └─ tcp:过滤器,只要 TCP 协议的包
# │ │ └───── interface:接口。tcpdump 必须知道抓哪块网卡
# │ └──────────── 所有网络接口(any 通配)
# └─────────────────── root 权限-i 是 interface(接口)的缩写。any 表示"所有网络接口都抓"——包括物理网卡、虚拟网卡和回环。如果只想看某一块,先 ifconfig 看看本机有哪些网卡:
# 列出本机网卡及它们的 IP/MAC
ifconfig输出里能看到诸如 eth0(物理/虚拟网卡)、lo(loopback,回环网卡,IP 固定 127.0.0.1)、云服务器常见的 ens33 / eth0 等。挑选一块来抓:
# 只抓 eth0 这块网卡上的 TCP 报文
sudo tcpdump -i eth0 tcp2. 按源 / 目的 IP 过滤:
# 抓"源 IP = 192.168.1.100"的 TCP 报文
sudo tcpdump src host 192.168.1.100 and tcp
# │ └──┴──────────── source,源地址,指发出这一端的 IP
# └─ src host:源主机 IP 过滤
# 抓"目的 IP = 192.168.1.200"的 TCP 报文
sudo tcpdump dst host 192.168.1.200 and tcp
# └─ dst host:目的主机 IP 过滤
# 同时限定源和目的 IP(用 and 连接多个条件)
sudo tcpdump src host 192.168.1.100 and dst host 192.168.1.200 and tcp这里 src host 与 dst host 表示源 / 目的 IP,and 是"并且"的逻辑,可以把多个条件串起来。tcp 仍是"只要 TCP"。
3. 按端口过滤:
# 抓 80 端口(HTTP 默认端口)的 TCP 报文
sudo tcpdump port 80 and tcp
# └─ port 80:端口过滤,抓进出该端口的所有包port 关键字按端口过滤。抓 9090 就用 port 9090,抓只跟某个主机的 9090 就再叠一层 host。
4. 保存到文件 + 从文件回放:
# 把抓到的包原样存成 data.pcap 文件(-w 是 write 到文件的缩写)
sudo tcpdump -i eth0 port 80 -w data.pcap
# │ └─ 把报文写入文件而非打印到屏幕
# └─ 抓取条件:eth0 网卡、80 端口
# 从 data.pcap 文件里读出来分析(-r 是 read 的缩写)
tcpdump -r data.pcap.pcap(Packet Capture,数据包捕获)是一种通用的抓包文件格式,几乎所有抓包工具都能读写。把抓到的包落盘再分析的好处是:抓包的瞬间和看包的瞬间可以完全分离——你可以在流量清白时用 -w 录下来,回办公室慢慢用 -r 或 Wireshark 拆解。
5. 三个贴心的抓包习惯:
# 抓本机回环接口上 9090 端口的 TCP 包
# -n 不做主机名/端口反解,-nn 连服务名也不反解,直接给你数字,输出更整洁
# -S 让 tcpdump 打印"绝对"序号(默认打印相对序号,看三次握手序号规律时务必给上)
sudo tcpdump -i lo tcp port 9090 -nn -S这三个选项基本是抓包标配,尤其是 -S——tcpdump 默认用相对序号显示,讲三次握手"序号 +1"的规律时会被误导,加上 -S 才能看到 ISN 的真面目。
决定抓包效果的那个关键变量:接口选对了吗
tcpdump 一个最容易被忽略的坑是接口必须和流量实际经过的路径对上。你要抓"服务器收到的握手包",就得确认这些包确实经过你 -i 指定的那块网卡。两条路径要分清:
- 本机回环(loopback):本机进程连本机进程(比如客户端和服务端都跑在同一台虚拟机里的
127.0.0.1上),数据不离开主机、不走物理网卡,只走lo这块虚拟网卡。此时必须抓lo,而不是eth0——否则什么都抓不到。 - 跨主机 / 云服务器:包走真实网卡(
eth0/ens33等),抓这块物理网卡即可。
这就是为什么我们这节课强烈建议"本机回环 + 两端同机"来做实验。 原因至少有三点:
- 回环上没有任何 NAT(Network Address Translation,网络地址转换)、路由、网关的"偷偷改包",三次握手的每一个字节都原样可见,最利于观察;
- 回环延迟接近为零,握手快到你几乎不用等,实验节奏舒服;
- 虚拟机如果用 NAT 网卡,主机和虚拟机、外网之间会做 IP 和端口替换(NAT 会改写源/目的 IP 与端口),导致抓到的包"地址对不上",会严重干扰新手理解。真要跨主机测,优先用"桥接网卡",或者直接用同一台云服务器上的两个进程互相连接,更直观省心。
抓包方向总结成一句:抓本机回环就 -i lo,抓真实出网就 -i eth0(以 ifconfig 看到的名为准)。
在 tcpdump 里亲眼看到三次握手
理论讲够了,动手。我们把服务器(Listen(2) 的 test_server)在 9090 端口跑起来,用一个简单到不行的 telnet 当客户端去连它,同时用 tcpdump 抓回环接口。telnet 是"远程登录"工具,这里只是借它"发送连接并回显"的能力来当个现成的 TCP 客户端。
# 起服务器(后台跑,避免占住终端)
./test_server 127.0.0.1 9090 &
# 另开一个终端,先让 tcpdump 在回环接口上盯住 9090 端口
sudo tcpdump -i lo tcp port 9090 -nn -S
# 再开第三个终端,用 telnet 连过去
telnet 127.0.0.1 9090前 1 秒内,tcpdump 会吐出一连串报文。我们把三次握手的三行挑出来,加注释给你看(IP 反解已关,序号用的是绝对序号,所以是巨大的随机数):
# ① 第一次握手:客户端 48180 → 服务器 9090,纯 SYN
IP 127.0.0.1.48180 > 127.0.0.1.9090: Flags [S], seq 3726147071, length 0
# ② 第二次握手:服务器 9090 → 客户端 48180,SYN+ACK
IP 127.0.0.1.9090 > 127.0.0.1.48180: Flags [S.], seq 1288513136, ack 3726147072, length 0
# ③ 第三次握手:客户端 48180 → 服务器 9090,纯 ACK
IP 127.0.0.1.48180 > 127.0.0.1.9090: Flags [.], ack 1288513137, length 0
对照前面那张时序图,三个报文的规律一目了然:
- ① [S](SYN):客户端
seq=3726147071,它自己的初始序号,记为X,不携带确认。 - ② [S.](SYN+ACK):服务器
seq=1288513136(记为Y),ack=3726147072,一丝不差地等于X+1。为什么是 +1?因为客户端那个 SYN 占掉了一号,服务器要确认的是"客户端接下来该发的字节"确实是X+1。 - ③ [.](纯 ACK):客户端
seq=3726147072(=X+1,接着头一个字节的位置),ack=1288513137(=Y+1)。它也把服务器的 SYN 占了的那一号还回去了。前两次握手里的 SYN 各占一号,这第三个纯 ACK 不再占新序号,所以叫"第三次握手不占序号"。
思考题 4: 若第一次握手的 SYN 序号是 seq=1000,第二次握手 SYN-ACK 的 seq 和 ack 应各是多少?第三次握手的 seq 和 ack 呢?
参考答案
设客户端 ISN=X=1000,服务器自己的 ISN 记为 Y。第二次握手(SYN-ACK):seq=Y(服务器自己的随机初始序号,具体数值只有服务器知道),ack=X+1=1001(确认客户端 SYN 占了一号,期望它从 1001 继续)。第三次握手(纯 ACK):seq=X+1=1001(客户端从被同步掉的位置继续),ack=Y+1(把服务器的 SYN 也占掉一号,确认收到)。三个报文的规律核心就一句:SYN 占一号,ACK 照着对方的 seq+1 做 ack。
观察"确认应答"与捎带应答
连接建好后,在 telnet 里敲一个字符,比如 h。你会发现 tcpdump 立刻多出几行,请着重看序号怎么衔接:
IP 127.0.0.1.48180 > 127.0.0.1.9090: Flags [P.], seq 3726147073:3726147074, ack 1288513137, length 1
IP 127.0.0.1.9090 > 127.0.0.1.48180: Flags [P.], seq 1288513137:1288513146, ack 3726147074, length 9
IP 127.0.0.1.48180 > 127.0.0.1.9090: Flags [.], ack 1288513146, length 0
- 你敲的
h变成一字节数据发出去:客户端[P.],seq=3726147073:3726147074表示"从序号 3726147073 开始,发 1 个字节(到 3726147073+1)",length 1就是这个数据长 1 字节。[P.]里的P是 PUSH(推),表示带数据的包 - 服务器收到后回了一个
length 9的包,seq=1288513137:1288513146(发 9 字节),ack=3726147074(把客户端那一字节确认掉)。注意它既带数据又带 ACK——这在 TCP 里叫"捎带应答"(piggybacking,把确认和数据塞进同一个报文),省了一次往返。这种"回显 + 确认"合流的情况非常常见。 - 客户端收到 9 字节后,回一个纯
[.]ACK,ack=1288513146,把这 9 字节确认掉,length 0 表示它本身不带数据。
看到没有,数据字节之间完全按之前的序号无缝接续,一字节不少、一字节不重。这就是"可靠字节流"的一种直观体现。
观察四次挥手(以及它为什么常常只看到三次)
收尾再抓一次"挥手"。TCP 关闭连接通常讲四次挥手:FIN → ACK → FIN → ACK。在 telnet 里输入 Ctrl + ] 进入 telnet 控制界面,再输入 quit 退出。此刻抓包窗口会出现(略去注释讲解序号接续,此处看动作):
IP 127.0.0.1.48180 > 127.0.0.1.9090: Flags [F.], seq 3726147074, ack 1288513146, length 0
IP 127.0.0.1.9090 > 127.0.0.1.48180: Flags [F.], seq 1288513146, ack 3726147075, length 0
IP 127.0.0.1.48180 > 127.0.0.1.9090: Flags [.], ack 1288513147, length 0
F 是 FIN(Finish,结束)标志。理想情况应该是四个包:客户端发 FIN →服务器 ACK → 服务器发 FIN → 客户端 ACK。但这里只看到三个。因为服务器的"ACK"和它自己的"FIN"合成了一包(发生了捎带应答,两个动作撞进同一个 [F.] 报文)。所以别被"四次挥手"吓到——在回显这类简单场景里,由于捎带应答把两次合并成一次,你抓到的常常是"三次挥手"。本质还是四步,只是有两步重合了。
代码里的一个"伏笔":close() 放哪很讲究
课件里大家封装的 TcpSocket 在 close(sockfd) 附近"故意留了一个问题"。结合我们刚学的挥手,你大概能猜到:close() 的时机和位置,直接决定你能不能在挥手阶段把数据完整排空。 如果某个方向在 close() 前还堆着没发完的数据,或者关闭得过于草率(比如不等对方把数据收完就关),就可能出现"数据没发完连接就断了"的现象。等你们自己把 TcpSocket 封装打开,可以在 close() 前后各加一句日志,再配合 tcpdump 观察挥手报文,就能亲眼验证这个问题的真实位置。这里先按下不表,作为课后自己动手的线索。
思考题 5: 为什么说本实验"抓本机回环"比"抓物理网卡"更容易看清三次握手?(提示:结合 NAT 和你实际的网卡接口)
参考答案
本机回环(lo)上,客户端与服务器都在同一台主机内,报文不经过物理网卡、不走任何网关与路由;同时因为是两端同机,回环上也没有 NAT 做 IP / 端口改写,抓到的四次元组(源/目的 IP 与端口)与你代码里写的一模一样,最"干净"。反之若用物理网卡或 NAT 虚拟机再挂网关,报文会被改写、跨接口,新手极易被"抓不到包"或"地址对不上"误导。所以观察协议状态机细节时,优先本机回环。
队列满了做什么:丢包、重置,还是拒绝
回到队列。前面只讲了"全连接队列满→丢弃第三次握手 ACK→连接卡 SYN_RECV"这一种表现。事实上队列溢出时内核有几种不同的"脾气",取决于你要不要保命(保连接)还是图省事(直接砍):
先认识两个内核参数,都可用 sysctl 查看与修改:
# 查看当前各参值
sysctl net.core.somaxconn # 全连接队列的全局硬顶(默认 4096 或 128)
sysctl net.ipv4.tcp_max_syn_backlog # 半连接(SYN)队列的上限(常见几百到几千,随内存动态)
sysctl net.ipv4.tcp_syncookies # 是否启用 SYN Cookie 抗洪水(1=启用)
sysctl net.ipv4.tcp_abort_on_overflow # 全连接队列满时是否直接回 RST(0=静默丢,1=回RST)- 半连接队列满:如果
tcp_syncookies=1(现代发行版默认开启),内核会走 SYN Cookie 方案:不再把连接真正放进半连接队列占内存,而是把连接关键信息"加密"成一个数字(cookie)当成 SYN-ACK 的初始序号发回去,等客户端把 ACK 带回来时"无中生有"地还原出连接。这样即使队列满了也能继续握手,是目前对抗 SYN 洪水的主力。如果tcp_syncookies=0,队列一满就直接丢弃新到的 SYN。 - 全连接队列满 +
tcp_abort_on_overflow=0(默认):静默丢弃第三次握手的 ACK(或丢弃新 SYN),客户端往往还误以为连接成功——本实验出现的正是这种。 - 全连接队列满 +
tcp_abort_on_overflow=1:内核直接回一个RST(Reset 重置)给客户端,客户端立刻connect failed——虽然连接失败,但"失败得快、不拖泥带水",某些对"宁可明确失败也不要用户傻等"的场景反而更合适。
一句话总结三种结果:半连接满、未开 Cookie → 丢包拒绝;全连接满、abort_on_overflow=0 → 静默丢 ACK(最阴险);全连接满、abort_on_overflow=1 → 明确 RST。 你可以在生产事故排查时用这几招,来判断服务器到底卡在哪一层。
SYN 洪水:把队列往死里灌
把上面的一切串起来,你就明白一个经典攻击——SYN 洪水(SYN Flood,也叫 SYN 泛洪攻击)——为什么奏效了。
攻击者伪造大量源 IP 的 SYN 包疯狂发向服务器,但从不回第三次握手的 ACK。服务器每收到一个 SYN 就要进半连接队列、分配一小块内存记住它、发一个 SYN-ACK,然后干等那个永远不会来的 ACK。结果就是:
- 半连接队列被成千上万的"半吊子"连接塞满;
- 队列一满,正常的、诚实的客户端 SYN 也没地方放,直接被丢;
- 服务器资源被大量无谓地消耗,合法用户连不上。
解决方案的顶梁柱正是上面提到的 SYN Cookie:让服务器在 SYN 队列满时不落任何内存,把身份信息编码成序号发回去,等真客户端的 ACK 带回来再现场还原连接。攻击者的 ACK 永远不会来,所以它根本消耗不了服务器的内存,半连接队列就"形同虚设"地空转——这也就是为什么现代内核默认 tcp_syncookies=1。它虽然牺牲了一点点(比如放弃某些对序号精准的 TCP 扩展协商),换来的是把洪水耗死在自己那一侧。
除 SYN Cookie 外,生产上还会配合:调大 tcp_max_syn_backlog 给半连接队列更大缓冲、调大 somaxconn 与 backlog 给全连接队列更多余量、控制 SYN_ACK 重传次数(tcp_synack_retries),以及用防火墙 / SYN proxy 提前过滤非法 SYN。
思考题 6: 一名攻击者不断发送只含 SYN、从不回 ACK 的伪造包,最可能把服务器的哪条队列打满?设置为 tcp_syncookies=1 为什么能显著缓解?
参考答案
打满的是半连接队列(SYN 队列)。因为这些连接只走到"服务器收到 SYN、发 SYN-ACK",永远不会有第三次握手的 ACK 把它们挪进全连接队列,于是只能积压在半连接队列里直到超时。启用了 tcp_syncookies=1 后,内核在半连接队列满时不再为每个 SYN 分配真实内存、也不再依赖队列来跟踪这些连接,而是把连接信息编码进 SYN-ACK 的初始序号(cookie)直接回包;等真正的客户端把它带回 ACK,内核现场解码还原连接即可。攻击者的 ACK 永远不会来,就永远不会真正占用内核资源,合法连接因此得以继续建立。
常见坑与排查清单
把容易踩的坑集中总结一下,便于日后自查。
accept()太慢会引发"全连接队列"堆积。服务器永远不accept()(比如死循环、或 accept 逻辑有 bug),已完成握手的连接就会堵满全连接队列,随后新连接握手失败。排查手段:ss -lnt看Recv-Q是否逼近Send-Q;netstat -s | grep -i listen看 "listen queue of a socket overflowed" 计数是否在涨。backlog传再大也可能被somaxconn截断。真正生效的是min(backlog, somaxconn)。想看某端口实际最大容量,用ss -lnt的Send-Q列。- 抓包没结果,先查接口。本机连本机要
-i lo,跨主机才用物理网卡;lo状态 CLOSE_WAIT 之类也不会出现在物理网卡。接口选错,tcpdump 一句话都抓不到。 - NAT 会改写 IP/端口。虚拟机 NAT 网卡、云服务器做公网映射时,抓到的地址与代码里看到的不一致,观察协议最好本机回环。
- 用 tcpdump 看序号规律务必加
-S。默认是相对序号,加-S才显示绝对 ISN,否则"SYN 占一号、ack = seq+1"的规律会看着对不上号。 tcp_abort_on_overflow决定满队列后是"静默丢"还是"明确 RST"。线上遇到"客户端 connect 成功却总连不上"的怪现象,优先怀疑它=0 时的静默丢弃。
讲到这里,我们把"一个 TCP 连接"从出生到死亡的过程都看过了:三次握手建立(SYN→SYN-ACK→ACK),中间经过内核的半连接队列(未完成握手)与全连接队列(完成握手、等待 accept),accept() 只是从全连接队列弹连接;listen() 的 backlog 决定全连接队列容量、somaxconn 给它封顶;队列满时内核可能静默丢 ACK、明确回 RST、或靠 SYN Cookie 硬抗洪水;而 tcpdump 让我们把一个字节一个字节的报文摊在眼前,亲眼验证了 "SYN 占一号、ack = seq + 1"、捎带应答、以及挥手的真身。
这背后其实藏着一条贯穿始终的朴素道理:协议栈替你扛了大部分脏活,但"资源永远是有限的"。 队列也好、序号也好、Cookie 也好,说到底都是在"有限的资源"和"无限的需求"之间做权衡。你真正理解了那些队列的存在和极限,写高并发服务器时就不会再对着莫名奇妙的连接问题挠头了。
建议你立刻动手做两件事:第一,把本课最后的实验(Listen(2) + 不 accept + 四个客户端 + netstat)完整跑一遍,亲眼看到那个卡住的 SYN_RECV;第二,用 tcpdump -i lo tcp port 9090 -nn -S 抓一次 telnet 的连接,亲口读出那三个握手的序号。等这两步都验证过了,再往下一个知识点走——处理大量并发连接时,accept 之后还得面对 select、poll、epoll 那一整套大戏。连接是被队列"接进来"的,而把它真正"用起来",是下一课的事情。
还没有评论 — 第一条由你来留。