忙了半个下午,帮某团队看一个服务间歇性连不上的问题。现象很奇怪:部分客户端连接超时,服务器端日志干干净净,CPU、内存都正常。最后用ss -s一查,半连接队列堆了一堆 SYN_RCVD,这才定位到是握手阶段就卡住了。事后那个负责的同事说,TCP 这些东西学校里都背过,可真到了线上,压根不知道从哪下手。
这才是我写这篇东西的动机。传输层是计算机网络里最"考试友好"的一章——提供服务、报文段格式、三次握手、四次挥手,每个考点都能画成图背下来。但"能背"和"能用"之间,隔着一整条线的经验。这篇文章就围绕标题里的三件事展开:传输层提供的服务到底解决了什么问题,TCP 报文段那 20 字节头部每个字段为什么这么设计,以及连接建立和释放背后的状态机、异常场景和攻防逻辑。适合正在学计网的在校生、准备面试的求职者,以及把 TCP 当黑盒用、想补底层功底的开发运维朋友。我不打算平铺直叙地念课本,而是尽量把每个知识点还原成"线上会以什么形式坑你"。
1. 传输层为什么存在:把"进程到进程"这件事说透
1.1 网络层管的是"机器",传输层管的是"程序"
很多人学到这里容易绕晕,觉得 IP 包明明已经能把数据送到目的地了,传输层不是多此一举吗。我习惯用一个类比解释:网络层解决的是"从北京西二旗的 A 栋送到上海张江的 B 栋",它只关心机器到机器;但数据到了 B 栋之后,得有人告诉前台这封信是给 3 楼的财务部,还是给 5 楼的研发部。这个"前台分拣"的动作,就是传输层的工作。
落实到技术上,网络层的地址是 IP,它定位的是一台主机的网卡;传输层的地址是端口(Port),它定位的是这台主机上的一个进程。一台服务器上同时跑着 Web 服务、数据库、SSH,外网包过来时,内核靠 TCP 或 UDP 头里的目的端口号,知道该把数据丢给哪个进程。所以传输层最基础的两个本领,一个是进程到进程的通信,一个是复用与分用——多个进程共用一个 IP,靠不同端口号区分,这就是复用;收包时按端口号分发到对应进程,这就是分用。
有个点经常被忽略:端口号不是全局唯一的,它只在"IP + 端口"这个组合里才有意义。两个不同 IP 上都有 8080 端口,互不冲突。真正唯一标识一条 TCP 连接的是四元组:源 IP、源端口、目的 IP、目的端口。排查问题时我见过有人只看目的端口就下结论,结果把跨节点的连接问题误判成端口冲突,方向就错了。
1.2 端口不是随便一个数:复用与分用的规则
端口是 16 位,范围 0 到 65535。常见划分是这样:0 到 1023 是知名端口,HTTP、HTTPS、DNS、SSH 都在这段;1024 到 49151 是注册端口,给应用层服务主动监听用的;49152 到 65535 是动态/私有端口,一般作为客户端发起连接时的源端口。
实际排查时,客户端源端口是内核临时分配的,你控制不了具体值;服务器要做的就是绑定一个固定端口并监听。如果服务没起来或者端口被占用,客户端就会收到 connection refused,这是最直白的"传输层提供的服务没就绪"的表现。另外,Linux 下ss -tlnp看监听端口时,State 那列出现 LISTEN 才是真的能接受新连接;出现 TIME_WAIT 或 ESTABLISHED 的端口,都不是"正在提供服务"的端口。
1.3 UDP 也在"提供服务":有些业务恰恰不需要可靠
说到传输层服务,课本必然对比 TCP 和 UDP:TCP 提供面向连接、可靠交付、流量控制、拥塞控制的字节流服务;UDP 提供无连接、不可靠、首部只有 8 字节的数据报服务。很多人默认"TCP 比 UDP 好",这在工程上是不成立的。
举几个典型场景:DNS 查询,一个请求一个响应,丢了就超时重发一次,用 TCP 反而要握手、释放,延迟和开销都更高;实时视频通话,一帧数据晚到比丢包更致命,TCP 的重传机制会引入惊人数量的排队延迟,反而让画面卡成幻灯片;游戏位置同步,旧的状态包已经失去意义,重传旧数据是帮倒忙。所以 UDP 的"不可靠"不是缺陷,而是把可靠性选择权交给应用层。这是传输层设计里非常关键的思想:下层不需要替上层做价值判断。
我自己的经验是,对延迟敏感、能容忍少量丢失的业务,优先考虑 UDP 或基于 UDP 封装的自定义协议(比如加了可靠性机制的类 QUIC 方案);对准确性要求极高、允许延迟的业务,才选 TCP。这属于典型的"没有银弹"问题。
2. 报文段头部的 20 字节,每一比特都有用
2.1 源端口与目的端口:一进一出的门牌号
TCP 报文段标准头部 20 字节,没有选项的情况下,所有字段加起来正好 20 字节整。前 4 字节是源端口和目的端口,各占 16 位。它们是连接四元组的一半,负责让接收端能找到对应 socket。
这俩字段本身没太多故事,但抓包时常会看到客户端端口是 50000 多的高位随机端口,服务器端口是 443、8080 这类固定端口。道理很简单:客户端发起连接的数量可能很多,用随机端口避免冲突;服务器端的知名服务必须固定端口,否则客户端不知道该连谁。
2.2 序号与确认号:字节流里的页码系统
这是 TCP 最核心的机制。TCP 是面向字节流的协议,发送方把应用层数据切成一堆 TCP 报文段,接收方按序重组后交给应用层。为了让乱序到达、甚至中间丢失的报文段能拼回原来的数据顺序,TCP 给每一个字节的数据都编了一个序号。
- 序号(Sequence Number,32 位):本报文段数据部分第一个字节在字节流中的位置。比如要发送的数据从字节 101 开始,这 1000 字节分成两段,第一段的 seq 是 101,第二段的 seq 就是 501。
- 确认号(Acknowledgment Number,32 位):期望收到对方下一个字节的序号,也就是"我已经完整收到了直到 N-1 的所有字节,请从 N 开始发"。
这个语义特别容易记岔。确认号说的不是"我刚收到了多少",而是"我期待下一个是什么"。所以累计确认的机制下,如果对方发来 seq=101 和 seq=501 两段,且中间没丢,接收方回复的 ACK 是 1501 的累计值。如果第二段先到了、第一段还没到,TCP 不会回复"我期待 1501",而会仍旧回复"我期待 101"——重复 ACK 也是快速重传的重要信号。
还有个细节很多教材不写:建立连接时的 SYN 报文段虽然不携带数据,但它会消耗一个序号(连接请求本身的编号也要被确认),所以握手第二次报文的确认号必须是"客户端的初始序号 + 1"。同样的道理,挥手时 FIN 报文段也消耗一个序号,这就是为什么第四次挥手时 ACK 的确认号是"对方 FIN 的序号 + 1"。很多人在画四场面图时,这里对不上号,本质就是没搞懂"谁消耗序号"。
2.3 数据偏移、六个标志位与窗口:把 TCP 语义压缩进 16 比特
数据偏移占 4 位,表示 TCP 头部的长度,单位是 4 字节。标准头是 20 字节,所以数据偏移字段通常为 5(5 × 4 = 20)。当头部带着选项(MSS、时间戳、SACK 等)时,这个值会变大,最大是 15,对应 60 字节头部,其中选项区最多 40 字节。这个字段的本质作用是告诉接收方,"数据从哪里开始"——因为 TCP 头不是固定长度的。
接下来的六个标志位,是理解 TCP 行为的关键。逐个说:
- URG:表示紧急指针字段有效。这个字段设计是用来发送"带外紧急数据"的,但实际应用中用得极少,现代应用基本走普通数据通道。
- ACK:确认号有效。除握手最初阶段外,几乎所有报文段都要置上。
- PSH:要求接收方立即把数据交给应用层,不要在缓冲区攒着。实际内核一般也能容忍短延迟,否则交互式应用会卡。
- RST:异常复位连接。收到 RST 的一端应立即关闭连接,不需要走四次挥手。端口未监听时收到 SYN,内核回的就是 RST。
- SYN:建立连接时置位。它是连接请求的标志,同时消耗一个序号。
- FIN:表示发送方数据发完了,请求释放连接。它同样消耗一个序号。
窗口(Window)占 16 位,它是接收方给发送方的"流量许可额度",表示从确认号开始,接收方还能收多少字节的数据。这样发送方就不能一股脑儿灌数据把接收方缓冲区塞爆。窗口机制是 TCP 流量控制的核心:发送窗口大小由接收方的可用缓冲区决定,而不是由发送方自己定。这也是为什么抓包时经常看到 Windows Size 在动态变化的直接原因。
校验和(16 位)覆盖范围不止 TCP 头和数据,还包含一个 12 字节的伪首部(源 IP、目的 IP、协议号、TCP 长度)。这等于把网络层的部分信息也纳入了校验,防止数据被送到错误的协议、错误的主机上。它算的是反码和,这个细节应付选择题有用,就不展开了。
2.4 抓一个真实报文段:Wireshark 里你应该看哪些字段
纸上完了,实操一把。用 Wireshark 抓一个 TCP 连接最直观,过滤表达式可以写tcp.port == 443,然后看首个 SYN 报文。你会看到类似这样的层次:
Transmission Control Protocol, Src Port: 51234, Dst Port: 443, Seq: 0, Ack: 0 Source Port: 51234 Destination Port: 443 [Stream index: 0] Sequence Number: 0 (relative sequence number) Acknowledgment Number: 0 Header Length: 32 bytes Flags: 0x002 (SYN) Window: 64240 Checksum: 0x1234 [unverified] Options: (12 bytes), Maximum Segment Size, No-Operation, Window Scale Maximum Segment Size: 1460 Window Scale: 7 (multiply window by 128)注意两点。第一,Wireshark 默认显示的是相对序号(relative sequence number),第一个 SYN 的 seq 显示为 0,实际线上的随机初始序号(ISN)要右键"Protocol Preferences"关掉 Relative Sequence Numbers 才看得到。很多新手第一次抓包,看到 seq=0 就以为 TCP 初始序号固定为 0,这是个经典误解。第二,Options 里的 MSS=1460,这是双方协商的最大报文段长度,由链路层 MTU(常见 1500)减去 IP 头 20 字节和 TCP 头 20 字节得到。MSS 是传输层选项,不是链路层属性,但它的上限必须参考 MTU,否则就会触发 IP 分片,而 IP 分片是性能杀手。
3. 三次握手:建立连接的默契与博弈
3.1 一次握手的完整时序和状态流动
建立连接的全过程,课本上那张时序图大家都见过,但状态机的变化值得反复看:
客户端初始是 CLOSED,主动调 connect 后发出 SYN,进入 SYN_SENT;服务端从 LISTEN 状态收到 SYN,进入 SYN_RCVD,并回 SYN+ACK;客户端收到 SYN+ACK 后,状态变为 ESTABLISHED,并回送最后一个 ACK;服务端收到这个 ACK,也进入 ESTABLISHED。自此双方都能正常收发数据。
各状态的 Linux 表示:服务端处于 SYN_RCVD 时,ss -tln能看到对应监听端口;客户端处于 SYN_SENT 时,ss -tn能看到对应连接。线上排查连接建立失败时,这两个状态是第一个要看的。
还有一个特别容易记混的细节:第三次握手报文允许携带应用数据,前两次不行。因为第三次 ACK 发出时,客户端已经确认服务端可以收发了;服务端收到第三次 ACK 时,也知道客户端状态正确。此时捎带数据能节省一次往返。但前两次中任何一方都不能带数据——尤其第二次 SYN+ACK 如果携带数据,客户端还没进入 ESTABLISHED,处理起来是违反协议语义的。
3.2 为什么必须是三次:两次和四次都不行
"为什么是三次握手而不是两次"是面试高频题。最严谨的答案要落到两点:同步双方的初始序号、防止失效的历史连接请求干扰。
先说同步序号。TCP 收发数据依赖双方的序号达成一致,客户端要知道服务端的初始序号,服务端也要知道客户端的初始序号。这个过程本质是双方的一次"确认闭环":
- 客户端发 SYN,带上自己的 ISN_c。服务端收到后,知道"客户端序号从 ISN_c 开始"。
- 服务端回 SYN+ACK,带上自己的 ISN_s,同时 ACK = ISN_c + 1,告诉客户端"你的序号我收到了"。
- 客户端回 ACK = ISN_s + 1,告诉服务端"你的序号我也收到了"。
只有到第三步,服务端才能确认"客户端确实收到了我的 SYN+ACK"——如果不确认,服务端就不知道客户端是否知道服务端的初始序号。两步做不到这件事。
再说历史连接问题。假设一个 TCP 连接请求在网络里堵了很久,客户端超时重传后搭好了新连接,结果旧 SYN 被延迟后又到达了服务端。如果是两次握手,服务端收到这个旧 SYN 会立刻分配资源、进入 ESTABLISHED,然后傻等客户端发数据——客户端这边根本没这个连接,也不会理它,白白浪费资源,还可能把旧连接里的脏数据误当成新连接的数据。三次握手时,服务端即使收到了旧 SYN 回发 SYN+ACK,客户端也会根据自己当前上下文判断这是旧请求,回一个 RST 把它断掉,服务端收到 RST 后释放资源,干净利落。
3.3 为什么四次多余:信息论的朴素解释
既然三次能同时完成"双方确认彼此收发能力 + 序号同步",第四次就没有任何新信息了。多一次只会白白增加一个 RTT 的建连延迟。对经常短连接的场景,少一次握手就是实打实的性能节省。所以"为什么不是四次"的答案一句话:三次已经构成完备的确认闭环,第四次是纯冗余。
这个话题常被延伸成"TCP 三次握手为什么不叫三次握手协议而叫三次"——稍微补充一下:RFC 793 里其实叫"three-way handshake",它强调的是三个报文段完成双向同步,而不是三次交互。含义一样,但理解成"需要三个包"会更准确。
3.4 ISN 随机化:序列号不是从 0 开始的
TCP 报文的序列号初始值 ISN 并不是固定的。协议要求初始序号是随机生成的,目的之一是防止同一个连接在恢复后出现新旧报文序号重叠,更重要的防恶意攻击。
如果 ISN 可预测,攻击者可以伪造一个合法的 SYN,在服务端回复 SYN+ACK 之后抢在真实客户端之前发一个带预测序号的 ACK,从而污染连接、注入数据。这就是经典的序列号预测攻击。现在的操作系统对内核的 ISN 生成做得很激进——比如基于时间和哈希生成不可预测的随机值。这就是为什么抓包工具里会看到一个巨大的随机序号,而 Wireshark 为了方便默认显示相对值。
和 ISN 相关的还有一个安全场景:SYN Flood 攻击。攻击者源源不断发送 SYN,但从不回最后一个 ACK,把服务端的半连接队列(SYN_RCVD 状态的连接)撑爆。新来的正常连接就进不了队列,表现为服务“连不上”。应对方案里最出名的就是 SYN Cookie:服务端收到 SYN 时不分配连接资源,而是把连接信息加密编码进 SYN+ACK 的初始序号里,等到第三个 ACK 回来时验证序号、再真正分配资源。这个机制在现代 Linux 上默认开启或可通过 sysctl 调整,是防御握手层攻击的重要防线。
4. 四次挥手和 TIME_WAIT:所有延迟都藏在这张图里
4.1 挥手全流程:四个 FIN/ACK 和一个 TIME_WAIT
连接释放时,主动关闭方发 FIN,之后两条方向的数据通路各自独立关闭。全过程状态变化是:
- 主动方调 close,发 FIN,进入 FIN_WAIT_1。
- 被动方收到 FIN,回 ACK(确认号 = 对方 FIN 序号 + 1),进入 CLOSE_WAIT。主动方收到 ACK 后进入 FIN_WAIT_2。
- 被动方的应用层处理完剩余数据后,也调用 close,发出 FIN,进入 LAST_ACK。
- 主动方收到这个 FIN,回最后一个 ACK,然后进入 TIME_WAIT,等待 2MSL 后关闭;被动方收到 ACK 后直接 CLOSED。
注意,被动方第一次回的 ACK 和它自己发出的 FIN 是两个独立的东西,中间可能隔一段时间——取决于被动方应用层还有多少数据没发完。所以"四次挥手"不是对称的往返,第二次和第三次之间可以有一个明显的时间差,抓包时如果看到 FIN 和 ACK 之间隔着几百毫秒,不一定是问题。
很多人困惑的是,为什么第三步是被动方也发 FIN,而不是把 ACK 和 FIN 合并成一个包。答案回到 TCP 的全双工特性:每一方向数据的结束,都要单独宣告一次。主动方说"我不再发了",被动方得先确认"我知道了",然后被动方把自己要发的东西发完,再说"我也不再发了"。这两个动作之间,数据可能还在单向流动。这种只关闭一个方向的方法叫半关闭,四次的根本原因就在这里。
4.2 半关闭才是四次挥手的根本原因
如果 TCP 是单工协议,挥手最多两次就完事。但 TCP 是两条独立的数据通路,所以关闭也必须分两次。理解半关闭最好的例子是文件传输协议:客户端发完下载请求后,可以 shutdown 掉发送方向(半关),但保留接收方向继续收文件数据;文件收完,服务端再关闭它的发送方向,连接才完全关闭。这样设计让协议不必等双向数据同时没了才关闭,灵活性大幅提升。
实际编程里,主动 shutdown(SHUT_WR) 之后如果没有关读端,连接会停在 FIN_WAIT_2;如果对端一直不发 FIN,这个状态会挂很久。线上经常能看到 FIN_WAIT_2 堆积,多数原因是应用层"关了一半忘了另一半"或者对端进程僵死。
4.3 2MSL 等待到底在防什么
TIME_WAIT 是连接管理里最爱考、实战也最痛的状态。主动关闭方在发出最后一个 ACK 之后,要停留 2MSL(MSL,Maximum Segment Lifetime,报文最大生存时间)才能回到 CLOSED。两个理由缺一不可:
第一个理由,万一最后的 ACK 丢了。被动方处于 LAST_ACK,如果迟迟收不到确认,会超时重发 FIN。主动方需要在这段时间里留着,才能再补一个 ACK。如果收到 FIN 就直接关闭,那被动方重发的 FIN 就没人应答,它会一直重试、长时间无法释放。
第二个理由,让旧连接的报文在网络中自然消亡。假设我们不等满 2MSL 就立即重用同一个四元组建立新连接,那么网络上可能还残留着旧连接延迟到达的数据包,新连接收到后会把它当作自己的数据,造成数据混乱。2MSL 保证了所有属于旧连接的报文要么被收到、要么已经消失,新连接不会跨时空收到脏数据。
正因为这样,TIME_WAIT 里的连接是不能被立即复用的。你看到的ss -tan里一堆 TIME_WAIT,其实是 TCP 用主动等待换来了协议的健壮性。
4.4 生产环境里被 TIME_WAIT 折磨的日子
短连接和踢得比较勤的高频请求,很容易在服务器端堆积大量 TIME_WAIT。比如某内部系统频繁拉取配置,每个请求新建连接,高峰期ss -s能看到 TIME_WAIT 上千甚至上万。这种状态本身不算错误,但会占着四元组和少量内存,极端情况下可能导致新建连接变慢。
治理手段从三个方向考虑:
- 使用连接池 / 长连接,从源头减少连接频率。这是最推荐的做法,能同时降低握手开销和 TIME_WAIT 压力。
- 调整 MSL 相关的内核参数,缩短 TIME_WAIT 停留时间。代价是牺牲防脏包的安全窗口,生产环境一般不建议动。
- 开启
tcp_tw_reuse。它允许内核把处于 TIME_WAIT 的 socket 复用为新的连接,前提是开启 TCP 时间戳选项,且一般只建议在客户端场景使用。老内核里的tcp_tw_recycle问题很多,会和 NAT 环境下的时间戳校验互相冲突,出现"部分用户连不上、抓包却一切正常"的诡异现象,新内核基本已移除。这个改动,实战里吃过亏的人不少。
我的建议是:先统计业务的连接特征,确认确实有大量短连接再考虑优化;如果只是偶发几百个 TIME_WAIT,完全不需要管它,TCP 自己会慢慢处理完。
5. 从背诵到排查:连接管理的高频误区和一条排障路径
5.1 先自检:这些细节你背对了吗
把几个常见误区和大家容易踩的细节整理成一张表,方便自测:
| 项目 | 错误理解 | 正确理解 |
|---|---|---|
| 确认号 | 已收到的字节数 | 期望收到的下一字节序号 |
| SYN 与 FIN | 不消耗序号 | 各消耗 1 个序号,所以 ACK 的确认号是序号 + 1 |
| 三次握手 | 双方都发 SYN 才能建立 | 第二次是 SYN+ACK 合并 |
| 初始序号 | 固定 0 或 1 | 随机生成且不可预测 |
| 第四次挥手后主动方状态 | 直接 CLOSED | 先进入 TIME_WAIT 等 2MSL |
| TIME_WAIT 归属 | 被动关闭方 | 主动关闭方 |
每一次线上排障,或者说每一道面试深挖题,基本都是从这张表里扯出来的。比如"为什么抓包看到握手只有三个包"——因为第二个包是 SYN+ACK 合并的,但这不影响"三次"的语义。
5.2 连接建立不起来:从 SYN_SENT 到 SYN_RCVD 的排查路径
我处理"连不上"问题时,基本按这个链路走,几乎覆盖了八成场景:
第一步,客户端看状态。ss -tn state syn-sent能看到卡在 SYN_SENT 的连接。如果客户端自己一直不发 SYN,那是应用层没调用 connect,属于代码问题。
第二步,确定包有没有到服务器端。服务端tcpdump -i eth0 port 8080抓包,看有没有持续来的 SYN。如果没有,就是中间网络路径上被防火墙或负载均衡策略丢弃了。这里一个常见的坑是安全组规则只放行了目标端口,却没放行回程流量,导致 SYN+ACK 回不去,客户端永远停在 SYN_SENT。
第三步,如果 SYN 到了但服务端没回 SYN+ACK,检查半连接队列是否溢出。ss -ltn 'sport = :8080'观察 Recv-Q 大小(表示全连接队列积压数),配合ss -s看 SYN_RCVD 总量。队列打满时通常伴随客户端 connect 超时的日志,服务端却无应用异常——因为问题在协议栈层,应用还没机会感知。
第四步,确认服务端进程还活着、还在监听。ss -tlnp | grep 8080看 State 是不是 LISTEN。如果进程崩了,端口消失,客户端会收到 RST,表现是 connection refused 而不是 hang 住。这个区分本身就能快速缩小排查范围。
5.3 抓包和状态命令:一套顺手的基础工具链
最后给一套够用的基础工具链,覆盖的是日常 90% 的连接管理排查工作。
状态类命令,习惯用ss而不是老的netstat:
# 查看某端口的监听状态和进程 ss -tlnp | grep 8080 # 查看所有 TCP 连接状态汇总 ss -s # 过滤出指定端口的已建立连接 ss -tn state established '( dport = :8080 or sport = :8080 )'抓包类命令,tcpdump是必须会的:
# 抓特定端口流量,并保存为文件供 Wireshark 分析 tcpdump -i eth0 -s 0 -w /tmp/conn.cap -nn port 8080 # 只抓建立连接过程中的包 tcpdump -i eth0 -nn 'tcp[tcpflags] & tcp-syn != 0 and tcp[tcpflags] & tcp-ack == 0'抓到包之后,最值得练的复盘动作是:找到完整连接的生命周期,按时间顺序勾出 SYN、SYN+ACK、ACK、中间数据、FIN、ACK、FIN、ACK 这几个关键包,再对照状态机推演一遍每端的当前状态。做上三五次,你对这套机制的理解会超过死记硬背几十张图。
我个人还有个习惯:遇到想不明白的连接问题,先不急着翻内核参数,而是回到"序号 + 确认号 + 标志位 + 状态"这条主轴上推演。TCP 这一层没有魔法,所有行为都写在报文里,所有坑都有对应的字段和状态。把这个主轴攥熟了,不管是考试、面试还是线上事故,都不会再觉得无迹可寻。