1. 从一个抓包现场说起:为什么这些概念总是搅在一起
很多人第一次接触网络协议,都是从“三次握手”这四个字开始的。背下来也容易:SYN、SYN+ACK、ACK,完事。但真正到了排查线上问题的时候,你会发现光知道这三个包根本不够用。连接建不上,可能是全连接队列满了;连接建上了但特别慢,可能是滑动窗口太小;丢包之后速度断崖式下跌,那是拥塞窗口在起作用;而数据包从你的机器到目标机器到底走了哪条路,又牵扯到路由表的查找逻辑。
这几个概念之所以总被放在一起讲,是因为它们本来就是一条链路上的不同环节。三次握手负责“建立连接”,全连接队列和半连接队列负责“管理连接”,滑动窗口负责“流量控制”,拥塞窗口负责“网络拥塞控制”,路由过程负责“找到路”。你把它们拆开看,每个都不难;但合在一起,才是一次完整的网络通信。
这篇文章我打算按实际排查问题的顺序来组织,而不是按教科书的章节顺序。因为我自己学这些东西的时候,最大的困惑不是“它是什么”,而是“它在哪里起作用、出问题的时候我该看哪个”。所以下面会从握手开始,一路讲到队列、窗口和路由,中间穿插我实际踩过的坑和常用的排查命令。
适合谁看?如果你写过网络编程、做过后端服务、或者被“连接超时”“吞吐上不去”这类问题折磨过,那这篇内容应该能帮你把零散的知识点串成一条线。如果你是完全的新手,也没关系,我会尽量用生活化的类比把原理讲清楚,再配上具体的命令和参数,让你能直接上手验证。
2. 三次握手到底在交换什么信息
2.1 为什么是三次而不是两次或四次
先把这个经典问题说透。三次握手的本质,是通信双方各自确认“我的发送能力”和“你的接收能力”都正常,并且同步初始序列号。
两次为什么不够?假设只有 SYN 和 SYN+ACK。客户端发了 SYN,服务端回了 SYN+ACK,从服务端角度看,它确认了客户端能发、自己能收,也确认了自己能发。但客户端还没告诉服务端“我收到你的 SYN+ACK 了”。这意味着服务端无法确认客户端能收。如果客户端因为某些原因没收到 SYN+ACK,服务端却已经认为连接建立了,就会白白维护一个半开连接。三次握手的最后一个 ACK,就是让服务端确认“客户端确实能收”。
四次为什么多余?因为 SYN+ACK 把“确认收到你的 SYN”和“我也要同步序列号”合并成了一个包。ACK 和 SYN 可以搭同一趟车,没必要分两趟。
用生活类比:你打电话给客服,你说“喂,能听到吗”(SYN),客服说“能听到,你能听到我吗”(SYN+ACK),你说“能听到”(ACK)。到这一步双方才确认通话链路双向通畅。如果只有前两步,客服不知道你那边是不是聋子。
2.2 序列号与确认号的实际含义
握手过程中交换的序列号不是随便生成的。初始序列号(ISN)的选取有一套规则,现代系统通常基于时钟和哈希生成,目的是防止旧连接的延迟包干扰新连接。
具体来说,客户端发送的 SYN 包里,seq 是客户端的初始序列号,比如 seq=x。服务端回复的 SYN+ACK 包里,seq 是服务端的初始序列号 seq=y,ack 是 x+1,表示“我已经收到你的 seq=x,下次请从 x+1 开始发”。客户端最后发的 ACK 包里,seq 是 x+1,ack 是 y+1。
这里有个容易忽略的点:SYN 和 FIN 这两个标志位会各消耗一个序列号。所以确认号要加一。普通数据包不消耗额外序列号,确认号就是 seq 加上数据长度。
2.3 抓包验证:用 tcpdump 看一次完整握手
理论说再多不如自己抓一次。在 Linux 上可以用 tcpdump 直接观察:
tcpdump -i eth0 -nn -S 'tcp[tcpflags] & (tcp-syn|tcp-ack) != 0 and port 80'参数说明:-nn不解析主机名和端口名,-S显示绝对序列号而不是相对序列号,过滤条件只抓带 SYN 或 ACK 标志的包。
你会看到类似这样的输出:
IP 10.0.0.1.54321 > 10.0.0.2.80: Flags [S], seq 1000, win 64240, options [mss 1460,sackOK,TS val 123 ecr 0,nop,wscale 7], length 0 IP 10.0.0.2.80 > 10.0.0.1.54321: Flags [S.], seq 2000, ack 1001, win 65535, options [mss 1460,sackOK,TS val 456 ecr 123,nop,wscale 7], length 0 IP 10.0.0.1.54321 > 10.0.0.2.80: Flags [.], ack 2001, win 64240, length 0注意第一行里的 options,这些是握手阶段协商的关键参数。MSS 决定单个包最大能带多少数据,SACK 开启选择性确认,wscale 是窗口缩放因子。这些参数直接影响后面的传输效率,后面讲滑动窗口时会再提到。
提示:抓包时如果只看到 SYN 没有 SYN+ACK,说明服务端没响应,可能是端口没监听、防火墙拦截、或者半连接队列满了。如果看到 SYN+ACK 但客户端没回 ACK,通常是客户端侧的问题,比如客户端进程已经退出。
3. 半连接队列与全连接队列:连接建立背后的两本账
3.1 两个队列分别在什么阶段起作用
这是很多人容易混淆的地方。服务端收到 SYN 之后,连接并没有立刻进入“已建立”状态,而是先被放进半连接队列。此时连接处于 SYN_RECV 状态。服务端回了 SYN+ACK,等待客户端的 ACK。
当服务端收到客户端的 ACK 后,连接从半连接队列移出,进入全连接队列,状态变为 ESTABLISHED。此时连接已经建立完成,等待应用程序调用 accept 把它取走。
所以两个队列的分工很明确:半连接队列管的是“握手还没完成”的连接,全连接队列管的是“握手完成了但应用还没接管”的连接。
3.2 队列满了会发生什么
半连接队列满了,新的 SYN 包会被丢弃。客户端会超时重传 SYN,表现为连接建立变慢甚至超时。全连接队列满了,服务端通常不会丢弃 ACK,而是根据内核参数决定行为。在 Linux 上,如果tcp_abort_on_overflow为 0(默认值),服务端会忽略这个 ACK,让客户端以为包丢了,触发重传。如果设为 1,服务端会直接回 RST,客户端立刻收到连接重置。
这两种行为各有适用场景。默认的忽略策略更温和,给应用一点时间把队列里的连接取走。但如果应用长时间不 accept,客户端会一直重传,最终超时。设为 1 则快速失败,适合对延迟敏感的场景。
3.3 关键参数与查看方法
半连接队列的大小由net.ipv4.tcp_max_syn_backlog控制。全连接队列的大小由listen()系统调用的 backlog 参数和net.core.somaxconn共同决定,取两者中的较小值。
查看当前队列情况:
ss -lnt输出中的 Send-Q 就是全连接队列的最大长度,Recv-Q 是当前已经完成握手但还没被 accept 的连接数。如果 Recv-Q 经常接近 Send-Q,说明应用 accept 的速度跟不上。
查看半连接队列:
netstat -nat | grep SYN_RECV | wc -l或者用ss -n state syn-recv。
注意:
tcp_max_syn_backlog在较新的内核里并不是硬限制,实际还会受到somaxconn和路由缓存等因素影响。调参时不要只盯着一个值。
3.4 实操心得:队列溢出怎么排查
我遇到过一次线上问题,服务端偶尔出现连接超时,但 CPU 和内存都很正常。后来用ss -lnt发现 Recv-Q 周期性飙高,接近 Send-Q。原因是应用层在处理某个耗时操作时阻塞了 accept 循环。解决办法是把 accept 和业务处理拆到不同线程,或者调大 backlog。
另一个坑是somaxconn的默认值。很多系统默认是 128 或 4096,在高并发场景下明显不够。我一般会把它调到 65535,同时把应用的 backlog 参数也设成一样的值。注意改完somaxconn需要重启应用才能生效,因为它是在 listen 时读取的。
4. 滑动窗口:流量控制的核心机制
4.1 窗口的本质是接收方的缓冲区余量
滑动窗口解决的是“发送方发太快,接收方处理不过来”的问题。接收方在自己的 TCP 头部里有一个窗口字段,告诉发送方“我还能接收多少字节”。发送方根据这个值调整发送速度,确保不会把接收方的缓冲区撑爆。
这个机制叫流量控制,是端到端的。注意它和拥塞控制不是一回事:流量控制关心的是接收方的处理能力,拥塞控制关心的是网络链路的承载能力。
窗口大小不是固定的。接收方每收到一批数据,应用层读走一部分,缓冲区腾出空间,窗口就会变大。如果应用层读得慢,缓冲区越来越满,窗口就越来越小,甚至变成 0。窗口为 0 时,发送方会停止发送,定期发窗口探测包,询问接收方什么时候能腾出空间。
4.2 窗口缩放因子为什么必要
TCP 头部里的窗口字段只有 16 位,最大表示 65535 字节。在早期网络里这够用,但在高带宽高延迟的场景下,65535 字节的窗口远远不够。举个例子,带宽 100Mbps、往返延迟 100ms 的链路,带宽时延积是 100Mbps × 0.1s = 10Mbit = 1.25MB。也就是说,要跑满这条链路,窗口至少得有 1.25MB。65535 字节连零头都不够。
窗口缩放因子在三次握手阶段协商,通过 TCP 选项里的 wscale 字段传递。双方各自通告自己的缩放因子,实际窗口大小等于头部窗口字段左移缩放因子位。缩放因子最大是 14,所以最大窗口可以到 65535 × 2^14,约 1GB。
4.3 滑动窗口的实际滑动过程
假设接收方通告窗口为 4000 字节,发送方已经发了 2000 字节,收到确认 1000 字节。此时发送方还能发多少?窗口右边界是 4000,已经发了 2000,已确认 1000,所以还能发 4000 - 2000 = 2000 字节。当收到新的确认,窗口右边界右移,发送方就能发更多数据。
这个过程里,发送方维护三个指针:已发送已确认、已发送未确认、未发送但在窗口内。接收方也维护类似的指针,区分已接收已确认、已接收未确认、还能接收多少。
4.4 零窗口与窗口探测
当接收方缓冲区满时,会通告窗口为 0。发送方收到零窗口后停止发送,但会启动一个持续计时器,定期发送窗口探测包。探测包通常只带一个字节的数据,接收方收到后如果缓冲区有空间了,就回复当前窗口大小;如果还是满的,就回复零窗口。
这里有个经典问题叫“零窗口死锁”。如果接收方腾出空间后发的窗口更新包丢了,发送方还在等,接收方以为发送方收到了,双方就僵住了。窗口探测机制就是用来打破这个僵局的。
提示:抓包时如果看到大量
[TCP ZeroWindow]和[TCP Window Update],说明接收方应用读取速度跟不上。这时候要么优化应用,要么调大接收缓冲区。
4.5 接收缓冲区和发送缓冲区的调优
Linux 上接收缓冲区由net.ipv4.tcp_rmem控制,发送缓冲区由net.ipv4.tcp_wmem控制。每个参数有三个值:最小值、默认值、最大值。
sysctl net.ipv4.tcp_rmem sysctl net.ipv4.tcp_wmem典型输出是4096 131072 6291456,表示最小 4KB,默认 128KB,最大 6MB。如果应用需要高吞吐,可以把最大值调大,比如 16MB。但要注意,缓冲区不是越大越好,太大会增加内存占用,也会让延迟变大。
自动调优参数net.ipv4.tcp_moderate_rcvbuf默认开启,内核会根据连接情况自动调整缓冲区大小。大多数场景下保持开启就行,只有在明确知道需要固定大缓冲区时才关闭。
5. 拥塞窗口:网络拥堵时的自我克制
5.1 拥塞窗口和滑动窗口的区别
滑动窗口是接收方告诉发送方“我能收多少”,拥塞窗口是发送方自己估计“网络能承受多少”。实际发送窗口取两者中的较小值。
拥塞窗口没有在 TCP 头部里传递,它是发送方自己维护的一个状态变量。发送方通过观察丢包和延迟来判断网络是否拥塞,然后调整这个值。
5.2 慢启动、拥塞避免、快速重传、快速恢复
这是拥塞控制的四个核心阶段。
慢启动:连接刚建立时,拥塞窗口从一个较小的值开始(通常是 10 个 MSS 左右),每收到一个 ACK 就翻倍。所以是指数增长,很快就涨到阈值。
拥塞避免:当拥塞窗口超过慢启动阈值后,改为线性增长,每个往返时间加一个 MSS。这样增长速度慢下来,避免一下子把网络撑爆。
快速重传:发送方收到三个重复 ACK,就认为某个包丢了,立刻重传,不用等超时。这比超时重传快得多。
快速恢复:快速重传之后,把慢启动阈值设为当前拥塞窗口的一半,拥塞窗口也设为这个值,然后进入拥塞避免阶段。这样避免了重新从慢启动开始,速度不会掉得太狠。
5.3 不同拥塞控制算法的选择
Linux 支持多种拥塞控制算法,常见的有 cubic、reno、bbr。
cubic 是 Linux 默认算法,适合大多数场景,在高带宽长距离链路上表现不错。reno 是老算法,现在很少用。bbr 是后来出现的算法,它不依赖丢包来判断拥塞,而是测量带宽和往返延迟,在有一定丢包的链路上表现更好。
查看当前算法:
sysctl net.ipv4.tcp_congestion_control切换算法:
sysctl -w net.ipv4.tcp_congestion_control=bbr注意:切换算法需要内核支持。可以用
sysctl net.ipv4.tcp_available_congestion_control查看可用算法列表。
5.4 实操案例:丢包导致的吞吐下降
我做过一次跨机房的传输测试,带宽 1Gbps,延迟 30ms,但实际吞吐只有 100Mbps 左右。抓包发现有不少重传。用ss -ti查看连接详情,看到拥塞窗口经常掉到很低的值。
原因是链路上有轻微丢包,cubic 算法把丢包当作拥塞信号,大幅降低拥塞窗口。换成 bbr 之后,吞吐提升到 600Mbps 以上。因为 bbr 不把丢包等同于拥塞,而是根据带宽和延迟来调整发送速率。
这个案例说明,拥塞控制算法的选择对实际性能影响很大。如果你的业务跑在有一定丢包的链路上,值得试试 bbr。
6. 网络包路由过程:数据到底走了哪条路
6.1 路由表查找的基本逻辑
当一台机器要发送数据包时,内核会查找路由表,决定从哪个网卡发出去、下一跳是谁。查找规则是“最长前缀匹配”:在多个匹配的路由条目中,选择子网掩码最长的那条。
查看路由表:
ip route show典型输出:
default via 10.0.0.1 dev eth0 10.0.0.0/24 dev eth0 proto kernel scope link src 10.0.0.2第一行是默认路由,所有不匹配其他条目的包都走这里。第二行是本地子网路由,目标地址在 10.0.0.0/24 范围内的包直接通过 eth0 发送,不需要经过网关。
6.2 数据包的完整旅程
一个数据包从发送到到达目标,大致经过这些步骤:
发送方查路由表,确定下一跳和出口网卡。如果下一跳的 MAC 地址未知,发 ARP 请求获取。然后包被送到网卡,加上以太网头部,发到链路上。
中间经过多个路由器,每个路由器都查自己的路由表,决定下一跳。路由器的路由表可能比主机复杂得多,包含各种动态路由协议学习到的条目。
到达目标网络后,最后一跳路由器通过 ARP 找到目标主机的 MAC 地址,把包送达。
目标主机收到包后,检查目标 IP 是不是自己。如果是,交给上层协议处理;如果不是,根据ip_forward参数决定是否转发。
6.3 Traceroute 的原理和局限
traceroute 利用 IP 头部的 TTL 字段来探测路径。发送方发一个 TTL=1 的包,第一跳路由器收到后 TTL 减为 0,丢弃并回复 ICMP 超时消息。发送方就知道了第一跳的地址。然后发 TTL=2 的包,以此类推。
traceroute -n 10.0.0.100-n表示不解析主机名,速度更快。
traceroute 的局限在于,很多路由器不回复 ICMP 超时消息,或者回复的优先级很低。所以看到* * *不代表链路断了,可能只是路由器不响应。另外,去程和回程的路径可能不一样,traceroute 只能看到去程。
6.4 策略路由和多路由表
Linux 支持策略路由,可以根据源地址、目标地址、端口等条件选择不同的路由表。这在多网卡、多线路的场景下很有用。
查看策略路由规则:
ip rule show默认规则是查 local 表、main 表、default 表。可以添加自定义规则,比如“来自 10.0.0.0/24 的包查表 100”。
ip rule add from 10.0.0.0/24 table 100 ip route add default via 10.0.1.1 dev eth1 table 100这样来自 10.0.0.0/24 的包就会走 eth1 出去,而不是默认的 eth0。
提示:策略路由配置错误很容易导致网络不通。改之前先用
ip route get确认当前路径,改完再验证一次。
7. 常见问题速查与排查思路
7.1 连接建立相关
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 连接超时,抓包只有 SYN | 服务端没监听、防火墙拦截、半连接队列满 | 检查服务端监听状态、防火墙规则、`netstat -s |
| 连接超时,有 SYN+ACK 但无 ACK | 客户端问题、SYN+ACK 被中间设备丢弃 | 客户端抓包、检查中间设备 |
| 连接被重置 | 全连接队列满且tcp_abort_on_overflow=1 | 检查ss -lnt的 Recv-Q 和 Send-Q |
| 连接建立慢 | SYN 重传、半连接队列频繁溢出 | 检查tcp_max_syn_backlog和 SYN 重传统计 |
7.2 传输性能相关
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 吞吐上不去 | 窗口太小、拥塞窗口受限、缓冲区不足 | ss -ti查看 cwnd 和 rwnd,检查缓冲区参数 |
| 大量重传 | 链路丢包、拥塞控制过于激进 | `netstat -s |
| 延迟忽高忽低 | 缓冲区膨胀、队列抖动 | 检查tc qdisc配置,考虑减小缓冲区 |
| 零窗口频繁出现 | 接收方应用读取慢 | 优化应用读取逻辑,调大接收缓冲区 |
7.3 路由相关
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 目标不可达 | 路由表缺少条目、网关配置错误 | ip route get <目标IP> |
| 去程和回程路径不一致 | 策略路由、多线路 | 两端分别 traceroute |
| 部分网段不通 | 子网掩码配置错误 | 检查ip addr和ip route |
7.4 几个容易踩的坑
第一个坑是只看一个参数就下结论。比如看到全连接队列满了就调大 backlog,但实际原因可能是应用 accept 太慢。调大 backlog 只是缓解症状,不解决根因。
第二个坑是忽略内核版本差异。不同内核版本对队列、窗口、拥塞控制的实现有差异。比如新内核的tcp_max_syn_backlog行为和老版本就不一样。排查时先确认内核版本。
第三个坑是抓包位置不对。在客户端抓包和在服务端抓包,看到的现象可能完全不同。定位问题时最好两端同时抓,对比分析。
第四个坑是过度调优。缓冲区调得太大,延迟会变高;拥塞窗口调得太激进,丢包会更严重。调优要基于实际测量,不要凭感觉。
8. 我个人的一些实操体会
这些东西刚学的时候觉得零散,用多了就会发现它们是一条链上的。我的习惯是,遇到网络问题先抓包,看握手是否完成、窗口是否正常、有没有重传。抓包能解决大部分疑惑,比看各种统计计数器直观得多。
另外,ss -ti这个命令强烈推荐。它能把连接的拥塞窗口、往返延迟、重传次数都列出来,排查性能问题时非常有用。我基本每次遇到吞吐问题都会先跑一遍。
调参方面,我的原则是能不调就不调,先找应用层的原因。应用层的缓冲区读取、accept 速度、并发模型,往往比内核参数影响更大。内核参数调优是最后一步,而且每次只调一个,调完观察一段时间再决定下一步。
最后说一个细节:MSS 的协商。如果两端 MSS 不一致,或者中间设备改了 MSS,可能导致分片,影响性能。抓包时留意 SYN 包里的 MSS 选项,确保两端协商一致。这个点很小,但实际排查中遇到过好几次。