
读CS168的传输层章节时我最大的感触是TCP不是一堆规则的堆砌而是一套围绕“不可靠网络如何构建可靠传输”展开的工程博弈。教科书里每一行状态迁移、每一次重传策略选择背后都对应着一个真实世界踩过的坑。这篇笔记不按目录复读而是把传输层的关键决策点拆开讲重点放在“为什么这么设计”和“实际排查时怎么用得上”适合正在吃透计算机网络、准备面试或者写网络服务时被各种连接问题折磨的开发者。1. 传输层到底管了什么主机到主机之上1.1 端口、多路复用与多路分解网络层的IP地址解决的是“哪台主机”但一台主机上同时跑着SSH、HTTP、数据库连接数据包到了网卡之后内核怎么知道该交给哪个进程这一层解耦靠的就是传输层的端口机制。TCP和UDP头里各有16位端口号范围0到65535其中0到1023是知名端口需要特权绑定1024到49151是注册端口49152到65535是动态或私有端口客户端随机分配用。多路复用发生在发送端多个进程的数据都通过同一个网卡发出传输层把这些数据按端口打上标签交给IP层封装。多路分解发生在接收端IP层收到包后根据传输层协议号和目的端口把报文分发给对应的socket。这个机制看起来简单但在内核实现里直接影响socket查找效率。Linux内核用哈希表来索引四元组源IP、源端口、目的IP、目的端口而不是线性遍历就是因为并发连接数可以到百万级别。理解这一点对后来排查“端口冲突”“连接数打满”都很有帮助。另外TCP和UDP虽然都用端口但两个协议栈的命名空间是独立的。可以有一个UDP socket占着5353端口同时另一个TCP socket也监听5353两者互不干扰。很多人在用netstat或ss时看到端口占用会困惑就是因为没意识到这一点。1.2 UDP为什么简单TCP为什么复杂传输层设计一个关键分叉点要不要维护连接状态UDP选择不维护发送端把数据丢给IP层就不管了接收端收到就交给应用收不到也不通知。这个“什么都不做”的设计反而让它非常适合DNS查询、音视频通话、实时游戏这类对延迟敏感、允许丢失的业务。今天QUIC虽然改进很多但它的无连接包头设计思路很多还是源自UDP。TCP则走完全相反的路线它要在不可靠的IP之上给应用提供一条看起来可靠的字节管道。这个目标衍生出一大堆机制——序列号、确认、重传、滑动窗口、拥塞控制、连接管理。教科书通常把这些拆开讲但实际设计是一体的没有序列号没法判断谁丢了没有确认不知道要不要重传没有窗口重传和接收都没有节奏没有拥塞控制网络早就被自己塞满。这套闭环是理解TCP所有细节的主线。很多初学者喜欢背“TCP可靠、UDP不可靠”比较准确的说法是TCP通过状态和反馈机制换取了可靠的字节流UDP不承诺任何交付质量但因此保留了极低的额外延迟和开销。用生活类比UDP像把信投进邮筒就不管了TCP更像挂号信每一封信都有编号收件人要签收丢了会补发但整个过程需要先建立一条投递线路结束还要确认封班。两种方式没有绝对好坏只看业务要什么。2. TCP可靠的底层支柱序列号、确认与重传2.1 字节流模型与序列号TCP从应用层读到的数据是一个连续字节流它把流切成段segment交给IP层发送。每个字节都会有一个序列号sequence number传输时段的序列号字段表示“本段第一个字节的序列号”。这里有个常见误区序列号不是“第几个包”而是“第几个字节”。所以一个包丢了重传的不只是那个包而是从丢的位置开始连续的字节。接收端靠序列号做两件事排序和去重。IP层可能乱序、重复、丢包到TCP层都通过序列号重新拼成有序字节流。为了让序列号不可预测TCP的初始序列号ISN不是固定值而是用一个随时间变化的公式生成通常还说初始化要加随机偏移。这是为了防伪造如果ISN可预测攻击者可以伪装成对端插入数据。这也是为什么三次握手时双方要各自交换一个ISN。分段大小方面TCP要根据路径MTU决定每个段携带多少字节这个值叫MSS最大段大小通常在握手时通过选项协商。MSS不是越大越好如果超过路径能承载的帧大小IP层会分片分片丢失后整个包都要重传反而更糟。实际网络中标准MSS大多是1460字节对应1500字节以太网MTU减去20字节IP头和20字节TCP头。2.2 累积确认、延迟ACK与重复ACK接收端收到数据后会发送ACK确认号表示“我期望收到的下一个字节序列号”这等价于确认此前的所有字节都收到了所以叫累积确认。累积确认的实现让接收端可以不用对每个包单独回复但如果接收端策略不当也会带来一个经典问题确认无法精确表达“我缺了哪块”。正因如此TCP有一个招人恨又必须存在的机制延迟ACK。接收端收到数据后不会立刻回ACK而是等待最多约200毫秒看能否合并确认或者顺路把响应数据捎带回去piggyback。延迟ACK对批量传输效率有好处但在交互式场景中如果恰好和对端的Nagle算法对上就可能导致肉眼可见的延迟。后面说排查时会详细聊这个组合坑。重复ACKdup ACK则是接收端连续收到多个“期望同一个序列号”的确认。例如期望收到字节1000但来了乱序包1100-1200接收端仍然确认1000于是这个ACK会在窗口里一再重复。传统TCP靠超时重传但网络良好时超时通常要等几百毫秒甚至更久。聪明做法是收到3个重复ACK就认为“大概率丢了”不等超时立刻重传这就是快速重传。这个机制也解释了为什么tcpdump或Wireshark里看到“TCP Dup ACK”不一定代表网络糟糕——有时只是乱序触发的自然现象。2.3 重传定时器与RTO计算超时重传依赖一个关键参数重传超时时间RTO。网络越好RTO应越小网络抖动越大RTO应越大否则会疯狂重传加剧拥塞。TCP用每个连接的往返时间RTT来动态测算RTO经典算法是Jacobson提出的SRTT (1 - α)·SRTT α·RTT再结合RTTVAR估算方差最终RTO SRTT 4·RTTVAR。α一般取1/8加权让历史样本的影响指数衰减保证对抖动敏感又不至于忽大忽小。这里有个教科书必须强调的细节Karn算法。如果一个包超时后重传紧接着来了ACK这个ACK到底对应原始包还是重传包无法分辨。如果贸然用它更新RTT样本会把RTT算得虚高。所以Karn算法规定发生重传后本次RTT采样不参与RTO计算并且RTO要按指数退避翻倍。这个策略避免了测量污染代价是丢包发生时恢复速度慢。这也是为什么后来SACK和快速重传成了TCP栈里更高效的手段——它们让发送端尽快知道丢的到底是哪个区间而不是傻等RTO。握手阶段没有RTT样本Linux初始RTO是1秒每次重传退避翻倍。如果对端一直没回应客户端同步段重传会走完6轮1s、2s、4s、8s、16s、32s最终在连接建立失败前花掉约1分钟。这个时间窗口对运维排障很关键很多“连接卡死”的问题其实是在等SYN重传。3. 连接管理拆解三次握手与四次挥手3.1 三次握手两个方向的序列号同步TCP的连接不是一条真实存在的物理线路而是两端的socket各自记录一组状态“对方的序列号到哪了、能力选项是什么”。三次握手的本质是让双方交换初始序列号并确认对方的能力参数。可以这样理解A说“我的初始号是x我准备发了”B说“收到你的x我的初始号是y我也准备发了”A再回一句“收到你的y我们可以开始”。如果只有两次握手A无法确认B是否收到了自己的ISN也无法防止过期连接请求错误地建立起一条连接。三次握手的细节状态迁移很值得记忆客户端先发送SYN进入SYN_SENT服务端收到后回复SYNACK进入SYN_RCVD客户端收到SYNACK后发送ACK并进入ESTABLISHED服务端收到这个ACK后也进入ESTABLISHED。注意服务端的ESTABLISHED状态比客户端晚半拍。线上常常能看到客户端已经连上开始发数据服务端socket却还是SYN_RCVD那多半是最后一个ACK丢了或服务端的accept队列满了。握手过程中还有一个很容易被忽略的点TCP选项的协商。MSS、窗口缩放因子window scale、SACK、时间戳都在SYN里声明对端在SYNACK里回显。如果一方的内核参数不支持大窗口即使设置了很大的接收缓冲区实际吞吐也上不去。排查长肥网络高带宽高延迟传输瓶颈时第一步就该看握手里有没有协商出window scale。3.2 四次挥手与TIME_WAIT关闭连接需要四次挥手因为TCP是全双工的A和B各自独立关闭自己的发送方向。A发FIN表示“我的数据发完了”B收到后回ACK并可以继续发数据B发完剩余数据后也发FINA回ACK至此双方都确认关闭。这个过程中有一侧会经历半关闭状态还能接收数据但不再发送。四次挥手里最有故事的是TIME_WAIT。主动关闭方发送最后的ACK之后必须等待2MSL两倍最大报文段生存期Linux默认60秒才能彻底释放连接。这有两个目的。第一保证最后的ACK如果丢了对端会重发FIN自己还有机会再回一个ACK第二让本连接内所有旧数据包在网络上彻底消失避免新连接复用同一个四元组后收到上一轮的残留数据。TIME_WAIT在生产环境里是一个高频问题。短连接高并发的服务端经常看到大量TIME_WAIT状态socket因为服务端往往是被动关闭方正常情况下不会进入TIME_WAIT。但如果是客户端主动关闭比如调用方短连接快速请求就会积压一堆TIME_WAIT。有人为了尽快释放端口直接调低time_wait使用、甚至开启reuse这种做法要谨慎。正确姿势是让连接的生命周期设计合理或者控制并发量而不是粗暴绕过TCP的状态设计。3.3 SYN Flood与backlog问题握手阶段服务端为了记住半连接状态需要在SYN_RCVD阶段分配一个请求块。如果攻击者只发SYN不回ACK服务端资源会被慢慢耗尽这就是SYN Flood的经典攻击形态。现代内核通过SYN Cookies应对不在收到SYN时立即分配完整连接块而是通过特殊编码的cookie来验证后续ACK的合法性。启用syncookies后正常连接不受影响半连接不占内存攻击包则被挡在门外。与这一机制密切相关的是监听队列backlog。应用层调用listen(fd, backlog)意味着内核的accept队列最多积压backlog个已完成握手但还没被accept取走的连接。队列满了以后新连接会被丢弃或直接Reset。表现就是客户端连接成功但服务端应用迟迟不响应或者并发压测时大量连接建立失败。排查时要同时看ss -lnt显示的Send-Q是否满、当前队列占用而不只是看监听端口是否存在。4. 流量控制与拥塞控制两个窗口的博弈4.1 滑动窗口与接收窗口rwndTCP发送端不能把数据一次性全部倒给网络它得控制“未确认的在途数据量”。接收端告诉发送端自己能收多少这个值叫接收窗口rwnd。发送端的发送窗口不能超过min(cwnd, rwnd)其中cwnd是拥塞窗口rwnd是流量控制窗口。简单说一个管“网络能不能扛住”一个管“接收方能吞下多少”。滑动窗口让TCP不是一包一停而是可以连续发送多个包再根据ACK滑动前沿。窗口大小直接决定了带宽延迟积BDPBandwidth-Delay Product能不能被填满。一个10ms延迟、1Gbps带宽的网络BDP大约1.25MB。如果窗口只有64KB即使网络再宽吞吐也会被限制在5MB/s左右。这也是为什么现代TCP都在握手时协商window scale选项把最大窗口从65535字节放大到1GB级别。接收端的零窗口zero window是个经典死角。如果接收缓冲区满了接收端通告rwnd0发送端就得暂停。为了防止发送端干等接收端会启动一个持续计时器定期去问“现在能收了吗”。如果窗口更新包丢了发送端的坚持探针能把链路重新激活。否则就可能出现“双方都在等对方先开口”的假死状态。排查这类问题时抓包里看到“ZeroWindow”和“WindowUpdate”交替出现基本可以断定是接收端应用消费太慢加buffer没用得优化应用逻辑。4.2 拥塞控制慢启动、拥塞避免与快速恢复拥塞控制是TCP设计中最复杂的部分核心目标是探测网络容量并公平分享带宽。发送端不知道网络什么时候拥堵只能通过丢包和时延来猜测。经典Reno家族的路径是慢启动从cwnd10个MSS开始每收到一个ACK就加1个MSS在途字节翻倍所以是每轮RTT翻倍指数增长增长到ssthresh后进入拥塞避免每轮RTT只加1个MSS线性增长一旦出现丢包重传则把ssthresh降到当前cwnd的一半cwnd回落到1个MSS重新慢启动如果触发快速重传则进入快速恢复cwnd减半而不是清零。很多面试题喜欢问“为什么慢启动不是真的慢”因为指数增长前期确实快两到三个RTT就能冲到很高这样规定的意义在于找到ssthresh附近时能快速逼近目标进入线性阶段后细水长流地试探。比起旧式TCP Tahoe把cwnd归1的“大起大落”Reno的快速恢复让吞吐在单包丢失时不至于崩掉这对当时的互联网是重大进步。现代网络里纯粹的丢包驱动拥塞控制显得粗糙数据中心和高速链路更多用DCTCP、BBR这类基于延迟或带宽探测的算法。但无论算法怎么变AIMD加性增、乘性减的思想仍是主线探测阶段温和增长出现拥塞时果断退让让所有TCP流最终收敛到一个相对公平的带宽份额。理解这个博弈比背一串算法名字重要得多。4.3 调优从rwnd到cwnd实践中最常见的TCP吞吐瓶颈一是rwnd不够二是cwnd增长太慢受限于ssthresh。Linux下可以查看和修改接收缓冲net.ipv4.tcp_rmem、net.ipv4.tcp_wmem分别控制最小、默认、最大缓冲区。BDP大的场景要把这两个参数和window scale打开让接收窗口能覆盖住延迟带宽积。慢启动ssthresh也能调Linux里可以设置初始拥塞窗口initcwnd比如路由类设备上用ip route change ... initcwnd 20可以提升短连接小文件传输体验但别调得太过否则多个流同时发作时会加剧拥塞。对大部分后端服务与其盲目调内核参数不如先抓包确认瓶颈在哪是握手的窗口选项没协商出来还是发送端cwnd一直原地踏步还是接收端零窗口不断。方向对了再动手参数才有意义。5. 从课堂到一线TCP问题排查实录5.1 ss/netstat看连接状态排查TCP连接问题第一步永远是看清当前连接都停在哪个状态。Linux下ss命令比netstat更快更直观ss -s # 汇总当前各状态连接数 ss -n -o state established ( dport :80 or sport :80 ) # 看目标端口80的连接 ss -lnt # 看监听套接字及队列占用大量连接堆积在某一个状态是强烈信号。比如一堆SYN_RCVD通常是对端不响应或SYN攻击一堆CLOSE_WAIT说明本地应用收到对端FIN后没有主动关闭socket这是代码里漏了close的经典表现一堆TIME_WAIT则是主动关闭方在等2MSL。结合状态去看系统日志和业务日志问题大多能快速缩小范围。最怕的是盲目“重启解决”连根因都没看到。5.2 端口复用与Address already in use“Address already in use”是开发网络服务时常遇到的报错。原因通常是上一个进程还占着端口或者处于TIME_WAIT状态。用SO_REUSEADDR可以让服务端在TIME_WAIT状态下也能重新监听同一端口这是监听socket的标准配置。但要注意SO_REUSEPORT和SO_REUSEADDR不是一回事前者允许多个socket绑定同一个端口做负载均衡多个进程共享连接分发这是高性能服务器常用的方式。客户端视角还有一种坑短连接频繁发起用完就主动关闭导致本地端口进入TIME_WAIT再次快速创建连接时四元组复用发生冲突出现“Cannot assign requested address”。解决思路包括杜绝频繁开关连接改成连接池、调整keepalive、谨慎使用tcp_tw_reuse。记住一个原则复用TIME_WAIT时要小心旧报文干扰内核的reuse实现会校验时间戳不是说开了就一定安全。5.3 重复ACK与乱序一个抓包案例一次线上排查中我看到tcpdump窗口里大量“TCP Dup ACK”和重传第一反应是链路丢包。后来把抓包文件拉进Wireshark按流分析才发现其实是发送端开启了大窗口后网卡多队列导致同一连接的分组被不同CPU处理的映射顺序不一致出现轻微乱序。而接收端每收到乱序包就会立即回一个重复ACK发送端收到3个重复ACK后按快速重传把先前的包又发一遍白白造成“假丢包”。这个案例的启示重复ACK是结果不是病因。要结合乱序距离、往返时延、队列丢包统计等多维度判断。排查时可以用tcpprobe或Wireshark的TCP stream分析图看序列号时间序列里的跳变模式。先把现象定性再谈优化不要一看到Dup ACK就喊链路丢包。5.4 实际调优参数速查下面是一组我常用的Linux TCP参数和适用场景建议按需修改不要照单全收参数默认值参考适用场景与说明net.ipv4.tcp_rmem4096 87380 6291456增大可提升高BDP接收吞吐1G/10G网卡建议调大net.ipv4.tcp_wmem4096 16384 4194304调大发送缓冲区以填满窗口net.ipv4.tcp_syncookies1开启SYN Cookies缓解SYN Floodnet.ipv4.tcp_sack1开启SACK允许精确报告丢失区间减少不必要重传net.ipv4.tcp_fastopen3客户端/服务端启用TFO减少短连接握手往返net.core.somaxconn4096左右调整最大accept队列长度与listen backlog协同net.ipv4.tcp_tw_reuse0谨慎开启仅用于客户端且确认时间戳可接受时net.ipv4.tcp_max_syn_backlog1024或更高SYN半连接队列上限高并发需要提高值得说明的是每一项参数都不是独立起作用的。比如调大tcp_rmem后还得确认握手时window scale协商出来调大somaxconn后应用listen的backlog也得相应放大开启fastopen前要确认对端支持。参数之间是联动的单独调一个往往看不到效果还可能引入隐患。最后再分享一个小技巧。读TCP协议栈源码或排障之前先学会用tcpdump和ss把状态和报文看清楚比背多少RFC都管用。我自己的经验是把CS168这类课程的传输层章节读完之后再去看线上的每个连接状态很多以前“玄学”的问题会突然变成一个个有迹可循的协议行为。设计者当年的每一个妥协最终都会在某些极端场景下重现这就是TCP最有意思的地方。