TCP 不知道什么是消息
Source: Dev.to
(请提供需要翻译的正文内容,我将为您翻译成简体中文,并保留原始的格式、Markdown 语法以及技术术语。)
介绍
当我在使用 HTTP 时,我心里一直有一个安静的假设:
如果我发送一样东西,另一端就会收到一样东西。
这看起来显而易见,几乎太显而易见以至于不值得质疑。
在更高层工作会让你养成这种思维方式。一次请求感觉像是一个单元。一次响应感觉是完整的。事物之间的边界似乎是真实的。一切看起来整洁且自成一体,于是你不再去思考其底层。请求是原子的。响应是完整的。
于是当我从 HTTP 转到 TCP 时,我把这个假设也带了过去。结果没维持多久。
我快速浏览了语法,写了一个小的 TCP 服务器,并尝试套用我已经掌握的知识。几乎立刻,事情就开始以我意想不到的方式表现出来。我会写一次数据,却以碎片的形式收到返回,就像本该完整的东西被拆成了几块。还有时候,两个独立的写入会合并在一起到达。有时根本没有任何数据到达,直到很久以后才出现。
我以为是我的代码有 bug。随后我恍然大悟:我仍然把 TCP 当作 HTTP 来对待。
在 HTTP 中,TCP 是不可见的。框架隐藏了流的细节。你发送一次请求,得到一次响应。错误是明确的。部分数据永远不会泄漏出来,所以你根本不需要去考虑它。
为什么 TCP 会这样表现
TCP 有一个核心职责:确保从一端发送的字节可靠地、按相同顺序到达另一端。
仅此而已。
- 它不理解消息。
- 它不保留边界。
- 它不在乎你打算如何解释这些数据。
一旦字节进入 TCP 流,它们就只是字节,何时交付取决于操作系统何时认为它们可用。
这就是为什么一次 write 可能会在多个 data 事件中到达,也就是为什么多个写入可以被合并在一起。你看到的分块并不是 TCP 的特性,而是交付细节。
如何处理这些字节完全由你负责。
关键要点
data事件不代表已经收到完整的消息。- 消息边界必须由你自行定义并强制执行。
- 偏移量不是记账——它关系到正确性。
- 大多数协议错误是悄然失败,而不是 loudly 报错。
如果你把偏移量错误地前进了哪怕一个字节,解析器并不一定会崩溃。它会继续运行——只是在错误的状态下。这就是 bug 能够不被注意到而溜过去的原因。
大多数示例忽略的后果
如果 TCP 不保留消息边界,那么任何基于它之上的协议都必须自行定义。
没有显式的帧划分,你无法判断一条消息何时结束、下一条何时开始。你也不知道自己收到的是完整的消息还是仅仅是其中的一部分。如果判断错误,错误并不总是显而易见——往往是沉默的。
这就是协议漏洞产生的方式。
这种认识迫使你做的事
一旦你接受 TCP 只提供有序的字节流,几条规则就不可避免地出现:
- 维护每个连接的缓冲区。 数据可能不完整到达或被合并在一起。
- 实现显式帧划分。 除非你定义,否则消息边界并不存在。
- 仅解析完整数据。 解析不完整的数据不是可恢复的错误。
如果解析尝试因数据不完整而失败,你必须回滚并等待。偏移量必须回到解析开始的位置;否则会悄悄破坏协议。
这也是状态机变得必要的地方。不同的字节在连接的不同阶段代表不同的含义。如果没有严格的状态约束,即使是正确帧划分的数据也可能被错误解释。
一旦我不再把 TCP 当作理所当然提供结构,其他一切自然随之而来:
- 缓冲区不再是可选的。
- 帧划分不再是细枝末节。
- 解析成为你必须争取的,而不是理所当然的。
每个字节都必须被计入,每个偏移都要有依据,每一次失败都要有意地处理。
在下一篇文章中,我将更进一步——探讨在字节流之上构建协议的真实含义。不是库或抽象,而是机制:缓冲区、帧划分,以及为何解析必须可重启。这正是 TCP 不再令人困惑而变得诚实的地方。
这篇文章是我在 Node.js 中从零构建 BitTorrent 系列的一部分,起始于原始 TCP 并向上构建。重点在于正确性和协议纪律,而非捷径或抽象。
如果你之前已经在这个层面上使用过 TCP,我很想知道是哪一个假设最先被打破的。