1. 从 ARPANET 到现代互联网:TCP 是被"逼"出来的
1.1 没有 TCP 的时代:NCP 只适合"自娱自乐"
要理解 TCP,得先回到它诞生之前的年代。ARPANET 是 1969 年建成的,它用的传输控制协议叫 NCP(Network Control Protocol),这个名字本身就暴露了它的局限——它只为一个网络设计,不考虑异构网络之间的互通。
NCP 由主机—主机协议和连接协议两部分组成,它假设底层网络是"可靠"的:报文发出去,基本都能到达;网络拓扑相对固定;对端的机器型号、操作系统都是已知的。这种假设在实验室里的封闭网络里没问题,可一旦要跨网络通信,就完全失灵。你可以把它想象成一台只能打内线电话的老式交换机,分机之间互相通话没问题,但要接到外线、拨到别的城市甚至其他国家,这套机制根本转不起来。
到了 1970 年代中期,ARPANET 面临一个现实问题:网络变多了,除了 ARPANET 还有分组无线电网络(PRNET)、卫星网络(SATNET)等,它们各有各的物理介质和传输特性。如果协议栈不具备跨网络的抽象能力,未来的互联网只能是空中楼阁。
1.2 Kahn 与 Cerf 的破局:让异构网络"对话"
1973 年,Robert Kahn 和 Vint Cerf 开始设计新的传输控制协议,最终成果就是 TCP/IP 的前身。他们提出的核心思路,放到今天看仍然非常超前:网络层只负责尽力而为地转发数据报,可靠性由两端主机通过确认和重传机制来保证。
最开始的 TCP 其实是一个"大杂烩"协议,同时承担了今天 IP 和 TCP 两种职责——既要寻址路由,又要端到端可靠传输。后来设计者发现这两件事的抽象层级完全不同:寻址路由是所有上层应用都要用的公共设施,而可靠性只是部分应用的需求。于是协议被拆成了两层:IP 负责路由转发,TCP 负责可靠传输。
拆层这件事看起来简单,实际影响深远。它意味着:任何网络,不管你是光纤、以太网、还是 Wi-Fi,只要能跑 IP 包,就能接入互联网。TCP 不关心底下是什么介质,它只关心数据有没有可靠地从 A 送到 B。
1983 年 1 月 1 日是互联网历史上的"旗日"(Flag Day),NCP 被正式关闭,TCP/IP 成了 ARPANET 的唯一协议栈。从这天起,现代互联网的骨架才算真正立起来。
1.3 拥塞崩溃:1986 年那场事故如何改写 TCP 的设计史
TCP 刚诞生的时候,只有一个简单的重传机制:超时没收到确认就重发。这个机制在小型网络里没问题,但随着互联网规模迅速膨胀,一个致命的缺陷暴露了出来。
1986 年,从劳伦斯伯克利实验室到 UC Berkeley 的一条链路,传输吞吐量从原来的 32 Kbps 暴跌到 40 bps,接近瘫痪。这就是网络史上著名的"拥塞崩溃"(Congestion Collapse)。原因不复杂:当网络拥堵时,路由器开始丢包,主机收到超时信号后立刻重传,重传加剧了拥堵,拥堵导致更多丢包,丢包又引发更多重传——一个正反馈的死循环。
LBNL 的 Van Jacobson 分析了这个现象,意识到问题的根源不在重传本身,而在主机对网络状态完全无感知,盲目地以同样的速率发包。他在 1988 年发表的论文里提出了慢启动(Slow Start)、拥塞避免(Congestion Avoidance)、快重传(Fast Retransmit)和快恢复(Fast Recovery)四部分算法,合称 TCP Tahoe/Reno 体系。核心思想就一句话:网络通畅时放开手脚发,网络拥塞时立刻收敛。
这套拥塞控制机制到今天依然是 TCP 的基石,后来的 New Reno、Vegas、CUBIC、BBR 都是在这个框架上演进出来的。所以你看,TCP 的很多关键特性不是设计者一拍脑袋想出来的,而是被一次次真实故障倒逼出来的。
2. TCP 的设计哲学:可靠是双方的事,与网络无关
2.1 端到端原则:网络只是"哑管道"
TCP 的设计有一个极其重要的哲学基础,叫端到端原则(End-to-End Principle),源自 1984 年 Saltzer、Reed 和 Clark 的经典论文。用大白话说就是:网络中的中间设备(路由器、交换机)只负责搬运数据,不做复杂的业务逻辑;可靠性、顺序性、去重这些工作,交给通信两端的主机来完成。
这个原则初看反直觉——既然要强化可靠性,那在中间的每个节点都做校验和确认,不是更保险吗?但仔细想就明白了:中间的节点没有能力判断数据"正确不正确",它不知道应用层的语义,只看到一个个孤立的数据包;而两端的应用知道全部上下文。把可靠性放在两端,既简化了中间节点,又避免了每一跳都做可靠性处理带来的巨大开销。
我在跟人聊 TCP 时经常用快递做类比。端到端原则的快递公司只负责把包裹从 A 城送到 B 城,中途丢了就通知发件方;发件方收到"丢件通知"就再寄一次,收件方自己检查数量对不对、顺序对不对。你不会要求每个中转站都把包裹拆开验一遍——那既慢又贵,而且中转站根本不知道包裹里装的是啥。
2.2 三条隐含假设与四件套机制
TCP 做出可靠性保证之前,先对网络做出了三条隐含假设,这是理解整个协议的关键:
- 信道不可靠:数据包可能丢失、损坏、乱序、重复,也可能在路上滞留很久。
- 网络容量未知且动态变化:两端都不知道当前路径上的可用带宽到底是多少。
- 通信双方有完整的上下文:连接两端维护各自的状态,网络不保存任何与连接相关的状态。
基于这些假设,TCP 用了四套机制来兑现可靠性承诺:
| 机制 | 解决的问题 | 实现方式 |
|---|---|---|
| 校验和 | 数据在传输中被篡改或损坏 | 对头部和伪头部做 16 位补码和校验 |
| 确认(ACK) | 发送方不知道数据是否到达 | 接收方回 ACK,携带期望的下一个序列号 |
| 超时重传(RTO) | 数据或 ACK 丢失 | 超过重传超时时间未收到 ACK 就重发 |
| 序列号 | 乱序、重复、丢失后的排序与去重 | 为每个字节分配唯一编号,接收方按号重组 |
这些机制单独拿出来都不难理解,但组合在一起后,它们形成了一个自洽的闭环:发出去不放心,所以等确认;等不到就重发;重发了可能重复,所以用序列号去重;数据流太长,于是分段编号。TCP 的可靠不是玄学,就是这四个件套精密协作的结果。
2.3 TCP 与 UDP 的边界:有时选 TCP 反而是错
TCP 设计哲学的另一面,是它为可靠性付出的代价。三次握手建立连接的延迟、ACK 确认导致的往返开销、超时重传带来的不确定性、队头阻塞(Head-of-Line Blocking)……这些在特定场景下是不可接受的。
队头阻塞是最典型的例子:TCP 是字节流协议,接收方必须按序向上层递交数据。如果中间的某个段丢了,后续已经到达的段都得在缓冲区里等着,直到丢失的段被重传补上。对 Web 页面加载来说这问题不大,但如果你的应用是实时语音、视频通话、云游戏,那队头阻塞就是致命的——画面没法等一个迟迟不来的包。
所以现在大量实时音视频系统直接跑在 UDP 上,由应用层自己实现丢包重传、抖动缓冲,甚至干脆丢旧包不重传(比如 WebRTC)。QUIC 协议之所以大势所趋,也正因为它在 UDP 之上重新实现了带流级多路复用的可靠传输,从根上规避了 TCP 的队头阻塞。
选 TCP 还是 UDP,不是"可靠更好"这么简单,而要看你能否接受:延迟比吞吐更重要的时候选 UDP,数据一个字节都不能丢的时候选 TCP,两者都要兼顾的时候研究 QUIC。
3. 报文格式逐字节拆解:20 字节固定头 + 选项字段
3.1 固定头部全景图与字段职责
TCP 报文段的固定头部是 20 字节,是所有网络协议里信息密度最高的结构之一。我先给出全景,再逐个讲。
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 源端口 | 目的端口 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 序列号 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 确认号 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 数据偏移 | 保留 |N|C|E|U|A|P|R|S|F| 窗口大小 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 校验和 | 紧急指针 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+源端口和目的端口分别是 16 位,共同构成四元组(源 IP、源端口、目的 IP、目的端口)的一部分,负责唯一标识一条连接。端口号的分配有一套惯例:0-1023 是系统端口,1024-49151 是注册端口,49152-65535 是动态/私有端口。但这只是约定,不是强制——你的服务完全可以监听在 8080、3306 这类常见端口,只要不冲突就行。
数据偏移字段占 4 位,表示 TCP 头部的总长度,以 4 字节为单位。固定头部是 20 字节,所以这个字段最小是 5。如果带有选项字段,头部变长,这个值会相应变大。抓包时看到"Header Length: 32 bytes",转化成十进制就是 8,意味着选项区占了 12 字节。
3.2 序列号与确认号:TCP 是按"字节"计数的
序列号和确认号是 32 位无符号整数,这两个字段是整个 TCP 可靠传输的地基。关键点在于:TCP 不是按报文计数,而是按字节计数。每个字节都有唯一的序列号,一个报文段的序列号,就是该段第一个字节在整个字节流中的偏移量。
举个实际例子。假设客户端要发送一个 1500 字节的数据,初始序列号(ISN)是 1000。第一个报文段携带字节 1000-2499,序列号就是 1000;第二个报文段携带字节 2500-3999,序列号就是 2500。接收方收到第一个段后,回复的 ACK 号是 2500——它的含义不是"我收到了 2500",而是"我期望的下一个字节是 2500",也就是隐含地确认了 2499 及之前的所有字节都已收到。
这种累加确认方式效率很高:一个 ACK 可以确认前面所有的数据,不需要逐段确认;也天然支持乱序——即使后到的段先到,接收方也可以用序列号把它们暂存在重排缓冲区里,等空缺补齐后再提交给应用。
你可能注意到了初始序列号为什么是随机的。如果 ISN 固定为一个常量,那一个"迟到"的旧连接报文,很可能被新连接误认为是当前连接的有效数据,造成数据串扰。随机化 ISN 就是为了防御这种序列号预测攻击。客户端和服务器在三次握手的 SYN、SYN-ACK 阶段,各自声明自己的初始序列号,之后双方都知道对方从哪里开始计数。
3.3 标志位:SYN、ACK、FIN、RST 的真实含义
TCP 头部有 9 个标志位(现代实现里 NS、CWR、ECE 是后来加的),最核心的是下面 6 个:
- SYN(Synchronize):发起连接,同时声明自己的初始序列号。SYN=1 的报文不携带任何应用数据,但会占用一个序列号。
- ACK(Acknowledgment):确认字段有效。除 SYN 和 FIN 外的所有报文都会带上 ACK=1。
- FIN(Finish):发起方表示"我要关闭连接了,数据发送完毕"。FIN 报文也占一个序列号。
- RST(Reset):异常重置连接。收到一个根本不存在的端口上发来的报文、或者连接状态混乱时,会回 RST 直接终止连接。
- PSH(Push):催促接收方尽快把数据交给应用层,不要暂存在缓冲区里。现代实现基本不依赖这个位,但还是保留着。
- URG(Urgent):指示紧急指针字段有效,表示有紧急数据需要优先处理。这个机制在实际应用中几乎没人用,很多协议栈甚至忽略它。
CWR 和 ECE 两个标志位是显式拥塞通知(ECN)机制的一部分,用于在丢包发生之前就感知网络拥塞。它要求网络设备配合——路由器在发现拥塞时给 IP 包打标记,而不是直接丢弃。实际部署率不算高,但了解它们对理解现代 TCP 的拥塞控制演进仍有帮助。
3.4 选项字段:MSS、窗口缩放、SACK 决定传输上限
TCP 选项位于固定头部之后,用 TL(Type-Length)格式编码。几个最具影响力的选项值得专门讲:
最大报文段大小(MSS):TCP 在建立连接时,双方各自通告自己愿意接收的最大报文段长度,这个值通常由 MTU 决定。以太网 MTU 是 1500 字节,减去 IP 头部 20 字节和 TCP 头部 20 字节,标准 MSS 是 1460。为什么 MTU 之上还要再减一层?因为 TCP 数据是要完整封装进 IP 包的,一个 IP 包的最大净荷承载能力就是由 MTU 决定的。
窗口缩放(Window Scale):TCP 头部的窗口字段只有 16 位,最大 65535 字节。在几十年前的网络上这绰绰有余,但今天动辄几十上百毫秒的 RTT、Gbps 级别的带宽,65535 字节的窗口只能让发送方每秒最多转发约 1 MB 数据,远不够用。窗口缩放选项允许双方协商一个左移位数,把 16 位窗口扩展到最大 1 GB。这就是为什么高带宽长链路必须启用"TCP Window Scaling",否则吞吐量会被窗口大小死死卡住。
选择性确认(SACK):早期 TCP 的确认是"累计式"的,这意味着如果中间某个报文段丢了,即使后面的段都到了,接收方也只能确认到丢包之前的字节。发送方被迫重传后面所有已到达的段,白白浪费带宽。SACK 允许接收方明确告诉发送方"我收到了哪几个区间、缺了哪几个区间",发送方只补发真正丢失的部分。广域网高丢包场景下,开启 SACK 的吞吐量提升非常明显。
时间戳(Timestamp):这个选项让 TCP 可以精确计算 RTT,还能在一定程度上区分"旧包"和"新包",配合 PAWS(Protect Against Wrapped Sequence)机制防止序列号回绕导致的数据混淆。31 位序列号在高速网络上几小时就会绕一圈,如果没有时间戳辅助,极有可能把旧连接的数据误判为当前数据。
4. tcpdump 抓包实战:从安装到三次握手全解析
4.1 抓包前的准备工作与 tcpdump 基础语法
理论讲再多,不如亲自抓一次包。tcpdump 是 Linux 环境下最强大的命令行抓包工具,几乎所有发行版都自带或可通过包管理器安装。Debian/Ubuntu 上执行sudo apt install tcpdump,CentOS/RHEL 上执行sudo yum install tcpdump即可。
tcpdump 抓包必须要有 root 权限,因为它要操作网络接口。基本语法是:
tcpdump -i eth0 -nn -XX参数说明:
-i eth0:指定抓包网卡,抓所有网卡用any-nn:不做域名反解,不做端口名反解,直接输出 IP 和端口号。这个参数强烈建议加上,否则 DNS 解析耗时且结果不可读-XX:同时输出十六进制和 ASCII 格式的报文内容,适合分析报文结构-c 50:抓满 50 个包后自动退出-s 0:抓取完整报文,默认只抓 96 字节的头部-w dump.pcap:把结果写入文件,供 Wireshark 打开-r dump.pcap:读取之前抓包保存的文件
过滤表达式是 tcpdump 的灵魂。常用组合如下:
# 按主机过滤 tcpdump -i eth0 host 192.168.1.100 # 按端口过滤 tcpdump -i eth0 port 443 # 按方向过滤 tcpdump -i eth0 src host 192.168.1.100 and dst port 443 # 过滤 SYN 包 tcpdump -i eth0 'tcp[13] & 2 != 0' # 排除 SSH 流量,避免自己干扰自己 tcpdump -i eth0 port not 22 # 抓整个网段的 HTTP 流量 tcpdump -i eth0 net 192.168.1.0/24 and tcp port 80最后一个tcp[13] & 2 != 0值得展开说一下:tcp[13]表示 TCP 头部的第 13 个字节偏移,即标志位所在的字节。SYN 对应的位是 2,所以这个表达式过滤出所有 SYN=1 的报文。想过滤 RST 包就把 2 换成 4,FIN 换成 1。这种位运算语法看起来很硬核,但排查问题时真能救命。
4.2 三次握手逐包拆解:seq 与 ack 的变化规律
我先起一个本地 HTTP 服务,然后用 curl 访问它,同时用 tcpdump 抓包。在另一个终端执行:
sudo tcpdump -i lo -nn port 8080 -c 4注意这里抓的是回环接口lo,本地访问回环地址时,流量全走这个接口。
发起请求后,抓到的前三个包大概长这样:
10:14:23.100001 IP 127.0.0.1.52134 > 127.0.0.1.8080: Flags [S], seq 1795689353, win 65495, options [mss 65495,sackOK,TS val 123456 ecr 0,nop,wscale 7], length 0 10:14:23.100003 IP 127.0.0.1.8080 > 127.0.0.1.52134: Flags [S.], seq 3562390871, ack 1795689354, win 65483, options [mss 65495,sackOK,TS val 123456 ecr 123456,nop,wscale 7], length 0 10:14:23.100005 IP 127.0.0.1.52134 > 127.0.0.1.8080: Flags [.], ack 3562390872, win 65536, length 0逐行拆开看:
- 第一个包:客户端发 SYN,seq=1795689353,这是客户端的初始序列号,随机生成。
- 第二个包:服务端回 SYN+ACK(
Flags [S.]即 SYN+ACK),seq=3562390871(服务端自己的初始序列号),ack=1795689354(等于客户端 seq+1)。 - 第三个包:客户端发 ACK,seq=1795689354(等于之前的 seq+1),ack=3562390872(等于服务端 seq+1)。
注意一个容易搞混的细节:SYN 和 FIN 报文都会占用一个序列号,但 ACK 报文不占用。三次握手里每次 seq 加 1,是因为 SYN 占掉了一个序号;握手完成后,数据报文的 seq 会按实际携带的字节数增长。
三次握手的过程可以理解为双方互相对表:客户端说"我要开始发了,我的起始序号是 J",服务端回"好,我收到了你的 J+1,我的起始序号是 K",客户端再回"我收到了你的 K+1,可以开始发了"。这个协商过程保证了两端都对对方的初始序列号了然于胸。
抓包时你会看到每个 SYN 包后面都跟着一串 options。mss 65495 在回环接口上很大,因为 loopback 接口的 MTU 通常是 65536;真实以太网环境里 MSS 一般是 1460。wscale 7 意味着窗口左移 7 位,即实际窗口大小是头部显示的窗口值乘以 128。
4.3 四次挥手与 RST 异常:连接消亡的两种方式
连接关闭的正常路径是四次挥手。同样用回环接口抓包,请求完成后连接会被关闭,会看到类似这样的序列:
10:16:00.100001 IP 127.0.0.1.52134 > 127.0.0.1.8080: Flags [F.], seq 1000, ack 500, length 0 10:16:00.100002 IP 127.0.0.1.8080 > 127.0.0.1.52134: Flags [.], ack 1001, length 0 10:16:00.100003 IP 127.0.0.1.8080 > 127.0.0.1.52134: Flags [F.], seq 500, ack 1001, length 0 10:16:00.100004 IP 127.0.0.1.52134 > 127.0.0.1.8080: Flags [.], ack 501, length 0四次挥手的本质是:两个方向各自独立关闭。主动关闭方发 FIN,对端回 ACK 表示收到 FIN;对端完成自己数据的发送后,也发 FIN;主动方再回 ACK。每一步都对应着一个方向的连接关闭。
三次挥手和四次挥手有个容易被误会的地方——为什么很多时候抓包只看到 3 个包?因为这些抓包发生在"对端正好没有剩余数据要发送"的情况下,被动方的 ACK 和 FIN 可能被合并成一个包发出。所以你在 Wireshark 里经常能看到FIN, ACK合在一起的包,这不算异常。
如果连接非正常终止,你会看到 RST 标志。RST 和 FIN 的区别是决定性的:
- FIN 是"我送完数据了,我们好好结束",是绅士行为。
- RST 是"我们这个连接根本不该存在,立刻终止",是暴力行为。
触发 RST 的常见场景包括:向一个没有进程监听的端口发起连接(内核直接回 RST);连接已经被对端关闭,但你还在往这个连接上发数据;防火墙主动发送 RST 来干扰连接。排查线上问题如果在一堆报错后看到 RST 包,先别急着骂代码——先用lsof -i确认端口上到底有没有服务在监听。
4.4 重传与乱序识别:网络质量照妖镜
在排查网络问题时,tcpdump 最能发挥价值的地方是识别重传和乱序。来看几个典型输出:
10:20:00.100001 IP 192.168.1.10.12345 > 192.168.1.20.80: Flags [P.], seq 1000:1460, ack 1, length 460 10:20:00.500003 IP 192.168.1.10.12345 > 192.168.1.20.80: Flags [P.], seq 1000:1460, ack 1, length 460同一个 seq 范围(1000:1460)出现了两次,第二次就是重传。tcpdump 原文会标记为[TCP Retransmission],Wireshark 里也直接显示。如果重传间隔约等于 RTO(第一次 0.5 秒,之后 1 秒、2 秒、4 秒倍数递增),说明是超时重传,大概率是报文在途中丢了或确认丢失。
乱序则表现为另一种模式:
10:20:01.100001 IP 192.168.1.20.80 > 192.168.1.10.12345: Flags [P.], seq 5000:6460, ack 1, length 1460 10:20:01.100002 IP 192.168.1.20.80 > 192.168.1.10.12345: Flags [P.], seq 3540:5000, ack 1, length 1460第二个包的 seq 比第一个小,说明接收方先拿到了更高序号的数据。接收方收到乱序段后,会立即回一个"重复 ACK"(Dup ACK),告诉发送方"我还在等序号 3540 之前的数据"。如果连续收到 3 个 Dup ACK,发送方会执行快速重传,不等超时就直接补发缺失段。
抓包中如果频繁看到这类现象,基本可以断定:要么路径上某个中间节点丢包率偏高,要么接收端的接收缓冲区太小导致内核丢弃了部分报文。此时可以用ss -t -i查看当前连接的 RTT 和重传计数,结合 tcpdump 的现象一起判断。
5. 高并发场景的 TCP 隐患与抓包自坑指南
5.1 TIME_WAIT:主动关闭方的 2MSL 等待
排查高并发服务时,TIME_WAIT是出镜率最高的词。用ss -tan查看连接状态,很多 TIME_WAIT 连接挂在列表里,不少人第一反应是"这肯定是泄漏了"。其实 TIME_WAIT 是 TCP 的正常状态,不是故障。
TIME_WAIT 出现在主动关闭方。当主动方发出最后一个 ACK 后,不会立刻释放连接,而是进入 TIME_WAIT 状态,等待 2MSL(Maximum Segment Lifetime,报文最大生存时间)。MSL 是 IP 包在网络上存活的最长时间,Linux 内核里通常把这个值定为 30 秒,所以 2MSL 约 60 秒。
为什么要等这么久?两个原因:第一,最后一个 ACK 可能丢失,对端会超时重发 FIN,主动方必须保留状态以便重发 ACK;第二,防止旧连接的数据包残留在网络中,和新建连接造成混淆。如果连接立刻释放,网络里可能还漂着一个旧的迟到包,恰好被新连接接收——这就是序列号随机化也防不住的情况,必须靠 TIME_WAIT 的等待来"隔离时间"。
对高并发的服务端来说,麻烦在于:TIME_WAIT 的数量可能非常大。如果服务端主动关闭了大量连接(比如每请求一个短连接),那服务端会积累海量 TIME_WAIT。这时候你该做的不是恐慌,而是先想清楚架构上能否避免主动关闭。更现实的做法是让客户端主动关闭连接,或者直接用连接池复用连接,避免频繁地建立和销毁连接——这比调整任何内核参数都有效。调小net.ipv4.tcp_fin_timeout或开启tcp_tw_reuse只是缓解手段,不是治本方案,而且在很多内核版本里tcp_tw_reuse的行为和直觉不完全一致,不建议无脑开启。
5.2 抓包自坑指南:校验和、回环、TSO 这些坑
抓包踩过的坑,比协议本身还多。我列几个几乎每个人都遇到过的:
坑一:Wireshark 里满屏 Checksum Offload 错误。抓到的包校验和明明是错的,但实际传输没任何问题。原因很简单:现代网卡硬件支持校验和卸载(Checksum Offload),在数据发出前或收到后由网卡硬件计算/验证校验和,内核和 tcpdump 抓到的只是"计算前"或"验证后"的包,看起来校验和就"不对"了。这不是网络问题,是抓包工具看到的中间态。
坑二:tcpdump 抓 loopback 接口的手包,需要-i lo。这个前面提过。很多新手在-i eth0上抓了半天本地回环流量,一个包都看不到,心想流量去哪了?记住:本机访问本机,流量不经过任何物理网卡,只在 loopback 接口上。
坑三:明明设置了 MSS 是 1460,抓到的数据包却长达 6000 字节。这是 TSO(TCP Segmentation Offload)在作怪。网卡把 TCP 分段工作接管了,内核给网卡下发的是一个大块数据,由网卡硬件切分成一个个 MSS 大小的段再发出去。tcpdump 在软件层抓到的就是这个"大块",看起来超过了 MSS。用ethtool -K eth0 tso off可以关掉这个特性再抓包,就能看到标准的 1460 字节分片了。
坑四:抓包能抓到请求,但看不到响应。如果防火墙或负载均衡设备直接干预了连接,它可能在中间回了 RST 或伪造了 ACK,原始服务器根本没收到请求。tcpdump 只能看到本机网卡上的流量,看不到被中间设备截胡的部分。此时要同时在客户端和服务器两端抓包,对比哪一侧出了问题。
5.3 一次真实的连接超时排查复盘
最后分享一个我帮团队排查过的经典案例。现象是某后端服务在高峰时段频繁出现客户端连接超时,客户端日志里全是"connect timeout",但进程活着、端口也在监听,看起来一切正常。
我们在服务端和客户端同时抓包。客户端 tcpdump 显示:SYN 正常发出,但没有收到任何 SYN-ACK;服务端 tcpdump 显示:完全没有收到客户端的 SYN。
问题就很清晰了:SYN 报文在中间环节被吃了。检查服务端防火墙规则,发现iptables的syn-flood保护规则在高峰期把源 IP 的 SYN 包静默丢掉了——因为服务端 accept 队列(backlog)已满,内核触发了丢包保护。再用ss -lnt看监听队列的溢出计数Send-Q,果然积压严重。
根因是应用层处理连接的速度跟不上新建连接的速度,队列被打满,新的 SYN 直接被丢掉,客户端只能干等超时。修复方法是两步走:一方面优化应用层的连接处理逻辑,另一方面调整 backlog 大小和相关内核参数,给瞬时洪峰留出缓冲。
这个案例里,tcpdump 的价值不在于"看到了什么错误包",而在于精确定位了"包是从哪段链路消失的"。只查服务端日志的话,你永远不会知道是防火墙在悄悄丢包。
我自己一直有个习惯:线上问题排查,先用 tcpdump 定位,再去看应用日志。网络层和应用层经常互相甩锅,抓包是唯一的裁判。有条件的话,建议每个后端工程师都亲手抓一次三次握手,盯着 seq 和 ack 的变化看一遍,你会对 TCP 有完全不一样的理解。这套东西看着琐碎,但关键时刻真的能救命。