主题
界面
主题
《网络分层模型与数据传输过程》讲了分层;本节深入传输层 ——这是后端开发最常打交道的一层:
TCP:可靠、有序、有连接 → 代价是"没有消息边界" + 延迟略高
UDP:不可靠、无连接 → 好处是"保留消息边界" + 延迟低★ 本节的核心实测是"粘包" ✓✓ ——它是理解 TCP 本质的最佳入口 ✓
TCP_NODELAY ★★;Socket / DatagramSocket 吗?✓先看一个真实的 bug ✗:
// 客户端:连续发两条消息
out.write("LOGIN alice\n".getBytes());
out.write("MSG hello\n".getBytes());
// 服务端:以为"一次 read 就是一条消息"
byte[] buf = new byte[1024];
int n = in.read(buf);
String msg = new String(buf, 0, n); // ✗ 可能一次拿到两条★ 本节的实测结果 ✓✓:
【客户端】连续 write 5 次,每次 20 字节
【服务端】发 5 次小的 write,看服务端要 read 几次:
read #1 拿到 18 字节:| MESSAGE-0--------\n |
read #2 拿到 72 字节:| MESSAGE-1--------\nMESSAGE-2--------\nMESSAGE-3--------\nMESSAG |
【服务端】共 read 2 次,收到 90 字节★ 客户端 write 了 5 次,服务端只 read 了 2 次 ✗✗ ——第 2 次一口气拿到了 4 条消息 ✓
这就是粘包 ✓ ——而它不是 bug,是 TCP 的正常行为 ✓✓
| 维度 | TCP | UDP |
|---|---|---|
| 连接 ★★★ | 面向连接(三次握手) | 无连接(直接发) |
| 可靠性 ★★★ | 保证不丢、不重、按序 ✓ | 什么都不保证 ✗ |
| 消息边界 ★★★ | 没有(字节流)✗ | 有(保留报文边界)✓✓ |
| 延迟 ★★ | 握手 + 确认 + 拥塞控制 ✗ | 低 ✓ |
| 开销 ★ | 头部 20 字节起 | 头部 8 字节 ✓ |
★ 本节的实测(本机回环,1000 次往返):
UDP 单次往返:约 94 微秒(无握手、无确认)
TCP 单次往返:约 115 微秒(复用连接 + 关闭 Nagle)★ 本机差距小,是因为"没有网络延迟,瓶颈是系统调用" ✓ —— 真实网络里差三点 ✓:
| 差异来源 | 影响 |
|---|---|
| 握手 ★★ | TCP 要先花 1.5 RTT 才能发第一个字节 ✗ |
| 重传与拥塞控制 ★★ | 为可靠性主动降速 ✓ |
| Nagle 算法 ★★★ | 为了减少小包会"攒着发" ——对延迟敏感的服务要 setTcpNoDelay(true) ✓✓ |
★ 所以选型是这样的 ✓✓:
| 场景 | 选 | 理由 |
|---|---|---|
| 文件传输、网页、数据库、RPC ★★ | TCP | 不能丢一个字节 ✓ |
| 音视频、实时游戏位置 ★★ | UDP | 丢几帧能忍,卡顿不能忍 ✓ |
| DNS 查询 ★ | UDP(大响应时退化为 TCP) | 一问一答,重试成本低 ✓ |
| 日志上报 / 指标采集 ⚠️ | UDP 可接受 | 丢少量可容忍 ✓ |
★ 为什么会粘包 ✓✓:
应用层视角:write("A") write("B") write("C")
TCP 视角: A B C 只是一串字节,没有任何"分界"概念 ✗
对端视角: 内核缓冲区里有多少就给你多少 → 可能 A / BC,也可能 ABC,也可能 A B / C★ 三个造成"合并或拆分"的现实因素 ✓:
| 因素 | 说明 |
|---|---|
| Nagle 算法 ★★★ | 小包攒一攒再发(减少网络上的小包数量)✗ |
| 内核发送/接收缓冲区 ★★ | 数据先写进缓冲区,对端从缓冲区读 ✓ |
| MSS 分段 | 超过最大段长会被拆成多个包 ✓ |
★ 两种方向 ✓:
| 现象 | 含义 |
|---|---|
| 粘包 ★★ | 一次 read 拿到多条消息(本例就是)✓ |
| 拆包 ★★ | 一条消息要多次 read 才拿全(消息大于缓冲区/MSS)✓ |
★★★ 三种划边界的方法(实际协议都在用) ✓✓:
| 方法 | 例子 | 优缺点 |
|---|---|---|
| ① 定长 ★ | 每条约 100 字节 | 简单,但长度不齐时浪费 ✗ |
| ② 分隔符 ★★ | 换行 \n、HTTP 头的 \r\n\r\n | 直观,但内容里不能出现分隔符(要转义)⚠️ |
| ③ 长度字段 ★★★ | 包头写明"后面有 N 字节" | 最通用,二进制协议首选 ✓✓ |
★ 三种在真实协议里的对应 ✓:
HTTP/1.1 头: 分隔符(\r\n\r\n 表示头结束)+ Content-Length(长度字段)✓
WebSocket: 长度字段(帧头里有 payload length)✓
Redis RESP: 分隔符(\r\n)+ 类型前缀 ✓
Netty/Dubbo: 长度字段(LengthFieldBasedFrameDecoder)✓★ 一句话 ✓✓:
"TCP 帮你去掉了丢包、乱序、重复这三个麻烦,但把'消息边界'这件事留给了应用层" ✓
Nagle 算法的规则 ✓:
如果还有"未被确认的小包",就先攒着,等确认后再一起发★ 它的目的:减少网络上的小包数量(小包效率低,头部开销占比大)✓
★ 它的代价:延迟变高 ✗✗ ——尤其是"请求-响应"式的交互:
发一个小请求 → 等对端确认 → 才发下一段数据 ✗
如果对端也在等你的完整数据,就形成"双方都在等"的僵局(延迟叠加)★ 所以 ✓:
| 场景 | 建议 |
|---|---|
| 交互式协议(RPC、游戏、即时通讯) ★★★ | socket.setTcpNoDelay(true) ✓✓ |
| 批量传输大文件 | 保持默认(开启 Nagle) ✓ ——它能减少小包,提高吞吐 ✓ |
★ 这就是为什么很多 RPC 框架默认关掉 Nagle ✓
问题 ✓:
TCP 保证"按序交付" → 如果第 1 个包丢了,第 2、3 个包即使收到了也必须等 ✗
(因为不能把乱序的数据交给应用)★ 它的影响 ✓✓:
| 层面 | 影响 |
|---|---|
| HTTP/1.1 | 一个连接同时只有一个请求在飞 ✗ |
| HTTP/2 ★★ | 多路复用解决了"应用层"的队头阻塞,但没解决"TCP 层"的 ⚠️ |
| HTTP/3 ★★ | 改用 QUIC(基于 UDP) ——在 UDP 上自己实现可靠传输,规避 TCP 层队头阻塞 ✓✓ |
★ 一条推论 ✓:
"用 UDP 自己实现可靠性"不是倒退,而是"把控制权拿回来" ✓✓
因为 TCP 的实现在内核里,改不动;而在用户态基于 UDP 实现,就能按业务定制 ✓
QUIC、KCP、游戏网络库都是这条路 ✓
本节的实测(本机回环):
发送 3000 个 UDP 数据报,收到 3000 个
丢失:0 个 | 乱序:0 个★ 本机不丢,但这不是保证 ✓✓:
回环接口没有真正的链路,也没有拥塞,所以几乎不丢 ✓
一到真实网络(跨机房、跨运营商),丢包与乱序就会真实发生 ✗
UDP 不会为此做任何补偿 ✓
★ 所以用 UDP 时要自己考虑 ✓:
| 要自己做的事 | 说明 |
|---|---|
| 序号 + 去重 ★★ | 判断丢了哪些、哪些重复了 ✓ |
| 重传(可选) ★ | 即时性要求高的场景可能选择"不重传,等下一帧" ✓ |
| 乱序重组 ★ | 靠序号排序 ✓ |
| 拥塞控制(可选) ⚠️ | 不做的话可能把网络打爆 ✗ |
★ 这条单子就是 QUIC 要做的事 ✓ ——所以"用 UDP 很简单"是个误解 ✗
// 发送:4 字节长度 + 内容
byte[] payload = "hello".getBytes(StandardCharsets.UTF_8);
ByteBuffer buffer = ByteBuffer.allocate(4 + payload.length);
buffer.putInt(payload.length).put(payload);
out.write(buffer.array());
// 接收:先读 4 字节长度,再读那么多字节
DataInputStream in = new DataInputStream(socket.getInputStream());
int length = in.readInt();
byte[] data = new byte[length];
in.readFully(data); // ★ readFully 会一直读到满★ DataInputStream 就是为了解决这个问题而存在的 ✓✓
BufferedReader reader = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
String line;
while ((line = reader.readLine()) != null) { // ★ readLine 内部处理了粘包与拆包
handle(line);
}★ 一行 readLine 背后就是"分隔符 + 缓冲区 + 状态机" ✓
见第 4 节 ✓
代码:labs/cs-basics/src/main/java/cn/kb/learn/cs/net/TcpUdpLesson.java
运行:
cd labs
.\mvnw.cmd -pl cs-basics compile
java "-Dstdout.encoding=UTF-8" -cp cs-basics/target/classes cn.kb.learn.cs.net.TcpUdpLesson实测输出(核心部分):
===== 1. UDP 的「不保证」长什么样 =====
发送 3000 个 UDP 数据报,收到 3000 个
丢失:0 个 | 乱序:0 个 ← 本机回环,不是保证
===== 2. TCP 的「保证送达」与它的代价 =====
发送 3000 条记录,收到 3000 条
丢失:0 条 | 乱序:0 条 ✓✓(TCP 保证了顺序与完整)
===== 3. 粘包与拆包 =====
【客户端】连续 write 5 次,每次 20 字节
read #1 拿到 18 字节:| MESSAGE-0--------\n |
read #2 拿到 72 字节:| MESSAGE-1--------\nMESSAGE-2--------\nMESSAGE-3--------\nMESSAG |
【服务端】共 read 2 次,收到 90 字节 ← ★★★ 粘包实测
===== 4. 单次往返延迟:TCP vs UDP(各 1000 次)=====
UDP 单次往返:约 94 微秒
TCP 单次往返:约 115 微秒(复用连接 + 关闭 Nagle)★ 三条可落地的结论 ✓:
| # | 结论 | 依据 |
|---|---|---|
| ① | 绝不要假设"一次 read = 一条消息" | write 5 次 → read 2 次 ✗✗ |
| ② | 协议必须自己定义消息边界(长度字段 / 分隔符 / 定长) | 粘包是 TCP 的正常行为 ✓ |
| ③ | 延迟敏感的 TCP 连接关掉 Nagle | setTcpNoDelay(true) ✓ |
flush() 去掉:观察数据被攒在缓冲区里迟迟不发 ★;setTcpNoDelay(true) 去掉并连续发小包:观察延迟变化 ★★;read(buffer, 0, 8)):观察一条消息被拆成多次 ✓;netstat -s(Linux/WSL)看重传次数与 TCP 统计 ✓。| # | 错误 | 说明 |
|---|---|---|
| 1 | 假设"一次 read 一条消息" ✗✗ | 粘包(5.2)✗✗ |
| 2 | 假设"一次 write 一定被一次收到" ✗✗ | 会被合并或拆分 ✓ |
| 3 | 用 UDP 却不做去重与排序 ✗✗ | 真实网络会乱序 ✓ |
| 4 | 对 GDP 敏感的服务不关 Nagle ✗ | 延迟叠加 ✓ |
| 5 | 以为"TCP 重传能保证业务一定收到" ✗✗ | 重传若干次后仍失败会直接断连 ✓ |
| 6 | 以为 HTTP/2 解决了队头阻塞 ✗ | 只解决了应用层的,TCP 层的还在 ✓ |
| 7 | 在消息里用分隔符却不转义 ✗ | 内容里出现分隔符就会解析错 ✗ |
目标:亲手复现粘包,并用两种方式修好它。
步骤:
TcpUdpLesson,确认 read 次数少于 write 次数 ✓;\n 切分",观察它正确解析出 5 条 ★;DataInputStream.readInt/readFully 读 ★★;验收方式:能说出"你的协议用什么划边界、为什么选它"。
扩展任务:实现一个最简单的应用层协议:
[4 字节长度][4 字节类型][内容] ✓;socket.getOutputStream().write(msg1);
socket.getOutputStream().write(msg2);
socket.getOutputStream().flush();如果对端用 read(buffer, 0, 1024) 读,会发生什么? 5. (编程)用 DataInputStream / DataOutputStream 实现一个"长度前缀"的消息收发,验证任意长度(1 字节~1 MB)都能正确收发。
writeInt(length) + write(bytes);接收端 readInt() + readFully(bytes) ✓ 边界验证:1 字节与 1 MB 都要用 readFully 保证读满 ✓TCP_NODELAY 的取舍 ★★;《HTTP 与 HTTPS》:应用层协议的细节,以及 TLS 到底做了什么。