Skip to content

TCP 与 UDP ​

S2-013建议学进阶约 60 分钟JDK 21计算机基础 · 计算机网络

1. 本节定位 ​

《网络分层模型与数据传输过程》讲了分层;本节深入传输层 ——这是后端开发最常打交道的一层:

text
TCP:可靠、有序、有连接   → 代价是"没有消息边界" + 延迟略高
UDP:不可靠、无连接       → 好处是"保留消息边界" + 延迟低

★ 本节的核心实测是"粘包" ✓✓ ——它是理解 TCP 本质的最佳入口 ✓

2. 学习目标 ​

  • 能说出 TCP 与 UDP 的五个关键差异 ★★★;
  • 知道 TCP 的"可靠"是怎么换来的(握手、确认、重传、拥塞控制)★★;
  • 能解释粘包与拆包的成因,并说出三种划边界的方法 ★★★;
  • 知道 Nagle 算法与 TCP_NODELAY ★★;
  • 知道"队头阻塞"及其影响 ★★;
  • 能按场景选 TCP 还是 UDP ★★★。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看一个真实的 bug ✗:

java
// 客户端:连续发两条消息
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);      // ✗ 可能一次拿到两条

★ 本节的实测结果 ✓✓:

text
【客户端】连续 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 的正常行为 ✓✓

5. 核心概念 ​

5.1 五个关键差异 ★★★ ​

维度TCPUDP
连接 ★★★面向连接(三次握手)无连接(直接发)
可靠性 ★★★保证不丢、不重、按序 ✓什么都不保证 ✗
消息边界 ★★★没有(字节流)✗有(保留报文边界)✓✓
延迟 ★★握手 + 确认 + 拥塞控制 ✗低 ✓
开销 ★头部 20 字节起头部 8 字节 ✓

★ 本节的实测(本机回环,1000 次往返):

text
UDP 单次往返:约  94 微秒(无握手、无确认)
TCP 单次往返:约 115 微秒(复用连接 + 关闭 Nagle)

★ 本机差距小,是因为"没有网络延迟,瓶颈是系统调用" ✓ —— 真实网络里差三点 ✓:

差异来源影响
握手 ★★TCP 要先花 1.5 RTT 才能发第一个字节 ✗
重传与拥塞控制 ★★为可靠性主动降速 ✓
Nagle 算法 ★★★为了减少小包会"攒着发" ——对延迟敏感的服务要 setTcpNoDelay(true) ✓✓

★ 所以选型是这样的 ✓✓:

场景选理由
文件传输、网页、数据库、RPC ★★TCP不能丢一个字节 ✓
音视频、实时游戏位置 ★★UDP丢几帧能忍,卡顿不能忍 ✓
DNS 查询 ★UDP(大响应时退化为 TCP)一问一答,重试成本低 ✓
日志上报 / 指标采集 ⚠️UDP 可接受丢少量可容忍 ✓

5.2 粘包:TCP 是字节流,不是消息流 ★★★ ​

★ 为什么会粘包 ✓✓:

text
应用层视角: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 字节"最通用,二进制协议首选 ✓✓

★ 三种在真实协议里的对应 ✓:

text
HTTP/1.1 头:  分隔符(\r\n\r\n 表示头结束)+ Content-Length(长度字段)✓
WebSocket:    长度字段(帧头里有 payload length)✓
Redis RESP:   分隔符(\r\n)+ 类型前缀 ✓
Netty/Dubbo:  长度字段(LengthFieldBasedFrameDecoder)✓

★ 一句话 ✓✓:

"TCP 帮你去掉了丢包、乱序、重复这三个麻烦,但把'消息边界'这件事留给了应用层" ✓

5.3 Nagle 算法与 TCP_NODELAY ★★ ​

Nagle 算法的规则 ✓:

text
如果还有"未被确认的小包",就先攒着,等确认后再一起发

★ 它的目的:减少网络上的小包数量(小包效率低,头部开销占比大)✓

★ 它的代价:延迟变高 ✗✗ ——尤其是"请求-响应"式的交互:

text
发一个小请求 → 等对端确认 → 才发下一段数据 ✗
如果对端也在等你的完整数据,就形成"双方都在等"的僵局(延迟叠加)

★ 所以 ✓:

场景建议
交互式协议(RPC、游戏、即时通讯) ★★★socket.setTcpNoDelay(true) ✓✓
批量传输大文件保持默认(开启 Nagle) ✓ ——它能减少小包,提高吞吐 ✓

★ 这就是为什么很多 RPC 框架默认关掉 Nagle ✓

5.4 队头阻塞:TCP 的固有代价 ★★ ​

问题 ✓:

text
TCP 保证"按序交付" → 如果第 1 个包丢了,第 2、3 个包即使收到了也必须等 ✗
(因为不能把乱序的数据交给应用)

★ 它的影响 ✓✓:

层面影响
HTTP/1.1一个连接同时只有一个请求在飞 ✗
HTTP/2 ★★多路复用解决了"应用层"的队头阻塞,但没解决"TCP 层"的 ⚠️
HTTP/3 ★★改用 QUIC(基于 UDP) ——在 UDP 上自己实现可靠传输,规避 TCP 层队头阻塞 ✓✓

★ 一条推论 ✓:

"用 UDP 自己实现可靠性"不是倒退,而是"把控制权拿回来" ✓✓

因为 TCP 的实现在内核里,改不动;而在用户态基于 UDP 实现,就能按业务定制 ✓

QUIC、KCP、游戏网络库都是这条路 ✓

5.5 实测:UDP 的"不保证"有多不保证 ★ ​

本节的实测(本机回环):

text
发送 3000 个 UDP 数据报,收到 3000 个
丢失:0 个 | 乱序:0 个

★ 本机不丢,但这不是保证 ✓✓:

回环接口没有真正的链路,也没有拥塞,所以几乎不丢 ✓

一到真实网络(跨机房、跨运营商),丢包与乱序就会真实发生 ✗

UDP 不会为此做任何补偿 ✓

★ 所以用 UDP 时要自己考虑 ✓:

要自己做的事说明
序号 + 去重 ★★判断丢了哪些、哪些重复了 ✓
重传(可选) ★即时性要求高的场景可能选择"不重传,等下一帧" ✓
乱序重组 ★靠序号排序 ✓
拥塞控制(可选) ⚠️不做的话可能把网络打爆 ✗

★ 这条单子就是 QUIC 要做的事 ✓ ——所以"用 UDP 很简单"是个误解 ✗

6. 递进示例 ​

6.1 最小示例:按长度字段拆包 ​

java
// 发送: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 就是为了解决这个问题而存在的 ✓✓

6.2 业务示例:按分隔符拆包 ​

java
BufferedReader reader = new BufferedReader(
        new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
String line;
while ((line = reader.readLine()) != null) {     // ★ readLine 内部处理了粘包与拆包
    handle(line);
}

★ 一行 readLine 背后就是"分隔符 + 缓冲区 + 状态机" ✓

6.3 反例:假设"一次 read 一条消息" ​

见第 4 节 ✓

7. 完整可运行代码与实测 ​

代码:labs/cs-basics/src/main/java/cn/kb/learn/cs/net/TcpUdpLesson.java

运行:

powershell
cd labs
.\mvnw.cmd -pl cs-basics compile
java "-Dstdout.encoding=UTF-8" -cp cs-basics/target/classes cn.kb.learn.cs.net.TcpUdpLesson

实测输出(核心部分):

text
===== 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 连接关掉 NaglesetTcpNoDelay(true) ✓

8. Debug 观察点 ​

  1. 把客户端的 flush() 去掉:观察数据被攒在缓冲区里迟迟不发 ★;
  2. 把 setTcpNoDelay(true) 去掉并连续发小包:观察延迟变化 ★★;
  3. 故意发一个 1 MB 的大消息:观察服务端要多次 read(拆包)★;
  4. 把服务端缓冲区调小(read(buffer, 0, 8)):观察一条消息被拆成多次 ✓;
  5. 用 netstat -s(Linux/WSL)看重传次数与 TCP 统计 ✓。

9. 常见错误 ​

#错误说明
1假设"一次 read 一条消息" ✗✗粘包(5.2)✗✗
2假设"一次 write 一定被一次收到" ✗✗会被合并或拆分 ✓
3用 UDP 却不做去重与排序 ✗✗真实网络会乱序 ✓
4对 GDP 敏感的服务不关 Nagle ✗延迟叠加 ✓
5以为"TCP 重传能保证业务一定收到" ✗✗重传若干次后仍失败会直接断连 ✓
6以为 HTTP/2 解决了队头阻塞 ✗只解决了应用层的,TCP 层的还在 ✓
7在消息里用分隔符却不转义 ✗内容里出现分隔符就会解析错 ✗

10. 动手实验 ​

目标:亲手复现粘包,并用两种方式修好它。

步骤:

  1. 跑 TcpUdpLesson,确认 read 次数少于 write 次数 ✓;
  2. 把服务端的读取改成"按 \n 切分",观察它正确解析出 5 条 ★;
  3. 改成"长度字段"方案(先写 4 字节长度)并用 DataInputStream.readInt/readFully 读 ★★;
  4. 故意发一条 2000 字节的消息(超过一次 read 的量),观察拆包 ✓;
  5. 对比"定长/分隔符/长度字段"三种方案的代码量 ✓。

验收方式:能说出"你的协议用什么划边界、为什么选它"。

扩展任务:实现一个最简单的应用层协议:

  • 消息格式:[4 字节长度][4 字节类型][内容] ✓;
  • 支持"一次性发多条、一条分多次收" ✓;
  • 写测试验证:发 1000 条随机长度的消息,接收端能完整还原 ★★

11. 练习题 ​

  1. 判断:TCP 一次 write 的数据,对端一定能一次 read 到。(对 / 错)
  2. 填空:TCP 是 ______ 流,UDP 保留 ;划边界的三种方法是 、、。
  3. 简答:为什么说"用 UDP 自己实现可靠传输"不是倒退?
  4. (代码阅读)
java
socket.getOutputStream().write(msg1);
socket.getOutputStream().write(msg2);
socket.getOutputStream().flush();

如果对端用 read(buffer, 0, 1024) 读,会发生什么? 5. (编程)用 DataInputStream / DataOutputStream 实现一个"长度前缀"的消息收发,验证任意长度(1 字节~1 MB)都能正确收发。

参考答案(要点)
  1. 错。可能被合并或拆分 ✓★(本节实测:write 5 次 → read 2 次)✓
  2. 字节;消息边界;定长、分隔符、长度字段 ✓
  3. 因为 TCP 实现在内核里、无法定制 ✗ ——而基于 UDP 在用户态实现,可以按业务需求裁剪(低延迟优先就不重传)✓★
  4. 对端可能一次读到两条消息粘在一起(粘包)✗ ——所以协议必须自己划边界 ✓
  5. 要点:writeInt(length) + write(bytes);接收端 readInt() + readFully(bytes) ✓ 边界验证:1 字节与 1 MB 都要用 readFully 保证读满 ✓

12. 掌握标准 ​

  • [ ] 能说出 TCP/UDP 的五个关键差异 ★★★;
  • [ ] 能解释粘包与拆包的成因 ★★★;
  • [ ] 知道三种划边界的方法,并能按场景选 ★★★;
  • [ ] 知道 Nagle 算法与 TCP_NODELAY 的取舍 ★★;
  • [ ] 知道"队头阻塞"以及 HTTP/3 为什么改用 QUIC ★★;
  • [ ] 知道"用 UDP 要自己做哪些事" ★★。

13. 扩展资源 ​

14. 下一步学习 ​

《HTTP 与 HTTPS》:应用层协议的细节,以及 TLS 到底做了什么。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引