---
title: 为什么 TCP 是面向字节流，UDP 是面向报文？（传输层）
description: 讲清 TCP 字节流与 UDP 报文的本质差异，解析粘包/拆包成因与解决方案，覆盖 Nagle、Delayed ACK 等常见面试考点。
category: 计算机基础
tag:
  - 计算机网络
head:
  - - meta
    - name: keywords
      content: TCP,UDP,字节流,报文,粘包,拆包,消息边界,Nagle,Delayed ACK,TCP_NODELAY
---

前面说 TCP 是面向字节流，UDP 是面向报文。这个点看起来像一句定义，但很多粘包、拆包问题，其实都藏在这里。

先说结论：**TCP 只保证字节可靠、有序地到达，不保证应用层消息边界；UDP 会保留应用层交给它的报文边界。**

这篇文章主要回答几个问题：

1. 为什么说 TCP 是面向字节流，UDP 是面向报文？
2. TCP 粘包、拆包到底是怎么产生的？
3. 应用层应该如何定义消息边界？
4. Nagle 算法和 Delayed ACK 为什么可能让小包变慢？

举个例子，应用层连续发送两条消息：

```
消息 1：hello
消息 2：world
```

如果用 UDP 发送，通常会对应两个 UDP 数据报。接收方调用 `recvfrom()` 时，也是按数据报来读：一次读取一个 UDP 报文，不会把两次发送的报文合成一个流。UDP 的接收队列里，一个元素就是一个数据报，消息边界天然保留了下来。

不过这里也有一个细节：UDP 保留的是传输层报文边界，不代表它适合发送任意大的消息。数据报太大时，底层 IP 层仍可能分片；接收端缓冲区太小时，也可能出现截断。所以 UDP 的“面向报文”不是“随便发多大都没事”，而是说它不会像 TCP 那样把应用数据抽象成一条连续字节流。RFC 768 对 UDP 的定义就是 datagram mode，并说明它提供的是最小协议机制，不保证可靠交付和去重。

如果用 TCP 发送，就不能这么理解。应用层调用两次 `send()`，只是把两段字节写进内核发送缓冲区。至于这些字节什么时候发、合成几个 TCP 段发、对端一次 `recv()` 能读到多少，都不是由这两次 `send()` 直接决定的。

比如，接收端可能一次读到（粘包）：

```
helloworld
```

也可能分几次读到（拆包）：

```
hel
lowor
ld
```

这不是 TCP 出错，而是 TCP 的工作方式本来就是这样。TCP 处理的是连续字节流，它只关心这些字节是否可靠、有序地到达，不关心应用层定义的“第几条消息”从哪里开始、到哪里结束。RFC 9293 也明确提到，TCP segment 和应用层 `send()` / socket write 的边界通常不是一一对应的，TCP 不保证应用读写缓冲区边界和网络分段边界相关。

![TCP 与 UDP 的消息边界](https://oss.javaguide.cn/github/javaguide/cs-basics/network/tcp-udp-byte-stream-tcp-udp-message-boundary.png)

所以，“TCP 粘包/拆包”这个说法更像是应用层视角下的现象。严格来说，TCP 没有“包”的概念，它传的是连续字节流。真正需要解决的是：**应用层协议如何定义消息边界**。

#### 为什么会出现粘包和拆包？

常见原因有这几个。

**1. TCP 是字节流协议，没有应用层消息边界。**

TCP 负责把字节可靠、有序地送到对端，但不会记录“这 20 个字节是第一条消息，那 30 个字节是第二条消息”。

**2. 一次 `send()` 不等于一次网络发送。**

`send()` 成功通常只表示数据从应用进程拷贝到了内核发送缓冲区。至于什么时候真正发出去、拆成几个 TCP 段发，要看 MSS、发送窗口、拥塞窗口、Nagle 算法、网卡队列等因素。

**3. 一次 `recv()` 也不等于读到一条完整消息。**

接收端只是从 TCP 接收缓冲区取字节。缓冲区里可能已经堆了多条消息，也可能只有半条消息。`recv()` 只会把当前可读的数据拷贝给应用，不会帮你按业务消息切分。

**4. 小包优化可能改变发送时机。**

Nagle 算法、Delayed ACK、Linux 自动合并小写入等机制，都可能影响小数据的发送时机。比如 Linux 从 3.14 开始有 `tcp_autocorking`，内核会尽量合并连续的小写入，减少发送包数量；应用也可以用 `TCP_CORK` 明确控制何时“拔塞”发送。

这也是为什么在 Netty、Dubbo、自定义 RPC、IM 网关、游戏服务里，协议编解码都很重要。只要底层用的是 TCP，就必须在应用层定义清楚消息边界。

![TCP 粘包 / 拆包为什么会出现？](https://oss.javaguide.cn/github/javaguide/cs-basics/network/tcp-udp-byte-stream-tcp-sticky-split-causes.png)

#### 怎么解决 TCP 粘包/拆包？

核心思路只有一个：**让接收方知道一条消息到哪里结束。**

![应用层如何定义消息边界？](https://oss.javaguide.cn/github/javaguide/cs-basics/network/tcp-udp-byte-stream-tcp-message-boundary-solutions.png)

常见做法有三种。

**1. 固定长度**

规定每条消息都是固定长度，比如 64 字节。接收方每读满 64 字节，就认为读到了一条完整消息。

这种方式实现简单，但灵活性差。消息短了要补齐，浪费空间；消息长了又要额外拆分。它适合消息格式非常固定的场景，不太适合通用业务协议。

**2. 分隔符**

在消息之间加特殊分隔符，比如换行符 `\n`、`\r\n`，或者自定义结束标记。

```
hello\n
world\n
```

接收方不断从缓冲区读数据，只要遇到分隔符，就切出一条完整消息。很多文本协议都会用类似思路。

这种方式直观，但要注意两个问题：第一，分隔符可能刚好出现在消息体里，这时需要转义；第二，分隔符本身也可能被拆在两次读取里，所以接收端解析时不能假设一次 `recv()` 就能读到完整分隔符。

**3. 长度头**

这是工程里更常见的一种方式。协议头里固定放一个长度字段，表示后面的消息体有多少字节。

```
| 4 字节长度 | 消息体 |
```

接收方先读固定长度的协议头，解析出消息体长度，再继续读取指定字节数。只要没有读满，就继续等待；如果读多了，就把多出来的字节留在缓冲区，作为下一条消息的开头。

很多二进制协议、RPC 协议都会用这种方式。实际设计时，协议头里通常不只放长度，还会放魔数、版本号、消息类型、序列号、序列化方式等字段。

长度头方案也有坑。长度字段要约定字节序，通常使用网络字节序；还要限制最大包体长度，避免对端传一个特别大的长度值，把内存撑爆。线上做协议解析时，不能只考虑正常路径，还要处理半包、异常长度、连接中途关闭、恶意构造请求等情况。

#### Nagle 算法和 Delayed ACK 为什么会让小包变慢？

讲粘包时，经常会顺带问到 Nagle 算法。

Nagle 算法的目标是减少小包数量。早期网络带宽有限，如果应用每次只写 1 个字节，TCP/IP 头部却有几十个字节，网络里就会充满“小包”，效率很低。RFC 896 讨论的就是这类 small-packet problem，并提出当连接上还有未确认数据时，新的小数据可以先暂缓发送，等 ACK 到来后再继续发送。

Delayed ACK 是接收端的优化。接收端收到数据后，不一定立刻发 ACK，而是等一小段时间，看能不能把 ACK 和要返回的数据一起发出去，减少纯 ACK 包数量。RFC 9293 也把这种“少于每个数据段一个 ACK”的策略称为 delayed ACK。

这两个机制单独看都有道理，放在一起就可能放大延迟。典型场景是：

```
客户端 write 小数据 A
客户端马上 write 小数据 B
客户端等待服务端响应
```

![Nagle + Delayed ACK 为什么可能让小包变慢？](https://oss.javaguide.cn/github/javaguide/cs-basics/network/tcp-udp-byte-stream-nagle-delayed-ack-latency.png)

小数据 A 发出去了，小数据 B 可能因为 Nagle 算法暂存在发送缓冲区里，等待 A 的 ACK。服务端收到 A 后，如果暂时没有业务响应要返回，Delayed ACK 又可能延迟发送 ACK。于是发送端等 ACK，接收端等更多数据或等延迟确认定时器，延迟就被放大了。

这类问题在短小 RPC、交互式协议、游戏同步、远程终端里更容易被感知。

解决思路不是“无脑关 Nagle”。更稳的做法是：

- 能合并的小写入，在应用层先合并成一次完整消息，再调用一次 `write()`。
- 请求/响应模型里，尽量避免连续多次小 `write()` 后马上等待响应。
- 对延迟敏感、消息很小的连接，可以评估开启 `TCP_NODELAY`，让小数据尽快发送。
- 对吞吐优先、希望攒够数据再发的场景，可以在 Linux 上评估 `TCP_CORK`，但它不适合写跨平台代码。
- 调参前先抓包确认，不要看到“慢”就直接改 socket 选项。

在 Java 里，很多网络框架都会暴露 `TCP_NODELAY` 配置，例如 Netty 的 `ChannelOption.TCP_NODELAY`。它确实能降低小消息的等待时间，但也可能增加小包数量。对高 QPS 服务来说，这个 trade-off 要结合消息大小、RTT、吞吐、CPU 和网卡包量一起看。Linux `tcp(7)` 也说明，`TCP_NODELAY` 会关闭 Nagle 算法，而 `TCP_CORK` 则用于避免发送不完整帧、等应用确认“可以发了”再发送。

#### 面试时怎么回答？

可以这么回答：

TCP 是面向字节流的。应用层写入的数据会进入内核缓冲区，TCP 只保证这些字节可靠、有序地到达对端，不保证一次 `send()` 对应一次 `recv()`，也不保留应用层消息边界。因此接收方可能一次读到多条消息，也可能只读到半条消息，这就是常说的粘包、拆包现象。

UDP 是面向报文的。应用层交给 UDP 的一次数据会作为一个 UDP 数据报发送，接收端也是按数据报读取，所以天然保留消息边界。不过 UDP 不保证可靠到达，也不保证顺序。

解决 TCP 粘包/拆包，本质是应用层协议自己定义消息边界。常见方案有固定长度、分隔符、长度头。工程里更常用长度头，因为它对二进制协议和变长消息更友好，但要处理字节序、最大长度限制、半包缓存和异常连接关闭等问题。

<!-- @include: @article-footer.snippet.md -->
