切换深浅色
第 3 章 TCP 字节流与应用层消息
某个客户端连续写入两条代理消息,服务端一次读取把它们一起返回;另一次相同操作却分三次才收到。若服务端把每次读取当成一条消息,就会出现看似随机的“粘包”或“拆包”。真正的问题是接收端采用了 TCP 没有提供的边界假设。
3.1 TCP 字节流的契约
实际问题与直觉
把一串字符放进有顺序的通道,接收者按顺序取得字符,但每次拿到几个由当时条件决定。这有助于理解字节流;不过实际 TCP 并非无限容器,也不保证永远成功送达,它有流量限制、重传、超时与连接错误。
字节流(byte stream)是按位置排列的字节序列。TCP 在每个方向提供有序、可靠的流传递服务,但不保留应用每次提交缓冲区的分块边界。[R9293 §2.2] “可靠”必须理解为协议通过检测、确认和重传等机制处理传输问题,不能改写成“无论断网多久,应用最终一定收到全部内容”。
严谨表达:拼接后的序列才是比较对象
设发送应用按顺序提交有限字节序列
其中
这项相等关系不要求
底层机制:序号与有序交付
TCP 以序号关联流中的位置,网络报文段携带某个范围的数据。由于路径条件,报文段可能丢失、重复或乱序;接收端 TCP 在正常状态下处理这些事件,只把按顺序可交付的数据提供给应用。应用无需把 TCP 层的重复重传内容当成额外业务数据。
序号并不是应用请求编号。TCP 只知道字节位置,不知道“这三字节是一条命令”或“这是一个用户的第三次请求”。两端初始序号及取模规则等属于传输实现;应用消息的编号、长度与完整性必须由应用协议定义。
如果前面缺少一段数据,即使后面的段已到达,普通 TCP 字节流也不能直接把后半段当作连续内容交付。后面的 HTTP/2 多路复用会把这种顺序依赖与多个逻辑流联系起来;此刻只要记住应用看到的是正常的有序前缀。
完整字节例子:两次写入,三次读取
发送端第一次写 ASCII 文本 ABC,第二次写 DEF,两片段的确定字节为 41 42 43 与 44 45 46。正常传递后的总流为:
text
41 42 43 44 45 46以下三种读取分块都满足相同流契约:
| 情形 | 第一次读取 | 第二次读取 | 第三次读取 |
|---|---|---|---|
| A | 41 42 43 | 44 45 46 | 无需第三次 |
| B | 41 | 42 43 44 45 | 46 |
| C | 41 42 43 44 45 46 | 无需第二次 | 无需第三次 |
表中的“无需下一次”不是说 TCP 自动返回某种消息结束标记,只是该示例的六字节目标内容已经取得。如果连接仍然正常开放,接着读取可以等候后续流字节。
在 A 中,读取边界恰好等于写入边界,但只是该情形的结果;B 跨过一次写入边界;C 合并两次写入。不能因为本机重复观察到 A,就把它写成 TCP 协议性质。
不提供消息边界意味着什么
没有额外协议规则时,接收端仅从 ABCDEF 不能唯一确定原来是 ABC 与 DEF,还是 A 与 BCDEF。这些不同划分都映射到相同字节流。若业务需要区别它们,就必须把边界信息编码进流或事先约定固定布局。
连接关闭也不总能替代消息边界。它可以在某些协议中界定一段直到结束的正文,却不能在一条持续连接上同时给多条消息标边界。第 2 章已说明持久 HTTP 连接能够承载多个请求,依赖的是 HTTP 定界规则。
常见误解与本节结论
TCP 的推送标志或关闭 Nagle 算法等传输选项,不提供一一对应的应用写入/读取边界。即使某次选项改变了发送时机,应用仍需按协议解析。具体选项的适用条件在性能章节讨论,不在此把调参当成消息格式。
本节结论:比较 TCP 传递结果时,先比较拼接后的字节序列和错误状态;比较应用消息时,再检查协议如何为这个序列编码边界。这两个问题不能互相替代。
3.2 写入、网络分段、读取与消息:四种边界
实际问题与严谨区分
抓包中出现三个 TCP 报文段,应用读取却返回两块数据,是否说明“丢了一个包”?不能这样判断,因为比较的是不同层的单位。TCP 报文段(TCP segment)包含 TCP 首部与其承载的数据;应用读取结果只呈现该连接可交付的流内容,不附带每个报文段的边界。[R9293 §3.7]
| 边界 | 由谁决定 | 接收应用能否直接据它确定业务消息 |
|---|---|---|
| 应用提交缓冲区 | 发送方应用与接口调用 | 不能,TCP 不保留该划分 |
| TCP 报文段 | TCP 实现、发送条件与分段限制 | 不能,普通流读取不提供该边界 |
| 接口返回片段 | 可用数据、缓冲容量与读取规则 | 不能,只代表本次取得的字节 |
| 应用消息 | 具体协议的长度、分隔或状态 | 能,但必须按协议解析 |
一种边界偶然与另一种重合,不会使两者成为同一概念。写一次、看到一个段、读一次,只是某次条件下的结果。
完整例子:相同十二字节的三种划分
继续使用十二字节教学请求:
text
01 01 00 06 01 bb e4 bd a0 e5 a5 bd假设发送方两次提交、传输层三段承载、接收接口三次返回。下面仅展示各段的数据部分,省略运行时 TCP 首部,且所有分段是假设的允许情况:
| 层次 | 第一个范围 | 第二个范围 | 第三个范围 |
|---|---|---|---|
| 应用提交 | [0,5):01 01 00 06 01 | [5,12):bb e4 bd a0 e5 a5 bd | 无 |
| TCP 承载数据 | [0,4):01 01 00 06 | [4,8):01 bb e4 bd | [8,12):a0 e5 a5 bd |
| 接口返回 | [0,2):01 01 | [2,6):00 06 01 bb | [6,12):e4 bd a0 e5 a5 bd |
每一行拼接后都是原来的十二字节。网络分段甚至把“你”的 UTF-8 三字节编码切成两部分,但并不改变流内容;TCP 不需要先识别中文字符才能传输。应用必须先恢复完整的编码单元或整条正文,再按 UTF-8 解码。
完整应用消息仍是 [0,12),其边界由六字节头和长度字段 6 决定,不由表中某个读取末尾决定。
分段大小为什么不是业务格式
最大分段大小(Maximum Segment Size,MSS)涉及 TCP 报文段数据部分允许的大小,与应用消息长度不是同一个量。一个大消息可以横跨多个段,多个小消息可以共享一个段。MSS、路径限制、拥塞状态、发送队列和调度共同影响实际分段,完整工程关系在第 30 章讨论。
本地抓包还可能受分段卸载与接收合并影响。TCP 分段卸载(TCP Segmentation Offload,TSO)允许把分段工作交给网卡;通用分段卸载(Generic Segmentation Offload,GSO)提供相应分段支持;通用接收卸载(Generic Receive Offload,GRO)可以合并接收处理中的内容。[K-OFFLOAD] 因此主机上的某个观测点可能展示合并后的较大对象,不能不加条件地当成链路上实际存在的一个大包。
这里仅说明观测位置会影响表示,不据此断言任意系统都启用了同样功能。分析真实抓包时应说明平台、接口、观测位置和相应设置。
“粘包”和“拆包”的准确含义
“粘包”通常指一次应用读取同时含多条消息;“拆包”通常指一条消息需要多次读取。它们是对应用现象的俗称,不是 TCP 在消息层出了错。因为 TCP 根本没有承诺该层消息边界,正确接收程序必须允许两种情况同时发生。
只把读取容量设置成某个消息的“通常大小”,不能解决可变长度、多个消息合并或前缀截断。插入发送间隔也只是改变某些时机,网络延迟与调度变化后仍会出现其他划分。消息格式需要可判定边界,而不是依赖时间巧合。
没有应用负载的 TCP 段不等于应用结束
TCP 可以发送只携带确认等信息而没有应用数据的报文段。内核处理它,并不因为数据部分为零就给应用返回一个“空消息”。普通读取中的零字节有自己的结束条件,第 2 章已经说明。不能把抓包某段的载荷长度零,与应用读取返回零等同。
相同地,若需要业务心跳,协议应明确心跳字段或消息类型;不能把纯 TCP ACK 当成已经完成某项应用检查。传输活动与业务状态仍属于不同层。
本节结论
正确分析应先标明自己统计哪一种边界,再比较字节范围。后续固定长度、长度前缀与分隔符三种方法,都是给应用消息提供可恢复的边界,完全不要求控制 TCP 怎样分段。
3.3 固定长度消息:边界由共同的宽度约定给出
实际问题与直觉
如果双方知道每条消息恰好四字节,接收端无需等待某个特殊字符:获得四字节就有一条,再获得四字节就有下一条。直观上像把序列每四个位置分一组,但分组起点必须由协议状态确定,不能从任意中间位置开始猜。
固定长度消息(fixed-length message)是双方约定每条完整消息均占同一个
底层机制:累计到固定宽度才解释
接收方维护尚未处理的字节。当已有至少
定界与字段验证是两个阶段。凑够四字节只说明边界候选已完整,版本、命令、范围和字段组合仍可能不合法。固定宽度没有自动的内容认证功能。
完整实例:两个四字节请求
定义本书的固定四字节教学消息:版本一字节、端口两字节大端、标志一字节。版本必须为 1;标志 bit 0 与 bit 1 使用第 1 章含义,其他位在本例必须为零。端口为 1—65535。
| 消息内偏移 | 长度 | 字段 |
|---|---|---|
| [0,1) | 1 | 版本 |
| [1,3) | 2 | 大端端口 |
| [3,4) | 1 | 标志 |
端口 443、标志 3 的消息为 01 01 bb 03;端口 80、标志 0 的消息为 01 00 50 00。连续完整流为:
text
01 01 bb 03 01 00 50 00| 流中区间 | 长度 | 恢复内容 |
|---|---|---|
| [0,4) | 4 | 版本 1、端口 443、标志 3 |
| [4,8) | 4 | 版本 1、端口 80、标志 0 |
假设首次读取六字节 01 01 bb 03 01 00。接收端解析前四字节,把 01 00 保留为下一条的前缀;下一次读取 50 00 后,拼成 01 00 50 00 再解释。不能丢弃第一次读取的末两字节,也不能把六字节整体当作一条更长消息。
为什么没有起点就不能任意分组
假如从流偏移 1 才开始观察,四字节 01 bb 03 01 并不是原来的一条消息。接收方必须知道连接或当前协议阶段从哪里开始这种格式。版本字节可能恰好在正文中出现,不能仅搜索值 01 来恢复边界。
固定长度的优点是边界计算直接,缺点是扩展字段宽度或支持不同内容长度不方便。协议若从四字节升级到六字节,需要在明确位置协商版本或切换格式,不能让新旧双方各自随意假设宽度。
变长文本塞进固定字段会发生什么
若用固定八字节存文本,就要规定短文本怎样补齐、长文本怎样处理,以及末尾零字节究竟是补位还是内容。直接截断 UTF-8 还可能切断多字节编码。即使视觉上的文本很短,它也可能超过固定字节容量。
例如六字节“你好”放入八字节字段,可以按某项教学约定补两个 00,但接收方必须知道文本是六字节;如果唯一规则是遇到 NUL 结束,那文本自身是否允许 U+0000 也要确定。固定字段不能把这些问题变成无需说明的默认行为。
适用条件与本节结论
固定长度适合信息结构稳定、范围很小、双方版本明确的控制数据。它也可用作变长格式的固定前缀,让接收端先取得长度字段。下节正是把固定宽度头与可变长度正文组合。
本节结论:固定长度以宽度和已知起点提供边界,但仍需要缓存不完整前缀、保留下一条数据、验证字段并规定扩展方式。定界足够简单,不等于协议设计已经完整。
3.4 长度前缀:先声明后续内容占多少字节
实际问题与直觉
固定宽度不适合任意长度正文。长度前缀(length prefix)把一个可解析的长度放在正文之前,使接收方先知道还要取得多少字节,再按这个范围恢复下一条消息。类似先写件数再交付物品,但这里数的是明确定义的字节范围,不是可见字符数或业务对象数。
这种方法不要求正文包含特殊分隔符,可以承载任意原始字节。代价是先取得并验证长度,才能决定该条消息的范围与资源需求。
严谨定义:前缀宽度和计数范围
本节定义教学格式:两字节大端无符号长度
| 消息内偏移 | 长度 | 字段 |
|---|---|---|
| [0,2) | 2 | 大端正文长度 L |
| [2,2+L) | L | 正文原始字节 |
不同真实协议可能把长度定义为整帧长度,或者只统计其中一部分。本例只是一个清楚的约定,不能从“有长度字段”推断所有协议的分母相同。若正文将按 UTF-8 解释,长度仍统计编码后的字节;若正文另有内部结构,也必须先按外层范围取得完整数据。
完整实例:三个连续消息
第一条正文 ABC 是三字节;第二条正文“你好”是六字节;第三条正文为空。完整消息分别为:
text
00 03 41 42 43
00 06 e4 bd a0 e5 a5 bd
00 00连续流为十五字节:
text
00 03 41 42 43 00 06 e4 bd a0 e5 a5 bd 00 00| 流中区间 | 字节数 | 长度值 | 正文区间 |
|---|---|---|---|
| [0,5) | 5 | 3 | [2,5):41 42 43 |
| [5,13) | 8 | 6 | [7,13):e4 bd a0 e5 a5 bd |
| [13,15) | 2 | 0 | [15,15):空 |
零长度消息仍占两字节前缀,它是一个有效应用消息,不是 TCP 读取结束。接收方消费这两字节后继续查下一条;如果只返回空正文却不推进已消费位置,就可能无限重复解析同一个前缀。
同一数据怎样逐次恢复
考虑以下允许的读取分块:
| 新到片段 | 新到字节数 | 解析结果 |
|---|---|---|
| 00 | 1 | 前缀未完整,保存 |
| 03 41 | 2 | 前缀为 3,已取得正文一字节,还缺两字节 |
| 42 43 00 06 e4 bd | 6 | 第一条完整;第二条长度 6,正文已有两字节 |
| a0 e5 a5 bd 00 00 | 6 | 第二条完整;第三条空正文完整 |
前缀也可能被拆开,所以“两次读取,第一次头、第二次正文”不是普适保证。接收状态应按目前已拥有的字节决定,而不是按接口调用次数决定。
同时,一次读取可能完成上一条、包含下一条的头部与部分正文。已消费部分和剩余部分必须准确划分;先解释第一条不意味着可以把本次读取的其余字节删除。
长度可表示范围与允许范围不同
两字节无符号长度能表示 0—65535,但服务程序可以规定单条正文最多 1024 字节。ff ff 在整数编码上合法,数值却超过本例服务政策,应该在接受正文资源请求前拒绝。格式值域与实现资源限制需要同时说明。
计算总长时,还要避免把
嵌套长度还需要交叉约束:若外层正文只允许十字节,内层字段声明后续一百字节,不能跨越外层边界去吞掉下一条消息。第 4 章的协议设计和后续安全记录都需要这种边界检查。
开销与解析时机
本格式固定增加两字节。三字节正文的头部占总长
读到长度以后,并非所有业务都必须把整条正文一次性保存到一个大缓冲区;可以在准确的剩余长度约束下逐步处理。但若业务需要完整认证或完整文本后才能使用内容,就不能在相关条件未满足前把片段作为可信业务数据。具体策略由上层规范和安全要求决定。
常见误解与本节结论
长度前缀不会减少 TCP 分片,也不提供保密性或认证。它解决的是定界,受到篡改的长度仍可能改变解析,因此后面的安全层需要自己的保护与错误处理。
本节结论:长度前缀先规定宽度、字节序和统计范围,再检查允许上限与总长算术;解析过程中持续保存未完整前缀和剩余正文,消费完整消息后准确继续下一条。
3.5 分隔符与转义:结束标记不能与正文混淆
实际问题与直觉
文本命令可以以换行结束,接收方扫描到行末就取得一条命令。这看起来比声明长度更自然,但如果正文自己含换行,分隔符就产生歧义。正确协议必须限制正文、转义正文或使用另一种定界规则。
分隔符定界(delimiter-based framing)在规定的解析状态中使用某个字节或字节序列标记边界。其含义依赖协议状态,不是说同一个标记在整个连接的任何位置都表示消息结束。
最简单的完整例子
先规定 ASCII 命令不能含 LF,以单字节 0a 结束。两条命令 PING 与 QUIT 的完整流为:
text
50 49 4e 47 0a 51 55 49 54 0a| 流中区间 | 总长 | 正文 | 结束标记 |
|---|---|---|---|
| [0,5) | 5 | [0,4):PING | [4,5):0a |
| [5,10) | 5 | [5,9):QUIT | [9,10):0a |
假设第一次取得 50 49,还没有分隔符,保留前缀;之后取得 4e 47 0a 51,第一条完整,末尾 51 属于第二条前缀。消息长度必须有上限,不能在持续没有分隔符时无限保存内容。
多字节分隔符可能跨读取边界
如果协议选择 CRLF,即 0d 0a,片段末尾只出现 0d 时不能立即确认分隔符,也不能把它丢掉。下一片段首字节若为 0a,两片段共同构成标记;若为其他值,应按协议规定把前一个 0d 当作正文或错误。
相同问题也出现在更长标记及标记的重复前缀中。接收方应保留足以判断匹配的尾部状态,并在新数据到达后继续扫描,而不是每次只搜索新片段内部。
任意字节正文如何避免歧义
本节再定义一个仅用于讲解的转义格式:原始 0a 为消息结束;1b 为转义引导。正文中的 0a 编码为 1b 2a,正文中的 1b 编码为 1b 3b,其他正文值原样编码。遇到转义引导后,下一个字节必须是 2a 或 3b,否则拒绝。
这个规则是本书定义,不属于某个现有代理协议。1b 在 ASCII 中有 ESC 名称,但这里的转换含义仍由本规则指定。
原始正文为:
text
41 0a 1b 42编码并附加结束标记后为:
text
41 1b 2a 1b 3b 42 0a| 线上区间 | 长度 | 线上内容 | 恢复或控制作用 |
|---|---|---|---|
| [0,1) | 1 | 41 | 正文 41 |
| [1,3) | 2 | 1b 2a | 正文 0a |
| [3,5) | 2 | 1b 3b | 正文 1b |
| [5,6) | 1 | 42 | 正文 42 |
| [6,7) | 1 | 0a | 消息结束,不进入正文 |
原始四字节变成六字节编码正文,再加一字节结束标记,总长七字节。若紧接再有一个 0a,那可以按本规则表示下一条空正文消息,而不是第一条消息的附加字符。
转义状态比可见文本更重要
接收端在普通状态看到 1b 后,进入等待转义后缀状态。若当前读取恰好结束在 1b,下一片段的首字节仍应作为后缀处理;不能把状态在每次读取后清零。普通状态中的 0a 结束消息,转义编码恢复的正文 0a 不结束消息。
若连接结束时仍在等待后缀,消息不完整;若已有正文却没有结束标记,完整消息也尚未形成。本规则下单独末尾 1b 不可以被静默删除。忽略这些条件会使不同接收者恢复不同正文。
原始长度、编码长度与开销
设原始正文长度为 0a 或 1b 共
因此开销随内容变化,不固定为一个字节。若全部字节都需转义,总长为
与 HTTP 的关系和常见误解
HTTP 请求行、字段行使用相应行分隔,但正文可能含任意换行或原始字节。不能把整个 HTTP 连接按 CRLF 分割成业务请求:进入正文后,解析规则要按照长度或其他传输定界状态改变。[R9112 §2.1、§6]
转义规则也不能与程序语言的反斜线写法混淆。本节的 1b 2a 是真实线上两个字节,不是仅为了显示而写下的 \n。日志显示可以再用一种转义方式展示它,但那是另外一层表示。
本节结论:分隔符必须在明确状态中解释;正文是否能包含它、怎样转义、分隔符跨片段怎样保存,以及过长输入怎样拒绝,都属于格式的一部分。
3.6 类型—长度—值:定界之外还需要解释字段
实际问题与直觉
协议加入一个可选字段后,旧接收方可能不知道它的意义。如果字段有明确类型和长度,接收方可以按协议政策跳过某些可忽略的扩展,而不必猜下一字段从哪里开始。但能跳过,不意味着可以忽略任何未知安全要求。
类型—长度—值(Type-Length-Value,TLV)是一类先标识值的类型、再声明值的长度、随后给出值的结构。它不是一套唯一字节规范:类型宽度、长度宽度、字节序、长度包含范围、重复字段和未知类型处理,都需要具体协议规定。
严谨定义:本节的教学 TLV
本例使用一字节类型、两字节大端长度
主机名与端口各必须出现一次,标志最多一次且可省略;端口范围为 1—65535。主机名的实际 DNS 规则以后再介绍,这里只示范明确的文本值 example.com。本格式接受三个字段的不同排列,但不接受规定字段重复。
TLV 序列自身不告诉接收方一整组字段何时结束。因此本例用第 3.4 节的两字节正文长度,在外层界定整组 TLV。外层长度与各内层长度承担不同职责。
完整字段与字节
example.com 是十一字节 ASCII,也具有相同 UTF-8 编码。类型 1、长度 11 的字段为:
text
01 00 0b 65 78 61 6d 70 6c 65 2e 63 6f 6d类型 2、长度 2、端口 443:
text
02 00 02 01 bb类型 3、长度 1、标志 3:
text
03 00 01 03| 组内区间 | 总长 | 类型 | 值长度 | 值区间与意义 |
|---|---|---|---|---|
| [0,14) | 14 | 1 | 11 | [3,14):example.com |
| [14,19) | 5 | 2 | 2 | [17,19):端口 443 |
| [19,23) | 4 | 3 | 1 | [22,23):标志 3 |
组内总长 00 17。完整二十五字节消息为:
text
00 17
01 00 0b 65 78 61 6d 70 6c 65 2e 63 6f 6d
02 00 02 01 bb
03 00 01 03表中偏移从组内正文开始计算;在完整消息中全部增加 2。例如端口值实际位于 [19,21),不是仍在 [17,19)。多层格式必须标明每张表的偏移基准。
底层机制:在外层范围内逐字段推进
得到完整外层范围后,接收方从组内偏移
若只剩一两字节,末字段头截断;若字段值跨过外层范围,内层长度不合法,不能把下一外层消息的字节拿来填满。完成组后还需检查必须字段是否出现,以及是否有不允许的重复或组合。
即使类型 2 声明长度 3,外层范围足够,也仍不合法:端口类型规定值必须恰好两字节。长度可读和类型语义正确是两个检查。
未知字段的可忽略与必须理解
为示范扩展策略,本例进一步约定:类型最高位为 0 的未知字段可在确认长度后跳过;最高位为 1 的未知字段必须拒绝。这个“重要性”规则只属于本教学格式,不推广到所有 TLV。
例如 7f 00 02 de ad 总长五字节,可在确认范围后跳过,它的值只是明确的教学常量,不是密文。若加入原有组,组长从 23 增至 28,外层前缀必须改为 00 1c。不能只插入字段而不更新外层长度。
相反,未知类型 ff 即使具有同样合法长度也必须拒绝,因为接收方不能理解它要求的行为。安全协商或必需功能若被旧程序静默忽略,可能使双方以为达成同一个协议,实际却执行不同规则。
重复、顺序与唯一解释
如果组内端口类型重复出现,一个组件取第一个、另一个组件取最后一个,将产生不同目标。TLV 的灵活结构不替设计者决定重复规则。本例直接拒绝重复的已规定字段,确保目标解释唯一。
如果真实协议允许多次出现某类字段,应说明它们是列表、覆盖、合并还是错误;如果顺序有意义,也应规定。不能只画出 TLV 三个字母就声称协议“天然向后兼容”。
第 19 章会遇到其他字段编码方式,其中某些机制与类型和长度概念相关,但并不使用本节一字节类型、两字节长度的具体布局。必须回到实际规范及源码,不按教学模板猜真实字节。
本节结论
TLV 把字段类型和范围一起带入数据,但完整定界、未知类型政策、必须字段、重复及顺序规则仍需另外定义。外层与内层边界交叉检查,才能在增加扩展的同时保持可唯一解释的消息。
3.7 缓冲区与增量解析:未完整不等于不合法
实际问题与直觉
长度前缀只到一字节时,接收方不能判断正文长度,更不能把它当作长度零。它应保存目前得到的前缀,等下一批字节继续解释。增量解析(incremental parsing)正是让解析状态跨多个输入片段持续存在,而不要求每次输入都是一条完整消息。
缓冲区(buffer)在本章指暂存字节的空间;解析状态记录当前阶段、已经得到多少以及还需要多少。只有保存数据却不保存解释状态,仍可能在下一次读取中把正文误认成新头部。
严谨契约:三种结果与已消费范围
一个增量解析阶段至少区分:已得到完整消息、需要更多数据、输入不合法。完整结果还要确定消耗了输入序列中的多少字节,未消费部分继续保留。
| 结果 | 意义 | 接收方的合理动作 |
|---|---|---|
| 完整 | 当前阶段所需范围及语义已满足 | 输出消息,推进消费位置,再尝试下一条 |
| 未完整 | 当前前缀尚可扩展成合法消息 | 保存前缀及状态,等待更多内容 |
| 不合法 | 已知字段或完整内容违反规则 | 按协议错误政策终止或采取明确处理 |
“尚可扩展”是精确区别。例如两字节大端前缀只到 00,仍可能组成合法长度;若完整前缀已是 ff ff,而本服务上限为 1024,就已知不合法,无需等待六万余字节后才拒绝。
状态不变量
对第 3.4 节的长度前缀格式,可维护“等待两字节头”与“等待声明正文”两个阶段。等待头时,尚未解释的缓存就是下一条消息的前缀;头完整并合法以后,长度固定为本条
完成正文后,后面的缓存属于下一条,不能继续附加到上一条。无论如何分批输入,已经输出消息与当前保留前缀拼接,应对应输入流的已取得部分,不能遗漏、重复或改变字节顺序。
完整实例:两条消息加一个未完整前缀
输入为:
text
00 03 41 42 43 00 02 4f 4b 00前五字节是一条 ABC,接下来四字节是一条 OK,末尾一个 00 是下一条长度前缀的一部分,总长十字节。
| 区间 | 解释 | 处理结果 |
|---|---|---|
| [0,5) | 长度 3,正文 ABC | 已输出,消费五字节 |
| [5,9) | 长度 2,正文 OK | 已输出,再消费四字节 |
| [9,10) | 未完整前缀 00 | 保留,不输出空消息 |
如果随后得到 01 5a,末尾前缀与新数据组合成 00 01 5a,恢复第三条正文 Z。如果此前流已结束,最后的 00 则是截断的头部,不能继续视为一个有效空正文。
图解:按已有字节推进
正在绘制图表…
查看图表原文
flowchart TD
A[新字节加入缓存] --> B{头部已完整?}
B -->|否| W[保留状态等更多字节]
B -->|是| C{长度与规则合法?}
C -->|否| R[协议错误处理]
C -->|是| D{声明的正文已完整?}
D -->|否| W
D -->|是| E[消费并输出一条消息]
E --> F{还有未消费字节?}
F -->|是| B
F -->|否| W
W --> A
这里“输出”仍以本层完整规则为条件;若正文自身还需 UTF-8 或安全记录验证,应先完成该层要求。不能只因为外层数量凑够,就在未认证或非法正文上执行业务操作。
长度上限、缓存上限与时间上限
单条消息长度上限限制一个字段的范围,缓存上限限制当前暂存总量,两者不自动相同。大量完整小消息排队也能形成大缓存;一个恶意或迟缓连接只交付少量前缀,也可能长期占据对象与状态。因此还需要并发数量、等待期限和处理队列等政策。
假设每条消息正文最多 64 KiB,1000 个连接各暂存一条最大正文,仅正文即可达到 62.5 MiB;还没有计入头、对象、发送队列和语言开销。这是容量推导,不是测得的某款软件内存占用。把“单条很小”推广成“服务器一定只用少量内存”是不成立的。
等待完整头部、等待完整正文与空闲期限也应分别定义,沿用第 2 章的超时层次。收到一个新字节就无限重置所有期限,会允许未完整状态持续占资源;政策需同时考虑正常高延迟连接的行为。
缓冲管理的效率和背压
如果每增加一字节都重新复制全部累计前缀,取得
这说明协议头很短并不保证实现开销低。合理的实现可以通过保存消费位置、分块缓冲或复用空间避免不必要复制;具体方案与语言及安全层条件有关,不能在本节给出未经测量的性能排名。
背压(backpressure)指下游暂时不能处理时,限制上游继续供给,使队列不无限增长。对于 TCP 转发,停止过度读取可以使接收缓冲压力逐步反映到传输层流量限制;若应用仍不断读出并放进自己的无限队列,就把压力转移到用户态,失去内存边界。第 5 章的代理转发将继续利用这个概念。
本节结论
解析器正确性取决于跨输入片段的状态不变量:该等待就保存,该完成就准确消费,该拒绝就不要继续猜。资源上限和数据有效性是不同检查,但两者都必须与消息定界一起设计。
3.8 综合实例:同一流怎样在不同分块下保持相同解释
场景与完整字节
继续采用两字节大端长度仅统计正文的格式,正文在本层是不透明字节,不强制按文本解释。第一条正文为 HELLO 五字节,第二条为任意四字节 ff 00 01 0a。所有字节均为本书确定性构造,不是网络密文或抓包。
完整十三字节流为:
text
00 05 48 45 4c 4c 4f 00 04 ff 00 01 0a| 范围 | 长度 | 意义 |
|---|---|---|
| [0,2) | 2 | 第一条正文长度 5 |
| [2,7) | 5 | 48 45 4c 4c 4f,HELLO |
| [7,9) | 2 | 第二条正文长度 4 |
| [9,13) | 4 | ff 00 01 0a,原始字节 |
第二条正文含 00 和 0a,但它们不在这个定界层上表示文本结束或消息结束。ff 也不能因为不是合法单字节 UTF-8 就被此层拒绝:本层约定承载原始字节;只有上层另要求文本时,才进行对应合法性检查。
四种读取方式
| 情形 | 读取长度序列 | 解析结果与保留状态 |
|---|---|---|
| A | 13 | 连续恢复两条,末尾无剩余 |
| B | 1、1、…、1,共十三次 | 多次保存未完整状态;累计七字节后第一条完整,十三字节后第二条完整 |
| C | 8、5 | 首次恢复第一条,保留第二条头部首字节 00;第二次恢复第二条 |
| D | 2、5、2、4 | 每次恰好取得一个头部或正文,但这是偶然划分 |
C 的第一个片段为 00 05 48 45 4c 4c 4f 00,第二个为 04 ff 00 01 0a。若接收端在第一次成功输出后清空全部缓存,会丢失第二条前缀的首字节。后续流将被错误解释为从 04 ff 开始的巨大长度。
D 常让错误程序在本机“看起来能用”:每次读取恰好对应预想阶段,但 B 与 C 能暴露它只依赖调用顺序、不依赖字节状态的问题。协议性质必须涵盖这些允许分块,而不是只在方便分块下成立。
为什么不需要列出所有分块
十三字节之间有十二个可能切分位置,每处选择切或不切,共有
可以用解析状态说明分块无关性。处理累计前缀 [0,k) 后,设已消费字节数为 [c,k)。新片段增加后,只把新内容追加到该缓存,再从同一个消息起点按规则判断。
头不足两字节就保留;头已齐但正文不足就保留;正文已齐则消费恰好 2+L 字节。每一步既不丢弃未消费内容,也不重复消费。由于当前消息长度由固定起点处的前两字节唯一确定,新片段边界不能改变这个长度,也不能改变消息正文。因此持续这个不变量就得到相同两条消息,而不是靠猜某次读取的结束位置。
三种失败与正常完成不同
| 输入变化 | 结果 | 理由 |
|---|---|---|
| 最后少一个 0a,然后连接结束 | 第二条截断 | 声明正文四字节,实际只有三字节 |
| 第二条长度改为 5,其他不变 | 第二条未完整;结束时截断 | 还缺一个声明字节 |
| 第一条长度改为服务不允许的大值 | 尽早拒绝 | 完整前缀已经违反上限 |
| 首次只取得八字节,连接仍正常 | 等更多数据 | 下一条头部前缀尚可扩展为合法消息 |
正常连接结束必须与当时解析状态一起判断。无剩余数据且协议允许消息序列结束时,可以完整结束;有未完成头部或正文时,即使 TCP 正常关闭,应用序列仍截断。
协议切换与读超前的例子
许多代理先读取控制头,随后把剩余数据作为目标应用流。若一次接口读取同时拿到完整控制头和第一段正文,控制解析器需要准确返回已消费范围,把剩余部分交给转发阶段;不能只因为“头已经处理”而清空所有数据。
这是第 1.7 节单个完整缓冲区契约与真实流式契约的区别。前者可以拒绝未声明尾随字节,后者必须按明确状态保存后续内容。后面的 SOCKS5、VLESS 和 WebSocket 都会在各自格式下出现类似问题,但不会共用本节的具体两字节前缀。
本节结论
正确消息解释应由协议和累计字节决定,而不由读取次数决定。分块无关性、消费范围、不完整状态与错误范围,是分析任何新流式协议时都可以反复检查的四项基础条件。
3.9 定界方法与正确性的三个层次
定界方法的准确比较
| 方法 | 边界依据 | 必须另外规定 | 主要取舍 |
|---|---|---|---|
| 固定长度 | 每条固定 N 字节及共同起点 | 版本、字段规则、截断处理 | 容易计算,但变长内容和扩展不便 |
| 长度前缀 | 固定或可解析的长度加声明范围 | 宽度、字节序、计数范围、上限 | 可承载任意字节,先验证长度 |
| 分隔符 | 特定状态中的标记 | 正文限制、转义、跨片段匹配、上限 | 文本直观,任意正文可能增加转义开销 |
| TLV | 每字段类型与长度 | 外层结束、未知类型、重复、顺序 | 便于扩展,但不是完整兼容性政策 |
| 直到发送方向结束 | 传输或上层的结束条件 | 哪个方向、结束与截断的关系 | 适合部分流式正文,不适合任意持久消息序列 |
这些方法可以组合:固定前缀包含长度,外层长度包含 TLV,文本头之后切换到长度约束的原始正文。组合时每一层都必须标明自己的偏移基准与范围,不能把内层声明当成越过外层的许可。
三项彼此独立的正确性
第一项是字节传递正确性:正常 TCP 流按顺序交付,写入与读取分块无需一一对应,错误状态也要分别判断。第二项是消息定界正确性:从累计字节唯一确定完整范围,不丢弃剩余数据,不把未到齐前缀误判成消息。第三项是消息内容正确性:在完整范围内检查版本、字段约束、文本编码及上层要求。
这三项还没有包括身份认证与保密性。一个精确解析的明文请求仍可来自不可信发送者,正确长度也无法证明未遭主动篡改。第 13 章以后把安全目标加入已建立的数据框架。
完整例子证明了什么
四字节固定消息可以跨读取边界;两字节长度前缀可以被拆开,也可以在同一次读取里得到多条消息;转义引导可以位于片段末尾;TLV 字段必须受外层限制。十三字节综合实例进一步用消费位置不变量说明不同输入分块恢复相同两条内容。
所有教学字节均按已声明规则构造,未声称来自真实网络。协议性质来自字段契约和状态推理;确定性算术核对用于发现偏移或长度错误,不据此生成性能排名。
与下一章的连接
第 4 章把定界机制放进更完整的协议设计:请求和响应的角色、Header 与 Payload、版本和命令、地址和身份、扩展与状态机,以及明确统计范围的开销。读者从此不应再问“TCP 怎样保证一次收一条”,而应分析“应用协议怎样规定并恢复这一条”。
本章参考资料
- [R9293] RFC 9293:§2.2 服务与流传递,§3.4 序号,§3.7 分段,§3.9.1 推送与发送服务。使用传输服务契约,不从某次分段推断应用边界。
- [L-SEND] send(2)、[L-RECV] recv(2):实际接口字节数量、读取条件与错误。
- [K-OFFLOAD] Linux Segmentation Offloads:TSO、GSO、GRO 相关实现条件,区分观测对象与网络分段。
- [R9112] RFC 9112:§2.1 消息结构及 §6 消息长度;说明文本头与正文定界不能混为一项全局分隔符规则。
- [PYHOWTO] Python Socket Programming HOWTO:Reading/Sending 数据及剩余内容的接口讲解。它含历史教学简化,对现代 HTTP 使用本书引用的 RFC,不沿用其单次传输的笼统表述。
本章的固定字段、前缀、转义和 TLV 教学布局由本书定义,不把它们称为某种真实代理的规范。引用登记与适用范围见 参考资料 和 资料登记。