1. 先想明白:TCP/IP 为什么要分层
TCP/IP 协议栈这六个字,几乎每个写网络应用的人都背过,可真要让你说清楚它到底解决了什么问题、为什么要分成四层,很多人反而讲不出个所以然。我自己带过不少跨系统联调的项目,每次排障到最后,基本都会回到一个共同点上——对协议栈分层的理解不够深,导致问题定位全凭直觉。这篇文章不适合当作教科书来读,我想做的是从日常实战的角度,把 TCP/IP 怎么工作、出了问题怎么查,彻底讲透。
1.1 分层的本质是解耦,不是多此一举
先说一个很容易被误解的问题:分层是不是给通信添麻烦?恰恰相反,分层是工程上的必然。网络通信要解决的事情远不止“把比特从 A 搬到 B”。它要寻址、要分片、要纠错、要保证顺序、要管理会话状态,还要承载不同业务——HTTP 网页、视频流、远程登录、邮件同步,这些业务的可靠性要求完全不同。如果全部搅在一起做成单一模块,任何一处改动都会牵一发动全身。比如底层从以太网换成光纤,上层应用代码就得重写,这种体系根本没法演进。
TCP/IP 采用分层设计,核心思路是“每一层各司其职,通过标准接口协作”。链路层管物理帧和相邻设备通信,网络层管全局寻址和路由,传输层管端到端的可靠性与流量控制,应用层管业务语义。每一层只暴露必要接口,下层对上层透明。所以应用层换成 HTTP/3 或者 WebSocket,传输层和网络层不需要跟着改;底层从有线网切到 Wi-Fi,TCP 也无感知。这种解耦带来的直接好处是:协议栈可以容纳几十种协议、跑在无数物理介质上。
1.2 封装与解封装:数据包的一次完整旅程
理解协议栈最好的方式,是跟着数据走一遍。假设你在浏览器里输入一个网址,浏览器生成一个 HTTP GET 请求,这串字节就是应用层数据。交给传输层之后,TCP 会给它加一个 TCP 头,头里写了源端口、目的端口、序号和校验相关字段,形成一个 TCP 段。然后网络层在 TCP 段外面再包一层 IP 头,写明源 IP 和目的 IP,形成 IP 报文。最后链路层把这个报文塞进一个以太网帧,加上目的 MAC、源 MAC 和帧类型,转成比特流发送出去。
对端收到后做反向操作:剥掉帧头帧尾,把 IP 报文往上送;网络层判断目的 IP 是否是本机,不是就转交给后续路由处理;如果是,再剥掉 IP 头,把 TCP 段交给传输层;传输层检查端口号,找到对应的应用进程,把应用数据交上去。整个过程就像寄包裹:每一层都在往外套一个信封,接收方逐层拆信。所以抓包工具里看到的所谓“完整报文”,其实是包含了所有层头的封装数据,只有应用层部分是真正的业务数据。
2. 四层模型逐层拆解
2.1 链路层:帧、MAC 和 ARP 的工作方式
链路层是离物理介质最近的一层,也是最容易被忽略的一层。很多人排障时只盯着 IP、TCP,可实际出问题最多的地方反而是链路层。以太网帧最重要的两个信息是目的 MAC 和源 MAC。当一个 IP 报文要发给同网段的另一台机器时,发送方必须先知道对方网卡的 MAC 地址,这个过程靠 ARP 协议完成。ARP 会先在本地缓存表中查,如果没有就广播一个“谁是 192.168.1.10?请把你的 MAC 告诉我”的请求,目标机器收到后单播回应,发送方再把 IP 到 MAC 的映射缓存起来。
这里有个常见误区:ARP 是链路层和网络层之间的“翻译”,不是链路层本身,但它直接影响链路层能不能发出帧。排障时如果发现目标 IP 能 ping 通、但上层业务不通,先把 ARP 缓存刷一遍,很多诡异问题就解决了。比如某次跨系统联调,A 机器绕过了健康检查但有大量丢包,最后查下来就是 ARP 缓存里的陈旧映射指向了已下线的网卡。
帧里还有一个关键字段是 EtherType,它标识上层协议是 IPv4 还是 ARP。抓包时看到 0x0800 就是 IPv4,0x0806 就是 ARP。这个字段决定了链路层把数据交给哪个模块处理。
实操提示:在 Linux 上
ip neigh show能看当前 ARP 缓存,ip neigh flush all可以清空缓存。不过敏感系统慎用后者,清空后瞬间会产生大量广播,影响整个二层网络。
2.2 网络层:IP 寻址、子网与路由
网络层解决的是“数据包如何从全球任意 A 点到达 B 点”。IP 地址就像门牌号,子网掩码决定你的地址段范围。一个 192.168.1.10/24 的主机要访问 10.0.0.5,路由判断两个地址不在同一子网,就会把报文交给默认网关,由路由器一层层转跳。每经过一个路由器,报文的 TTL 减一,减到零就丢弃,同时回一个 ICMP 超时错误报文,这就是traceroute能显示路径的原理。
排障时最常用的网络层工具是ping和traceroute。Ping 通说明 IP 层可达,但不能说明 TCP 一定通。如果 ping 不通,基本可以断定问题出在网络层之下——要么路由缺失、要么防火墙丢 ICMP、要么物理链路断了。有个细节值得注意:很多服务器为了安全会禁掉 ICMP,这种情况下 ping 不通不代表主机不在线,你得结合 TCP 探测综合判断。
IP 分片也是一个经典坑点。当报文大于链路层 MTU 时,发送方可能把 IP 报文拆成多片,接收方再重组。分片会带来重组耗时和丢片问题,性能差。现代 TCP 靠 MSS 协商来避免分片:双方在三次握手时各自通告自己能处理的最大段大小,比如以太网 1500 字节 MTU 下,MSS 通常是 1460(1500 减 20 字节 IP 头、20 字节 TCP 头)。如果分片真的出现,抓包时能看到 IP 层的 Fragment offset 字段非零。
2.3 传输层:TCP 与 UDP 的取舍
传输层是协议栈里最直接影响开发体验的一层。TCP 提供可靠、有序、面向连接的字节流传输,UDP 则只做尽力而为的数据报传输。两者没有绝对的好坏,只有适不适合业务。
| 对比维度 | TCP | UDP |
|---|---|---|
| 连接状态 | 面向连接,需要握手 | 无连接,直接发 |
| 可靠性 | 可靠,有确认、重传 | 不可靠,不确认 |
| 顺序性 | 保证字节流顺序 | 不保证顺序 |
| 头部开销 | 20 字节以上 | 8 字节 |
| 适用场景 | HTTP、数据库、远程登录 | 音视频实时传输、DNS 查询、游戏帧同步 |
我在实际项目中做过一次实时音视频系统,最初用 TCP 传输媒体流,结果网络抖动严重时画面卡顿明显。换成 UDP + 应用层丢包处理之后,主观体验反而好了。原因很简单:视频帧对实时性敏感,对个别丢包容忍度高,TCP 的重传反倒引入延迟。反过来,文件传输、订单请求这类不能丢数据的场景,就老老实实走 TCP,业务省心,规则清晰。
2.4 应用层:协议决定业务上限
应用层是我们最熟悉、也最常写代码的地方。HTTP 基于 TCP,DNS 一部分走 UDP,FTP 用 TCP,这些协议的选择都基于对业务可靠性和效率的综合考量。DNS 查询只有几十字节,一次失败重发即可,用 TCP 反而握手开销大,所以标准 DNS 查询用 UDP,区域传输才用 TCP。HTTP/1.1 的头部是文本逐行,HTTP/2 把它改成了二进制分帧,性能提升明显,但传输层还是 TCP;到了 HTTP/3,直接换成 QUIC,底层用 UDP 实现多路复用和丢包重发,这又印证了传输层可以因业务演进而改变——分层的意义就在于此。
做联调排障时,我先看应用层日志,再看抓包,最后才怀疑传输层和网络层。多数“连接不上”其实是应用层协议没聊拢,比如 HTTP 状态码 400、TLS 握手失败、WebSocket 升级被拒。所以应用层的排查首先要确认你用的协议本身是通的,别一上来就抓 TCP 包,容易浪费时间。
3. TCP 核心机制:先懂原理再谈排障
3.1 三次握手:为什么一个都不能省
TCP 是全双工可靠连接,连接建立必须保证双方的发送接收能力都对端可知。三次握手的过程是:客户端发 SYN,携带一个随机初始序号;服务端收到后回 SYN+ACK,确认号设为客户端的初始序号加一,同时带上自己的随机序号;客户端再回一个 ACK,确认服务端的序号。这个过程的精妙之处在于,任何一次丢失都会让双方收敛到失败状态,而不是卡住。
很多人问能不能只握两次手?不能。试想客户端发 SYN,一个旧连接的网络延迟分组迟到了。如果没有第三次握手,服务端收到这个旧 SYN 会建立一条废弃连接,浪费资源。第三次握手携带的确认号,能让服务端确认“这是新连接,不是残留”。半连接队列(SYN queue)配合三次握手,还解决了资源预留问题——服务端不会为未完成的连接分配完整连接资源。
抓包时三次握手长这个样:第一条是 SYN,第二条是 SYN+ACK,第三条是 ACK。如果只看到 SYN 发出,没有回应,大多是防火墙丢包或服务端没监听。如果看到 SYN+ACK 但客户端不回 ACK,通常是被本地安全软件拦了,或客户端没有处于可用状态。
3.2 可靠传输与滑动窗口
TCP 的可靠性靠编号加确认加重传。发送方给每个字节编序号,接收方收到数据后返回 ACK,确认号表示“我之前的数据都收到了,接下来从我这个序号开始要”。这里的累积确认机制让 TCP 不需要逐个字节确认,也能保证有序。
窗口则是控制“一次能发多少数据”。接收方通过 TCP 头里的 Window 字段通告自己能接收的字节数,发送方根据窗口大小控制发送量,没收到 ACK 就不能超过这个量。窗口协议解决的是“发送太快,接收方处理不过来”的问题。实际抓包里看到的 Window 字段如果不断变小,说明接收方内存或处理能力紧张,应用层读取太慢造成了背压。
重传机制也经历了几代进化:超时重传(RTO)最早,优点是简单,缺点是超时估计不准会导致效率低下。快速重传则是收到 3 个重复 ACK 就立即重传,不需要等超时。后来又有 SACK 选项,允许接收方告诉发送方哪些段丢了,避免重传多余数据。在 Wireshark 里看到大量“TCP Dup ACK”或“TCP Fast Retransmission”,基本可以判断网络存在丢包,得先从链路质量和中间设备入手查。
3.3 拥塞控制:网不会凭空变快,只会主动限速
拥塞控制是 TCP 对整条链路带宽的判断逻辑。它和流控不同:流控考虑接收方能力,拥塞控制考虑网络内部的承载能力。TCP 的逻辑是:开始不知道链路有多宽,于是用慢启动,先发 1 个 MSS,收到确认后翻倍增长,也就是 1、2、4、8,直到达到慢启动阈值 ssthresh。超过阈值就进入拥塞避免阶段,每个往返周期只加 1 个 MSS,线性增长,这是为了避免网络被突然冲垮。
如果发生超时,TCP 认为链路拥堵严重,会把 ssthresh 设为当前拥塞窗口的一半,重新慢启动。如果只是收到 3 个重复 ACK,表示还有数据包在网络上流动,用快重传加快恢复,把拥塞窗口砍半但不停在慢启动阶段。这套机制使得 TCP 的吞吐量在平稳网络中接近带宽,在拥塞网络中则主动退让——这也是为什么 TCP 一向是“公平的”。但对于低延迟业务来说,这种退让有时反而不可接受,所以 HTTP/3 采用 QUIC,能在应用层更精细地控制拥塞算法。
3.4 四次挥手与 TIME_WAIT
断开连接时,主动关闭方发 FIN,对端回 ACK,进入半关闭状态,等对端应用层也不发数据了再发 FIN,主动方回 ACK,连接才关闭。这就是典型的四次挥手。为什么是四次?因为 TCP 支持半关闭:一方关闭发送不意味着立刻停止接收,所以 FIN 和 ACK 是分开的。
TIME_WAIT 是主动关闭方最后停留的状态,要等 2MSL(最大报文段生存时间,通常 60 秒)。它的作用有两个:一是确保最后一个 ACK 能抵达对端,如果丢了可以对端重发 FIN;二是让旧连接的“残留报文”在网络中自然消亡,避免污染新连接。高并发短连接场景下,大量 TIME_WAIT 会让端口耗尽,这是后端服务最常见的坑之一。
4. 实战抓包:用 Wireshark 验证 TCP 行为
4.1 抓包前的环境准备
抓包不是打开 Wireshark 点个开始就能顺利看懂的。先想清楚抓包位置:客户端网卡抓的是发出去的包,服务端网卡抓的是到达的包,中间链路上的包需要交换机端口镜像或旁路部署。如果抓包位置不对,分析结果会误导你。比如在服务端网卡看到 SYN 没有对应的 ACK 返回,说明 SYN 到了但响应没回,和客户端抓包看到的连接超时现象可能完全不同。
实用技巧:抓包前先确定过滤条件,避免无用的包涌入。在命令行下可以用 tcpdump 抓取并落盘,适合线上服务器使用。一个常见命令是:
tcpdump -i eth0 -s 96 -w /tmp/tcp.pcap host 172.16.0.10 and port 8080这里的-s 96只抓链路层头几个字节,能保留关键头部但减少 IO 开销。落盘后拷贝到本地用 Wireshark 分析,比直接在线分析更稳。
4.2 三次握手与四次挥手的抓包形态
在 Wireshark 打开抓包文件后,加显示过滤:
tcp.port == 8080 && tcp.flags.syn == 1能看到握手第一条。找到第一条 SYN,右键 Follow TCP Stream 可以看完整流。三次握手重点看 SEQ、ACK、Window 三个核心字段。实战中很容易踩的坑:看到大量 SYN 重传但客户端显示连接超时,基本可以断定中间有防火墙丢包;看到 SYN+ACK 却没第三次 ACK,多半是半开连接被客户端安全软件拦截。
四次挥手同样能在包里看到 FIN 标志位。主动方发 FIN 后进入 FIN_WAIT_1,对端 ACK 后进入 FIN_WAIT_2;对端发 FIN 后主动方回 ACK,进入 TIME_WAIT。Wireshark 里 TIME_WAIT 一般不直接显示,但如果你抓的是同一台机器,能看到某个 TCP 流结束之后没有立刻彻底释放。这就是为什么高并发短连接服务的复盘里经常出现“大量 TIME_WAIT”的判断。
4.3 识别重传、乱序与窗口收缩
Wireshark 的 Expert Info 会自动标出错误、警告和备注。想手动确认重传,可以看 TCP 层的序号是否与之前重复,或者直接看标注了 TCP Retransmission 的报文。乱序则会出现前一段 seq 和下一段 seq 不连贯、且 ACK 号倒退的情况,通常意味着中间路由器或负载均衡设备做了重排。窗口收缩看起来是 Window 字段突然变小,需要结合时间轴判断是正常流控还是应用读缓存不及时。
我总结过一个实用原则:单个重传不致命,持续重传才是大问题。一两次重传可能只是网络瞬时拥塞,如果每秒出现大量重传,先怀疑带宽、丢包率、中间设备缓冲,再怀疑应用发数据过猛。另外要记得看 ACK 的“重复性”,如果大量 TCP Dup ACK,说明对端已经收不到后续数据,重传数也会同步上升。
5. 线上故障排查的完整思路
5.1 连接超时:从应用层到链路层逐层定位
排查“连接失败”不是从底层开始,而是从应用层开始。第一步看服务端有没有监听端口、进程是不是活着,用ss -lntp马上确认。第二步看防火墙规则有没有放通这个端口,很多人在云环境里排半天,最后发现安全组规则漏了某个 IP。第三步看网络层能不能通,ping一下,通但是 TCP 不通,基本是防火墙或服务端协议栈问题,不通则继续向下查路由、ARP、物理链路。
给一个真实案例:某跨平台系统经常出现“连接 3 分钟自动断开”的报警。抓包后发现,每隔 65 秒就有一个 RST 包。进一步排查发现,是中间负载均衡把“空闲连接超过 65 秒”判定为死链直接断开。应用层和传输层协议本身没问题,问题出在中间设备的空闲超时策略。排查时别把思路局限在协议栈内,中间设备(负载均衡、云防火墙的会话表)同样是一个关键环节。
ss -lntp # 查看监听端口与进程 ping -c 3 10.0.0.5 # 探测网络层 traceroute 10.0.0.5 # 看路由路径5.2 MTU 引发的“能通但传不动”问题
一个常见但隐蔽的问题是“ping 小包通,传大文件卡住”。表象是 TCP 连接能建立,但发送大包时卡住、反复重传。原因是路径 MTU 小于发送侧所在链路的 MTU,中间设备不回 ICMP 分片错误,或者 ICMP 被防火墙拦截,导致发送方无法感知需要降低包大小,于是一直重传。
排查方法是固定大小不断 ping,并禁止分片。Linux 上可以用:
ping -M do -s 1472 -c 4 10.0.0.5以太网 MTU 是 1500,ICMP 头占 8 字节,所以 1472 是最大可用载荷。如果 1472 能通、1473 不通,说明中间链路 MTU 小于 1500,常见于 PPPoE 拨号网络(MTU 1492)或某些隧道场景。可以临时把接口 MTU 调低、启用 MSS clamping,或者调整 TCP MSS 协商值。
重要:改动 MTU 前先备份网卡配置,并在业务低峰期操作,不然可能导致整个链路瞬间不可达。
5.3 TIME_WAIT 堆积与端口耗尽
高并发短连接服务里最常见的现象是ss -s看到 TIME_WAIT 数量巨大,而客户端连接偶尔失败。TIME_WAIT 占用的连接表项需要时间释放,如果单位时间内新建连接远大于连接生命周期,端口资源就会耗尽。Linux 下客户端随机端口范围通常从 32768 到 60999,最多约 28000 个端口,每个短连接占用一个,短爆一下就没了。
常规解决手段有几个。第一是提升客户端端口范围:
sysctl -w net.ipv4.ip_local_port_range="1024 65535"第二是合理利用 TCP 时间戳和连接复用。不同内核版本对 tcp_tw_reuse 的默认策略差异较大,千万别盲目开启 tcp_tw_recycle,那个在 NAT 环境下会导致连接异常。第三是业务侧改成长连接,连接复用能大幅降低新建频率。第四是优化连接池参数,这通常比调内核更可控。
调内核参数有个“够用就好”的原则,别看着教程就东改一个西改一个。内核协议栈的参数相互影响,改错会导致更隐蔽的故障。
6. 常见问题速查与踩坑记录
6.1 TCP 状态机速查对照
网络排查里,TCP 状态相当于“症状”。以下是我整理的状态速查表:
| 状态 | 含义 | 常见原因 |
|---|---|---|
| LISTEN | 服务端正在监听 | 正常 |
| SYN_SENT | 客户端发了 SYN 等回应 | 对端无回应、路由不通、防火墙拦 SYN |
| SYN_RECV | 服务端收到 SYN 等 ACK | 半连接、客户端不回应 |
| ESTABLISHED | 连接已建立 | 正常,但注意数量是否异常激增 |
| FIN_WAIT_1 | 主动方已发 FIN 等 ACK | 对端回应不及时 |
| FIN_WAIT_2 | 主动方已收到 ACK 等对端 FIN | 对端应用不关闭 |
| TIME_WAIT | 主动方等待 2MSL 释放 | 短连接服务常见 |
| CLOSE_WAIT | 被动方收到 FIN 但应用没 close | 应用层忘关 socket,数量上涨就要查 |
CLOSE_WAIT 是后端服务最常见的泄漏型问题。服务端进程收到 FIN 后,必须调用 close(),如果业务代码里因为异常分支忘了关闭句柄,CLOSE_WAIT 会不断涨,最后文件描述符耗尽,新连接全部被拒。这种事故定位容易:先用ss -tlnp | grep CLOSE_WAIT确认,再看业务日志定位没 close 的代码路径。
6.2 内核参数调优的“够用就好”
TCP/IP 相关的内核参数数不胜数。我的调优原则是:能不动尽量不动,动一个就先观察三天。最常见且安全的几个参数有:net.ipv4.tcp_keepalive_time默认 7200 秒,调小可以快速发现消失的对端;net.core.somaxconn是进程 accept 队列的上限,服务端高并发时会起作用,但要和应用层 listen backlog 配合;net.ipv4.tcp_max_syn_backlog是 SYN 队列长度,面对连接洪峰时有用。
我还见过一个典型故障案例。某服务把 somaxconn 调得很大,但应用层的 listen backlog 还是默认 128,最终大量连接卡在 SYN 队列进不了 accept 队列,客户端频繁超时。调优不是单改一个参数,必须和应用层配置配合才有效。
6.3 工具清单与独门习惯
排查网络问题,我的常用工具链是这样组织的:
| 场景 | 工具 | 说明 |
|---|---|---|
| 查看端口 | ss / netstat | ss 比 netstat 快,直接看 socket、状态、进程 |
| 抓包 | tcpdump / Wireshark | 线上 tcpdump,本地看 Wireshark |
| 探测路径 | ping / traceroute / mtr | mtr 持续观察丢包,多轮跑才知道稳定性 |
| 压测 | ab / wrk | 简单压测用 wrk,TCP 层探测看连接建立情况 |
| 分析工具 | tcptrace | 大量 pcap 时用 tcptrace 出报表 |
一定要养成的习惯是“先归档再分析”。线上抓到 pcap,记录当时的时间点、版本、链路拓扑、是否做了变更。没有背景资料的抓包文件,事后只能当摆设。很多疑难问题,最后都是靠“当时的变更记录”回溯出来的。
最后收个尾,分享一点个人体会。协议栈的东西,平时看起来又枯燥又偏理论,真到业务出事的时候,它们就是最值钱的“底牌”。我见过太多人,一遇到连接问题第一反应是“重启一下”“加大超时”,结果问题反复出现。背后真正的病因往往是 MTU、半连接队列、中间设备策略这类不常写进文档里的细节。希望每个读到这里的人,都能养成一个习惯:动手抓包之前,先问自己三个问题——这个连接是谁发起的、中间经过了什么设备、我的抓包点能不能代表真实链路。想清楚这三点,一半的问题已经解决了一半。