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。
②朴素做法:把 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 连接不在网线上,在两端内核的账本里
所以“建立连接”就是两边把账本开起来、对上起始编号;“关闭连接”就是两边把账本销掉。下面每一步都是在问:怎样开、怎样销,才不会记错账。
④-2 三次握手:两个起始编号,各要一次“报出”和一次“确认”
每一方都要报出自己的起始序列号(ISN,随机挑的,防止被猜中伪造),并且亲眼看到对方确认收到了。两个方向各一发一确认,本来是 4 个段;库存服务的“确认你的”和“报出我的”可以塞进同一个段(SYN-ACK),所以是 3 个。
两次握手:服务端回了就算建好被放弃的做法
- 迟到的旧 SYN 会凭空造出连接,服务端白白占着资源
- 服务端的起始编号有没有被对方收到,它自己不知道:后面的数据从哪号数起,没法对账
三次握手:等到对方确认我的编号才算TCP 的做法
- 服务端进
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_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 就销账被放弃的做法
先关的一方等 2MSLTCP 的做法
决定胜负的约束:最后一个 ACK 没有人能确认它(再确认就无穷无尽了),网络又可能把旧段扣留一阵再放出来。只能让发最后一个 ACK 的那一方多留一会儿账本,等到“对方的重传”和“路上的旧段”都不可能再出现。代价是一个四元组在 60 秒里不能复用,这就是短连接的天花板。
④-5 账上记的是字节:边界会丢,小包会被攒
write 只是把字节拷进发送缓冲区,什么时候发、切成几段,由内核按窗口、MSS 和 Nagle 算法决定;read 只是把接收缓冲区里当时有的字节拷出来,最多拷满你给的数组。两端都没有地方记“这几个字节是一条”。
Nagle 算法(1984 年)是为 telnet 这类“一次敲一个键”的程序设计的:有小段没被确认时,后面的小段先攒着,等 ACK 回来一起发,省掉大量只带一两个字节的包。延迟 ACK 则是接收方等一会儿,希望把 ACK 捎在回复里。各自都合理,碰上“写、写、读”这种请求格式就互相卡住。比喻和真机制对不上的地方:Nagle 不是按时间攒,是按“有没有未确认的小段”攒;延迟 ACK 的 40 ms 是 Linux 的下限,不同系统不一样。
最少的 API
// 服务端: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 显示绝对序列号)。每走一步,先猜下一个段的确认号是多少,再点“下一步”。
走完三种方式对比一下:谁先 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。这也是为什么这个坑在“调一次试试”时常常看不出来。
⑥边界和变体:线上真正出事的六个地方
每个坑旁边的徽标指回 ④ 里推出它的那条原理。标“实测”的是 ⑤ 里跑过的。
注意事项
- 现象
- 本地联调一切正常,上了压测,接收方偶尔报“非法指令”或 JSON 解析失败;抓包看数据完全正确。
- 根源
- 把一次
read的结果当成一条消息。接收方一慢,多条就粘在一起;消息一大,一条就被拆开。 - 做法
- 用长度前缀,
readFully读够;用 Netty 就用LengthFieldBasedFrameDecoder,并设maxFrameLength,长度字段超限直接断开连接,否则一个错位的长度就能让你分配 2 GB 的数组。
- 现象
- 延迟分布在 40 ms 附近有一个很尖的峰,和负载无关。全景里的 HTTP/1.1 对照组 p50 41.2 ms,本页 ⑤ 41.0 ms。
- 根源
- 一次请求被分成两次以上的小
write,后面那次被 Nagle 扣住等 ACK,而对方的延迟 ACK 也在等。 - 做法
- 请求响应式的协议开
TCP_NODELAY(Netty、gRPC 默认已开);更根本的是一次请求拼好再一次写出(BufferedOutputStream+ 一次flush),少一次系统调用,也少一个包。
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。
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 堆积,查的是自己的代码,不是网络。
- 现象
- 早高峰第一批请求超时,之后恢复。中间的防火墙、NAT 或云上负载均衡器会清理空闲连接,而且不通知两端。
- 根源
- 两本账都还在,但路已经断了。没有包来往,谁也发现不了,直到下一次发数据、重传到放弃。
- 做法
- 空闲长连接要有探活:应用层心跳(gRPC 的
keepAliveTime、Dubbo 的心跳),或 TCP keepalive。但 Linux 默认空闲 7200 秒才开始探(tcp_keepalive_time),比大多数中间设备的空闲超时都长,要调小才有用。
- 现象
- 服务刚启动或大促开始,大量新连接同时进来,有的
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 s | TIME_WAIT 的时长。没有 sysctl 能改;本机 ss 计时器从 59sec 倒数 |
tcp_fin_timeout | 60 | 孤儿连接(应用已经 close、内核还在收尾)停在 FIN_WAIT_2 的最长时间。和 TIME_WAIT 同为 60 s,所以常被误认为是它。⑤ 的 leak 模式里,客户端那 500 条 FIN-WAIT-2 就是按它超时的 |
tcp_tw_reuse | 2 | 0 关,1 全开,2 只对回环地址开(本机默认值)。允许新发起的连接复用处于 TIME_WAIT 的四元组,靠 TCP 时间戳区分新旧包,所以只帮客户端、要求时间戳开着 |
tcp_max_tw_buckets | 65536 | TIME_WAIT 总数上限,超了新的直接销账不等,内核打日志。是防攻击的保险丝,不是调优旋钮 |
ip_local_port_range | 32768–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。
这一页没展开的
- 重传和 RTO:丢了一个段,等多久重发,为什么 p99 会出现 200 ms、1 s 的台阶
- 滑动窗口和拥塞控制(慢启动、CUBIC、BBR):长连接为什么比新连接快
- SYN 洪水和 SYN cookie 的原理
- TCP Fast Open、QUIC:把握手的往返再省掉
- Netty 怎么分帧:
ByteToMessageDecoder的累积缓冲(J04 Netty 那一页) - tcpdump、Wireshark 读包的完整方法:重传、窗口满、零窗口
↺回到⑤:带着⑥改一遍
把 NagleDelayedAck 客户端的输出流套上 BufferedOutputStream,两次 write 后一次 flush,TCP_NODELAY 保持 false。先猜 p50,再跑。对照坑 2 的“更根本的做法”。
照 TimeWait 写一个 keep 模式:只建一条连接,在上面一问一答 500 次,最后关一次。数 TIME_WAIT 是几条、总耗时是多少,和 client 模式比。
在 Framing 的发送方故意把第 3 条的长度写成 0x7fffffff。不加检查时接收方会怎样?加上 1 MB 上限后呢?再用 Netty 的 LengthFieldBasedFrameDecoder(1<<20, 0, 4, 0, 4) 重写接收方。
跑 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 的预算一下去掉七分之一。