TCP字节流:分帧、半关闭与未知结果
先读 进程和资源预算。本页把 TCP 协议保证、JDK 系统调用包装、应用消息协议分开;不会把操作系统“收到了字节”解释为业务提交。协议依据固定 RFC 9293,JDK 实现基线 openjdk/jdk17u 的 jdk-17.0.12+7。
协议保证与业务误解
TCP 向两个端点提供可靠、有序的字节流,不传递应用 write 的边界。一次 write 可被多次 read 取得,多次 write 可在一次 read 中出现。应用读缓冲大小、发送时机、内核缓冲与网络分段都影响结果;所谓“粘包”不是 TCP 把业务消息弄坏,而是应用把流当成消息队列。
分清四层确认:本地 write 返回表示这次交给本地发送路径的字节数;对端 TCP ACK 表示协议序号空间的接收确认;应用响应表示服务定义的阶段;数据库提交/业务终态需要相应持久语义。前三者不能自动推出第四者。即使抓包看到对端 ACK,进程也可能在读出请求前崩溃。连接序号也不是跨重连的业务去重键。
TCP 只在连接活着且传输能够继续的条件下推进;永久分区会使操作失败或等待超时,不提供“最终业务必成功”。HTTP/MQ/RPC 对提交与恢复的语义是在这之上的另一层契约。
长度前缀解析器必须保存什么状态
例:4 字节大端无符号语义的长度头,应用只接受 0..1024 字节载荷。接收状态至少包含已收头字节数、已验证的载荷长度、已收载荷字节数;只有整帧完成才交付上层。若使用带 position/limit 的累积 ByteBuffer,读后 flip,逐帧消费,不足一帧 compact;compact 保留的是未消费字节,不是清空整个缓冲。
| 输入变化 | 必须保持的不变量 |
|---|---|
| 头只到 2 字节 | 不解析不完整的 int,更不能按默认 0 当空帧 |
| 两帧合并到达 | 循环解析两帧,保留第三帧残片,而非整个 read 当一条 |
| 长度负数/超上限 | 在分配载荷前拒绝;若允许无符号长度,先转 long 再检查,防止整数溢出 |
| 零长度帧 | 协议允许则立即交付一次,不能陷入不消费输入的死循环 |
| 多字节 UTF-8 被切开 | 对完整载荷统一解码,或持久 CharsetDecoder;逐块 new String 会产生替代字符 |
| EOF 且有残片 | 报截断,不能把最后残片当完整业务 |
定长适合固定记录,分隔符协议必须处理转义/最大行长,长度前缀便于二进制流但需防超长声明、慢速填帧与并发内存放大。限制单帧还不够:帧组装 deadline、每连接累计字节与全局预算一起约束,接纳边界见 NIO 背压。
JDK 读写分支:一次调用不负责填满帧
调用链是 SocketChannelImpl.read/write → IOUtil → dispatcher → 平台 socket 调用。ByteBuffer position 随实际传输推进;堆缓冲可能经过临时直接缓冲,应用帧大小不是系统调用完成条件。以下是原码,不是教学解析器。
连续摘录:SocketChannelImpl.java L417–L432,只统一公共缩进。
n = IOUtil.read(fd, buf, -1, nd);
if (blocking) {
while (IOStatus.okayToRetry(n) && isOpen()) {
park(Net.POLLIN);
n = IOUtil.read(fd, buf, -1, nd);
}
}
} catch (ConnectionResetException e) {
connectionReset = true;
throwConnectionReset();
} finally {
endRead(blocking, n > 0);
if (n <= 0 && isInputClosed)
return IOStatus.EOF;
}
return IOStatus.normalize(n);阻塞模式在内部可重试状态且通道仍开时等待 POLLIN;不是“没读满应用帧就继续”。reset 记录异常,输入关闭且没读到正数时返回 EOF,normalize 把内部状态转为公开返回值。非阻塞正容量读取的 0 是本轮没字节,不是 EOF;空 ByteBuffer 的 0 则根本不能拿来判断网络状态。
写端连续摘录:同文件 L532–L544。
n = IOUtil.write(fd, buf, -1, nd);
if (blocking) {
while (IOStatus.okayToRetry(n) && isOpen()) {
park(Net.POLLOUT);
n = IOUtil.write(fd, buf, -1, nd);
}
}
} finally {
endWrite(blocking, n > 0);
if (n <= 0 && isOutputClosed)
throw new AsynchronousCloseException();
}
return IOStatus.normalize(n);这里也只重试内部未能推进的状态,不按 buf.hasRemaining() 包办所有剩余数据。若一次写了 3 字节就把包含 10 字节的缓冲丢弃,会丢失应用数据。非阻塞下保留缓冲位置,有待发数据才监听 OP_WRITE;返回 0 时回事件循环而非无限自旋。关闭/中断竞争通过 finally 收尾,不能把任何 IOException 都解释为“远端肯定没执行”。
半关闭、EOF、RST 与请求终态
shutdownOutput 表示本端不再发送,先前已发送数据之后对端可读到 EOF;本端仍可读回响应。服务端读到 -1 只说明接收方向结束,若协议以完整请求后 EOF 为终止符,可继续处理并写回;若有残帧必须失败。把读到 EOF 就立刻双向关闭的模板套到所有协议,会丢响应。
正常结束与 RST 不同:RST 是中止信号,不是可当作完整帧结束的分隔符。Java 异常还可能来自本地关闭、线程中断等,异常类名与线上的某个包并非一一对应。半开连接(某端丢失连接状态,另端尚未发现)也不同于显式半关闭。
反例时间线:客户端写全请求 → 服务端提交 → 服务端开始发响应 → 连接失败 → 客户端异常。哪怕客户端尚未读到一个响应字节,也不能反推服务未提交。若应用收到完整且可验证的终态响应,则按该协议解释;残缺响应、EOF 或 RST 本身都不给业务裁决。
流控、拥塞与失活不是同一个超时
接收窗口限制对端能再送多少未接收数据,拥塞窗口限制网络中的注入量,两者都不等于业务线程池容量。服务端暂停 read 后,内核仍可能接受缓冲和在途字节,窗口缩小是滞后背压,不会立即消除堆里的排队请求。
握手超时、重传计时器、TCP keepalive、应用 idle timeout 和请求 deadline 分别回答不同问题。连接仍存活不表示某条请求能按时完成;操作系统 keepalive 默认时间尺度也可能远大于业务预算。一个请求 deadline 应包含队列、建连与响应,不能靠不断收到少量字节反复续期。HTTP 的阶段预算见 完整请求边界。
失败时间线:连接恢复不等于业务恢复
连接断开后重新建连只能恢复传输通道。写请求需要稳定业务键、相同请求摘要、持久终态和查询入口;重连不能生成新扣款身份。同键不同摘要应报冲突;通用事务/幂等方案引用 业务幂等的当前正文,不把 TCP 包装成 exactly-once。
架构师在跨区高 RTT 条件下,应分开测 DNS、握手/TLS、首字节、完整帧和业务终态。连接复用减少握手,但拓扑更新、负载均衡空闲回收、旧连接排空和重连洪峰改变恢复成本。增加请求重试可能延长队列,使原请求与重试同时执行;预算、接纳与状态查询必须共同设计。
实验:控制输入边界,不猜网络分段
公开 runner 的 tcp 模式使用 127.0.0.1、端口 0:编码“甲”和“beta”两帧;直接向同一解析器逐字节输入以及一次输入两帧,断言同样顺序;loopback 再用小读取缓冲还原。发送方 half-close 后仍读回接收方确认,断言干净 EOF;声明 100 字节但只给 20,断言截断;非法长度与半个头也必须拒绝。
确定性的分片输入测试证明解析器不依赖 read 边界;loopback 证明与真实流、EOF 的组合。不能断言两次 write 在网络上必定形成两个段,也不靠 sleep 制造“粘包”。本实验不抓包、不测拥塞算法、不证明广域网吞吐;运行环境和实际结果见实验页。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。