SYN · SYN-ACK · ACK · 序列号 · FIN · 半关闭 · CLOSE_WAIT · TIME_WAIT · 2MSL · 字节流 · 粘包 / 拆包 · 长度前缀 · Nagle · 延迟 ACK · TCP_NODELAY · SO_LINGER · backlog · keepalive

TCP:握手、挥手、TIME_WAIT 与粘包。连接只是两端内核各记的一份账,数据是一条没有边界的字节流

网线上没有“连接”,只有一个个可能丢、可能乱序、可能迟到的包。TCP 让两端内核各自记一份账:我发到第几号字节、你收到第几号字节。握手是两边互相报上起始编号并确认对方收到了;挥手是两个方向各自说“我发完了”;TIME_WAIT 是先关的那一方多等一会儿,让最后一个 ACK 和迷路的旧包都有个交代。账上记的是字节,不是消息,所以 消息的边界要上层自己标,这就是“粘包”的全部真相。

全景:订单服务调库存服务的一次 RPC · 第 3、7 站地图:J04 · TCP:握手、挥手、TIME_WAIT、粘包第一圈:从输入 URL 到页面展示(握手只算了一个往返)

①要解决的问题:在不可靠的包上,搭一条可靠的字节管道

在全景第 7 站,订单服务的 Netty 把 12 字节的 gRPC 消息交给内核,这一步要解决的是:IP 只管“尽力把包送到”,丢包、乱序、重复、迟到都可能发生;订单服务却要求库存服务一个字节不差、按顺序收到。第一圈“从 URL 到页面”只把握手当成一个往返的耗时;这一圈要回答三个问题,并解释全景里那两个现象:HTTP/1.1 对照组每次调用多出来的 40 ms,以及短连接调用量一大,订单服务这边堆满 TIME_WAIT。

订单服务 Netty 写出 12 字节 00000007 08b74a… 10.0.1.5:51352 库存服务 要按顺序、不丢不重 收到这 12 字节 10.0.2.9:9090 IP 网络:只管“尽力送到” #1 #2 丢 #4 #3 #3 乱序重复 还有:上一条连接的旧包,迟到了几十秒才到 问题 1 · 开始:握手 两边各从第几号字节开始数?对方知道吗? 问题 2 · 中间:边界 一串字节,哪几个算一条请求? 问题 3 · 结束:挥手和 TIME_WAIT 怎么说“发完了”?旧包别串进新连接 全景实测:HTTP/1.1 对照组每次调用 p50 41.2 ms 短连接调用量一大:订单服务这边堆满 TIME_WAIT

②朴素做法:把 TCP 当成“一次发送 = 一条消息”的管子

直觉上,socket 像一根管子:这头 write 一次,那头 read 一次,正好拿到这一条;用完就关,下次再开。两个直觉在本机各跑了一遍(JDK 17,回环地址),都不成立。

直觉 1:write 一次,read 一次实测

客户端 write 5 次,每次 32 字节的一条请求
服务端晚 100 ms 才去读:
  第 1 次 read 拿到 160 字节:5 条粘在一起

客户端 write 1 次,200 KB
服务端用 64 KB 的数组读:
  read 了 4 次:65536 + 65536 + 65536 + 8192
  • 多条请求被一次读到,叫“粘包”;一条被分几次才读完,叫“拆包”。按 read 的返回值当一条消息去解析,就会解错
  • 它不是偶发的:只要接收方读得比发送方慢一点,就一定发生

直觉 2:每次调用开一条连接,用完就关实测

订单服务调库存 500 次,每次新建连接、先 close
用 ss 数一数:
  TIME-WAIT  订单服务这边 500 条
             库存服务那边   0 条
  其中一条 timer:(timewait,59sec,0)
  • 每次调用都多一个往返的握手;跨机房 1 ms 的 RTT,就是每次多 1 ms
  • 每条连接关掉后还要在订单服务这边占着一个本地端口 60 秒。本地端口一共 28232 个(32768~60999),对同一个库存实例,平均每秒新建超过 470 条就会用光

不懂精华时的典型误用:看到 TIME_WAIT 多,就去改内核参数“让它快点消失”(调小 tcp_fin_timeout、打开早已删除的 tcp_tw_recycle),或者给每次调用的 read 加 sleep 等数据“到齐”。前者改错了地方(⑥第三圈),后者只是把粘包变得不那么常见。正确的方向是:边界自己标,连接要复用。

③几种思路:边界怎么标,连接怎么用

两件事各有几种做法,后面的协议基本都是在这几种里挑的组合。

定长

每条消息固定 N 字节,不够补零。

[32 字节][32 字节][32 字节]

简单,但内容长短不一就浪费,超长就塞不下。适合行情这类固定格式。Netty:FixedLengthFrameDecoder

分隔符

每条以约定的字节结尾,比如换行。

reserve 9527 88 2\n reserve …\n

人能读,但内容里出现分隔符就要转义,接收方要逐字节扫。Redis 命令行、HTTP/1.1 的头部(\r\n\r\n 结尾)用它。Netty:LineBasedFrameDecoder

长度前缀

先写“后面有几个字节”,再写内容。

[00 00 00 07][7 字节][00 00 00 1f][31 字节]

接收方先读够长度,再读够内容,不用扫描,内容随便是什么字节。gRPC(5 字节前缀)、HTTP/2 帧(9 字节帧头里 3 字节长度)、Dubbo(16 字节头里 4 字节长度)都用它。Netty:LengthFieldBasedFrameDecoder

连接怎么用握手次数TIME_WAIT代价谁在用
短连接:一次调用一条每次一个往返每次调用留一条,60 秒调用量一大,端口和 CPU 都耗在建连、关连上早期 HTTP/1.0、随手写的 new Socket()
连接池:每条连接一次只跑一个请求池子建满后就没有了只在池子缩容、连接过期时才有并发多少,就要多少条连接;请求排队等空闲连接HTTP/1.1 客户端、JDBC、Redis(Jedis)
一条长连接,多路复用每个实例一次几乎没有要在字节流上再分出“哪个字节属于哪个请求”;一条连接的队头阻塞HTTP/2、gRPC、Dubbo、Redis(Lettuce)

选择规则:服务之间的调用,默认用长度前缀 + 长连接;这正是全景里 gRPC 做的:每个库存实例一条 HTTP/2 长连接(第 3 站),每条消息前面 5 字节(第 5 站)。本页学的是它脚下那一层:TCP 怎么建立、怎么关闭,为什么会粘包,为什么非得自己标边界。

④关键洞察:两本账、两个方向、一条没有边界的流

握手、挥手、TIME_WAIT、粘包看起来是四件事,其实都从一句话推出来:TCP 连接 = 两端内核各自记的一份账,账上记的是“第几号字节”。下面五小节都在回答“这份账是怎么开的、怎么销的、记的是什么”。

先知道这些
  • 段(segment):TCP 交给 IP 的一个包。头部里有序列号、确认号、几个标志位(SYN、ACK、FIN、RST、PSH),后面跟 0 个或多个数据字节。抓包里的 [S] [S.] [.] [P.] [F.] [R.] 就是这些标志,点 . 表示带 ACK。
  • 序列号给字节编号,不给包编号:发了 12 字节、起始号是 1001,下一个字节就是 1013。确认号 ack=1013 的意思是“1013 之前的我都收到了,下一个要 1013”。
  • 四元组:源 IP、源端口、目的 IP、目的端口。内核靠它找到“这个包属于哪份账”。客户端的源端口通常由内核从本地端口范围里随便挑一个。
  • 一次往返(RTT)和握手的耗时,第一圈讲过:从输入 URL 到页面展示。

④-1 连接不在网线上,在两端内核的账本里

订单服务的内核 四元组 10.0.1.5:51352 ⇄ 10.0.2.9:9090 状态ESTABLISHED 我下一个发1013 我等对方的5001 发送缓冲区:已发未确认的字节 接收缓冲区:到了、应用还没 read 的 库存服务的内核 四元组 10.0.2.9:9090 ⇄ 10.0.1.5:51352 状态ESTABLISHED 我下一个发5001 我等对方的1013 发送缓冲区 接收缓冲区 seq=1001 len=12 [P.] ack=1013 [.] 网线上只有这些段,没有“连接”这个东西 推论:网线断了、对面机器掉电,两本账都还在。没有包来往,谁都不知道对面没了,直到下一次发数据超时,或者 keepalive 探一下。

所以“建立连接”就是两边把账本开起来、对上起始编号;“关闭连接”就是两边把账本销掉。下面每一步都是在问:怎样开、怎样销,才不会记错账。

④-2 三次握手:两个起始编号,各要一次“报出”和一次“确认”

每一方都要报出自己的起始序列号(ISN,随机挑的,防止被猜中伪造),并且亲眼看到对方确认收到了。两个方向各一发一确认,本来是 4 个段;库存服务的“确认你的”和“报出我的”可以塞进同一个段(SYN-ACK),所以是 3 个。

两次握手:服务端回了就算建好被放弃的做法

订单服务库存服务 SYN seq=100(30 秒前的旧包,迟到了) SYN-ACK ack=101 以为建好了:ESTABLISHED 开缓冲区,干等数据 “我没发过这个” 丢掉,什么也不回
  • 迟到的旧 SYN 会凭空造出连接,服务端白白占着资源
  • 服务端的起始编号有没有被对方收到,它自己不知道:后面的数据从哪号数起,没法对账

三次握手:等到对方确认我的编号才算TCP 的做法

订单服务库存服务 SYN seq=100(同一个旧包) SYN-RECV SYN-ACK seq=7000 ack=101 RST:没有这份账 半开连接被清掉
  • 服务端进 SYN-RECV,要等第三个段确认了它的编号,才算建好
  • 旧 SYN 引来的回复,客户端对不上账,回 RST,服务端把它丢掉(RFC 9293 说的头号理由就是防旧的重复连接请求)

决定胜负的约束:网络会让旧包迟到。只有让每一方的起始编号都被对方确认过,双方才知道后面每个字节的编号算的是同一本账;做到这一点最少要 3 个段,再多就是浪费。

握手由内核完成,不用 Java 代码参与:accept() 只是从“已建好的连接”队列(accept 队列)里取一条。队列长度受 new ServerSocket(port, backlog) 的 backlog 和内核 somaxconn 两个值里较小的那个限制,满了会怎样放到 ⑥ 第三圈。

④-3 挥手:两个方向各关各的,所以通常是四段

账本有两个方向:我→你、你→我。FIN 的意思只是“我这个方向不再发了”,对面照样可以继续发。对方收到 FIN,应用 read() 返回 -1;等对方的应用也调了 close(),才发出它那个方向的 FIN。两个方向各一个 FIN、各一个 ACK,就是四次挥手。

订单服务 库存服务 ① close() → FIN:订单→库存这个方向关了 ② ACK CLOSE_WAITread() 返回 -1,还能 write FIN_WAIT_2 ③ 应用 close() → FIN:库存→订单也关了 ④ ACK TIME_WAIT ② 和 ③ 之间隔多久,取决于库存服务的应用 什么时候 close;立刻 close 时 ②③ 合成一段

两个推论,⑥ 第一圈会用到:CLOSE_WAIT 只能由本端应用结束(它在等你调 close(),内核不会替你关),所以 CLOSE_WAIT 堆积几乎总是代码漏了 close;先发 FIN 的一方(主动关闭方)最后要发那个 ACK,所以 TIME_WAIT 落在它身上,和谁是客户端无关。Java 里只关一个方向用 socket.shutdownOutput(),这就是“半关闭”。

④-4 TIME_WAIT:最后一个 ACK 没人确认,旧包可能还在路上

主动关闭方发出最后一个 ACK 后,不马上销账,而是进 TIME_WAIT 等 2MSL(MSL 是一个段在网络里最多能活多久)。Linux 把它定成 60 秒,本机 ss 看到的计时器正是从 59sec 开始倒数。等这么久有两个理由:

收到 ACK 就销账被放弃的做法

订单服务库存服务 ✕ 最后的 ACK 丢了 重发 FIN RST:账已经销了 库存服务以错误收场 另一种:同一个四元组马上又建了新连接 上一条连接迟到的段落进来,被当成新连接的数据

先关的一方等 2MSLTCP 的做法

订单服务库存服务 TIME_WAIT:账还留着 ✕ 最后的 ACK 丢了 重发 FIN 再回一次 ACK 正常关闭 迟到的旧段在这 60 秒里到达:丢掉

决定胜负的约束:最后一个 ACK 没有人能确认它(再确认就无穷无尽了),网络又可能把旧段扣留一阵再放出来。只能让发最后一个 ACK 的那一方多留一会儿账本,等到“对方的重传”和“路上的旧段”都不可能再出现。代价是一个四元组在 60 秒里不能复用,这就是短连接的天花板。

④-5 账上记的是字节:边界会丢,小包会被攒

write 只是把字节拷进发送缓冲区,什么时候发、切成几段,由内核按窗口、MSS 和 Nagle 算法决定;read 只是把接收缓冲区里当时有的字节拷出来,最多拷满你给的数组。两端都没有地方记“这几个字节是一条”。

字节流:边界在 write 的那一刻就丢了 write × 5,每条 32 字节 发送缓冲区:160 字节 网线上:几个段 怎么切由内核定 接收缓冲区 read = 160 补救只能在上层:每条消息前写长度,接收方按长度切(③ 的长度前缀) 小包被攒:Nagle 等 ACK,延迟 ACK 等回复,两边互等 订单服务 库存服务 write 5 字节:立刻发 write 7 字节:Nagle 说“前一个小段还没被确认,先攒着” 只收到 5 字节,凑不成请求,没有回复可捎带 ACK:延迟 ACK 定时器在走 ≈40 ms 单独回 ACK 7 字节 → 回复 一次调用 41.0 ms(本机实测 p50);开 TCP_NODELAY 后 0.06 ms 时间 →(示意,未按比例)

Nagle 算法(1984 年)是为 telnet 这类“一次敲一个键”的程序设计的:有小段没被确认时,后面的小段先攒着,等 ACK 回来一起发,省掉大量只带一两个字节的包。延迟 ACK 则是接收方等一会儿,希望把 ACK 捎在回复里。各自都合理,碰上“写、写、读”这种请求格式就互相卡住。比喻和真机制对不上的地方:Nagle 不是按时间攒,是按“有没有未确认的小段”攒;延迟 ACK 的 40 ms 是 Linux 的下限,不同系统不一样。

最少的 API

Java SocketJDK 8+取值和坑放到 ⑥
// 服务端:backlog 是 accept 队列长度的上限之一,不写就是 50
ServerSocket server = new ServerSocket(9090, 1024);
Socket s = server.accept();                 // 握手早已由内核做完
// 客户端
Socket c = new Socket();
c.connect(new InetSocketAddress("inventory", 9090), 1000);  // 连接超时:握手最多等 1 s
c.setSoTimeout(300);                        // 读超时
c.setTcpNoDelay(true);                      // 关掉 Nagle(默认 false)
c.setKeepAlive(true);                       // 空闲探活(默认 false,开了也要 2 小时后才探)
// 按长度前缀收发
DataOutputStream out = new DataOutputStream(new BufferedOutputStream(c.getOutputStream()));
out.writeInt(body.length); out.write(body); out.flush();     // 拼好一次发出
DataInputStream in = new DataInputStream(c.getInputStream());
int len = in.readInt(); byte[] resp = new byte[len]; in.readFully(resp);  // 读够为止
c.shutdownOutput();                         // 半关闭:只发我这个方向的 FIN
c.close();                                  // 两个方向都关;先调的这一方进 TIME_WAIT

⑤手推一遍:一条短连接的一生,一个段一个段地看

下面是本机真抓的包:订单服务新建一条连接,发 1 字节请求,收 1 字节回复,然后先 close()。程序是 study-labs 里的 TimeWait,抓包命令是 sudo tcpdump -i lo -nn -S(-S 显示绝对序列号)。每走一步,先猜下一个段的确认号是多少,再点“下一步”。

两端的状态
订单服务 CLOSED库存服务 LISTEN
第几步 · 抓包时间0 / 11
还没有包

走完三种方式对比一下:谁先 close,TIME_WAIT 就在谁那边;FIN 和 SYN 一样占一个序列号,所以对 FIN 的确认号要加 1;SO_LINGER=0 用 RST 直接销账,两边都不留状态,代价见 ⑥ 第三圈。

在本机跑出来:粘包、长度前缀、40 ms、TIME_WAIT 在谁那边

三个类都在同一个目录里,不依赖任何库,--release 8 编译。机器:2 核 ARM,Linux 6.8,OpenJDK 17.0.20.1,全部走 127.0.0.1。完整代码:study-labs/java/network/tcp-handshake-timewait-framing →

$ cd study-labs/java && mvn -q -pl network/tcp-handshake-timewait-framing compile
$ CP=network/tcp-handshake-timewait-framing/target/classes; P=labs.network.tcp_handshake_timewait_framing
$ java -cp $CP $P.Framing
== 1) 粘包:客户端 write 5 次,每次一条 ==
   服务端第 1 次 read 拿到 160 字节: reserve order=9527 sku=88 qty=2⏎reserve order=9528 …⏎
== 2) 拆包:客户端 write 1 次,200 KB ==
   服务端 read 了 4 次: 65536 + 65536 + 65536 + 8192 = 204800 字节
== 3) 长度前缀:每条前面加 4 字节长度 ==
   第 1 条 (31 字节): reserve order=9527 sku=88 qty=2
   …
   第 5 条 (31 字节): reserve order=9531 sku=88 qty=3     # 同样晚读、同样粘在一起,照样切得开

$ java -cp $CP $P.NagleDelayedAck                 # 一次请求分两次 write:5 字节前缀 + 7 字节消息
TCP_NODELAY=false  200 次调用(统计后 100 次)  p50 41.00 ms  p99 41.15 ms  前 5 次: 1.6 41.8 41.0 41.0 41.0
TCP_NODELAY=true   200 次调用(统计后 100 次)  p50 0.06 ms   p99 0.11 ms   前 5 次: 0.2 0.1 0.1 0.1 0.1

$ java -cp $CP $P.TimeWait client 500           # 订单服务先 close
   TIME-WAIT  客户端(订单服务) 500 条   服务端(库存服务)   0 条
   其中一条: TIME-WAIT 0 0 [::ffff:127.0.0.1]:46472 [::ffff:127.0.0.1]:41385 timer:(timewait,59sec,0)
$ java -cp $CP $P.TimeWait server 500           # 库存服务先 close
   TIME-WAIT  客户端(订单服务)   0 条   服务端(库存服务) 500 条
$ java -cp $CP $P.TimeWait linger0 500          # SO_LINGER=0,发 RST
   和这个端口有关的连接一条都没留下
$ java -cp $CP $P.TimeWait leak 500             # 库存服务读到 -1 却忘了 close
   CLOSE-WAIT 客户端(订单服务)   0 条   服务端(库存服务) 500 条
   FIN-WAIT-2 客户端(订单服务) 500 条   服务端(库存服务)   0 条

NagleDelayedAck 的第 1 次只有 1.6 ms:Linux 在连接刚建好时处于“快速 ACK”模式,头几个段立刻确认,之后才开始延迟 ACK。这也是为什么这个坑在“调一次试试”时常常看不出来。

到这里已经能交差了

自己定协议时,每条消息前写 4 字节长度,收的一方 readInt + readFully;一次请求拼好再一次 flush,或者开 TCP_NODELAY;服务之间用长连接,不要一次调用一条连接。

还没处理的细节

长度字段被写错或被恶意填成 2 GB 怎么办(坑 1、规范);连接悄悄断了怎么发现(坑 5);突发建连时 accept 队列满了(坑 6);TIME_WAIT 能不能缩短(第三圈)。

⑥边界和变体:线上真正出事的六个地方

每个坑旁边的徽标指回 ④ 里推出它的那条原理。标“实测”的是 ⑤ 里跑过的。

第一圈

注意事项

坑 1自定义协议偶尔解析出乱码,压测时更多← ④-5 字节流实测
现象
本地联调一切正常,上了压测,接收方偶尔报“非法指令”或 JSON 解析失败;抓包看数据完全正确。
根源
把一次 read 的结果当成一条消息。接收方一慢,多条就粘在一起;消息一大,一条就被拆开。
做法
用长度前缀,readFully 读够;用 Netty 就用 LengthFieldBasedFrameDecoder,并设 maxFrameLength,长度字段超限直接断开连接,否则一个错位的长度就能让你分配 2 GB 的数组。
坑 2小请求每次都是 40 ms 左右,CPU 很闲← ④-5 Nagle × 延迟 ACK实测
现象
延迟分布在 40 ms 附近有一个很尖的峰,和负载无关。全景里的 HTTP/1.1 对照组 p50 41.2 ms,本页 ⑤ 41.0 ms。
根源
一次请求被分成两次以上的小 write,后面那次被 Nagle 扣住等 ACK,而对方的延迟 ACK 也在等。
做法
请求响应式的协议开 TCP_NODELAY(Netty、gRPC 默认已开);更根本的是一次请求拼好再一次写出(BufferedOutputStream + 一次 flush),少一次系统调用,也少一个包。
坑 3调用量一上来,报 Cannot assign requested address← ④-4 TIME_WAIT实测 TIME_WAIT 分布
现象
客户端 connect 失败,ss -s 里 TIME_WAIT 几万条。⑤ 里 500 次短连接,订单服务这边就留了 500 条,每条 60 秒。
根源
主动关闭的一方要在 TIME_WAIT 里占着这个四元组 60 秒。对同一个目标(IP + 端口),本地端口只有 28232 个,每秒新建超过约 470 条就会用光。
做法
用长连接或连接池,这是唯一的根治。实在要短连接:扩大 ip_local_port_range、多准备几个目标地址,或在懂的前提下开 tcp_tw_reuse(第三圈)。别去调 tcp_fin_timeout,它管的不是 TIME_WAIT。
坑 4CLOSE_WAIT 越积越多,最后 Too many open files← ④-3 两个方向各关各的实测
现象
ss -tan state close-wait 一直涨,不会自己消失;进程的文件描述符用光,新连接、打开文件全失败。⑤ 的 leak 模式:服务端 500 条 CLOSE-WAIT,客户端 500 条 FIN-WAIT-2。
根源
对方已经发了 FIN,这边的应用没调 close()。CLOSE_WAIT 只能由本端应用结束,内核不会替你关。
做法
所有 socket、流、HTTP 响应体都放进 try-with-resources;用连接池时确认“归还”也会在异常路径上发生。看到 CLOSE_WAIT 堆积,查的是自己的代码,不是网络。
坑 5长连接放了一晚上,第二天第一个请求卡住直到超时← ④-1 连接只是两本账按文档
现象
早高峰第一批请求超时,之后恢复。中间的防火墙、NAT 或云上负载均衡器会清理空闲连接,而且不通知两端。
根源
两本账都还在,但路已经断了。没有包来往,谁也发现不了,直到下一次发数据、重传到放弃。
做法
空闲长连接要有探活:应用层心跳(gRPC 的 keepAliveTime、Dubbo 的心跳),或 TCP keepalive。但 Linux 默认空闲 7200 秒才开始探(tcp_keepalive_time),比大多数中间设备的空闲超时都长,要调小才有用。
坑 6突发流量时,一部分客户端连接超时,服务端却很闲← ④-2 accept 队列按源码
现象
服务刚启动或大促开始,大量新连接同时进来,有的 connect 要 1 秒、3 秒才成功(SYN 重传)。nstat -az TcpExtListenOverflows 在涨。
根源
握手完成的连接排在 accept 队列里等应用取。队列满了,内核就丢掉新的握手包,客户端只能重传。JDK 的 new ServerSocket(port) 默认 backlog 是 50(JDK 17 源码)。
做法
backlog 设大(Tomcat 的 server.tomcat.accept-count,Netty 的 SO_BACKLOG),并确认内核 somaxconn 不比它小;更重要的是 accept 线程别被业务卡住。
第二圈

使用规范

规范防的是什么
自定义协议一律“长度前缀 + 最大长度检查”,不用分隔符坑 1:粘包拆包;错位或恶意的长度撑爆内存
能用现成框架的分帧就不自己拼(Netty 解码器、gRPC、Dubbo)半包、半个长度字段这类边角,框架都处理过
服务之间用长连接:HTTP/2 一条,或 HTTP/1.1 连接池坑 3:TIME_WAIT 和端口耗尽;每次多一个握手往返
请求响应式协议开 TCP_NODELAY,一次请求一次写出坑 2:40 ms
每个 socket 都在 try-with-resources 或 finally 里关坑 4:CLOSE_WAIT 和文件描述符泄漏
空闲长连接配心跳,周期短于中间设备的空闲超时坑 5:被悄悄掐断的连接
监控 ss -s 的 TIME_WAIT、CLOSE_WAIT 数和 ListenOverflows坑 3、4、6 在出事之前都会先在这几个数上露头
不碰 tcp_tw_recycle(Linux 4.12 已删除),不随手改 tcp_tw_reuse前者在 NAT 后面会丢掉正常的握手;后者改错了排查极难
第三圈

TIME_WAIT、RST、两个队列

TIME_WAIT 能不能缩短:60 秒是写死的,能动的是“复用”

参数本机值它到底管什么
(内核常量 TCP_TIMEWAIT_LEN)60 sTIME_WAIT 的时长。没有 sysctl 能改;本机 ss 计时器从 59sec 倒数
tcp_fin_timeout60孤儿连接(应用已经 close、内核还在收尾)停在 FIN_WAIT_2 的最长时间。和 TIME_WAIT 同为 60 s,所以常被误认为是它。⑤ 的 leak 模式里,客户端那 500 条 FIN-WAIT-2 就是按它超时的
tcp_tw_reuse20 关,1 全开,2 只对回环地址开(本机默认值)。允许新发起的连接复用处于 TIME_WAIT 的四元组,靠 TCP 时间戳区分新旧包,所以只帮客户端、要求时间戳开着
tcp_max_tw_buckets65536TIME_WAIT 总数上限,超了新的直接销账不等,内核打日志。是防攻击的保险丝,不是调优旋钮
ip_local_port_range32768–60999客户端可用的本地端口,共 28232 个,决定坑 3 的天花板

⑤ 的回环实验因为 tcp_tw_reuse=2,就算建满 28232 条也不一定报错;跨机器时这个值等于关闭,坑 3 才会原样出现。这一点是按内核文档的推论,本机没有跨机器复现。

SO_LINGER=0:用 RST 代替 FIN,没有 TIME_WAIT,也没有体面

setSoLinger(true, 0) 后 close() 不走挥手,直接发 RST:⑤ 的抓包里最后一段是 [R.],ss 里一条都没留下。代价有三个:发送缓冲区里没发完的数据直接丢掉;对方的 read 抛 Connection reset;最后几个字节到底有没有送达,谁都不知道。所以它只适合“这条连接已经坏了,立刻中止”的场景,比如协议解析出错、心跳超时,不适合拿来消灭 TIME_WAIT。

两个队列:半连接和全连接

队列里面是什么上限(本机)满了会怎样
SYN 队列(半连接)收到 SYN、回了 SYN-ACK、等第三个 ACK 的(SYN-RECV)tcp_max_syn_backlog = 1024,并受 accept 队列长度影响tcp_syncookies=1 时改用 SYN cookie,不占队列,这是抵御 SYN 洪水的办法
accept 队列(全连接)握手完成、等应用 accept() 取走的min(backlog, somaxconn = 4096)默认丢掉第三个 ACK 或新 SYN,客户端重传,表现为建连慢 1 s、3 s

版本差异:Linux 5.4 起 somaxconn 默认从 128 提到 4096,老内核上 backlog 设多大都会被截到 128;Java 这边 ServerSocket 不写 backlog 时是 50。JDK 13 用 NIO 重写了 Socket 的实现(JEP 353),对外行为不变;JDK 11 起可以用 jdk.net.ExtendedSocketOptions 按连接设 TCP_KEEPIDLE 等 keepalive 参数,不用改全局 sysctl。

下一圈

这一页没展开的

↺回到⑤:带着⑥改一遍

练习 1不开 TCP_NODELAY,也能消掉 40 ms 吗

把 NagleDelayedAck 客户端的输出流套上 BufferedOutputStream,两次 write 后一次 flush,TCP_NODELAY 保持 false。先猜 p50,再跑。对照坑 2 的“更根本的做法”。

练习 2一条长连接跑 500 次

照 TimeWait 写一个 keep 模式:只建一条连接,在上面一问一答 500 次,最后关一次。数 TIME_WAIT 是几条、总耗时是多少,和 client 模式比。

练习 3给长度前缀加上保险

在 Framing 的发送方故意把第 3 条的长度写成 0x7fffffff。不加检查时接收方会怎样?加上 1 MB 上限后呢?再用 Netty 的 LengthFieldBasedFrameDecoder(1<<20, 0, 4, 0, 4) 重写接收方。

练习 4CLOSE_WAIT 和 FIN_WAIT_2 谁先消失

跑 TimeWait leak 500 后,让程序别退出(加一行 Thread.sleep(90_000)),每 10 秒 ss -tan | grep -c 一次两种状态。先猜,再对照第三圈 tcp_fin_timeout 那一行。

1. 用一句话说出这一页的精华。

TCP 连接是两端内核各记的一份按字节编号的账:握手是互相确认起始编号,挥手是两个方向各自说“发完了”,TIME_WAIT 是发最后一个 ACK 的一方多留 60 秒账本;账上没有消息边界,所以边界由上层用长度前缀来标。

2. “write 一次 read 一次”和“一次调用一条连接”两个朴素做法,各疼在哪?

前者:TCP 是字节流,读到的可能是几条粘在一起,也可能是半条,按 read 解析就会解错(⑤ 实测 5 条一次读到、200 KB 读了 4 次)。后者:每次多一个握手往返,而且主动关闭方每条连接留 60 秒 TIME_WAIT,对同一目标每秒新建超过约 470 条就把本地端口耗光。

3. CLOSE_WAIT 堆积,为什么第一反应是查自己的代码?是哪条原理?

④-3:FIN 只关一个方向。对方发了 FIN,本端进 CLOSE_WAIT,要等本端应用调 close() 才发出自己的 FIN、离开这个状态;内核不会替应用关。所以它一直涨,说明有连接在某条路径上没被关掉。

4. 预测:把 NagleDelayedAck 改成一次 write 12 字节,TCP_NODELAY 仍然是 false,还会有 40 ms 吗?

不会。④-5:Nagle 只在“有未确认的小段”时才扣住新的小段。12 字节一次写出时,前面没有未确认的数据,立刻发出;库存服务收齐后马上回复,ACK 捎在回复里,延迟 ACK 也就不用等了。这正是练习 1 的道理。

5. 预测:库存服务每次回完就主动 close()(HTTP/1.0 的老做法),一秒 2000 次调用,TIME_WAIT 堆在哪边?会不会把端口用光?

④-4:TIME_WAIT 落在先发 FIN 的一方,所以在库存服务。⑤ 的 server 模式实测也是这样。服务端的四元组里本地端口固定是 9090,变的是对方的 IP 和端口,所以它不会“用光本地端口”,代价是十几万条 TIME_WAIT 占内存,可能撞上 tcp_max_tw_buckets。订单服务收到 FIN 后走的是被动关闭,一条 TIME_WAIT 都没有。

6. 放回全景:第 7 站前后各发生了什么?TCP 这一层出问题,整条链路会怎样?

前面是第 6 站:订单服务的 Netty EventLoop 把 HTTP/2 帧编码成字节,写进 socket;第 3 站那条长连接早已握手建好。后面是第 8 站:库存服务的 EventLoop 被 epoll 叫醒,读出字节,按 HTTP/2 帧头和 gRPC 的 5 字节前缀切出完整消息,这一步就是在给“字节流”补边界。TCP 这层出事:如果连接被中间设备悄悄掐断(坑 5),第 10 站的 300 ms deadline 先到,订单服务看到超时,而库存服务什么都没收到;如果换成短连接,每次调用多一次握手、订单服务堆 TIME_WAIT(坑 3);如果对照组那样两次写加 Nagle,每次调用平白多 40 ms,300 ms 的预算一下去掉七分之一。