☰
TCP高频面试题知识地图:握手挥手、重传窗口与拥塞控制调优
2026/10/27 13:31:43 网站建设 项目流程

做后端开发这些年,被问得最多的网络问题基本都绕不开 TCP。不管是校招一面还是工作五六年后的技术终面,面试官翻开简历看到"熟悉网络编程",下一句大概率就是"说说三次握手"。很多人背得滚瓜烂熟,真遇到线上大量的 CLOSE_WAIT、连接建立慢、吞吐上不去这类情况,还是两眼一抹黑。这篇东西我想换个角度写:把 TCP 高频面试题当成一张知识地图,从面试官真正想考察的点出发,一路拆到内核参数和生产环境里的排查手法。适合正在准备面试的同学,也适合工作几年但一直没系统梳理过传输层的朋友,看完至少能做到面试时答得有层次、线上出问题时知道往哪儿看。

1. 先把问题拆开:TCP 面试到底在考什么

1.1 面试官的三层考察意图

大部分人准备 TCP 面试的方式是背结论:"三次握手是为了防止失效连接请求突然到达服务端"。这句话本身没错,但只答到这一层,面试官往往会追问一句"还有别的原因吗",然后就卡住了。我观察下来,TCP 相关的提问基本分三层。

第一层是事实层,考你知不知道这件事,比如 TIME_WAIT 的时长是 2MSL、快速重传的阈值是 3 个重复 ACK。这一层靠记忆就能过,也是最容易被刷掉的地方——不是记不住,而是记得零碎,一追问就露馅。

第二层是机制层,考你知不知道这个设计解决了什么问题。比如为什么握手是三次而不是两次,本质上是双方都要完成"发送自己的初始序列号"和"确认对方的初始序列号"两个动作,任何一方少做一次,序列号同步就不完整。答到这一层,面试官会觉得你理解了协议而不是背了书。

第三层是工程层,考你能不能把协议知识用到实际场景里。典型问法:"线上服务端出现几万个 CLOSE_WAIT,你怎么排查"、"跨机房传输只有 200Mbps,但带宽是 1Gbps,可能是什么原因"。这一层最能区分候选人的真实水平,因为它要求你把协议行为、内核实现、应用代码三者串起来看。

1.2 为什么 TCP 是面试的固定保留节目

我在实际带人和被面的过程中发现,TCP 之所以常考,是因为它天然具备三个特点。

一是可验证。你答得对不对,面试官很容易判断,不像架构设计题那样见仁见智。二是有梯度。同一道题从"三次握手是哪三次"到"SYN 队列满了会怎样"可以问出五六个深度,能精确测出候选人的水位。三是强关联。TCP 和并发模型、连接池、超时设置、负载均衡、微服务调用链都直接相关,一个对 TCP 没概念的人写出来的 RPC 客户端,大概率会在超时重试和连接复用上踩坑。

所以准备这部分内容,正确的姿势不是刷题库,而是把 TCP 当成一个"带状态的可靠字节流协议"来理解,把状态机、序号空间、窗口机制这三根支柱想透。后面的所有细节,都是这三根支柱长出来的枝叶。

1.3 一条主线串起所有考点

如果只能记一条主线,我建议记这个:TCP 用序号解决"顺序和去重",用确认和重传解决"丢包",用窗口解决"发太快或收太慢",用握手挥手解决"从什么状态开始、到什么状态结束"。

四次挥手对应结束,三次握手对应开始,中间传数据的过程全靠序号 + 确认 + 窗口在跑。你在面试里遇到的绝大部分问题,都可以挂到这条主线上。比如"为什么需要 TIME_WAIT",挂到"结束"上;"滑动窗口和拥塞控制的区别",挂到"窗口"上——一个是接收方能力,一个是网络能力。有了这条主线,回答的时候就不容易东一榔头西一棒子。

提示:面试里遇到不熟悉的 TCP 问题,先想它属于"开始 / 传输 / 结束"哪一段,再从对应的机制去找答案,比硬想结论靠谱得多。

2. 连接建立与断开:握手挥手里藏着的细节

2.1 三次握手每一步究竟在协商什么

先把报文摆清楚,这里的每个字段面试官都可能追问。

步骤方向关键字段语义
第一次客户端 → 服务端SYN=1, seq=x我想建连接,我的初始序列号是 x
第二次服务端 → 客户端SYN=1, ACK=1, seq=y, ack=x+1收到你的 x,我确认;我的初始序列号是 y
第三次客户端 → 服务端ACK=1, seq=x+1, ack=y+1收到你的 y,我确认

注意ack=x+1这个加一,很多人只会说"确认号是 x 加 1",但说不清为什么加一。原因是SYN 报文虽然不带数据,但要消耗一个序号,所以对端下一个期望收到的字节序号就是 x+1。同理 FIN 报文也消耗一个序号,这就是四次挥手最后 ACK 是ack=w+1的原因。这条如果答得出来,面试官基本会认为你确实理解序号空间,而不是背答案。

另一个容易被问的点是"第三次握手的 ACK 能不能带数据"。答案是可以。因为此时客户端已经知道自己的发送能力正常、服务端的接收能力正常,携带数据不会造成混乱。而前两次握手不行:第一次的 SYN 如果带数据,服务端在没确认的情况下不知道这些数据该不该收;第二次的 SYN+ACK 带数据,客户端同样处于半信半疑的状态。这一问本质考的仍然是"什么时候双方对彼此的收发能力都有把握了"。

2.2 为什么必须是三次,两次和四次差在哪

"两次握手行不行"是最高频的追问。标准答案是防止历史失效连接的请求突然到达服务端,导致服务端白白建立连接、浪费资源。这个场景可以讲得更具体一点:客户端发了一个 SYN,因为网络拥塞在某个节点滞留了很久,客户端早就超时重发并完成了传输、关闭了连接。结果这个迟到的 SYN 送到了,服务端如果只靠两次握手就直接进入 ESTABLISHED,就会分配资源等一个永远不会来的数据,一直挂到超时。

这个解释是对的,但只说了一半。更完整的回答应该包含另一层:三次握手才能完成双方初始序列号的可靠同步。序列号不是随便选的,它是后续所有确认、去重、重排的基准。如果只有两次,服务端发出了自己的 ISN(y)但无法确认客户端是否收到;如果客户端因为某些原因没收到,后续所有ack=y+1都是错的。第三次 ACK 的存在,就是让服务端确信"客户端的序号我收到了,我的序号客户端也收到了"。

至于"四次握手行不行",理论上当然可以,多一次确认不会有错误,但没必要——第二次的 SYN 和 ACK 合并发送已经足够,再拆开只是多一个 RTT,对建立延迟没有任何收益。这也是理解 TCP 设计哲学的一个切口:够用就好,多余确认不加。

2.3 半连接队列、全连接队列与 SYN Flood

这组概念在实际面试中出现频率极高,因为它是"你懂不懂内核实现"的分水岭。

服务端收到 SYN 之后,连接并不会立刻交给应用,而是先进半连接队列(SYN 队列),此时连接处于 SYN_RECV 状态。等第三次握手的 ACK 到达,连接被移入全连接队列(Accept 队列),状态变成 ESTABLISHED,等待应用调用accept()取走。

两个队列的容量由不同参数控制:

# 半连接队列上限,实际值还受 tcp_max_syn_backlog 和 somaxconn 共同影响 net.ipv4.tcp_max_syn_backlog = 8192 # 全连接队列上限,listen(fd, backlog) 的 backlog 会被这个值截断 net.core.somaxconn = 4096 # SYN Flood 防护 net.ipv4.tcp_syncookies = 1

一个非常实用的问题:如果全连接队列满了,新的握手会怎样。在开启tcp_abort_on_overflow=0(默认)的情况下,服务端会丢弃第三次握手的 ACK,客户端会认为连接已建立并开始发数据,但数据到达后被丢弃或回 RST,表现出来就是"连接建好了但请求超时"。把tcp_abort_on_overflow设成 1 则直接回 RST,客户端立刻报连接重置,反而更容易定位问题。

我在一次压测里就踩过这个坑:QPS 上不去,客户端日志全是超时,服务端 CPU 和带宽都很闲。用netstat -s | grep -i listen一看,times the listen queue of a socket overflowed在飞涨,才知道是somaxconn太小、Accept 队列溢出。改完参数重启服务,QPS 直接翻了几倍。这个排查思路值得记住:服务端不忙但请求超时,先查队列溢出计数。

半连接队列溢出则是另一种表现。SYN Flood 攻击下,攻击方只发 SYN 不回 ACK,半连接队列迅速占满,正常用户的 SYN 被丢弃,表现出来是"连接建立阶段就超时"。这时候看netstat -s | grep -i syn,SYNs to LISTEN sockets dropped会大幅增长。

2.4 四次挥手的完整状态流转

挥手之所以是四次,是因为 TCP 支持半关闭:一方发完数据了可以先关掉自己的发送方向,但仍然能继续接收对方的数据。这个特性在实际场景里很有用,比如客户端把请求发完就shutdown(SHUT_WR),等着服务端把响应流式返回。

状态谁含义
FIN_WAIT_1主动关闭方发了 FIN,等对方 ACK
CLOSE_WAIT被动关闭方收到 FIN 并回了 ACK,等应用调用 close
FIN_WAIT_2主动关闭方收到对方 ACK,等对方的 FIN
LAST_ACK被动关闭方发了 FIN,等最后一个 ACK
TIME_WAIT主动关闭方最后一个 ACK 已发,等待 2MSL
CLOSED双方连接彻底结束

这张表真正有用的地方在于用状态定位问题。

CLOSE_WAIT堆积几乎只有一个原因:应用代码收到了 FIN,但没有调用close()。可能是逻辑里漏了 finally 块,可能是连接被放进了池子但没做健康检查,也可能是某个回调里异常抛出导致关闭逻辑没执行。这时候翻内核参数是没用的,只能翻代码。我见过一个典型例子:某团队用连接池访问下游,下游做灰度发布时主动断开连接,但因为异常路径没有正确归还和关闭,池子里的死连接越积越多,ss -s里 CLOSE_WAIT 一路涨到几千。

FIN_WAIT_2堆积则说明对方收到了 FIN 却迟迟不发自己的 FIN,通常是对方应用层没有关闭连接。这里有个内核参数决定 FIN_WAIT_2 的存活时间:

# 单位秒,默认 60,意味着孤儿连接在 FIN_WAIT_2 最多待这么久 net.ipv4.tcp_fin_timeout = 60

注意:tcp_fin_timeout只影响孤儿连接(应用已关闭),对应用尚未关闭的连接不生效。很多人以为调小它就能解决 FIN_WAIT_2 堆积,其实是无效操作。

2.5 TIME_WAIT 到底在等什么

TIME_WAIT 是面试里最容易被反复纠缠的话题,想答出层次,得说清楚它存在的两个理由。

理由一:保证最后一个 ACK 能到达对方。假设主动关闭方发出的最后一个 ACK 丢了,被动方会超时重发 FIN。如果主动方此时已经进入 CLOSED,收到这个 FIN 就会回一个 RST,被动方会认为连接异常终止,数据可能丢失。TIME_WAIT 状态下重发的 FIN 能被正常接收并再次回 ACK。

理由二:让本次连接产生的所有报文在网络中自然消亡。报文在网络里可能迷路,最长存活时间叫 MSL。等待 2MSL(一个来回)就能确保双向的旧报文都过期了,新连接(相同四元组)不会被旧报文污染。这也解释了为什么 TIME_WAIT 出现在主动关闭方。

TIME_WAIT 多了会不会有危害?要分情况。它本身不占文件描述符,只占内核里的一小块内存和四元组资源。真正的风险是端口耗尽——如果本机作为客户端高频短连接打向同一目标,源端口只有 6 万多个,可能不够用。常见的应对手段我列在下面,但要强调:大部分时候正确做法是改成长连接或连接池,而不是调内核参数。

# 允许将 TIME_WAIT 状态的连接复用于新的出向连接(需开启时间戳) net.ipv4.tcp_tw_reuse = 1 # 时间戳,tw_reuse 的前置条件 net.ipv4.tcp_timestamps = 1 # TIME_WAIT 套接字数量上限,超过直接销毁并打日志,慎用 net.ipv4.tcp_max_tw_buckets = 262144

tcp_tw_reuse只对客户端出向连接生效,服务端被动关闭的场景帮不上忙;它依赖时间戳递增来判断旧报文是否可以安全丢弃。至于早年常见的tcp_tw_recycle,在 NAT 环境下会误杀正常连接,新版内核已经移除,面试里如果有人问起,能说清它为什么被删掉反而是加分项。

3. 可靠性是怎么堆出来的:序号、重传与窗口

3.1 序号、确认与字节流语义

TCP 最容易被误解的一点是:它不保证"消息"可靠,它保证的是"字节流"可靠。协议本身没有消息这个概念,应用写进去的可能是 100 字节,对端读出来的可能是 30 + 70,也可能等两次写(50 + 50)一起读出来 100 字节。这就是粘包问题的根源,后面会专门展开。

序号是按字节编号的,不是按报文。假设客户端发了一个 1000 字节的段,seq=1,那么下一个段的 seq 就是 1001,接收方回的 ack 是 1001 表示"1 到 1000 我都收到了,下次从 1001 开始"。这种按字节编号的设计,是 TCP 能够灵活重组、部分确认、SACK 的基础。

确认分两种。累积确认的意思是 ack=N 表示 N 之前的所有字节都收到了,N 本身还没收到。所以如果收到 1-1000 和 2001-3000,但 1001-2000 丢了,接收方只会回 ack=1001,即使它手里已经握着 2001-3000。这是很多初学者困惑的地方:"明明收到了后面为什么说是前面的"。答案是累积确认只表达"连续收到哪里"。

选择确认(SACK)就是对累积确认的补丁。接收方在 TCP 选项里告诉发送方"我额外收到了 2001-3000 这段",发送方就只用重传 1001-2000。SACK 在高丢包链路上能显著减少重传量,现代内核默认开启。还有 D-SACK 用于判断是不是自己重传了对方已经收到的数据,主要用来区分"包丢了"和"包乱序了",避免不必要的拥塞窗口收缩。

3.2 四种重传触发方式与 RTO 的计算

丢包了怎么办?内核有四套机制,按生效顺序大概是这样。

超时重传(RTO)是兜底手段。发送方为每个段启动定时器,超过 RTO 还没收到确认就重传。RTO 不是固定值,它根据 RTT 动态计算,用的是加权移动平均:

SRTT = (1 - α) * SRTT + α * RTT # α 通常取 1/8 RTTVAR = (1 - β) * RTTVAR + β * |SRTT - RTT| # β 通常取 1/4 RTO = SRTT + 4 * RTTVAR

这个公式面试里被问到的概率不低,因为它体现了"用统计量估计网络抖动"的思路。加分点是能说出 RTO 有上下界(Linux 里最小 200ms,最大 120s),以及指数退避:连续重传时 RTO 翻倍,避免在网络拥塞时火上浇油。

快速重传是主力。发送方收到 3 个重复 ACK(也就是同一个 ack 号重复三次),就认为该段丢了,不等超时立刻重传。为什么是 3 而不是 1?因为网络存在轻微乱序,1 个重复 ACK 可能只是乱序导致,3 个基本能排除巧合。这个阈值可调,但一般不建议动。

SACK 驱动的重传,收到 SACK 块后就能精确知道丢了哪些段,重传效率最高。

RACK / TLP是较新的机制。TLP(Tail Loss Probe)专门对付尾包丢失——一批数据发完最后几个包丢了,没有后续包来触发重复 ACK,只能干等 RTO。TLP 会在接近 RTO 时主动发一个探测包,逼出重复 ACK 走快速重传。RACK 则用时间维度判断丢包,谁的发送时间早于最近被确认的包且还未被确认,就认为丢了,对乱序场景更鲁棒。

# 重传相关观测 netstat -s | grep -E "retrans|timeout|SACK" ss -ti # 单个连接的 cwnd、rtt、retrans、sacked 都能看到

ss -ti是我最推荐的工具,输出里几个字段值得记住:cwnd是拥塞窗口,rtt是平滑后的往返时间,retrans是当前连接的重传次数,bytes_retrans是重传字节数。线上排查吞吐问题时,先ss -ti看一眼 cwnd 有没有涨起来、retrans 有没有异常,比抓包快得多。

3.3 滑动窗口、流量控制与死锁风险

滑动窗口解决的是"发太快"的问题,但这里的"太快"指的是接收方来不及处理。接收方在 ACK 里带一个 window 字段,告诉发送方"我还能收多少字节"。发送方的发送窗口取两者较小值:

发送窗口 = min(拥塞窗口 cwnd, 接收窗口 rwnd)

这里有两个经典坑,也是面试常考点。

第一个是零窗口。接收方缓冲区满了,通告 window=0,发送方就停止发送。如果后续的"窗口更新"报文丢了,双方就会僵死——发送方等窗口更新,接收方等数据。TCP 用零窗口探测解决:发送方启动一个定时器,定期发 1 字节的探测包,接收方收到后必须回当前窗口值。这个探测是带退避的,间隔逐渐拉长。

第二个是糊涂窗口综合征(SWS)。如果接收方每次只腾出几十字节就急着通告,发送方就会发一堆小包,报文头占比极高,网络效率极差。标准的规避手段是接收方等到窗口能空出一半或者达到 MSS 才通告更新;发送方这边则用Nagle 算法合并小包。

Nagle 算法规则很简单:如果还有未确认的数据,小包就先攒着,等凑够一个 MSS 或收到 ACK 再发。它和延迟 ACK(接收方等 40ms 或收到两个段再回 ACK)撞在一起,就会产生著名的 40ms 延迟问题,在请求-响应型的小包场景下特别明显。

// 对延迟敏感的 RPC 客户端,通常会关掉 Nagle int flag = 1; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));

我的实操建议是:请求-响应型、小包、低延迟场景关掉 Nagle;大批量顺序传输的场景保留默认。不要无脑全局关闭,那会让大文件传输产生大量小包。

4. 拥塞控制与生产环境调优

4.1 慢启动到拥塞避免:cwnd 的一生

拥塞窗口 cwnd 是发送方自己维护的一个"我对网络承载能力的估计",它管的是网络而不是接收方。经典算法分四个阶段。

慢启动:连接刚建立时 cwnd 从很小开始(Linux 现代版本是 10 个 MSS),每收到一个 ACK 就加 1 个 MSS,效果是每个 RTT 翻一倍,指数增长。名字叫"慢启动"其实不慢,叫"慢"是相对于一上来就发满窗口而言。

拥塞避免:cwnd 涨到慢启动阈值 ssthresh 后改为线性增长,每个 RTT 加 1 个 MSS。这一步是为了小心试探网络上限。

快重传 + 快恢复:收到 3 个重复 ACK 时,说明网络还有传输能力(对方还在回包),所以不必回到起点。做法是把 ssthresh 设为当前 cwnd 的一半,cwnd 也设为一半左右,直接进入拥塞避免。这就是"快恢复"。而超时重传是更严重的信号,会把 cwnd 直接打回 1 个 MSS 重新慢启动。

这解释了一个常见的性能现象:偶发丢包对吞吐的打击远大于持续丢包。因为一次超时就让 cwnd 归零,重新爬坡要花好几个 RTT。

4.2 从 CUBIC 到 BBR:换算法的判断标准

Linux 默认拥塞控制算法是CUBIC,它用三次函数曲线代替了传统的线性增长,在长肥管道(高带宽高延迟链路)上恢复速度更快。但它本质仍是基于丢包的算法——把丢包当成拥塞信号。

问题在于,现代网络里丢包不一定意味着拥塞。无线链路误码、中间设备缓冲区太小都会丢包,CUBIC 一看到丢包就降速,导致在弱网和高丢包链路上吞吐惨不忍睹。

BBR换了思路,它不把丢包当信号,而是主动测量两个量:瓶颈带宽和最小 RTT,用两者的乘积(BDP)来估计应该发多快。所以 BBR 在有丢包的链路上也能保持较高吞吐。代价是它对公平性不太友好,与其他算法共存时可能抢占带宽,而且对深队列敏感。

# 看当前可用算法和正在使用的 sysctl net.ipv4.tcp_available_congestion_control sysctl net.ipv4.tcp_congestion_control # 切换到 BBR(需要内核支持,通常 4.9+) sysctl -w net.ipv4.tcp_congestion_control=bbr

实际选型我的经验是:公网跨区域传输、有明显丢包的链路,BBR 收益显著;内网低延迟环境,CUBIC 和 BBR 差距不大,不必折腾。切换前一定要做 A/B 对比,别拍脑袋改生产。

4.3 带宽时延积:窗口到底要开多大

这是我认为最实用的一个计算,很多人调优失败就是因为没算过这个数。

BDP(带宽时延积)= 带宽 × RTT,代表"在途数据量",也就是管道里能装多少数据。发送窗口必须不小于 BDP,才能把带宽跑满。

举个例子:一条 1Gbps、RTT 40ms 的链路。

BDP = 1 Gbps × 40 ms = 1000 Mbps × 0.04 s = 40 Mbit = 5 MB

也就是说,接收窗口至少要 5MB 才能跑满 1Gbps。而内核默认的tcp_rmem最大值往往只有 6MB 左右(不同发行版差异较大),如果对端通告的窗口只有 200KB,那么实际吞吐上限就是:

吞吐上限 = 窗口 / RTT = 200 KB / 0.04 s = 5 MB/s = 40 Mbps

差了 25 倍。这就是"带宽充足但传输很慢"最常见的原因。

对应参数:

# 三段分别是 min / default / max,单位字节 net.ipv4.tcp_rmem = 4096 131072 16777216 net.ipv4.tcp_wmem = 4096 65536 16777216 # 窗口缩放因子,让窗口字段能表达超过 64KB 的值,必须开启 net.ipv4.tcp_window_scaling = 1 # 允许应用直接指定接收缓冲区大小,用于自定义协议栈 net.core.rmem_max = 16777216 net.core.wmem_max = 16777216

有一点要注意:tcp_rmem的 max 只是上限,内核会根据实际需要自动调整。如果对端一直不发满,缓冲区不会立刻扩到 max。另外应用层如果自己调了setsockopt(SO_RCVBUF),会覆盖内核的自动调整,反而可能把窗口锁死在一个小值上。我见过有人为了"优化性能"手动设了个 64KB 的缓冲区,结果整条链路吞吐被卡死,排查了半天。

4.4 长连接的保活与超时设置

长连接不是建好就一劳永逸。中间设备(防火墙、NAT 网关、负载均衡)都有空闲超时,可能在你不知情的情况下把连接表项清掉,客户端还以为连接可用,一发包就超时。

TCP 有内置的 keepalive,但默认参数非常保守:

# 空闲多久开始探测,默认 7200 秒,也就是两小时 net.ipv4.tcp_keepalive_time = 600 # 探测间隔 net.ipv4.tcp_keepalive_intvl = 30 # 探测失败几次判定连接死亡 net.ipv4.tcp_keepalive_probes = 3

两小时的空闲阈值对大多数服务都太长了。更关键的是,TCP keepalive 只能探测连接是否还活着,不能判断对端应用是否健康。所以生产上的通行做法是应用层心跳 + 合理超时:客户端每隔 N 秒发一个轻量心跳,超过 M 秒没响应就主动断开重连。心跳间隔要小于中间设备的最短空闲超时,这个值得跟运维确认清楚。

提示:心跳间隔不是越短越好。太短会带来额外的包量和唤醒开销,手机端还会显著增加耗电。一般取中间设备超时的三分之一到二分之一比较稳妥。

5. 高频陷阱题与线上排查实录

5.1 粘包:一个被叫错名字的问题

"TCP 粘包"这个说法其实不准确,TCP 是字节流协议,本来就没有包的概念,谈不上"粘"。真正的问题是应用层没有定义消息边界。

UDP 会不会有类似问题?严格说不会,UDP 保留消息边界,一次 sendto 对应一次 recvfrom(只要缓冲区够大)。但如果你在应用层用一个大缓冲区循环 recvfrom,也会读出错位的数据,那是应用层的事。

解决办法就三种,我按推荐度排序。

第一种,长度字段前缀,也是绝大多数二进制协议的做法。包头里固定位置放一个长度字段,先读包头拿到长度,再按长度读 body。

import struct def pack(payload: bytes) -> bytes: # 4 字节大端无符号整数表示 body 长度 return struct.pack(">I", len(payload)) + payload def unpack(buf: bytearray): if len(buf) < 4: return None, buf (length,) = struct.unpack(">I", buf[:4]) if len(buf) < 4 + length: return None, buf body = bytes(buf[4:4 + length]) return body, buf[4 + length:]

注意unpack返回未消费的剩余缓冲区,调用方要在一个循环里反复取,直到取不出完整消息为止。这个"半包"处理是新手最容易写错的地方——只处理了一次就返回,剩下半个包留在缓冲区里,下次读又从头解析,直接错位。

第二种,分隔符。适合文本协议,比如每行一条 JSON 用\n分隔。实现简单但要求业务数据里不能出现分隔符,或者做转义,容易出问题。

第三种,固定长度。实现最简单但浪费带宽,只适合定长消息。

不管用哪种,缓冲区一定要做成可增长的,并设置上限防止恶意超长包把内存打爆。我见过一个服务因为没限制长度字段,被构造了一个声明 4GB 的包直接 OOM。

5.2 一张表搞定常见现象与排查路径

线上遇到 TCP 相关问题,很多人第一反应是抓包,其实先看统计计数器效率高得多。下面这张表是我这些年攒下来的对照表。

现象优先查看常见原因处理方向
连接建立阶段超时netstat -s | grep -i syn半连接队列满、SYN Flood、backlog 太小调tcp_max_syn_backlog、开 syncookies
连接建立成功但请求超时netstat -s | grep -i listenAccept 队列溢出,应用 accept 太慢调somaxconn、加并发 accept
大量 CLOSE_WAITss -s、应用代码收到 FIN 未调 close检查异常路径、连接池回收逻辑
大量 TIME_WAITss -tan state time-wait | wc -l高频短连接、主动关闭方改长连接/连接池,必要时tcp_tw_reuse
吞吐远低于带宽ss -ti看 cwnd 和 rtt窗口太小、BDP 不足调大tcp_rmem/wmem、确认窗口缩放开启
偶发卡顿数百毫秒抓包看重传RTO 超时重传、尾包丢失开 TLP/SACK,考虑换 BBR
小请求固定延迟 40ms抓包看 ACK 间隔Nagle + 延迟 ACK开TCP_NODELAY
长连接莫名断开抓包看 RST中间设备空闲超时清理加应用层心跳

表格里最后一列的"调参数"其实应该放到最后。先定位是应用逻辑问题还是网络问题,再决定动不动内核,这个顺序不能反。我见过太多团队一出问题就批量改 sysctl,改完连原来的问题是什么都忘了。

5.3 一次真实的吞吐排查过程

说一个我印象比较深的案例,过程比结论更有参考价值。

某系统做跨区域数据同步,源端和目的端之间标称 500Mbps 带宽,实际用iperf测单流只有 60Mbps 左右,波动很大。第一反应是带宽不够,但同机多流并发能跑到 400Mbps,说明带宽没问题,是单流受限。

第一步,算 BDP。用 ping 测 RTT 大约 50ms,那么单流跑满 500Mbps 需要的窗口是:

500 Mbps × 0.05 s = 25 Mbit ≈ 3.1 MB

第二步,ss -ti看实际窗口。输出里cwnd长期停在 60 左右,rtt稳定在 50ms 上下,retrans在涨。60 个 MSS 按 1460 字节算约 87KB,对应的吞吐上限是87KB / 0.05s ≈ 1.7MB/s ≈ 14Mbps,和实际观察对不上但方向一致——窗口严重不足。

第三步,查内核参数,发现tcp_wmem的 max 是默认的小值,接收端tcp_rmem也没放开。同时tcp_window_scaling虽然开着,但由于默认缓冲小,缩放因子也没起作用。

第四步,改参数并验证:

sysctl -w net.ipv4.tcp_rmem="4096 131072 33554432" sysctl -w net.ipv4.tcp_wmem="4096 65536 33554432" sysctl -w net.core.rmem_max=33554432 sysctl -w net.core.wmem_max=33554432

改完复测,单流跑到 380Mbps 左右,接近理论上限。剩下的差距来自重传和协议开销,属于正常范围。

这个案例里最关键的不是最后那几个参数,而是第二步用 ss 量化了窗口不足。如果一上来就抄一份"网络优化参数大全"贴进去,可能碰巧解决了,但你不知道是哪一项起了作用,下次换个环境还得重新试。

5.4 几个容易被问倒的细节

最后整理几个面试里常被追问、但答错率很高的点。

"TCP 一定可靠吗"。协议层面它保证有序、去重、不丢(在连接存活期间),但它不保证应用层语义正确。比如连接被 RST 时缓冲区里还没发出去的数据会丢;比如重传可能导致对端收到重复数据,业务必须自己做幂等。正确回答是:TCP 提供的是连接级的有序可靠字节流,不是端到端的消息投递保证。这也是为什么很多 MQ 要在 TCP 之上再做一层确认和去重。

"为什么服务端也可能是 TIME_WAIT 多的一方"。因为 TIME_WAIT 出现在主动关闭方,跟客户端服务端角色无关。如果服务端配置了短连接且不保持,或者是服务端先关闭,那就是服务端攒 TIME_WAIT。

"RST 什么时候发"。常见几种:连一个没监听的端口;往已关闭的连接发数据;全连接队列溢出且tcp_abort_on_overflow=1;应用调用了SO_LINGER且超时为 0 时 close。RST 有个特性是会直接清空发送缓冲区,未发数据全部丢弃,所以SO_LINGER=0的 close 要慎用。

"滑动窗口和拥塞窗口的区别"。滑动窗口(rwnd)反映接收方的处理能力,由接收方通告;拥塞窗口(cwnd)反映发送方对网络的估计,发送方本地维护不传输。实际发送量取两者较小值。这个区分必须一口答清楚,含糊了会被认为概念不清。

"长连接怎么检测对端已经挂了"。分三层:TCP keepalive 只能探测链路,周期太长;应用层心跳能探测进程,但需要协议支持;业务请求超时是最后一道防线。三者要配合用,不能只依赖其中一个。

写到这里,我觉得准备 TCP 面试最有效的方式其实是把协议和自己写过的代码对上号。你调过一次TCP_NODELAY,就永远记得 Nagle 和延迟 ACK 的组合;你排查过一次 Accept 队列溢出,就再也不会把somaxconn和tcp_max_syn_backlog搞混。纯背答案应付一面可以,往深了走,还是得让这些机制在实际场景里跑一遍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询