TCP 不知道什么是消息

发布: (2026年1月7日 GMT+8 18:57)
7 min read
原文: Dev.to

Source: Dev.to

(请提供需要翻译的正文内容,我将为您翻译成简体中文,并保留原始的格式、Markdown 语法以及技术术语。)

介绍

当我在使用 HTTP 时,我心里一直有一个安静的假设:

如果我发送一样东西,另一端就会收到一样东西。

这看起来显而易见,几乎太显而易见以至于不值得质疑。

在更高层工作会让你养成这种思维方式。一次请求感觉像是一个单元。一次响应感觉是完整的。事物之间的边界似乎是真实的。一切看起来整洁且自成一体,于是你不再去思考其底层。请求是原子的。响应是完整的。

于是当我从 HTTP 转到 TCP 时,我把这个假设也带了过去。结果没维持多久。

我快速浏览了语法,写了一个小的 TCP 服务器,并尝试套用我已经掌握的知识。几乎立刻,事情就开始以我意想不到的方式表现出来。我会写一次数据,却以碎片的形式收到返回,就像本该完整的东西被拆成了几块。还有时候,两个独立的写入会合并在一起到达。有时根本没有任何数据到达,直到很久以后才出现。

我以为是我的代码有 bug。随后我恍然大悟:我仍然把 TCP 当作 HTTP 来对待。

在 HTTP 中,TCP 是不可见的。框架隐藏了流的细节。你发送一次请求,得到一次响应。错误是明确的。部分数据永远不会泄漏出来,所以你根本不需要去考虑它。

为什么 TCP 会这样表现

TCP 有一个核心职责:确保从一端发送的字节可靠地、按相同顺序到达另一端。

仅此而已。

  • 它不理解消息。
  • 它不保留边界。
  • 它不在乎你打算如何解释这些数据。

一旦字节进入 TCP 流,它们就只是字节,何时交付取决于操作系统何时认为它们可用。

这就是为什么一次 write 可能会在多个 data 事件中到达,也就是为什么多个写入可以被合并在一起。你看到的分块并不是 TCP 的特性,而是交付细节。

如何处理这些字节完全由你负责。

关键要点

  • data 事件不代表已经收到完整的消息。
  • 消息边界必须由你自行定义并强制执行。
  • 偏移量不是记账——它关系到正确性。
  • 大多数协议错误是悄然失败,而不是 loudly 报错。

如果你把偏移量错误地前进了哪怕一个字节,解析器并不一定会崩溃。它会继续运行——只是在错误的状态下。这就是 bug 能够不被注意到而溜过去的原因。

大多数示例忽略的后果

如果 TCP 不保留消息边界,那么任何基于它之上的协议都必须自行定义。

没有显式的帧划分,你无法判断一条消息何时结束、下一条何时开始。你也不知道自己收到的是完整的消息还是仅仅是其中的一部分。如果判断错误,错误并不总是显而易见——往往是沉默的。

这就是协议漏洞产生的方式。

这种认识迫使你做的事

一旦你接受 TCP 只提供有序的字节流,几条规则就不可避免地出现:

  1. 维护每个连接的缓冲区。 数据可能不完整到达或被合并在一起。
  2. 实现显式帧划分。 除非你定义,否则消息边界并不存在。
  3. 仅解析完整数据。 解析不完整的数据不是可恢复的错误。

如果解析尝试因数据不完整而失败,你必须回滚并等待。偏移量必须回到解析开始的位置;否则会悄悄破坏协议。

这也是状态机变得必要的地方。不同的字节在连接的不同阶段代表不同的含义。如果没有严格的状态约束,即使是正确帧划分的数据也可能被错误解释。

一旦我不再把 TCP 当作理所当然提供结构,其他一切自然随之而来:

  • 缓冲区不再是可选的。
  • 帧划分不再是细枝末节。
  • 解析成为你必须争取的,而不是理所当然的。

每个字节都必须被计入,每个偏移都要有依据,每一次失败都要有意地处理。

在下一篇文章中,我将更进一步——探讨在字节流之上构建协议的真实含义。不是库或抽象,而是机制:缓冲区、帧划分,以及为何解析必须可重启。这正是 TCP 不再令人困惑而变得诚实的地方。

这篇文章是我在 Node.js 中从零构建 BitTorrent 系列的一部分,起始于原始 TCP 并向上构建。重点在于正确性和协议纪律,而非捷径或抽象。

如果你之前已经在这个层面上使用过 TCP,我很想知道是哪一个假设最先被打破的。

Back to Blog

相关文章

阅读更多 »

HTTP 缓存,回顾

请提供您希望翻译的具体摘录或摘要文本,我才能为您进行简体中文翻译。

API 如何工作:一次友好的实时魔法探索

嘿,技术好奇的朋友!想象一下,你在咖啡店里,给咖啡师递上一张纸条——你的拿铁像魔法一样出现。这就是 API:Application Programming Interface(应用程序编程接口)。