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,中间丢了一段,接收方仍然回 ack=101,直到缺口补齐。这就叫累计确认:确认号 N 代表 N 之前的所有字节都收到且有序,不需要给每一个字节单独回执,大幅减少了确认报文的数量。但它有一个弱点:如果中间丢了一个段,后面的段即使到了,接收方也不能推进确认号,只能等着发送方重传缺口。SACK 选择性确认选项能弥补这个问题,后面讲重传的时候再细说。3. 三次握手,连接不是一开始就存在3.1 一次完整握手的编号过程建立连接看起来就是三个报文,但每个报文的序号和标志位极其讲究。我习惯用一个具体数字演一遍:客户端初始序号 ISN 设为 1000,发SYN, seq=1000。服务端收到后,把自己的初始序号定为 7000,回SYN+ACK, seq=7000, ack=1001。这条报文同时完成两件事:确认客户端的同步请求,并声明自己的初始序号。客户端再回ACK, seq=1001, ack=7001,握手完成。注意客户端第三次握手报文里的 seq 为什么是 1001?因为第一次握手的那一个控制字节也要占用一个序号,虽然它不携带业务数据,但它也在序号空间里被“消费”掉。同理,服务端的 SYN+ACK 也占用了 7000 这一个序号位置,所以客户端下一次确认号是 7001。3.2 为什么不能改成两次握手这是个高频面试问题,也是理解握手本质的关键。核心原因有两点。第一,握手不只是“打个招呼”,而是要同步双方的初始序号。只有双方都确认了对方的初始序号,后续的数据编号才有共同的起点。如果只有两次,服务端不知道客户端是否已经收下了自己的 SYN+ACK,两边对序号状态的理解可能不一致。第二,要防止历史连接请求的残留报文干扰。假如客户端发了一个 SYN,因为网络拥塞一直没到,客户端以为丢了,超时重传了一次,第二次正常完成了连接。结果第一条老的 SYN 又慢慢悠悠地到了服务端。服务端一看是个正常的连接请求,立刻回 SYN+ACK 并分配资源,可客户端根本不需要这个连接,就会回一个 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.]是 SYN+ACK,[.]是 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。发送方收到 rwnd=0 后必须停止发送,等待对方发来窗口更新通知。这里有一个隐藏问题:如果接收方发出“窗口已经变大了”的报文,而这个报文在网络上丢了,发送方还在傻等,接收方也在等发送方继续发数据,两边就互相等死。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-sent;curl -v看卡在哪一步服务端堆积 SYN_RECV半连接队列满、遭受 SYN Flood、握手包回不去ss -ant state syn-recv;netstat -s看 SYN 相关统计大量 TIME_WAIT短连接频繁主动关闭`ss -ant state time-wait传输慢但带宽没满丢包触发拥塞控制降速、接收窗口过小`netstat -s连接被 RST对端端口未监听、对端应用崩溃、防火墙重置ss -ant state established;tcpdump 看[R]包方向连接建立成功后立刻断开应用层握手失败、鉴权不通过抓包看应用层报文,确认是否由 close 触发 FIN排查时有个先后顺序,我个人的习惯是:先看连接状态分布,再看系统层统计,最后才抓包。连接状态能告诉你问题出在建立阶段还是数据传输阶段;系统统计能告诉你是不是有重传、丢包;抓包则告诉你具体报文长什么样。从粗到细,能省下大量时间。另一个容易被忽略的点是防火墙和负载均衡设备。它们可能不按协议常规出牌,有的设备对半开连接有超时限制,有的设备会主动发 RST 清理空闲连接。这些行为在客户端看就是“无缘无故断连”,但在抓包里能清晰看到对端发了 RST,来源却不是业务进程的端口特征,这时候就要想到中间链路设备了。最后分享一个我在初学阶段觉得性价比最高的做法:在自己电脑上搭一个最小复现环境,用 tcpdump 盯着三次握手和四次挥手抓一遍。不需要复杂的业务代码,一个监听端口的工具、一个 curl 命令就够。把每个报文里的 seq、ack、flags 和状态变化对照着看一遍,很多概念会突然串起来。协议这东西,光靠看和背很容易忘,抓过包之后,那些状态和数字会一直在你脑子里,比抱着书啃一周都管用。
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,中间丢了一段,接收方仍然回 ack=101,直到缺口补齐。这就叫累计确认:确认号 N 代表 N 之前的所有字节都收到且有序,不需要给每一个字节单独回执,大幅减少了确认报文的数量。但它有一个弱点:如果中间丢了一个段,后面的段即使到了,接收方也不能推进确认号,只能等着发送方重传缺口。SACK 选择性确认选项能弥补这个问题,后面讲重传的时候再细说。3. 三次握手,连接不是一开始就存在3.1 一次完整握手的编号过程建立连接看起来就是三个报文,但每个报文的序号和标志位极其讲究。我习惯用一个具体数字演一遍:客户端初始序号 ISN 设为 1000,发SYN, seq=1000。服务端收到后,把自己的初始序号定为 7000,回SYN+ACK, seq=7000, ack=1001。这条报文同时完成两件事:确认客户端的同步请求,并声明自己的初始序号。客户端再回ACK, seq=1001, ack=7001,握手完成。注意客户端第三次握手报文里的 seq 为什么是 1001?因为第一次握手的那一个控制字节也要占用一个序号,虽然它不携带业务数据,但它也在序号空间里被“消费”掉。同理,服务端的 SYN+ACK 也占用了 7000 这一个序号位置,所以客户端下一次确认号是 7001。3.2 为什么不能改成两次握手这是个高频面试问题,也是理解握手本质的关键。核心原因有两点。第一,握手不只是“打个招呼”,而是要同步双方的初始序号。只有双方都确认了对方的初始序号,后续的数据编号才有共同的起点。如果只有两次,服务端不知道客户端是否已经收下了自己的 SYN+ACK,两边对序号状态的理解可能不一致。第二,要防止历史连接请求的残留报文干扰。假如客户端发了一个 SYN,因为网络拥塞一直没到,客户端以为丢了,超时重传了一次,第二次正常完成了连接。结果第一条老的 SYN 又慢慢悠悠地到了服务端。服务端一看是个正常的连接请求,立刻回 SYN+ACK 并分配资源,可客户端根本不需要这个连接,就会回一个 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.]是 SYN+ACK,[.]是 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。发送方收到 rwnd=0 后必须停止发送,等待对方发来窗口更新通知。这里有一个隐藏问题:如果接收方发出“窗口已经变大了”的报文,而这个报文在网络上丢了,发送方还在傻等,接收方也在等发送方继续发数据,两边就互相等死。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-sent;curl -v看卡在哪一步服务端堆积 SYN_RECV半连接队列满、遭受 SYN Flood、握手包回不去ss -ant state syn-recv;netstat -s看 SYN 相关统计大量 TIME_WAIT短连接频繁主动关闭`ss -ant state time-wait传输慢但带宽没满丢包触发拥塞控制降速、接收窗口过小`netstat -s连接被 RST对端端口未监听、对端应用崩溃、防火墙重置ss -ant state established;tcpdump 看[R]包方向连接建立成功后立刻断开应用层握手失败、鉴权不通过抓包看应用层报文,确认是否由 close 触发 FIN排查时有个先后顺序,我个人的习惯是:先看连接状态分布,再看系统层统计,最后才抓包。连接状态能告诉你问题出在建立阶段还是数据传输阶段;系统统计能告诉你是不是有重传、丢包;抓包则告诉你具体报文长什么样。从粗到细,能省下大量时间。另一个容易被忽略的点是防火墙和负载均衡设备。它们可能不按协议常规出牌,有的设备对半开连接有超时限制,有的设备会主动发 RST 清理空闲连接。这些行为在客户端看就是“无缘无故断连”,但在抓包里能清晰看到对端发了 RST,来源却不是业务进程的端口特征,这时候就要想到中间链路设备了。最后分享一个我在初学阶段觉得性价比最高的做法:在自己电脑上搭一个最小复现环境,用 tcpdump 盯着三次握手和四次挥手抓一遍。不需要复杂的业务代码,一个监听端口的工具、一个 curl 命令就够。把每个报文里的 seq、ack、flags 和状态变化对照着看一遍,很多概念会突然串起来。协议这东西,光靠看和背很容易忘,抓过包之后,那些状态和数字会一直在你脑子里,比抱着书啃一周都管用。