TCP/IP 协议栈学到传输层基本就到了劝退区和加分区的分水岭。上一篇把分层模型、地址解析、路由这些地基盘完剩下的硬骨头就是传输层而传输层的主角就是 TCP。HTTP、HTTPS还有日常接口调用、数据库连接底座几乎都是 TCP你遇到接口卡住、连接池报错、请求超时根因大概率就在这一层。这篇内容专门把这部分核心机制拆开讲连接建立、连接断开、可靠传输、流量控制、拥塞控制最后落到实际开发里容易踩的粘包、TIME_WAIT、心跳这几个坑。我不打算堆教科书式的大段理论每个机制都尽量配合能在本机复现的命令和抓包结果来讲。适合刚接触 JavaEE、想把网络原理一次性补齐的同学也适合面试前想快速把 TCP 这关过掉的人。1. 从分层模型到传输层为什么这一层必须重点啃1.1 分层模型里传输层的位置TCP/IP 习惯上分成四层应用层、传输层、网络层、数据链路层。每一层都有很明确的分工层级职责典型协议/技术应用层面向具体业务定义数据语义HTTP、HTTPS、DNS、FTP传输层面向端口提供端到端通信能力TCP 还会保证可靠交付TCP、UDP网络层面向主机负责寻址和路由选择IP、ICMP数据链路层面向物理链路负责在相邻节点间转发帧以太网、交换机很多初学者会把注意力放在 HTTP 上觉得会发请求就能写后端。但 HTTP 本身是不管连接可靠性的它把报文交给 TCP至于报文会不会丢、会不会乱序、要不要重传全是 TCP 的活。也就是说应用层的“稳”是传输层用一堆机制换来的。1.2 端口和套接字决定了通信对象网络层通过 IP 地址找到了某台机器但那台机器上可能同时跑着 Web 服务、数据库服务、缓存服务数据到底交给哪个进程靠端口区分。所以传输层引入了端口号和套接字的概念。一个完整的 TCP 连接可以用四元组来定位源 IP、源端口、目的 IP、目的端口。要是两个连接的四元组完全一致在协议层看来它们就是同一个连接否则就是两个独立连接。这个认知挺重要后面分析连接数高、端口耗尽、TIME_WAIT 堆积时都离不开它。TCP 的可靠是在一个“看起来不可靠的 IP 网络上”硬生生做出来的可靠。IP 层只负责“尽力而为”地把数据包送出去可能丢、可能重、可能乱序。但 TCP 面向的是字节流要保证上层收到的数据顺序正确、不重复、不丢失。这块师出有名的机制就是确认、重传、序号、窗口这一整套组合拳。2. TCP 为什么能可靠从字节流到报文段2.1 应用层看到的是流TCP 看到的是段你调用 send 写进去的是连续的字节TCP 并不关心你分了几次写。它会把字节流按一定长度切成一个个报文段再交给 IP 层去发。这个切分长度不是拍脑袋定的而是要考虑下方链路层的 MTU。以太网常见的 MTU 是 1500 字节。IP 头一般是 20 字节TCP 头一般是 20 字节所以一个 TCP 报文段里最多放着 1500 - 20 - 20 1460 字节的业务数据。这个值就是 MSS双方在握手的时候会互相通告。之所以要控制这个长度是为了避免在 IP 层发生分片——分片不仅增加开销还容易因为某个分片丢了导致整个报文重传性能很难看。2.2 头部里那几个关键字段就是可靠性的账本TCP 头部不算选项至少 20 字节但真正决定协议行为的是下面这组字段字段作用源端口/目的端口标识通信的进程序号 sequence本报文段第一个字节的编号确认号 ack期望收到对方的下一字节编号标志位SYN、ACK、FIN、RST、PSH、URG 各自承担连接控制职责窗口大小 window告诉对方我还能收多少字节校验和检查报文在传输过程中有没有损坏这里最容易绕晕的是序号和确认号的配合。TCP 每个连接有两个方向每个方向都有一套独立的序号空间。发送方按字节编号接收方确认时不是报“我收到了第几段”而是报“下一个你该发的字节编号是多少”。2.3 用流水账模型理解累计确认假设发送方发了 100 个字节编号从 1 到 100接收方完整收到后回复的确认号就是 101意思是“1 到 100 我都拿好了你下一条从 101 开始发”。如果发送方又发了 101 到 300接收方只收到了 101 到 150中间丢了一段接收方仍然回 ack101直到缺口补齐。这就叫累计确认确认号 N 代表 N 之前的所有字节都收到且有序不需要给每一个字节单独回执大幅减少了确认报文的数量。但它有一个弱点如果中间丢了一个段后面的段即使到了接收方也不能推进确认号只能等着发送方重传缺口。SACK 选择性确认选项能弥补这个问题后面讲重传的时候再细说。3. 三次握手连接不是一开始就存在3.1 一次完整握手的编号过程建立连接看起来就是三个报文但每个报文的序号和标志位极其讲究。我习惯用一个具体数字演一遍客户端初始序号 ISN 设为 1000发SYN, seq1000。服务端收到后把自己的初始序号定为 7000回SYNACK, seq7000, ack1001。这条报文同时完成两件事确认客户端的同步请求并声明自己的初始序号。客户端再回ACK, seq1001, ack7001握手完成。注意客户端第三次握手报文里的 seq 为什么是 1001因为第一次握手的那一个控制字节也要占用一个序号虽然它不携带业务数据但它也在序号空间里被“消费”掉。同理服务端的 SYNACK 也占用了 7000 这一个序号位置所以客户端下一次确认号是 7001。3.2 为什么不能改成两次握手这是个高频面试问题也是理解握手本质的关键。核心原因有两点。第一握手不只是“打个招呼”而是要同步双方的初始序号。只有双方都确认了对方的初始序号后续的数据编号才有共同的起点。如果只有两次服务端不知道客户端是否已经收下了自己的 SYNACK两边对序号状态的理解可能不一致。第二要防止历史连接请求的残留报文干扰。假如客户端发了一个 SYN因为网络拥塞一直没到客户端以为丢了超时重传了一次第二次正常完成了连接。结果第一条老的 SYN 又慢慢悠悠地到了服务端。服务端一看是个正常的连接请求立刻回 SYNACK 并分配资源可客户端根本不需要这个连接就会回一个 RST 把它断开。虽然最终也能纠错但白费了一次连接建立的资源。三次握手时客户端收到服务端对旧 SYN 的确认后可以通过序号判断这不是自己期待的连接直接 RST服务端也能更快释放资源。3.3 本地抓包看握手全过程纸上谈兵不够建议你亲手抓一次。开一个本地回环接口的临时服务端比如用简单工具监听 9000 端口再另开一个终端用 curl 去访问同时用 tcpdump 抓包# 终端一监听 nc -l 9000 # 终端二抓包只拿 9000 端口的前 12 个包 sudo tcpdump -nn -i lo port 9000 -c 12 # 终端三发起请求 curl http://127.0.0.1:9000/正常会看到类似这样的报文IP 127.0.0.1.xxxxx 127.0.0.1.9000: Flags [S], seq 123456 IP 127.0.0.1.9000 127.0.0.1.xxxxx: Flags [S.], seq 654321, ack 123457 IP 127.0.0.1.xxxxx 127.0.0.1.9000: Flags [.], ack 654322[S]是 SYN[S.]是 SYNACK[.]是 ACK。第一次抓包时把 seq 具体数值和 tcpdump 输出对应上会比背十遍状态转换图都管用。3.4 握手阶段的几个常见坑SYN Flood 攻击的本质是攻击方只发 SYN不回最后的 ACK让服务端傻等。服务端为此维护了一个半连接队列如果队列被占满正常用户的连接请求就进不来了。Linux 下可以用ss -ant state syn-recv查看半连接状态协议栈也提供了一些保护手段比如net.ipv4.tcp_syncookies开启后当半连接队列满时服务端会通过一种特殊的方式编码信息不依赖队列也能完成后续握手。线上如果发现大量 SYN_RECV第一反应不应该是盲目调大队列参数而是先确认是不是真的有异常流量进来。应用层还有一种常见误报客户端连不上服务端看日志啥也没有tcpdump 也没抓到包这时多想想是不是中间设备把 SYN 直接丢了。我处理过几次类似问题最后都发现是安全策略拦截跟协议本身没关系。4. 四次挥手把双向通道优雅地拆掉4.1 断开连接的过程和状态流转连接是全双工的数据可以两个方向同时跑。关闭的时候也要两个方向分别关不能一条命令同时把两边都掐断。所以主动关闭方调用 close发送FIN。被动关闭方收到 FIN 后回ACK。此时主动方不再发送数据但还能收。被动关闭方把剩余数据处理完也调用 close发送FIN。主动关闭方收到 FIN 后再回ACK进入 TIME_WAIT。用 Java 的角度粗暴理解你调socket.close()只是关闭了本端发送能力对端的发送通道是否关闭取决于对端什么时候也调用 close。这也是为什么某些连接关闭看起来不像“瞬间完成”。如果只关写方向、还想继续读数据可以用shutdownOutput()这对应协议里的半关闭。HTTP 那种一问一答的场景很少用到半关闭但做自定义协议、P2P 传文件时就可能遇到。4.2 TIME_WAIT 为什么那么招人恨主动关闭方发送完最后的 ACK 后不是立刻进入 CLOSED而是要停留大约 2MSL 的时间。MSL 是最长报文段寿命通常假设为 30 秒到 2 分钟不等Linux 里 TIME_WAIT 的持续时间比较常见的是 60 秒左右。这个等待有什么意义第一保证最后那个 ACK 能到达对方。如果 ACK 丢了被动关闭方会超时重发 FIN主动关闭方需要一个兜底窗口来重新回 ACK否则被动方会一直卡在 LAST_ACK连接无法彻底关闭。第二防止旧连接里的迟到报文污染新连接。在 TIME_WAIT 期间四元组仍然保留着网络里残留的旧报文即使到达也会因为序号范围对不上而被丢弃不会混进新建的连接。TIME_WAIT 本身不是 bug是协议设计的一部分。短连接服务在大量请求后出现成百上千个 TIME_WAIT其实说明连接管理正常。真正需要警惕的是TIME_WAIT 占用的四元组无法被立刻复用在连接数非常大的极端情况下可能拖累新连接建立速度。4.3 主动方是谁结果完全不同TIME_WAIT 只出现在主动关闭方。HTTP 1.1 连接默认由服务端还是客户端主动关闭直接影响机器上 TIME_WAIT 的规模。所以排查 TIME_WAIT 过高时先想清楚你到底在哪一端。如果是客户端大量 TIME_WAIT 通常是每次都新建连接、频繁主动关闭导致的可以考虑连接复用、连接池、长连接把连接的创建和销毁频率降下来。如果是服务端 TIME_WAIT 多多半是服务端在响应结束后主动 close要看业务是否允许调整。Linux 下查看连接状态分布是基本功ss -ant | awk {print $1} | sort | uniq -c # 或者只看 TIME_WAIT 的数量 ss -ant state time-wait | wc -l每次线上有“连接数过高”的告警我都是先用这一个命令看状态分布。状态分布比总数值更能说明问题全是 ESTABLISHED 和全是 TIME_WAIT 的处理方向完全不同。5. 确认与重传可靠传输的实操细节5.1 停等协议为什么不行最朴素的可靠传输思路是发一个等一个发送方发一段数据等到确认再发下一段。这个模型叫停等协议逻辑足够简单但效率低得离谱。假设相隔 100 毫秒的链路发一个包要 100 毫秒才能等到确认而发送本身只需要 1 毫秒发送方有 99% 的时间在空等。所以要引入“连续 ARQ”也就是发送方在一个窗口内可以连续发多个报文段不必等每一段的确认回来再动。这和现实里寄快递很像你不可能寄一件、等签收、再去寄下一件而是先让一批快递上路再根据签收情况决定补寄哪些。但网上跟现实快递不一样的是包裹可能走丢、可能走错、可能重复。TCP 用序号、确认、重传机制把这些都处理掉。5.2 重传策略超时重传与快速重传发送方给每个报文段启动一个重传计时器。如果超过 RTO重传超时时间还没收到确认就认为这个段丢了重新发送。RTO 不是固定值而是根据历史 RTT 样本动态计算出来的。网络时延大RTO 就放宽网络通了RTO 就收紧。如果 RTO 设置得太固定快网络里等太久会拖慢速度慢网络里又容易误判丢包。超时重传的缺点是傻等如果丢了某个段可能要等到超时才触发。快速重传解决了一部分等待成本接收方只要收到乱序数据就立刻重复确认当前期望的序号发送方连续收到三个相同的 ACK基本能判断那个序号丢了不等超时马上重传。举个例子发送方一次发了 1 到 10 号段第 5 号丢了第 6 到 10 号都到了。接收方每收到一个乱序段都会回“我需要第 5 号”。发送方连续看到三个来自接收方的“给我第 5 号”就断定第 5 号丢包立即补发。这样不用傻等一个超时周期整体重传速度快很多。5.3 SACK 选项解决了什么问题没有 SACK 时如果一堆包里只丢了一个接收方虽然已经拿到了后面的数据但确认号只能停在缺口之前。发送方有时候会重传一大片数据其中不少是对方已经拿到的浪费带宽。SACK 让接收方在确认报文里额外携带一个“我已经收到的区段列表”。发送方一看列表就知道哪些段需要补哪些段不用重传。现在主流操作系统默认都开启 SACK 支持。抓包时你可能会看到 TCP 选项里有一串SACK记录那就是双方在协商这种精细补包能力。5.4 从重传统计看网络质量线上排查时不用抓包也能快速了解重传情况。Linux 上可以看netstat -s | grep -i retrans如果重传率长期偏高说明链路质量差或者拥塞严重这时候调应用代码意义不大先看网络。我见过有团队把连接超时时间调了又调最后查出来是机房网络丢包纯属方向跑偏。6. 流量控制防止接收方被灌爆6.1 滑动窗口和接收窗口的关系TCP 头部里有一个窗口大小字段这是接收方写给发送方的“你最多再发这么多字节我这边还放得下。”这个值叫接收窗口rwnd。发送方不是想发多少就发多少而是要看接收方还剩多少缓冲区。如果发送方不管不顾地狂发接收方内存缓冲区满了新来的数据只能丢掉然后双方陷入反复重传的死循环。流量控制就是为了避免这种“生产速度超过消费速度”的崩溃。生活里对应很好理解你给一个朋友传文件对方电脑磁盘快满了你不可能还把几十 G 的文件一股脑传过去而是先看对方还剩多少空间再决定一次传多少。6.2 窗口收缩与零窗口死锁接收方处理完一部分数据后缓冲区腾出空间会在下一个报文的窗口字段里把这个值加大。但如果接收方一直不处理窗口就慢慢缩到 0。发送方收到 rwnd0 后必须停止发送等待对方发来窗口更新通知。这里有一个隐藏问题如果接收方发出“窗口已经变大了”的报文而这个报文在网络上丢了发送方还在傻等接收方也在等发送方继续发数据两边就互相等死。TCP 的解决办法是让发送方启动一个持续计时器窗口为 0 时定期发一个很小的探测包迫使接收方重新回应最新的窗口大小。这个机制在真实场景里救了很多次场虽然平时很少有人注意到。6.3 窗口扩大因子旧协议里窗口字段只有 16 位最大能表示 65535 字节这个值在网络带宽越来越大的今天根本不够用。TCP 后来加了一个窗口扩大因子选项把窗口数值左移若干位实际能表示的窗口可以达到 GB 量级。所以抓包看到window 64240如果 options 里写着wscale 7真实的接收窗口其实是 64240 左移 7 位大概 8MB。只看表面数值去分析吞吐量会得出完全错误的结论。这也是我建议初学阶段不要只看 tcpdump 默认输出要展开 TCP 选项看细节的原因。7. 拥塞控制照顾整条链路的交通负荷7.1 拥塞窗口和接收窗口是两回事流量控制只关心接收方忙不忙而拥塞控制关心的是整条链路忙不忙。接收方缓冲区很空但中间的网络设备可能已经堵成一团发送方发太快照样丢包。TCP 在发送方本地维护另一个变量叫拥塞窗口cwnd。真正决定发送速率的窗口是实际可发送量 min(拥塞窗口 cwnd, 接收窗口 rwnd)也就是说即使接收方说“你随便发”发送方也不敢一口气把带宽打满而是试探性地逐步增加发送量。公路就这么宽你不能因为自己家车库大就同时把所有车开上高速。7.2 慢启动和拥塞避免连接刚建立时发送方不知道链路到底能承受多大负荷所以从很小的 cwnd 开始比如初始值 10 个 MSS。每收到一个确认cwnd 就增加一个 MSS这个阶段是指数增长1 个数据段变成 2 个2 个变成 4 个快速探路。但一直指数增长会瞬间打爆网络所以设置了一个慢启动阈值 ssthresh。cwnd 小于阈值时走慢启动大于等于阈值时进入拥塞避免每个往返时间 RTT 只把 cwnd 增加 1 个 MSS从一个相对安全的速度线性爬升。一旦监测到丢包说明网络快撑不住了TCP 会果断调整把 ssthresh 降到当前 cwnd 的一半并重新收敛。这个过程很容易让人误解慢启动并不“慢”它只是让发送从很谦逊的起点开始爬升速度反而快得惊人真正低调克制的是后面的拥塞避免阶段。7.3 快速恢复的基本逻辑以前只要发生超时重传TCP 就把 cwnd 直接打到 1重新慢启动性能损失非常大。后来引入快重传后不再把超时和收到重复 ACK 等同对待。收到三个重复 ACK 时TCP 判断丢包但网络链路还能工作就进入快速恢复把 ssthresh 减半cwnd 也降到一个相对合理的值而不是归零重来。这样能把一次丢包对吞吐量的伤害控制在一定范围内不至于整条链路急刹车。Linux 上实际使用哪种拥塞控制算法可以查sysctl net.ipv4.tcp_congestion_control常见的是 cubic它在高带宽长距离链路上表现很好。初学阶段不用钻实现细节但要知道拥塞控制是一套动态适应机制不是死参数。7.4 拥塞控制的实际意义对后端开发来说最直接的体会是网络不稳定的时刻TCP 会自动降低发送速度应用层看到的延迟会上升。这不是应用故障而是协议在保护整条链路。有些性能测试会忽略拥塞控制拿万兆内网压测跑到很高 QPS 后以为自己优化得很好结果上了公网一地鸡毛就是因为公网链路不满足“无丢包、低时延”的假设。所以做性能测试时明确测试环境跟真实链路的差异别把实验室数字直接当成上线承诺。8. 从原理到 Java 开发绕过这几个经典大坑8.1 TCP 是字节流应用必须自己处理粘包和半包这是 Java Socket 编程最典型的入门坑。客户端连续调两次 send服务端 recv 一次可能把两段数据一起读出来这叫粘包反过来客户端一次 send 一大段数据服务端 recv 两三次才读完这叫半包。原因不神秘TCP 只保证“你写进来的字节流会按序送达”不保证“你写几次对方就读几次”。应用层看到的是一个没有边界的字节管道边界得自己定。常见的解法是给每条消息加固定格式。比如自定义协议时先发 4 字节的消息长度再发消息体接收方先读长度再按照长度读消息体。用 Netty 时可以直接用现成的帧解码器LengthFieldBasedFrameDecoder(1024 * 1024, 0, 4, 0, 4)它会把“4 字节长度头 消息体”的字节流自动拆成一帧一帧的 ByteBuf开发者在 handler 里拿到的就是一个完整消息不用自己写拆包逻辑。但核心逻辑还是那个TCP 帮你按顺序搬家不帮你打包拆包。8.2 长连接和心跳为什么必须自己做TCP 本身有个 keep-alive 机制但默认值非常不敏感很多系统默认是两小时才探测一次。对绝大多数业务来说这个时间窗口太长了服务端连着网线拔了客户端可能要两个小时才能感知到连接失效。所以应用层普遍自己做心跳每隔几十秒发一个心跳消息对端回一个心跳响应如果在超时时间内没收到就判定连接不可用并重连。即时通讯、推送、长轮询这类场景都离不开这套设计。心跳间隔要权衡。太短会让每一条连接都在白白消耗带宽太长又会让故障发现太慢。一般取值在 15 到 60 秒之间具体看业务容忍度。心跳消息本身尽量做得很小别把大量业务数据塞进心跳里否则判断标准和数据通道耦合排查时很痛苦。8.3 TIME_WAIT 影响重启的端口复用自己写一个 TCP 服务端做测试时改了代码重启可能会报 “Address already in use”。原因就是上次进程主动关闭时留下的 TIME_WAIT 还在端口没有立刻释放。Java 里可以在绑定端口前设置ServerSocket serverSocket new ServerSocket(); serverSocket.setReuseAddress(true); serverSocket.bind(new InetSocketAddress(port));SO_REUSEADDR的意义是允许新连接复用 TIME_WAIT 状态下的端口。开发环境这样设置很省事但生产环境不能只知道开这个开关还得配合连接生命周期管理一起分析。8.4 UDP 依然是重要选项TCP 可靠但牺牲了一部分实时性和传输效率UDP 不用握手、不维护连接状态、没有确认重传延迟低。DNS 查询、DHCP、音视频通话这类对延迟敏感或能容忍少量丢失的场景UDP 反而更合适。Java 里用DatagramSocket收发 UDP 报文也是老牌基础能力。真正的判断标准是数据丢了你能不能接受。能接受少量丢失且需要低延迟UDP 就胜出一个字节都不许丢老老实实 TCP。9. 线上 TCP 问题排查速查表把这些年遇到过的连接异常分门别类整理成一张表遇到问题先对号入座比盲目抓包高效得多现象可能原因快速排查手段客户端一直 SYN_SENT目的端口没监听、防火墙丢 SYN、路由不可达ss -ant state syn-sentcurl -v看卡在哪一步服务端堆积 SYN_RECV半连接队列满、遭受 SYN Flood、握手包回不去ss -ant state syn-recvnetstat -s看 SYN 相关统计大量 TIME_WAIT短连接频繁主动关闭ss -ant state time-wait传输慢但带宽没满丢包触发拥塞控制降速、接收窗口过小netstat -s连接被 RST对端端口未监听、对端应用崩溃、防火墙重置ss -ant state establishedtcpdump 看[R]包方向连接建立成功后立刻断开应用层握手失败、鉴权不通过抓包看应用层报文确认是否由 close 触发 FIN排查时有个先后顺序我个人的习惯是先看连接状态分布再看系统层统计最后才抓包。连接状态能告诉你问题出在建立阶段还是数据传输阶段系统统计能告诉你是不是有重传、丢包抓包则告诉你具体报文长什么样。从粗到细能省下大量时间。另一个容易被忽略的点是防火墙和负载均衡设备。它们可能不按协议常规出牌有的设备对半开连接有超时限制有的设备会主动发 RST 清理空闲连接。这些行为在客户端看就是“无缘无故断连”但在抓包里能清晰看到对端发了 RST来源却不是业务进程的端口特征这时候就要想到中间链路设备了。最后分享一个我在初学阶段觉得性价比最高的做法在自己电脑上搭一个最小复现环境用 tcpdump 盯着三次握手和四次挥手抓一遍。不需要复杂的业务代码一个监听端口的工具、一个 curl 命令就够。把每个报文里的 seq、ack、flags 和状态变化对照着看一遍很多概念会突然串起来。协议这东西光靠看和背很容易忘抓过包之后那些状态和数字会一直在你脑子里比抱着书啃一周都管用。