TCP连接状态机与重传观测:谁在 TIME_WAIT,为什么重传救不了死链
先读 TCP 字节流与故障边界——那页讲分帧、半关闭与业务终态;本页讲连接本身的建立、关闭、重传与保活。协议依据 RFC 9293(2022-08,取代 RFC 793,查询 2026-10-25)、重传计时器 RFC 6298、keepalive RFC 1122 §4.2.3.6;Linux 专属参数就近引用 tcp(7) 并标注推导。JDK 实现基线 openjdk/jdk17u 的 jdk-17.0.12+7,本机实测 macOS 26.7.1 x86_64 + Oracle JDK 17.0.12+8-LTS-286,平台差异逐处标注。
状态机是协议的,观测是内核的
RFC 9293 §3.3.2 用一张状态图规定连接生命周期:被动方 LISTEN → SYN-RECEIVED → ESTABLISHED,主动方 SYN-SENT → ESTABLISHED;关闭时主动方走 FIN-WAIT-1/2 → TIME_WAIT,被动方走 CLOSE-WAIT → LAST-ACK。握手三次的原因是双方都要确认"我的发送序号对方收到了":SYN 携带初始序号,SYN+ACK 与 ACK 各确认一个方向。关闭比握手多两次报文,因为 FIN 之后还允许对方继续发数据(半关闭,见字节流页)。
最常见的两个排障状态都有明确归属:TIME_WAIT 只属于先发 FIN 的主动关闭方,持续 2MSL(RFC 9293 §3.5;本机 macOS net.inet.tcp.msl 语义下实测一条 TIME_WAIT 在 ~30 秒后消失,见实验节,单机观察值);CLOSE_WAIT 属于被动方收到 FIN 但应用一直不调 close——它是应用层泄漏,堆积不代表网络问题,代表"代码没关"。拿 netstat 看状态分布就能回答"谁没关连接":TIME_WAIT 堆在客户端 → 短连接太多;CLOSE_WAIT 堆在服务端 → 应用漏关。
| 状态 | 谁进入 | 正常离开方式 | 排障含义 |
|---|---|---|---|
| SYN-SENT | 主动方,SYN 已发未答 | 收到 SYN+ACK | 对端无响应:端口不通或被丢弃,等待重传/超时 |
| TIME_WAIT | 主动关闭方 | 2MSL 到期 | 正常现象;数量大说明短连接多,先复用再调参 |
| CLOSE_WAIT | 被动关闭方 | 应用调用 close | 应用泄漏:找"读到 EOF/异常后没有 finally close"的路径 |
| FIN-WAIT-2 | 主动方等对端 FIN | 对端关闭 | 长期停留说明对端应用既不读也不关 |
RST 不是"更快的 FIN"
FIN 是流的一部分:有序、到达前数据全部有效、对端读到 -1(EOF)。RST 是中止:内核立即丢弃发送与接收缓冲,对端下一次系统调用得到错误而不是 EOF。三个确定性来源,本机全部实测:
SO_LINGER(true, 0)后 close:close 变成 RST。对端已收到的字节仍可读出,但对端的下一次 read 抛SocketException: Connection reset(实测),不再返回 -1。linger 0 的语义是"丢弃未发送数据并要求立即中止",把它当"快速关闭"用会撕掉还在途的数据。- 进程被 SIGKILL 且接收缓冲有未读数据:内核关闭 socket 时发现收到的数据没人读,以 RST 代替 FIN(实测:客户端写 8 字节、子进程从不读、
kill -9后客户端 write 得Broken pipe、read 得Connection reset)。 - 连接到无监听端口:内核以 RST 回应 SYN,客户端得到
ConnectException: Connection refused(实测 ~3ms 内返回)。
错误消息映射(macOS + Oracle JDK 17.0.12+8 实测)随平台可能不同,断言时压"异常类 + reset/broken pipe 关键词",不要压完整文案。另一类常见误判:半开连接(对端消失、本端不知)第一次 write 常常"成功"——数据进入本地缓冲,RST 回来后第二次调用才失败;一次成功不代表对端活着。
重传、RTO 与 keepalive 是三个时钟
三者回答不同问题,混用会得出错误结论:
- **重传计时器(RTO)**回答"我发出去的数据多久没被确认要重发"。RFC 6298 由平滑 RTT 计算
RTO = SRTT + 4·RTTVAR,超时指数退避;快速重传由重复 ACK 触发(RFC 9293 §3.3.5)。环回无丢包,本票无法实测重传;Linux 上限参数(tcp_retries1/2)按 tcp(7) 推导并标注未实跑。 - **keepalive(SO_KEEPALIVE)**回答"这条空闲连接对端还在吗"。RFC 1122 默认 2 小时空闲后才探测;本机 macOS sysctl 实测
net.inet.tcp.keepidle=7200000ms、keepintvl=75000ms、keepinit=75000ms(只读观察,Linux 的tcp_keepalive_time等按 tcp(7) 推导)。JDK 17 可用 jdk.net.ExtendedSocketOptions 的TCP_KEEPIDLE/TCP_KEEPINTERVAL/TCP_KEEPCOUNT按连接调整(实验实测选项可设可读,探测行为需分钟级空闲未断言)。默认时间尺度远大于业务超时:指望默认 keepalive 快速发现死链,会把 2 小时的等待当成"快速失败"。 - 应用层 deadline回答"这个请求还要不要继续"。它不在这三个时钟里,见 HTTP/TLS 页。
一个必然推论:重传救不了死链。对端主机消失时,重传只是把同样的数据发往一个不再应答的地址,直到重传预算耗尽——恢复要靠应用层超时与新连接,不是等 TCP 自己"重试成功"。
连接建立:connect 的三条出路与监听队列
Socket.connect(addr, timeout) 只有三种结果:建立(ESTABLISHED)、被拒(RST → ConnectException)、无响应(SYN 重传直至应用超时)。JDK 17 的 java.net.Socket 由 NIO 承载:
原码连续摘录:NioSocketImpl.java L584–L627。
boolean connected = false;
FileDescriptor fd = beginConnect(address, port);
try {
// configure socket to non-blocking mode when there is a timeout
if (millis > 0) {
configureNonBlocking(fd);
}
int n = Net.connect(fd, address, port);
if (n > 0) {
// connection established
connected = true;
} else {
assert IOStatus.okayToRetry(n);
if (millis > 0) {
// finish connect with timeout
long nanos = MILLISECONDS.toNanos(millis);
connected = timedFinishConnect(fd, nanos);
} else {
// finish connect, no timeout
boolean polled = false;
while (!polled && isOpen()) {
park(fd, Net.POLLOUT);
polled = Net.pollConnectNow(fd);
}
connected = polled && isOpen();
}
}
// restore socket to blocking mode
if (connected && millis > 0) {
configureBlocking(fd);
}
} finally {
endConnect(fd, connected);
}
} finally {
connectLock.unlock();
}
} catch (IOException ioe) {
close();
throw SocketExceptions.of(ioe, isa);带超时的 connect 走"非阻塞发起 + timedFinishConnect 在截止前轮询",不是阻塞调用加外部计时器;失败分支先 close() 再抛出由 SocketExceptions.of 补充远端信息的异常——所以 refused 异常能带上 addr/port。注意两点边界:timeout 只覆盖建立阶段,TLS 握手、连接池等待都不在内;HttpClient.connectTimeout 同样只管建连,请求级预算见 HTTP/TLS 页。
服务端 ServerSocket 的 backlog 是已完成握手、等 accept 的队列长度;队列溢出的行为平台相关(Linux 默认丢 SYN 使客户端重传,见 tcp(7);macOS 类似丢弃)——本机未构造压满队列的场景,此段按文档推导。bind 默认不设 SO_REUSEADDR 时,TIME_WAIT 中的本地端口不能重绑定(实测 EADDRINUSE),设了可以——这是"重启监听被旧 TIME_WAIT 挡住"的解法,但它不等于可以无脑给所有 socket 开 REUSEADDR(两个 REUSEADDR socket 抢同一地址的语义平台相关)。
实验:环回上的状态机观测
公开 runner 的 tcpstates 模式在 127.0.0.1、端口 0 上断言五件事:半关闭后仍收到回复与干净 EOF;linger-0 关闭后对端下一次 read 得 reset(不是 EOF);主动关闭方的端口在 netstat 中呈 TIME_WAIT、无 REUSEADDR 重绑定被拒(EADDRINUSE)、设 REUSEADDR 成功;服务端收到 FIN 后不 close,netstat 计出 ≥3 条 CLOSE_WAIT;对端 SIGKILL(有未读数据)后本端 write/read 得 Broken pipe/Connection reset。macOS 的 sysctl net.inet.tcp.* 与 netstat 输出是只读观察项,Linux 对应参数按 tcp(7) 推导。
独立 spike 补一个时长观察:同法制造 TIME_WAIT 后每 2 秒轮询 netstat,~30 秒后消失(macOS 单机观察值,2026-10-25,不作为契约;Linux 为 60 秒量级,按 2MSL 推导)。连接超时未实测:本机网络对 RFC 5737 文档地址 192.0.2.1 竟完成了 TCP 握手(spike 实测 connect 成功)——文档地址在真实网络不保证是黑洞,超时路径只能按上面 NioSocketImpl 分支与 JDK 手册推导,这本身就是"文档地址不可信"的反例。实验也不能证明:重传/RTO 行为、拥塞控制、backlog 溢出与内核参数效果(环回无丢包,均未实跑)。
架构师推演:短连接的 TIME_WAIT 账单(算例,非实测)
设客户端 1 万 req/s、平均每连接承载 10 个请求:建连率 1000/s,每条主动关闭后 TIME_WAIT 停留 ~60s(Linux 量级,本机 ~30s),稳态积压 ≈ 1000 × 60 = 6 万条。客户端临时端口池(Linux 默认 net.ipv4.ip_local_port_range 约 2.8 万个,推导)在 28 秒内被打穿——症状是 Cannot assign requested address,而不是连接被拒。三种处置按成本排序:改长连接/复用(连接数降两个量级,零内核参数)、错峰+端口段扩容(治标,需要实例级预算)、tcp_tw_reuse(仅出方向安全,按 tcp(7) 推导,不改进方向 TIME_WAIT)。恢复责任:前两者是应用/架构变更,最后一个是内核参数变更,回滚窗口与验证手段都不同——变更前先用 netstat 计数确认 TIME_WAIT 确实是瓶颈,而不是 CLOSE_WAIT 应用泄漏被误判。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
来源与版本
- RFC 9293(查询 2026-10-25):状态机 §3.3.2、TIME_WAIT 2MSL §3.5、快速重传 §3.3.5、RST §3.4.3。
- RFC 6298、RFC 1122 §4.2.3.6(查询 2026-10-25):RTO 计算与 keepalive 约定;tcp(7):
tcp_retries1/2、tcp_keepalive_*、ip_local_port_range(Linux,推导未实跑)。 - OpenJDK
jdk-17.0.12+7NioSocketImpl.javaL584–L627(本页摘录):timed connect 分支与失败 close;本机运行为 Oracle JDK 17.0.12+8,构建号不同如实分记。 - 本机实验:Oracle JDK 17.0.12+8-LTS-286、macOS 26.7.1 x86_64,见实验 README 与证据记录;netstat/sysctl/TIME_WAIT 时长为 macOS 单机观察值,重传与 backlog 行为未实跑、按 RFC 与 tcp(7) 推导。