TCP 连接、字节流与故障处理
一条 TCP 连接连接两个端点。每个端点由 IP 地址和端口组成,内核在两个方向分别维护序列号、缓冲区和关闭状态;应用通过 socket 读写字节。
客户端进程 服务端进程
└─ socket ├─ 监听 socket
192.0.2.10:53000 ───── TCP 连接 ──────────────└─ 已连接 socket
198.51.100.20:8080
连接四元组:源 IP、源端口、目标 IP、目标端口
两个方向:客户端 → 服务端;服务端 → 客户端图中的地址是文档示例地址。实际连接还受网络命名空间、路由、NAT 和防火墙影响。应用层的 HTTP、数据库协议和自定义消息协议在 TCP 字节流上定义各自的消息格式;HTTP/3 则使用 QUIC,不经过 TCP。
端点、监听与第一次通信
IP、端口和 socket 各自标识什么
IP 地址用于网络层寻址:IPv4 为 32 位,IPv6 为 128 位。端口是传输层的 16 位编号,同一个数值的 TCP 端口与 UDP 端口属于不同空间。客户端通常让系统分配临时源端口;服务端在约定端口监听,让客户端知道连接入口。
socket 是操作系统提供给进程的通信对象。Java 的 Socket、ServerSocket 是对应的编程接口;Linux 中它还会占用文件描述符。一个监听端口可以接受许多连接,每个 accepted socket 都有自己的对端和收发状态,监听 socket 继续接收后续连接。它们不会因为本地端口相同而成为同一个对象。
| 绑定或访问地址 | 含义 |
|---|---|
| 127.0.0.1、::1 | 当前网络命名空间的回环地址 |
| 0.0.0.0 | 监听当前命名空间的所有 IPv4 本地地址,不是客户端应使用的目标 |
| :: | IPv6 任意地址;能否同时接收 IPv4 取决于双栈配置 |
| 某个网卡地址 | 仅在该本地地址接受连接 |
| 容器中的 127.0.0.1 | 指向该容器网络命名空间,通常不是宿主或另一个容器 |
例如应用在容器中监听 0.0.0.0:8080,Docker 可以把宿主 127.0.0.1:18080 映射到它。应用若只监听容器的回环地址,普通端口发布通常无法从容器外到达。宿主端口映射与应用监听是两个设置。Docker 端口发布文档说明发布地址及可达范围。
用半关闭发送一段请求
Linux 上准备完整 JDK 17 或 25、Bash。JDK 安装与版本选择见Java 平台与版本基线。以普通用户操作,实验只访问回环、让内核分配端口,不需要管理员权限。
下载 TCP 实验包,解压进入 tcp-connection 目录。HalfClose.java 的完整内容如下:
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public final class HalfClose {
public static void main(String[] args) throws Exception {
try (ServerSocket listener = new ServerSocket()) {
listener.bind(new InetSocketAddress("127.0.0.1", 0));
listener.setSoTimeout(2000);
try (Socket client = new Socket()) {
client.connect(new InetSocketAddress("127.0.0.1", listener.getLocalPort()), 1000);
client.setSoTimeout(2000);
client.getOutputStream().write("hello".getBytes(StandardCharsets.UTF_8));
client.shutdownOutput();
try (Socket server = listener.accept()) {
server.setSoTimeout(2000);
byte[] request = server.getInputStream().readNBytes(6);
if (request.length != 5 || server.getInputStream().read() != -1) {
throw new AssertionError("expected five bytes followed by EOF");
}
server.getOutputStream().write("ack:5".getBytes(StandardCharsets.UTF_8));
}
String reply = new String(client.getInputStream().readNBytes(16),
StandardCharsets.UTF_8);
if (!"ack:5".equals(reply)) throw new AssertionError(reply);
System.out.println("outputShutdown=" + client.isOutputShutdown()
+ " requestBytes=5 serverEof=true reply=" + reply);
}
}
}
}一个进程中按顺序操作客户端和服务端,便于观察两个方向;连接仍由真实 TCP socket 建立。生产部署通常把它们放在不同进程。服务端限制只读至多 6 字节,防止把不受限制的网络输入全部装入内存。
在解压目录执行,JAVA_HOME 改为本机实际安装位置:
export JAVA_HOME=/opt/jdk-25
unset JAVA_TOOL_OPTIONS JDK_JAVA_OPTIONS _JAVA_OPTIONS
LAB_OUT=$(mktemp -d /tmp/tcp-teaching.XXXXXX)
"$JAVA_HOME/bin/javac" --release 17 -Xlint:all -Werror \
-d "$LAB_OUT" HalfClose.java FrameCodec.java TcpLab.java
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" HalfClose预期输出:
outputShutdown=true requestBytes=5 serverEof=true reply=ack:5hello 的 UTF-8 编码是 5 字节。客户端 shutdownOutput 结束发送方向,服务端读取完这 5 字节后得到 EOF,再发送 ack:5。客户端此时仍能读取响应。服务端关闭自己的 socket 后,客户端读到响应的末尾。
Socket API规定 shutdownOutput、读取超时与关闭行为。例子中的 connect 超时 1 秒、读取超时 2 秒只是回环实验上限;生产设置应取自调用预算。若抛 SocketTimeoutException,先确认运行的源码和模式、进程资源以及字节流是否已经结束,不删除超时继续无限等待。
只有 Docker 的机器可以使用同一源码:
docker run --rm --user 10001:10001 --read-only --network none \
--cap-drop ALL --security-opt no-new-privileges \
--tmpfs /tmp:rw,exec,size=256m \
-v "$PWD:/src:ro" eclipse-temurin:25.0.4_7-jdk bash /src/run.sh宿主用户须已有 Docker 使用权限;该权限本身具有很高的宿主控制能力。容器中的编译和运行身份都是 10001,源码只读,class 写入临时目录,退出自动清理。--network none 仍允许容器内部回环。镜像首次使用需要下载,企业内网可导入经批准的镜像归档,不随意替换为个人镜像源。
字节流怎样组成消息
应用一次 write 的长度和对端一次 read 的返回长度没有一一对应关系。TCP 可以分段发送,也可以把连续写入的字节合并交付;接收缓冲区当前有什么、调用方请求读多少,都会影响一次读取的结果。
应用消息 A:长度 3,内容 01 02 03
应用消息 B:长度 2,内容 04 05
线上字节:00 00 00 03 01 02 03 00 00 00 02 04 05
读操作:可能先读 2 字节,再读 5 字节,再读其余字节
解析器:积累完整长度字段 → 校验长度 → 收齐内容 → 处理一条消息常用分帧方式包括固定长度、分隔符、长度前缀和连接 EOF。分隔符协议要定义转义;长度前缀必须在分配内存前检查上限;EOF 能结束一次交换,却不能在同一发送方向继续承载下一条请求。
实验中的 FrameCodec 使用四字节大端长度,最多允许 4096 字节内容:
public static byte[] read(InputStream input) throws IOException {
DataInputStream data = new DataInputStream(input);
int length = data.readInt();
if (length < 0 || length > MAX_BYTES) throw new IOException("invalid frame length");
byte[] payload = data.readNBytes(length);
if (payload.length != length) throw new EOFException("truncated frame");
return payload;
}该方法用于“必须再读一帧”的位置:长度字段不完整或内容提前结束均为异常。持续会话若允许帧间正常 EOF,应另外区分“还没有新帧首字节”和“帧已开始但未收全”。DataInputStream的 readInt 与InputStream的 readNBytes 有不同的不足长度行为,因此两步都要检查。
保持上一节 LAB_OUT,在同一终端执行:
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" TcpLab frames
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" TcpLab truncated
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" TcpLab oversizefirstLength=3 secondLength=2 eof=true
truncatedRejected=true allocatedLimit=4096
oversizeRejectedBeforeAllocation=true前一项通过真实 socket 连续发送两帧;第二项声明 5 字节却只发送 2 字节后半关闭;第三项发送超大长度字段。错误输入在交给业务前被拒绝。协议最大长度、队列最多帧数和每连接总缓存量还应一起限制,单帧安全不能约束无限累积的消息。
建立连接与可靠传输
握手完成后,应用还要 accept
通常的主动打开过程如下。x、y 是两个方向各自选择的初始序列号:
客户端 服务端
CLOSED LISTEN
── SYN, seq=x ───────────────────────────→ SYN-RECEIVED
←─ SYN+ACK, seq=y, ack=x+1 ────────────────
── ACK, seq=x+1, ack=y+1 ─────────────────→ ESTABLISHED
ESTABLISHEDSYN 占用一个序列号。客户端随后发送 n 字节,首字节序列号是 x+1,最后一个是 x+n,连续接收后的确认号是 x+n+1。ACK 字段表示接收方下一次期待的序列号;纯 ACK 不额外占用序列号。握手和状态转换的完整定义见 TCP 规范 RFC 9293。
服务端内核完成连接建立后,连接可以先在待接受队列中等待,应用再通过 accept 取得已连接 socket。Linux 的 listen backlog 主要约束已建立而未被应用接受的队列,实际值还受 somaxconn 限制;尚未完成握手的队列另有控制。把 backlog 增大并不会增加业务处理能力。listen 系统调用
实验刻意先让客户端 connect 返回,再调用服务端 accept:
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" TcpLab acceptconnectReturned=true acceptCalled=false
acceptCalled=true acceptedConnected=true第一行出现时,应用尚未 accept,客户端已经建立连接。服务端可以在这个阶段因线程停顿或队列积压迟迟不处理请求。测量“连接耗时”和“等待首字节耗时”有助于区分这两类等待。
序列号、确认与重传
TCP 把一个方向的字节排列成连续序列。发送端保留尚未确认的数据;接收端可以缓存乱序片段,但通常只有连续字节才能交给应用。同一片段被重传多次时,接收端通过序列号消除重复,不会把重传内容当成一条新应用消息。
累计 ACK 告诉发送端连续接收到了哪里。启用 SACK 后,接收端还能报告缺口之后已经收到的区间,帮助发送端只重传缺失片段。SACK 选项描述报告方式。网络仍可能丢弃重传包,因此恢复需要时间。
重传可以由重复确认等反馈触发,也可以等重传计时器到期。RTO 根据测得的往返时延及其波动计算,超时后会退避;它不是应用设置的固定读取超时。RTO 计算
TCP ACK 到达意味着对端 TCP 已确认相应字节。应用可能还没读取,数据库事务也可能尚未开始。订单是否创建成功,需要应用响应、查询接口或去重记录来判断;传输确认没有包含这些业务信息。
接收窗口与拥塞窗口
发送方同时受到两个方向的限制:
| 限制 | 解决的问题 | 常见观察 |
|---|---|---|
| 接收窗口 rwnd | 接收端还有多少接收缓存空间 | 应用不及时读取时,窗口可以逐步缩小至零 |
| 拥塞窗口 cwnd | 网络路径当前允许多少未确认数据在途 | 丢包、确认和拥塞控制算法影响发送速率 |
| 应用供给与发送缓存 | 应用是否有数据、能否继续排入内核 | 发送缓存满时,阻塞 write 可能等待 |
| 对端处理速度 | 字节进入应用后的工作速度 | 数据库、磁盘或下游等待可能拖慢读取和响应 |
经典拥塞控制包含慢启动、拥塞避免、快速重传和快速恢复;现代系统也可能使用 CUBIC、BBR 等算法,不能从 RFC 中的一组窗口常数推断所有主机。RFC 5681提供基础算法定义,Linux 的可配置项见 tcp 手册。
零窗口通常应先看接收进程为什么不读。盲目扩大缓冲区可能延后问题出现,同时让更多数据占用内存。吞吐受限时还要考虑带宽时延积:高带宽、高 RTT 的路径需要足够在途数据;小响应接口则常受往返次数和服务端处理时间影响。
Nagle 算法尝试合并小段,TCP_NODELAY 可关闭这一合并策略。它适合确有小消息延迟问题的协议,但每写几个字节就 flush 仍可能产生大量小包;应用层合理批量写出通常也值得检查。
关闭、超时与连接状态
FIN 结束一个方向,RST 中止连接
正常关闭通过 FIN 表示“该方向不会再发送数据”。对端先读完此前数据,再读到 EOF;另一个方向可以继续发送。半关闭实验正利用了这点。
主动结束发送的一端 收到 FIN 的一端
ESTABLISHED ESTABLISHED
── FIN ─────────────────────────→ CLOSE-WAIT
FIN-WAIT-1 ←─ ACK ──────────────────
FIN-WAIT-2 应用仍可发送剩余响应
←─ FIN ────────────────────────── LAST-ACK
── ACK ─────────────────────────→ CLOSED
TIME-WAIT → 等待结束 → CLOSEDACK 与 FIN 可以合并,双方也可能同时关闭,因此抓包不一定恰好出现四个独立报文。TIME_WAIT 用于保留状态,处理迟到片段及最后确认相关的重传;通常由主动关闭的一侧进入,同时关闭时双方也可能进入。CLOSE_WAIT 表示本地 TCP 收到了对端 FIN,本地应用尚未结束发送方向;这个状态本身不能证明代码已经读到了 EOF。
RST 则会中止连接。来源可能是未监听端口、应用主动中止、进程关闭含未读数据的 socket,或中间设备。仅凭异常字符串“Connection reset”无法确定是哪一个组件发出的复位。
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" TcpLab reset
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" TcpLab timeoutresetObserved=true gracefulEof=false
readTimedOut=true socketStillUsable=true nextByte=42reset 模式在服务端使用 SO_LINGER=0 主动中止,客户端确实收到 SocketException;该结果限定于实验中的 Linux TCP 实现。timeout 模式先让一次 read 等待超时,再由对端写入 42,客户端第二次读取成功。Java 的 SO_TIMEOUT 到期不会自动关闭这个 Socket,重试读取是否安全还取决于应用协议已经读取了多少内容。
多种“保活”和超时不能混用
| 设置 | 约束范围 | 设置后的处理 |
|---|---|---|
| connect timeout | 本次建立连接等待 | 失败后检查地址、路由、监听与入口队列 |
| SO_TIMEOUT | Java 阻塞读取的一次等待 | 处理部分消息状态,决定继续读取或关闭 |
| 业务总截止时间 | 从排队到获得业务结果的整次调用 | 到期取消等待,并处理结果未知 |
| TCP keepalive | 空闲连接的探测 | 系统间隔可能很长,不适合作为短业务预算 |
| 应用心跳 | 协议双方约定的活跃性检查 | 需要定义应答、间隔、丢失容忍与重连 |
| TCP_USER_TIMEOUT | Linux 中数据长期未被确认,或因零窗口而无法发送的时间上限 | 由内核中止连接;不会改写重传算法 |
HTTP keep-alive 表示复用连接,与 TCP keepalive 探测是不同功能。连接池的空闲淘汰时间还应与负载均衡器、代理和服务器的空闲关闭时间协调;从池中拿到对象后仍可能发现连接已被对端关闭。
单次 read 超时也不是整条消息的期限:对端若不断在超时前发送少量字节,循环读取可能持续很久。长消息解析需要总截止时间和长度上限。Java 普通 Socket 的 SO_TIMEOUT 不限制阻塞 write;要限制完整调用,应选择支持截止时间和取消的客户端库,或由调用层负责关闭/取消底层操作。
用 Linux 输出定位故障
观察一个真实的 CLOSE_WAIT
继续使用已编译的 LAB_OUT。下面命令启动一个只保持 30 秒的回环连接,读取到 FIN 后暂不关闭服务端 socket:
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" TcpLab probe 30 &
PROBE_PID=$!
ss -tanp
wait "$PROBE_PID"程序打印本轮 PID、listenPort 和 clientPort。若第一次 ss 执行得早于程序输出,看到端口后再执行一次。正常窗口中能同时看到 LISTEN、FIN-WAIT-2 和 CLOSE-WAIT;端口及 PID 每次不同。Java 在 Linux 上可能以 [::ffff:127.0.0.1] 显示 IPv4 映射地址,这是双栈 socket 的显示方式。
需要 Docker 环境中的 ss 时,下载网络实验工具源码,解压到独立目录并执行:
docker build -t local/network-web-tools:1 .Dockerfile 从固定 Temurin 25 镜像安装发行版的 iproute2、curl、OpenSSL、DNS 与抓包工具。安装软件阶段使用 root,最终 USER 为 10001;系统包的修订版本由发行版仓库决定,运行 ss --version、curl --version 等命令记录实际版本。离线使用应提前构建并导入组织认可的镜像。
回到 TCP 源码目录,可在同一个隔离网络命名空间编译、运行和观察:
docker run --rm --user 10001:10001 --read-only --network none --cap-drop ALL --tmpfs /tmp:rw,exec,size=256m -v "$PWD:/src:ro" local/network-web-tools:1 bash -ec '
javac --release 17 -d /tmp /src/*.java
java -cp /tmp TcpLab probe 8 &
probe_pid=$!
sleep 1
ss -tanp
wait "$probe_pid"
'8 秒窗口结束后,两端自动关闭。短暂 CLOSE_WAIT 是正常状态转换;长期累积且没有回落时,再追查谁持有 socket、为什么没有关闭,以及是否卡在业务读取或资源释放代码。
从监听到收发队列
在目标服务所在 Linux 网络命名空间执行。查看其他身份的 PID 信息可能需要经批准的 sudo:
ss -lntp
ss -tinp
ss -tan state close-wait
ss -tan state time-waitss 手册说明状态过滤和 TCP 内部字段。对于已连接 TCP socket,Recv-Q 表示尚未被应用读取的数据量,Send-Q 表示尚未被对端确认的数据量;监听行的队列字段含义不同,不能按待发送业务字节解释。ss -i 可提供 RTT、RTO、拥塞窗口和重传等信息,具体字段受内核版本影响。
| 观察 | 优先检查 | 处理后怎样复测 |
|---|---|---|
| 立即 connection refused | 目标地址/端口是否监听,是否有主动拒绝规则 | 同一命名空间检查监听,再以原目标重连 |
| connect 超时 | 路由、防火墙丢弃、入口拥塞、地址族 | 对照入口和客户端观察,确认握手恢复 |
| connect 快、首字节慢 | accept/线程队列、业务处理、下游依赖 | 比较连接、TTFB 和服务端处理跨度 |
| Recv-Q 持续增长 | 应用读取线程、CPU、下游阻塞 | 恢复读取后观察队列能否回落 |
| CLOSE_WAIT 持续增长 | EOF/异常分支的资源关闭 | 重复相同负载,比较存活 socket 数 |
| TIME_WAIT 多 | 短连接频率、连接复用、主动关闭方 | 看端口资源与错误率,再评估连接复用 |
| 复用连接偶发 reset | 对端与中间层空闲超时、连接健康检查 | 调整淘汰与重连,副作用请求先确认可重放 |
在有授权的实验机上,可用 tcpdump -ni lo tcp 观察回环 TCP 标志;缩小为实际端口可减少无关内容。抓包需要相应权限,文件可能含明文请求和凭证。TLS 流量通常只能直接观察连接和 TLS 记录,不能从加密包中读取 HTTP 业务内容。硬件卸载还可能使本机抓包显示看似异常的校验和或大段,不能直接判定线上报文损坏。
命令给出的是某一时刻的观察,排障时应保留相同请求的客户端结果、服务端日志及必要的网络样本。HTTP 层的状态与缓存继续见 HTTP 方法与语义,调用超时后的重复写入见 超时、重试与幂等。
本机 JDK 实验完成后,在原终端检查 LAB_OUT 确实是本次创建的 /tmp/tcp-teaching.* 目录,再删除该目录中的 class;不删除源码目录。Docker 的 --rm 和 tmpfs 会随实验容器退出移除临时产物。
权威资料与规范地址
按协议、系统调用与 Java API 查阅完整定义。
