TCP 连接、半开与故障:从字节流到可证明的连接生命周期
一次 HTTP 调用报 Connection reset,真正有价值的问题不是“网络为什么不稳定”,而是复位发生在连接生命周期的哪一段:三次握手尚未完成,服务端尚未读取完请求,响应仍在发送,还是连接已经被池化后又遭中间设备回收。TCP 只提供有序、可靠的字节流;它不认识 HTTP 请求,不知道一条业务消息在哪里结束,也不会保证对端进程仍在健康工作。把这些责任混在一起,才会出现“TCP 已连接,所以服务可用”“写成功,所以订单成功”之类危险推断。
连接不是一根管子,而是两套独立的序列空间
TCP 的每个方向都有自己的初始序列号、发送窗口、接收窗口和关闭状态。三次握手不是形式动作:双方交换初始序列号,确认彼此的发送能力,并把一个四元组映射为内核中的连接状态。connect() 成功只能证明握手当时完成,不能证明应用线程已经 accept(),更不能证明后续业务会在超时内完成。
RFC 9293 定义的是可靠、有序的字节流。应用一次 write() 的边界不会被原样保留:发送端的 8 KiB 可能被拆成多个段,也可能与下一次写入合并;接收端一次 read() 既可能只得到半个业务帧,也可能读到多个帧。协议必须自己定义长度前缀、分隔符或固定长度,并限制最大帧长。否则一个伪造的 2 GiB 长度字段就足以把“粘包问题”升级成内存拒绝服务。
可靠也不等于“立即送达”。发送成功通常只说明字节进入本机发送缓冲区;确认只说明对端 TCP 栈接收了相应序列空间,不说明对端业务事务已经提交。请求已提交但响应确认丢失时,调用方看到的仍是超时,这正是重试必须与幂等绑定的根因。
半关闭揭示 TCP 的双向性
shutdownOutput() 发送 FIN,表示本端以后不再发送字节,但仍可以继续接收。对端读到 EOF,只能得出“这个方向正常结束”,不能得出整个连接已关闭。协议若使用 EOF 作为请求结束标记,半关闭就是合法流程;若使用持久连接承载多个请求,EOF 则通常意味着连接不能复用。
CLOSE_WAIT 长时间堆积通常不是“内核没回收”,而是应用已经收到 EOF,却迟迟没有关闭自己的方向。排查应把 socket inode 映射到进程与线程栈,寻找仍持有连接的任务、未退出的读取循环或未完成的响应。FIN_WAIT_2 堆积则要确认对端为何不发送 FIN,以及系统是否有孤儿连接回收策略。
TIME_WAIT 由主动关闭方承担,它既保证最后一个 ACK 丢失时还能重发,也阻止旧连接的迟到报文污染相同四元组的新连接。盲目缩短它可能让压测数字好看,却破坏协议的时间隔离。真正的容量计算应从主动关闭速率、临时端口区间、目标地址分布和连接复用率出发:若单一源地址可用临时端口为 P,对同一目标每秒主动关闭 R 条,粗略压力与 R × TIME_WAIT 同阶;先提高复用、减少无意义短连接或扩展源地址,再讨论内核参数。
RST 不是“更快的 FIN”
FIN 表示此前字节流正常结束,RST 则立即使连接失效。常见来源包括:向不存在的监听端口发起连接;向已关闭连接继续发送;应用以未读数据的方式异常关闭;中间设备主动复位;进程崩溃后内核拒绝旧连接。收到 RST 后,未确认数据是否被对端应用处理往往不可知,因此错误处理不能自动等同于“请求未执行”。
examples/backend-development/network-web/tcp-connection/TcpHalfCloseDemo.java 让客户端关闭输出方向,服务端读到 EOF 后仍返回确认;TcpResetDemo.java 通过 SO_LINGER(0) 制造复位。运行方式:
javac --release 17 -Xlint:all -Werror examples/backend-development/network-web/tcp-connection/TcpHalfCloseDemo.java examples/backend-development/network-web/tcp-connection/TcpResetDemo.java
java -cp examples/backend-development/network-web/tcp-connection TcpHalfCloseDemo
java -cp examples/backend-development/network-web/tcp-connection TcpResetDemo输出为:
client-output-shutdown=true server-eof=true response=ack:5
resetObserved=true gracefulEof=false两个结果给出一条可执行判据:EOF 是正常的方向性终止,异常复位不是 EOF。生产代码应分开统计 eof、connect timeout、read timeout、connection reset、broken pipe,而不是全部折叠成“网络异常”。折叠之后,重试策略和故障归属都会失真。
半开连接为什么不会自己及时暴露
主机断电、链路黑洞或 NAT 状态丢失时,本端可能仍保持 ESTABLISHED,因为没有任何报文到达来否定旧状态。TCP 没有固有的业务存活保证。内核 keepalive 能探测长期空闲连接,但默认周期通常不适合秒级服务目标,而且它只能回答传输对端是否响应,不能回答线程池是否耗尽、依赖是否阻塞或业务是否还能完成。
应用心跳必须同时定义:谁发起、多久无业务才发、响应期限、连续失败次数、是否占用正常流控窗口、断线后是否允许重连和重放。心跳间隔过短会放大移动网络、NAT 和大规模连接上的周期流量;过长又无法满足故障发现目标。合理关系通常是:业务请求 deadline 最短,应用心跳负责会话级检测,TCP keepalive 作为更慢的僵尸连接清扫兜底。
从监听队列到端口耗尽,容量瓶颈有不同证据
监听路径至少要区分未完成握手队列与已完成握手、等待应用 accept() 的队列。SYN 洪泛、CPU 抢占、accept 线程停顿、工作线程阻塞会留下不同信号。仅看“服务端连接数”无法判断瓶颈:应联合观察 SYN 重传、listen overflow、accept 速率、握手 RTT、已建立连接、连接年龄、收发队列字节和应用排队时间。
客户端则常见连接池借用超时、DNS 候选端点集中、临时端口耗尽和 NAT 表容量不足。连接池上限不是越大越好。每条连接都消耗文件描述符、内核缓冲区、TLS 状态和服务端并发槽位;把池从 200 调到 2 万,只会把上游的排队转移到下游。池容量应与下游可承受并发、单请求服务时间和调用 deadline 一起计算,并设置等待队列上限。
一份能复盘的连接证据至少包含:本地与远端地址、连接状态、socket 错误码、发生时间、请求阶段、已发送/已接收字节、连接年龄、是否来自池、池中空闲时长、重试序号。抓包用序列号、ACK、FIN、RST 和重传证明传输事实;ss -tinp 或系统等价工具证明内核当时的队列、拥塞和进程归属;应用 trace 证明业务阶段。三者时间轴对齐,才能避免把应用主动超时误判为对端复位。
可上线的连接生命周期
连接管理器需要把资源所有权写进状态机,而不是散落在异常分支:解析出候选地址后,以受控并发建立连接;完成 TLS 与应用握手后才进入可借用状态;借出时设置本次调用的绝对 deadline;归还前确认响应体已消费、协议状态完整且连接未收到关闭信号;超过最大年龄、空闲期限或错误阈值时只允许一个关闭者执行清理。
安全边界同样位于生命周期中。服务端要限制握手速率、并发连接、单连接空闲时间、帧长度和未完成请求数;客户端不能因为“内网地址”就关闭 TLS 身份校验;代理后的源地址只可从受信任代理注入的字段恢复。TCP 负责搬运字节,连接之上的每一层仍要自行证明身份、边界和完成语义。
当故障发生时,先问连接处于哪个状态、哪个方向先结束、最后被确认的字节在哪里,再问是否重试。这个顺序会把含糊的“网络抖动”收敛为可验证的协议事件。
用序列号复盘一次复位事故
假设客户端日志显示请求体写完后 80 ms 收到 reset,服务端访问日志没有完成记录。不能据此断言服务端未处理。先在抓包中定位四元组和握手,沿客户端发送方向找到请求末尾序列号 N,再看服务端累计 ACK 是否达到 N。若 ACK 小于 N,至少有部分请求字节未被对端 TCP 确认;若 ACK 已到 N,只能证明对端协议栈接收完字节,仍要用服务端 trace、事务日志或幂等表确认应用结果。RST 包自身的发送方地址也不能直接等同于责任方,中间 NAT、代理和防火墙可能代表后端发出复位。
若服务端在响应中途复位,客户端还要检查已解析的 HTTP 边界。完整状态行和部分响应体不构成可用成功响应;流式下载则可能依据 Range、对象版本和校验和续传。任何续传都必须绑定同一资源版本,否则前半段来自旧对象、后半段来自新对象,字节总数正确也会生成静默损坏。
拥塞、流控与应用背压不要混为一谈
接收窗口表达接收方还能缓存多少字节,拥塞窗口表达网络路径当前允许多少在途数据,应用队列表达业务还能处理多少任务。三者都可能让发送变慢,却需要不同处置。零窗口持续出现要检查接收应用是否停止消费;重传与拥塞窗口下降要检查路径丢包;socket 写队列持续增长而业务队列已满,则应停止读取上游或快速拒绝,不能继续把压力藏进内核缓冲。
Nagle、延迟 ACK 和小包交互可能增加低吞吐交互的延迟,TCP_NODELAY 也不是所有连接的固定答案。开启前应证明协议产生大量依赖前一小响应的微小写入;更根本的优化通常是合并业务帧、减少往返和批量发送。吞吐型传输盲目禁用合并会增加包率、中断和 CPU。
验收一套连接治理时,可以进行四种故障注入:监听端口不存在验证快速拒绝;防火墙静默丢弃验证 connect deadline;请求到达后服务端延迟验证 read deadline 与幂等;响应中途 SO_LINGER(0) 验证 reset 分类。每个实验都要证明连接、线程、池配额和业务状态最终释放,而不只是客户端收到了预期异常。
