TCP三次握手到TIME_WAIT:C/C++后端网络面试高频考点

发布时间:2026/10/26 13:31:21
TCP三次握手到TIME_WAIT:C/C++后端网络面试高频考点

去年帮几个做嵌入式和后端的同学做模拟面试,十个人里有八个栽在 TCP 三次握手这道题上:流程背得滚瓜烂熟,一问“为什么不能是两次”就开始支支吾吾,再追一句“半连接队列满了会怎样”基本就缴械了。C/C++ 八股文里的计算机网络篇,看着是最容易背的部分,实际上是最容易暴露基本功深浅的地方。这篇是我自己整理的一版复习笔记,聚焦在连接管理和传输层这几块最常被问、也最容易被问穿的内容上,包括分层模型的数据封装、三次握手的状态迁移与队列机制、四次挥手之后 TIME_WAIT 和 CLOSE_WAIT 的处理、TCP 与 UDP 的选型判断,以及粘包这类经典误区。适合正在准备 C/C++ 后端、嵌入式、音视频方向面试的同学,也适合工作两三年、想把网络基础重新捋一遍的开发者。文中给的命令和参数都是在 Linux 上能直接跑起来的,不是纸上谈兵。

1. 岗位视角:这几块八股为什么年年考

1.1 面试官到底在筛什么

先把话说明白:面试官问三次握手,不是想听你把 SYN、ACK 三个字母念一遍,那东西百度一搜就有。他真正想确认的是你有没有在真实环境里跟连接打过交道。C/C++ 岗位的网络相关代码,很多时候是直接调 socket API、直接处理 fd 的,出了问题没有 Java 那套框架帮你兜底,你得自己判断是连接没建起来、是半连接队列被打满了、还是应用层忘了 close 导致 fd 泄漏。这些问题在监控面板上长得都很像,全靠你对协议状态机的理解去区分。

我做过的几个项目里,印象最深的一次是某即时通讯模块上线后,服务端 fd 数量每隔几天就缓慢上涨,重启能缓解但不解决问题。最后定位下来是异常分支里没有释放连接,对端关闭后本地一直停在 CLOSE_WAIT。这个 bug 如果只背过“四次挥手”的流程,是根本查不出来的,因为流程图上根本没告诉你 CLOSE_WAIT 会堆积、堆积意味着什么。所以八股文的正确打开方式,是把它当成排查手册的索引来背,而不是当成考试答案来背。

从这个角度看,网络篇的复习优先级其实很清楚:分层模型和数据封装决定了你能不能算清楚一个包的大小;连接建立与关闭决定了你能不能读懂线上状态;TCP 与 UDP 的差异决定了你做技术选型时有没有依据;粘包、Nagle、延迟 ACK这些细节决定了你写出来的代码会不会有莫名其妙的延迟。这四块撑起了 C/C++ 网络面试的绝大部分内容,也是后面所有深入问题的基础。

1.2 一份可执行的复习顺序

很多人复习网络是从“背 OSI 七层”开始的,背完就卡住了,因为七层跟实际代码对不上。我建议的顺序是反过来的:先从一次 socket 调用出发,看数据怎么一层层封下去;再回到连接建立和关闭,把状态机画出来;然后拿抓包工具实际看一遍握手和挥手;最后再补拥塞控制、滑动窗口这些偏理论的部分。这个顺序的好处是每一步都能落到具体的代码或者命令上,记忆点不是文字而是画面。

具体到时间分配,如果只有一周准备,我一般建议:分层与封装花半天,握手挥手花两天并且必须动手抓包验证,TCP/UDP 与粘包花一天,剩下的时间用来刷高频题和整理话术。别小看“话术”这两个字,同样一个知识点,有人讲三句话面试官就点头,有人讲三分钟面试官还在皱眉,差别就在于有没有先给结论再展开。

下面几章就按这个顺序展开。我尽量把每个知识点都落到“为什么这么设计”和“线上怎么验证”两个问题上,这两点才是面试里真正拉开差距的地方。

2. 分层模型:开场送分题,也是最容易埋雷的地方

2.1 七层、四层、五层到底该说哪个

面试里被问到“说说网络分层”,最稳妥的回答是先摆出两套模型再说明对应关系。OSI 七层是教学模型,分物理层、数据链路层、网络层、传输层、会话层、表示层、应用层;TCP/IP 四层是工程模型,分网络接口层、网络层、传输层、应用层。实际工程里大家更常用的是一种折中的五层说法:物理层、数据链路层、网络层、传输层、应用层,把会话层和表示层的职责并到应用层里去。

这里有个小陷阱:如果你上来就说“OSI 七层是实际使用的模型”,懂行的面试官会立刻追问“那会话层和表示层在 Linux 里对应什么代码”。这个问题不好答,因为这两层在现代协议栈里基本被应用层吸收了。比较安全的表述是:OSI 是参考模型,用来划分职责;TCP/IP 是落地模型,实际协议栈按它实现;五层是教学上为了方便讲解的简化版。把三者的定位说清楚,比死记硬背层名有价值得多。

再往深一层,每层要能说出对应的典型协议和设备。物理层是网线、光纤、中继器;数据链路层是以太网、ARP、交换机;网络层是 IP、ICMP、路由器和三层交换机;传输层是 TCP、UDP;应用层是 HTTP、DNS、SSH 这些。这个对应关系能答顺,基本的分层题就不会丢分。

2.2 一次请求的数据封装与拆解过程

真正能体现理解深度的,是把“数据从应用到网线”这个过程讲清楚。假设你在应用层写了一段 100 字节的数据,调用send之后,内核会依次加上 TCP 头、IP 头、以太网帧头,最后交给网卡发出去。每一层加的头长度是相对固定的,算清楚这些数字,很多面试题就迎刃而解了。

标准以太网的 MTU 是 1500 字节,指的是 IP 包的最大长度,也就是 IP 头加 IP 载荷不超过 1500。IP 头固定部分 20 字节,TCP 头固定部分也是 20 字节,所以 TCP 能承载的单个报文最大数据量,也就是 MSS,等于 1500 减 20 减 20,得到 1460 字节。如果算上以太网帧头 14 字节和帧尾的 4 字节校验,一个满载的以太网帧总长度是 14 加 20 加 20 加 1460 加 4,正好 1518 字节。这组数字建议直接背下来,面试里问到 MSS 怎么来的、为什么是 1460,能一口气报出来会显得很扎实。

要注意 TCP 头和 IP 头都还有选项字段。IP 头选项最多 40 字节,TCP 头选项最多也是 40 字节,窗口缩放因子、SACK、时间戳这些都塞在 TCP 选项里。所以严格来说 TCP 头最长能到 60 字节,头长度字段是 4 位,单位是 4 字节,最大表示 15,也就是 60 字节。这个细节在被问到“TCP 头为什么是 20 到 60 字节”时能派上用场。

对应的拆解过程是反过来的:网卡收到帧,去掉帧头和校验,交给网络层;网络层看 IP 头里的协议号,如果是 6 就交给 TCP,如果是 17 就交给 UDP;传输层根据端口号找到对应的 socket,把数据放进接收缓冲区;应用层recv的时候从缓冲区里取。这里有个容易被忽略的点:分用是靠端口号完成的,而端口号只在传输层存在,网络层只认 IP 地址。这也是为什么 NAT 设备要改端口号,因为它需要在传输层做映射。

2.3 常见追问与踩坑点

这一块的追问主要集中在三个方向。第一个是“ARP 属于哪一层”,标准答案是网络层和链路层之间,通常说它是链路层协议但服务于网络层,回答时把“它把 IP 地址解析成 MAC 地址”这个功能说清楚就行,不必纠结归属。第二个是“ICMP 属于哪一层”,它是网络层协议,虽然它封装在 IP 包里。第三个是“为什么要有分层”,标准答法是解耦和复用,每层只关心自己的职责,上层不需要知道下层怎么实现,换网卡不需要改应用代码。

踩坑最多的是把“TCP/IP 四层”和“五层”混着说,前半句说四层后半句冒出个会话层,一听就是背串了。还有就是说不清“数据链路层和网络层的分工”,我一般用快递打比方:网络层负责规划从哪个城市到哪个城市,链路层负责这一站到下一站怎么走,IP 地址像收件地址,MAC 地址像这一程的司机。这个类比不算严谨,但能帮你在紧张的时候快速组织语言。

提示:被问到分层时,如果你不确定面试官期待哪种模型,可以先说“我按 TCP/IP 四层来讲,需要的话我再展开 OSI 的对应关系”,把选择权交回去,比硬答一个错模型要安全。

3. 三次握手:背流程容易,讲清楚为什么难

3.1 状态迁移不是背下来的,是推出来的

三次握手的流程本身很简单:客户端发 SYN,进入 SYN_SENT;服务端收到后回 SYN+ACK,进入 SYN_RCVD;客户端再回 ACK,双方进入 ESTABLISHED。但面试里真正考的是你能不能把状态迁移和队列机制串起来。服务端在 LISTEN 状态下收到 SYN,会把这个连接放进半连接队列,也叫 SYN 队列,同时回 SYN+ACK;收到客户端的 ACK 之后,才把它从半连接队列挪到全连接队列,也就是 accept 队列,等待应用调用accept取走。

这两个队列的长度限制直接决定了服务端能扛住多少并发建连。全连接队列的上限由listen函数的 backlog 参数和内核参数net.core.somaxconn共同决定,取两者较小值;半连接队列的上限由net.ipv4.tcp_max_syn_backlog控制。这两个参数在老版本内核上默认值都很小,backlog 默认 128,somaxconn 默认也是 128,高并发场景下不改就是等着丢连接。

线上验证这两个队列最直接的办法是ss -lnt,看 LISTEN 状态那一行的 Send-Q 和 Recv-Q。对于监听套接字,Send-Q 表示全连接队列的最大长度,Recv-Q 表示当前已经完成三次握手但还没被 accept 取走的连接数。如果 Recv-Q 长期接近 Send-Q,说明应用 accept 得太慢,全连接队列快满了,这时候新来的连接可能会被直接丢弃或者回 RST。

# 查看监听队列的积压情况 ss -lnt # 查看半连接队列相关计数 netstat -s | grep -i "SYNs to LISTEN"

3.2 为什么必须是三次,两次和四次各错在哪

这是整章的核心问题,答不好前面白搭。先说两次为什么不行。核心原因是服务端无法确认客户端收到了自己的 SYN+ACK。如果只握手两次,服务端发完 SYN+ACK 就认为连接建立了,但这条确认可能丢在路上。此时服务端已经分配了资源、进入了 ESTABLISHED,而客户端还在等,一旦客户端超时重发 SYN,服务端就会为一个根本不存在的连接长期占用资源。

更隐蔽的问题是历史连接的干扰。假设客户端之前发过一个 SYN,因为网络延迟它在链路里游荡了很久,客户端早就超时放弃并建立了新连接。这个迟到的旧 SYN 到达服务端后,如果只握手两次,服务端会直接建立一个连接,然后按旧连接的序列号去通信,双方数据完全对不上。三次握手的第三次 ACK 由客户端根据当前上下文发出,能把这个历史连接识别出来并拒绝掉。这个理由比“确认双方收发能力”更接近本质,面试时优先讲这个。

再说四次为什么没必要。理论上握手的目的是同步双方的初始序列号,客户端的 SYN 同步了自己的,服务端的 SYN 同步了自己的,服务端的 ACK 确认了客户端的,客户端的 ACK 确认了服务端的,四个动作确实存在。但服务端的 SYN 和 ACK 可以合并成一个报文发出去,因为这两个动作在时间上是连续的,没有等待的必要。挥手之所以是四次,是因为被动关闭方收到 FIN 时可能还有数据要发,ACK 和 FIN 不能合并。这个对比讲出来,面试官基本就认可你把两个流程都吃透了。

3.3 初始序列号为什么要随机

如果 ISN 固定从 0 或者 1 开始,攻击者只要猜到序列号,就能伪造报文注入数据,甚至直接伪造 RST 把连接打断。所以 RFC 里要求 ISN 必须不可预测。早期的实现是用时钟计数器,每 4 微秒加一,现在更常见的做法是把四元组做哈希再加上一个随机偏移,既保证不可预测,又保证同一对四元组在短时间内重连时 ISN 不会撞车。

ISN 随机化带来一个副作用:序列号是 32 位的,在高速网络下会回绕。万兆网卡跑满的时候,32 位序列号空间大概几十秒就能绕一圈。为了防止旧报文被误认为是新报文,TCP 用了时间戳选项做 PAWS 检测,接收方会丢弃时间戳比当前记录更小的报文。这也是为什么tcp_tw_reuse必须配合时间戳开启才能生效,因为它依赖时间戳判断一个连接是不是真的过期了。

还有一个小细节值得提:ISN 不是一个纯随机数,它是在一个基准值上递增的,基准值跟时钟有关。这样设计是为了让同一台主机在短时间内建立的多个连接的 ISN 有区分度,同时避免完全随机带来的碰撞概率问题。面试里如果被追问“随机数怎么生成”,能答到“四元组哈希加时钟偏移”这个层面就足够了。

3.4 SYN Flood 与半连接队列的实际表现

SYN Flood 的原理很直白:攻击方发大量 SYN 但不回最后的 ACK,服务端的半连接队列很快被占满,正常用户的 SYN 进不来,服务就挂了。防御手段主要有三个层次。第一层是调大队列,把tcp_max_syn_backlog和somaxconn都调大,但这只能拖延时间。第二层是开启net.ipv4.tcp_syncookies,队列满的时候不再入队,而是把一个加密的 cookie 放在序列号里回给客户端,等客户端回 ACK 时再根据 cookie 重建连接信息。这个方案的问题是 cookie 里放不下所有 TCP 选项,窗口缩放因子这类信息会丢失。第三层是在前面加负载均衡或者防火墙做清洗,这一层就不属于协议栈范畴了。

实际排查的时候,netstat -s里的SYNs to LISTEN sockets dropped和listen queue overflow这两个计数是关键指标。如果前者持续增长,说明半连接队列在丢包;如果后者增长,说明全连接队列满了。这两个指标含义完全不同,处理方式也不同,前者要防攻击或者调队列,后者要去查应用为什么 accept 慢。

# 调整全连接队列上限(需配合 listen 的 backlog 参数) sysctl -w net.core.somaxconn=4096 # 调整半连接队列上限 sysctl -w net.ipv4.tcp_max_syn_backlog=8192 # 查看丢包统计 netstat -s | grep -i -E "listen|syn"

注意:somaxconn调大之后,代码里listen(fd, backlog)的 backlog 也要跟着调,否则取的是两者较小值,改了内核参数也没用。这是我在实际项目里踩过的坑,监控上看着队列上限很高,实际还是 128。

4. 四次挥手与 TIME_WAIT:最容易翻车的一章

4.1 挥手流程与六个状态

主动关闭方调用close之后发 FIN,进入 FIN_WAIT_1;被动关闭方收到 FIN 回 ACK,进入 CLOSE_WAIT,主动方收到 ACK 后进入 FIN_WAIT_2。被动方把剩余数据发完之后调用close,发 FIN 并进入 LAST_ACK;主动方收到 FIN 回 ACK,进入 TIME_WAIT;被动方收到 ACK 后进入 CLOSED。主动方在 TIME_WAIT 停留 2MSL 之后才真正关闭。

这里有个常被忽略的状态叫 CLOSING,出现在双方同时关闭的情况:主动方发完 FIN 还没收到 ACK 就先收到了对方的 FIN,这时候它没法确定对方是否收到了自己的 ACK,就进入 CLOSING 等待对方的 ACK。这个状态在实际生产里很少见,但面试官偶尔会问,能说出来说明你真的看过状态机而不是只背了主流程。

还有半关闭的概念。TCP 是全双工的,close会关闭整个连接的两个方向,但如果只想关闭发送方向、保留接收能力,要用shutdown(fd, SHUT_WR)。这个区别在写代理类程序时很重要,我见过有同学在实现一个转发程序时用close关闭上游,结果下游回包全丢了,就是因为没有区分半关闭。

4.2 为什么挥手是四次而握手是三次

前面提过一次,这里展开讲。握手阶段服务端收到 SYN 时,自己还没有任何数据要发,SYN 和 ACK 可以合并;挥手阶段被动关闭方收到 FIN 时,应用层可能还有数据没发完,它只能先回一个 ACK 告诉对方“我知道你要关了”,等自己数据发完了再补一个 FIN。这两个动作在时间上不连续,所以拆成两个报文。

如果被动方确实没有数据要发了,ACK 和 FIN 能不能合并?Linux 的实现里是有这个优化的,延迟发送的 ACK 可能和 FIN 一起发出,从抓包上看就是三次挥手。但这种优化依赖具体实现和时序,不能当成协议保证。面试时如果被问到“有没有可能三次挥手”,可以答“实现上有优化可能合并,但协议规定是四次,因为两个方向的关闭是独立的”。

这个问题的本质是全双工连接的两个方向要独立关闭。理解这一点,四次挥手就不需要死记,你能自己推出来。

4.3 TIME_WAIT 到底在等什么

TIME_WAIT 存在两个理由,都得说。第一个是保证最后一个 ACK 能到达对端。如果这个 ACK 丢了,对端会重发 FIN,此时主动方还在 TIME_WAIT 状态,能重新回一个 ACK;如果已经进入 CLOSED,收到 FIN 会回 RST,对端就会认为连接异常终止。第二个是让本次连接产生的所有报文在网络中自然消亡,防止它们干扰使用相同四元组的新连接。这两个理由里,第一个是主要的,第二个是顺带的。

时长是 2MSL,MSL 是报文最大生存时间,RFC 建议 2 分钟,实际 Linux 里 TIME_WAIT 的时长是硬编码的 60 秒,宏定义是TCP_TIMEWAIT_LEN,值为 60 乘以 HZ。这里有个特别容易答错的点:net.ipv4.tcp_fin_timeout改的不是 TIME_WAIT 的时长,它控制的是 FIN_WAIT_2 状态的超时。很多人以为调小这个参数能减少 TIME_WAIT,其实完全没用。TIME_WAIT 在 Linux 上没法通过 sysctl 直接改短,能做的是复用或者减少主动关闭。

TIME_WAIT 堆积的危害主要是端口耗尽和内存占用,每个 TIME_WAIT 连接占一个四元组。算一笔账:本地端口范围默认是 32768 到 60999,可用端口大约 28000 个,减去系统保留的部分,实际能用两万多个。如果服务是短连接模式,每个连接都会在主动关闭方留下一个 60 秒的 TIME_WAIT,那么理论上每秒最多能建立的连接数就是 28000 除以 60,大约 466 个。超过这个量级,就会出现Cannot assign requested address的报错。

这个计算很有说服力,面试时能报出来会加分。对应的解决办法也很清楚:能用长连接就别用短连接;必须用短连接的话,用连接池复用;客户端侧可以开启net.ipv4.tcp_tw_reuse复用 TIME_WAIT 连接,但它只对主动发起连接的一方有效,而且需要时间戳开启。

# 统计各状态的连接数量 ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn # 只统计 TIME_WAIT ss -tan state time-wait | wc -l

4.4 CLOSE_WAIT 堆积才是真正的代码问题

TIME_WAIT 是正常状态,CLOSE_WAIT 堆积则几乎一定是应用层的 bug。被动关闭方收到 FIN 后进入 CLOSE_WAIT,此时连接已经半关闭,但只要应用不调用close,这个状态就会一直保持,fd 也不会释放。堆积起来之后,先是 fd 耗尽,然后是内存上涨,最后服务直接不可用。

排查思路很固定:先用ss -tanp找到处于 CLOSE_WAIT 的连接,看它属于哪个进程、哪个 fd,然后去代码里找对应的处理路径。常见原因有三类:一是异常分支里return之前忘了close;二是用了智能指针管理 socket 但 deleter 写错了;三是把 fd 交给了线程池,任务队列积压导致迟迟没执行到close。第二类在 C++ 项目里特别常见,我见过有人用std::unique_ptr包 fd 但自定义删除器里写的是空实现。

# 定位 CLOSE_WAIT 连接及其进程 ss -tanp state close-wait # 统计某进程的 fd 数量 ls /proc/<pid>/fd | wc -l

提示:压测阶段就应该盯着 CLOSE_WAIT 的数量,别等上线之后再查。正常的服务在稳定运行状态下,CLOSE_WAIT 应该接近 0,如果随时间单调增长,基本可以确定有 fd 泄漏。

5. TCP 与 UDP:选型题背后的工程判断

5.1 头部开销与传输效率的真实差距

TCP 头固定 20 字节,加上选项最多 60 字节;UDP 头固定 8 字节,只有源端口、目的端口、长度、校验和四个字段。如果应用层每次只发 10 字节的心跳包,走 TCP 的话头部开销是 20 加 20 等于 40 字节,走 UDP 只要 8 加 20 等于 28 字节,差距接近一倍。高频小包场景下这个差距会体现在带宽和 CPU 上。

但选型不能只看头部开销。TCP 提供的有序、可靠、流量控制、拥塞控制,这些东西你自己在 UDP 上实现一遍,代码量和踩坑成本远超省下来的那点带宽。真正该选 UDP 的场景有三类:一是对延迟极度敏感、能容忍少量丢包的实时场景,比如音视频通话和游戏同步;二是需要组播或广播的场景,TCP 根本不支持;三是应用层协议本身就做得很轻,比如 DNS 查询,一次请求一次响应,用 UDP 省掉握手开销很划算。

反过来说,绝大多数业务场景该用 TCP。文件传输、HTTP 接口、数据库连接,这些都不能容忍乱序和丢包。我见过有团队为了“性能”把内部 RPC 改成 UDP 自己实现可靠层,最后做出来的东西就是半个 TCP,还多了一堆 bug,这个投入产出比很不划算。

5.2 粘包不是 TCP 的错,是应用层没定边界

这是被误解最深的一个概念。严格来说 TCP 根本没有“包”的概念,它是字节流协议,你调用send三次发三个 100 字节,接收方recv可能一次读到 300 字节,也可能分三次读到 50、100、150。所谓粘包和拆包,本质是应用层没有定义消息边界,跟 TCP 本身没有关系。

解决办法有三个思路。定长消息最简单,每条消息固定 N 字节,不够就补齐,缺点是浪费带宽。分隔符方案用一个特殊字符标记结尾,比如换行符,HTTP 的头部就是这么干的,缺点是消息体里不能出现这个字符,或者需要转义。最通用的是长度字段方案:消息头里放一个固定长度的字段表示消息体长度,接收方先读头、解析出长度、再读够这么多字节。实际项目里基本都是长度字段为主、分隔符为辅的组合方案。

UDP 呢?UDP 是数据报协议,一次sendto对应一次recvfrom,天然有边界,不存在粘包。但 UDP 会丢包、会乱序、可能重复,而且单个数据报超过 MTU 会被 IP 层分片,任意一片丢了整个包就废了。所以基于 UDP 做可靠传输的协议,比如各种实时传输方案,都会把应用层数据切到 MTU 以下,避免 IP 分片。

// 长度字段方案的服务端读取示例(简化版) uint32_t len = 0; // 先读 4 字节长度头 if (read_n(fd, &len, sizeof(len)) != sizeof(len)) { // 连接异常或对端关闭 return -1; } len = ntohl(len); if (len > MAX_MSG_LEN) { // 防御非法长度,避免内存被撑爆 return -1; } std::vector<char> body(len); if (read_n(fd, body.data(), len) != static_cast<ssize_t>(len)) { return -1; }

这段代码里read_n必须循环读够指定字节数,因为read可能只返回一部分。很多人第一版就栽在这里,以为read一次就能读满,测试环境数据量小没问题,一上量就出乱码。另外MAX_MSG_LEN这个上限一定要有,否则对端发个长度为 4GB 的头部,你的vector一分配就是内存灾难。

5.3 Nagle 算法与延迟 ACK 的相互折磨

这两个机制单独看都是好东西,凑在一起就会产生诡异的延迟。Nagle 算法的规则是:如果还有未被 ACK 的小包,就先攒着不发,等 ACK 回来或者攒够一个 MSS 再发。延迟 ACK 的规则是:收到数据不立刻回 ACK,等一小会儿看看有没有数据要捎带回去,Linux 上这个时间通常是 40 毫秒。两个机制一叠加,就出现了经典的“40 毫秒延迟”:发送方等 ACK 才发下一个包,接收方等 40 毫秒才回 ACK。

这个问题在请求响应式的短交互里特别明显,比如游戏里的操作指令、金融行情推送。解决办法是设置TCP_NODELAY关掉 Nagle,让每个小包立刻发出去。代价是包数量变多,头部开销占比上升,所以在吞吐优先的场景反而要保持 Nagle 开启。

// 关闭 Nagle,适合延迟敏感的小包交互 int flag = 1; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));

还有一个相关参数是TCP_QUICKACK,用来临时关闭延迟 ACK,但它不是永久的,设置一次只对下一次生效,需要反复设置,一般不建议在业务代码里用。真正要解决延迟问题,优先考虑改协议设计,把多次小包合并成一次发送,比调参数更治本。

6. 高频问题速查与面试表达技巧

6.1 一张表过完最高频的十个问题

复习到后期,我习惯把所有高频问题压缩成一张表,每个问题只写三个关键词,面试前扫一遍就能把记忆激活。下面这张表是我自己用的版本,答案部分只给要点,展开靠自己讲。

问题回答要点常见陷阱
三次握手为什么不是两次防止历史连接、确认双方 ISN 同步只说“确认收发能力”不够深
四次挥手为什么不是三次全双工两个方向独立关闭忽略被动方可能还有数据要发
TIME_WAIT 为什么等 2MSL保证最后 ACK 到达、旧报文消亡误以为 tcp_fin_timeout 能改它
CLOSE_WAIT 堆积怎么办查应用是否漏调 close错当成内核参数问题去调
MSS 为什么是 14601500 减 IP 头 20 减 TCP 头 20忘记算选项长度
TCP 粘包怎么解决定长、分隔符、长度字段说成是 TCP 的缺陷
Nagle 和延迟 ACK 的冲突40ms 延迟、TCP_NODELAY不知道两者会叠加
TCP 和 UDP 怎么选可靠性需求、延迟敏感度、是否组播只说“TCP 可靠 UDP 快”
半连接队列和全连接队列SYN 队列、accept 队列、两个内核参数混淆两个队列的丢包计数
序列号为什么要随机防伪造、防注入不知道时间戳和 PAWS 的作用

这张表建议自己手写一遍,写的过程中会发现哪些地方只有模糊印象。我自己的经验是,凡是写不出来的条目,基本都是没真正理解的,回去补对应的章节比重复背书有效得多。

6.2 答题结构比知识量更能决定印象分

同样一个知识点,组织方式不同,面试官的观感差别很大。我总结了三种比较稳的表达结构。第一种是“结论加理由加代价”,适合选型类问题,比如问 TCP 和 UDP 怎么选,先说结论“我一般优先选 TCP”,再说理由“业务不能容忍乱序丢包”,最后说代价“握手和头部开销会多一些,但这个量级可以接受”。第二种是“流程加状态加异常”,适合握手挥手类问题,先把主流程说清楚,再把关键状态点出来,最后主动补充异常情况,比如“如果最后一个 ACK 丢了会怎样”。主动提异常是最容易加分的动作,因为它说明你不是在背书。

第三种是“现象加排查加根因”,适合故障类问题。比如被问到“线上出现大量 CLOSE_WAIT 怎么查”,可以按这个顺序讲:先看现象是 fd 数量持续上涨,再用ss定位到具体进程和连接,最后定位到代码里某个异常分支没释放资源。这种答法比直接说“因为没调 close”要有说服力得多,因为它展示了完整的排查链路。

还有一个细节:不要在面试里主动展开你没把握的内容。网络这块知识面很宽,拥塞控制的几种算法、BBR 的实现细节、QUIC 的握手优化,这些都容易讲着讲着就露怯。正确的做法是把话题控制在你熟悉的范围内,面试官追问再答,答不上来就坦率说“这块我了解得不深,我的理解是……不一定准确”。诚实比硬撑更能赢得好感。

6.3 几个我踩过的坑

第一个坑是只背结论不看抓包。我早年准备面试的时候把三次握手的流程背得很熟,但第一次用抓包工具看真实流量时,发现 SYN 包后面跟着一堆选项,窗口缩放、SACK 许可、时间戳,完全不知道是什么。后来花了半天把这些选项查清楚,才发现很多面试题的答案就藏在这些选项里。

第二个坑是把tcp_tw_recycle当成银弹。这个参数在 NAT 环境下会导致大量连接失败,因为它是按源 IP 判断时间戳的,同一个 NAT 后面的多台设备时间戳不同步就会被误判。这个参数在新版本内核里已经被移除了,看到旧文章推荐开启一定要留个心眼。

第三个坑是忽略 backlog 参数和内核参数的配合。前面提过,listen的 backlog 和somaxconn取较小值,只改一个没用。这个坑我在一个压测项目里踩过,把somaxconn调到 65535,压测还是丢连接,查了半天才发现代码里listen写的是 128。

第四个坑是把应用层的消息边界问题归咎于 TCP。早年写一个文件传输程序,接收端按固定大小读,结果最后一块总是读多或者读少,当时以为是 TCP 的问题,后来才明白是自己没处理消息长度。这个认知转变之后,再遇到类似问题就知道该往哪查了。

6.4 后续可以继续深挖的方向

连接管理这块吃透之后,往下走有三个方向值得投入。第一个是拥塞控制,从慢启动、拥塞避免到快重传快恢复,再到现在的各种新算法,这块能解释很多“为什么带宽上不去”的问题。第二个是滑动窗口和流量控制,零窗口探测、窗口缩放因子这些细节,在处理大带宽高延迟链路时特别关键。第三个是应用层协议,HTTP 的 keep-alive、HTTP/2 的多路复用、HTTP/3 基于 UDP 的设计,这些内容是网络八股和实际业务的交汇点。

最后分享一个我自己的复习习惯:每学完一个知识点,强迫自己用三句话讲给一个完全不懂网络的人听,讲不清楚就说明没真懂。这个方法看着笨,但对付面试特别有效,因为面试官本质上就是一个随时会追问的听众,你能不能把复杂的东西讲简单,直接决定了沟通效率。