先说我为什么想写这篇。前两天线上有个服务跨地域传输大文件,吞吐率一直上不去,RTT明明只有几十毫秒,带宽也买了 100M,可实际速率就是卡在 2MB/s 左右。抓包一看,重传满天飞,每秒几十个 DUP ACK,于是把问题定位到 TCP 重传和拥塞窗口上。说来惭愧,干了这么多年 Linux 后端,真正把 TCP 可靠传输整条链路吃透,还是靠那次"被线上教做人"的经历。
这篇文章我打算彻底讲透 Linux 下的 TCP 可靠传输机制。不讲那种"三次握手四次挥手"的浅层八股,而是从协议设计动机出发,一路拆到 Linux 内核的实现路径,再到实际排查中用得到的命令、参数和案例。全程配合生动例子和文字图解,把每一个"为什么这么做"都说清楚。无论是准备面试的后端研发、搞网络排查的运维、还是刚接触嵌入式网络编程的同学,这篇文章都值得存下来反复看。
1. 可靠传输这个需求,到底是被什么逼出来的
1.1 网络从诞生那天起,就没承诺过"不丢"
以太网、IP 网络从设计之初走的就是"尽力而为"路线。什么叫尽力而为?就是说每个网络节点都会尽可能帮你把数据转发到目的地,但如果出现链路拥塞、缓冲区溢出、信号干扰、路由环路,它可以选择直接丢弃数据包,而且不通知任何人。这事听起来很反直觉,因为在我们的日常认知里,网络就像一根水管,水倒进去就应该从另一头流出来,但物理世界从来没这么美好。
打个比方。把 IP 网络想象成一个快递系统,但是没有电话回执、没有签收确认、没有物流跟踪。你把包裹交给快递员,快递员会尽量帮你送,但路上可能遇到暴雨把包裹淋湿了,可能分拣中心爆仓把你的包裹搁置了,甚至可能快递员自己也迷路了。你作为发件人,完全不知道包裹到底送到了没有。这就是无连接、不可靠的 IP 服务模型。
那应用层能不能忍?能忍才有鬼了。我们传输文件、发消息、看网页、调接口,任何一条数据丢失都可能导致文件损坏、消息缺失、页面打不开、接口报错。所以必须在 IP 之上加一层协议,专门解决"怎么知道对方收到了""没收到怎么办""重复收到了怎么处理""收到的顺序乱了怎么恢复"这四个问题。TCP 就是干这个的。
1.2 不可靠的三个"罪魁祸首",对应 TCP 的三套对策
实际网络中,数据出问题无非以下三种情况,每一种都对应 TCP 一套精密的机制。
第一是丢包。路由器、交换机、服务器的网卡队列都有容量上限,流量一超过上限,新来的包就只能丢掉。TCP 的对策是确认与重传:每收到一个数据段都要回一个 ACK,发送方如果发现超时没收到 ACK,就认为丢了,重新发送。
第二是乱序。路径上的多个路由器可能选择了不同的转发路径,先发的包反而后到。TCP 的对策是序列号排序:每个字节都有一个唯一的序列号,接收方按序列号把数据重新排好再交给上层。
第三是重复。超时重传可能导致同一个数据段被发送两次,接收方可能重复收到。TCP 的对策还是序列号:接收方用序列号去重,序列号已经收过的数据直接丢弃。
这三种情况加在一起,构成了 TCP 可靠传输的全部核心问题域。内核里那一堆让人头大的状态机、定时器、缓冲区管理,本质上都是在围绕这三个问题做文章。
1.3 我们常说的"TCP 连接",内核视角到底是什么
面试和实战中经常被问到:TCP 连接到底是什么?应用层看到的是一个 socket 文件描述符,但内核视角下,一条 TCP 连接是一对四元组 (src_ip, src_port, dst_ip, dst_port),以及挂在这对四元组上的一堆数据结构:发送缓冲区、接收缓冲区、拥塞控制状态机、重传定时器、保活定时器、序列号空间等。
你可以把 TCP 连接理解为两个主机之间的一组"契约状态"。这些状态分散在两台主机的内核里,不占物理线路,不占中间设备资源,纯粹靠协议行为维持。一旦中间链路断开、双方主机重启,这组状态就失效了,对端可能永远不知道你已经"消失",直到某个超时机制发现了它。这也是后面要讲 TIME_WAIT、保活机制的根本原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三次握手和四次挥手:TCP 的建立与拆除机制,远不止背状态码那么简单
2.1 起始序列号(ISN)为什么不能固定
TCP 建立连接的第一步,是交换初始序列号。如果所有连接的初始序列号都从一个固定值开始,很容易被两种攻击利用:一是攻击者可以猜测序列号,发送伪造的 RST 包切断连接;二是历史连接中的残留数据段可能恰好落在新连接的序列号空间内,造成数据污染。
Linux 内核的做法是初始化序列号随时间变化,具体实现是 secure_tcp_seq() 函数,它基于一个全局的随机数种子和当前时间抖动生成。也就是说,即使同一台服务器上短时间内反复建立同样的四元组连接,每一次的 ISN 都不同。这一点在设计 NAT、防火墙规则时尤其重要,很多只认四元组的中间设备在实现上如果偷懒,可能忽略序列号校验,导致连接被 RST。
2.2 三次握手的状态迁移,以及 SYN 洪泛攻击的应对
三次握手的完整过程,用文字图表示如下:
code复制客户端 服务端
|----------- SYN, seq=x --------------->| 进入 SYN_RECV
|<------- SYN+ACK, seq=y, ack=x+1 ------| 内核分配连接控制块
|----------- ACK, seq=x+1, ack=y+1 ---->| 进入 ESTABLISHED
每次握手都带两个关键字段:seq 是自己的序列号起始点,ack 是对接收到的数据的确认号。注意 ACK 号表示"我期望收到的下一个字节的序列号",也就是说 ack=x+1 表示"你发来的 seq=x 我收到了,下一个请从 x+1 开始发"。
关于握手,绝大多数人只知道状态迁移,但不知道内核在握手期间做了大量资源分配和队列管理。服务端收到 SYN 后,内核并不是直接把连接放到全连接队列,而是先放入半连接队列(SYN Queue),然后回复 SYN+ACK。只有收到客户端的最终 ACK,连接才会从半连接队列移动到全连接队列(Accept Queue),等待应用调用 accept() 把它取走。
这就引出一个经典问题:如果客户端故意不回最终的 ACK,服务端就一直在半连接队列里躺着无效连接,队列一满,正常的新连接就进不来了。这就是 SYN 洪泛攻击的基本原理。Linux 的应对之策是 SYN Cookie:不分配任何资源,把连接的必要信息通过精心构造的序号计算出来,放到 SYN+ACK 的序号里返回。客户端回 ACK 时带上这个序号,服务端从 ACK 中提取并进行校验,校验通过再分配资源建立连接。内核参数 net.ipv4.tcp_syncookies=1 就是干这个的,一般建议保持默认开启。
2.3 全连接队列溢出,实际业务中比 SYN 洪泛更常见
很多老运维都遇到过这样的情况:服务端应用处理慢,连接堆积在队列里,客户端那边表现就是连接卡在握手阶段迟迟无法完成,或者握手完成后发数据失败。这通常不是网络问题,而是全连接队列(Accept Queue)满了。
Linux 下可以通过 ss -lnt 查看全连接队列的状态:
bash复制ss -lnt
State Recv-Q Send-Q Local Address:Port
LISTEN 0 128 0.0.0.0:8080
这里的 Send-Q 是监听 socket 的全连接队列最大长度,由应用 listen(fd, backlog) 传入。Recv-Q 表示当前已建立等待 accept() 的连接数量。如果 Recv-Q 长期接近 Send-Q,说明应用 a
