传输数据的设计思想
# 08.传输数据的设计思想
# 目录介绍
- 01.工作案例引入
- 1.1 设备上报数据的五重Bug
- 1.2 Bug背后的数据传输知识图谱
- 02.数据传递的流程
- 2.1 先看一个案例
- 2.2 数据的发送处理
- 2.3 数据接收处理
- 2.4 为何要层层封装
- 03.数据传输设计
- 3.1 封装和解析报文
- 3.2 什么是TCP流
- 3.3 数据传输流程设计
- 3.4 用例子理解流程
- 3.5 MTU与MSS的设计
- 3.6 分片与重组机制
- 04.数据基础的设计
- 4.1 报文设计思想
- 4.2 数据报/报文段
- 4.3 数据包的设计
- 4.4 帧Frame的设计
- 4.5 各层PDU对比
- 05.数据传输中的问题
- 5.1 大端和小端序
- 5.2 网络字节序
- 5.3 数据校验机制
- 5.4 数据完整性保证
- 06.数据传输的性能
- 6.1 带宽和吞吐量
- 6.2 延迟的组成
- 6.3 数据传输优化
- 6.4 零拷贝技术
- 07.数据序列化设计
- 7.1 为何需要序列化
- 7.2 常见序列化方案
- 7.3 协议缓冲区设计
- 7.4 序列化性能对比
- 08.综合案例:文件传输协议的四次进化
- 8.1 案例背景与目标
- 8.2 第一代:裸字节传输(没有边界的流)
- 8.3 第二代:文本序列化(可读但慢)
- 8.4 第三代:二进制序列化(高效但有门槛)
- 8.5 第四代:零拷贝优化(逼近物理极限)
- 8.6 四种方案横向对比
- 8.7 从案例看主流框架设计
- 8.8 全文知识图谱回顾
- 09.思考题与作业
- 9.1 基础思考题
- 9.2 进阶思考题
- 9.3 动手作业
# 01.工作案例引入
# 1.1 设备上报数据的五重Bug
场景:小陈是一名后端工程师,负责公司 IoT 平台的设备数据上报服务。需求很清晰:各类物联网设备(温湿度传感器、智能门锁、工业网关)每次启动或定时上报设备信息(设备 ID、固件版本、传感器配置等),服务端接收后存库并下发配置指令。
开发阶段一切顺利——小陈用自己 MacBook 上的虚拟机做服务端,本地跑几个模拟设备,JSON 格式的数据来来回回,日志清晰,测试通过。
上线第二周,各种诡异问题开始浮现。
Bug ① —— 设备 ID 变成了另一个数字:运维发现数据库中某些设备的 ID 字段存的值完全不对。比如设备上报的 ID 应该是 2882400001,数据库中存的却是 186773531536。排查日志后发现——这些错数据全部来自 ARM 架构的工业网关,x86 上的设备数据正常。同一个字段、同一段代码,只是跑在不同 CPU 架构的设备上,结果就天差地别。
Bug ② —— JSON 把 CPU 吃光了:业务扩展后,每天有 30 万台设备上报数据,每条消息约 2000 字节。服务端 CPU 持续在 85% 以上,火焰图显示 > 60% 的 CPU 消耗在 JSON 的序列化/反序列化上。小陈算了笔账:
每条消息 2000 字节 JSON,有效数据字段约 500 字节
带宽浪费 = (2000 - 500) / 2000 = 75%
30 万台×每条浪费 1500 字节 = 450MB/天的无效传输
2
3
Bug ③ —— 部分数据字段丢失,不报错也不报异常:运营反馈"海外某客户的设备数据经常丢字段",查看日志——请求正常、响应 200、无异常。进一步抓包发现,丢字段的设备都使用 UDP 协议上报,且某几份数据报的长度刚好超过网络路径中的最小 MTU(1492 字节),被 IP 层分片了。
Bug ④ —— 改用 Protobuf 后出现"解包失败":为了解决 JSON 的性能瓶颈,小陈把协议换成了 Protobuf。CPU 降下来了,但偶尔会收到"数据校验失败"的告警。排查发现——多条 Protobuf 消息在上一个 TCP 段中粘在一起(因为 TCP 是字节流),服务端按一条消息去解析,直接读到下一条消息的数据,反序列化失败。
Bug ⑤ —— 文件下发到设备耗时 30 秒:产品新增了"远程固件升级"功能,服务端通过 Socket 向设备下发固件文件(约 50MB)。小陈用最朴素的方式实现——read(file) → write(socket)。结果下发一个固件要 30 秒,用户等得烦躁。更严重的是,在此期间 Socket 的发送缓冲区被占满,影响了心跳消息的及时响应,导致部分设备被误判为离线。
疑惑链条:
- "同一个 int,为什么不同 CPU 上不一样?" → 字节序问题——大端 vs 小端,网络传输必须是网络字节序(大端)
- "JSON 慢在哪?能不能更快?" → JSON 是文本格式,每个字段名都重复传输;二进制序列化(Protobuf)用字段编号代替字段名,加上 Varint 压缩整数,体积和速度都提升数倍
- "UDP 丢字段跟 MTU 有什么关系?" → IP 分片后,任何一片丢失整个数据报报废,UDP 没有重传机制
- "Protobuf 消息为什么会粘在一起?" → TCP 是字节流,不保留消息边界,需要自己设计帧格式(长度前缀)
- "文件传输为什么慢?read+write 做了多少无用功?" → 4 次数据拷贝 + 4 次上下文切换,零拷贝技术(sendfile)可以把拷贝次数降到 2 次
小陈这一串问题,本质都是在问:数据从应用层到底层传输,经历了什么转换?每一步的开销是多少?如何在"跨平台兼容、高效传输、数据可靠"之间找到最优解?——这正是"传输数据的设计思想"要回答的。
# 1.2 Bug背后的数据传输知识图谱
把这次事故翻译成数据传输知识语言:
设备数据在传输层次中的转换路径:
设备端 struct { deviceID, version, sensors... }
↓ ① 序列化
JSON/Protobuf 字节流 ← Bug②⑤的战场
↓ ② 字节序转换
网络字节序(大端) ← Bug①的根因
↓ ③ TCP分段 / UDP分片
TCP Segment / UDP Datagram ← Bug③的根因(MTU限制)
↓ ④ 加上TCP/UDP头部 + IP头部
IP Packet(最大65535字节)
↓ ⑤ IP分片(如果超过MTU)
多个 Fragment ← Bug③的后果
↓ ⑥ 加上MAC头部 + FCS
Ethernet Frame ← CRC校验在这里
↓ ⑦ 比特流
物理传输
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
五重Bug与后续章节的映射关系:
| Bug | 症状 | 根因所在的知识点 | 对应章节 |
|---|---|---|---|
| ① | 设备ID数字变了 | 大端/小端 → 网络字节序 | 05.字节序、05.网络字节序 |
| ② | JSON吃光CPU | 文本序列化开销大 → 二进制序列化 | 07.序列化设计 |
| ③ | UDP丢字段 | MTU → IP分片 → 分片丢失 | 03.MTU设计、03.分片与重组 |
| ④ | Protobuf解包失败 | TCP字节流 → 消息边界 → 帧格式 | 03.TCP流、03.数据传输流程 |
| ⑤ | 文件传输慢 | 4次拷贝 → 零拷贝技术 | 06.零拷贝技术 |
本章的主线就是沿着这五重Bug,一层一层拆解数据在传输过程中的每一次转换和每一笔开销。读完之后,你不仅能解决这五个问题,还能理解为什么 Protobuf 能取代 JSON 成为微服务通信标准、为什么 Kafka 用 sendfile 实现零拷贝、为什么 TCP 不保留消息边界却反而更灵活。
# 02.数据传递的流程
# 2.1 先看一个案例
假设甲给乙发送邮件,内容为:"早上好"。而从 TCP/IP 通信上看,是从一台计算机 A 向另一台计算机 B 发送邮件。我们通过这个例子来讲解一下 TCP/IP 通信的过程。
疑惑:发送一封邮件到底要经过多少层处理?每个邮件服务商都自己定义格式不行吗?
答疑:如果没有统一的分层协议,每个应用都要自己实现"怎么找到对方、怎么保证送达、怎么处理传输错误",开发和维护成本极高。分层协议把这些通用问题抽象出来,让应用层只关心业务逻辑。
# 2.2 数据的发送处理
- 应用程序处理
启动应用程序新建邮件,将收件人邮箱填好,再由键盘输入"早上好",鼠标点击"发送"按钮就可以开始 TCP/IP 的通信了。
首先,应用程序会对邮件内容进行编码处理,例如:UTF-8,GB2312 等。这些编码相当于 OSI 的表示层功能。
应用在发送邮件的那一刻建立 TCP 连接,从而利用这个 TCP 连接发送数据。它的过程首先是将应用的数据发送给下一层的 TCP,再做实际的转发处理。
- TCP 模块处理
TCP根据应用的提示,负责建立连接,发送数据以及断开连接。TCP 提供将应用层发来的数据顺利发送至对端的可靠传输。
为了实现 TCP 的这一功能,需要在应用层数据的前端附加一个 TCP 的首部。TCP 的首部中包括源端口号和目标端口号、序号。随后将附加了 TCP 首部的包再发送给 IP。
- IP 模块处理
IP 将 TCP 传过来的 TCP 首部和 TCP 数据合起来当做自己的数据,并在 TCP 首部的前端加上自己的 IP 首部。IP 首部中包含接收端 IP 地址,发送端 IP 地址。随后 IP 包将被发送给连接这些路由器或主机网络接口的驱动程序,以实现真正的发送数据。
- 网络接口(以太网驱动)的处理
从 IP 传过来的 IP 包,对于以太网卡来说就是数据。给这些数据附加上以太网首部并进行发送处理。以太网首部中包含接收端 MAC 地址,发送端 MAC 地址,以太网类型,以太网数据协议。根据上述信息产生的以太网数据将被通过物理层传输给接收端。
# 2.3 数据接收处理
- 网络接口(以太网驱动)的处理
主机收到以太网包以后,首先从以太网的包首部找到 MAC 地址判断是否为发给自己的包。如果不是发给自己的则丢弃数据,如果是发给自己的则将数据传给处理 IP 的子程序。
- IP 模块处理
IP 模块收到 IP 包首部以及后面的数据部分以后,也做类似的处理。如果判断得出包首部的 IP 地址与自己的 IP 地址匹配,则可接受数据并从中查找上一层的协议。并将后面的数据传给 TCP 或者 UDP 处理。对于有路由的情况下,接收端的地址往往不是自己的地址,此时需要借助路由控制表,从中找出应该送到的主机或者路由器以后再进行转发数据。
- TCP 模块处理
在 TCP 模块中,首先会校验数据是否被破坏,然后检查是否在按照序号接收数据。最后检查端口号,确定具体的应用程序。数据接收完毕后,接收端则发送一个"确认回执"给发送端。数据被完整地接收以后,会传给由端口号识别的应用程序。
- 应用程序处理
接收端应用程序会直接接收发送端发送的数据。通过解析数据可以获知邮件的内容信息。
# 2.4 为何要层层封装
疑惑:为什么不直接把应用层的数据扔到网线上,而要一层一层地包装?
答疑:层层封装的本质是关注点分离。每一层只关心自己该做的事情,不关心其他层的细节。
类比寄快递:
- 你(应用层)只关心信的内容
- 快递员(传输层)关心的是包裹的收件人和寄件人
- 物流中心(网络层)关心的是从哪个城市到哪个城市
- 运输工具(数据链路层)关心的是这一段路用卡车还是飞机
- 公路/航线(物理层)负责实际的运输
这种分层设计的核心优势:
- 替换灵活:数据链路层从以太网换成WiFi,上面的协议都不需要改动
- 组合自由:应用层可以选择TCP或UDP,传输层不关心上面跑的是HTTP还是FTP
- 分工明确:每层有自己的头部格式和处理逻辑,开发和调试都更容易
- 标准化:不同厂商的设备只要遵循同一层的协议标准就能互通
回到Bug④的场景:如果你理解了分层设计,就会明白——TCP 只保证字节流的可靠传输,它不关心字节流里的"消息"是什么意思。消息边界是应用层自己需要处理的事(用长度前缀、分隔符等方案),这不是 TCP 的缺陷,而是分层设计的结果——TCP 做了它该做的(可靠传输),更多的灵活性留给上层。
# 03.数据传输设计
# 3.1 封装和解析报文
封装报文是从上层到下层:应用层-->传输层-->网络层-->数据链路层-->物理层 ,解封装报文是从下层到上层。
数据在各层之间的形态变化:
应用层 "早上好" (Message)
↓ 加上TCP头(端口号、序号)
传输层 [TCP头 | "早上好"] (Segment)
↓ 加上IP头(IP地址、TTL)
网络层 [IP头 | TCP头 | "早上好"] (Packet)
↓ 加上以太网头尾(MAC地址、FCS)
链路层 [MAC头 | IP头 | TCP头 | "早上好" | FCS] (Frame)
↓ 转为电信号/光信号
物理层 比特流 (Bits)
2
3
4
5
6
7
8
9
10
11
# 3.2 什么是TCP流
这里的 TCP 流,就是英文的 TCP Stream。Stream 这个词有"流"的意思,也有"连续的事件"这样一个含义,所以它是有前后、有顺序的,这也正对应了 TCP 的特性。
跟 Stream 相对的一个词是 Datagram,它是指没有前后关系的数据单元,比如 UDP 和 IP 都属于 Datagram。
在 Linux 网络编程里面,TCP 对应的 socket 类型是 SOCK_STREAM,而 UDP 对应的,就是 SOCK_DGRAM 了。显然,DGRAM 就是 Datagram 的简写。
在具体的网络报文层面,一个 TCP 流,对应的就是一个五元组:传输协议类型、源 IP、源端口、目的 IP、目的端口。
(TCP, your_ip, your_port, geekbang_ip, 443)
一个 IP 报文,包含了所有这五个元素,所以 Wireshark 在解析抓包文件时,自然就能通过五元组知道每个报文所属的 TCP 流了。
疑惑:TCP 流不保留消息边界,那为什么还要用它?这不是给应用层添麻烦吗?
答疑:边界由应用层自己管理,恰恰是灵活性的体现。如果 TCP 强制保留边界,那么:
- 每次
write(10字节)就必须生成一个独立 TCP 段 → Nagle 算法无法合并小包 - 接收方必须按同样的粒度
read(10字节),无法灵活调节缓冲区大小 - 应用层无法将一个大数据结构拆分后复用同一连接传输
实际上,"不保留边界"让 TCP 的流量控制、拥塞控制、重传机制可以自由地合并和切分数据,以达到最优的传输效率。Bug④ 的解决方案很简单——加一个4字节的长度前缀,接收方先读4字节知道消息多长,再读那么多字节——实现成本极低,但享有了 TCP 流式传输的全部优势。
# 3.3 数据传输流程设计
报文由应用层产生,被称作报文(Message)/数据(Data),经过传输层的封装形成报文段(Segment)/数据报(Datagram),再经过网络层的封装形成分组/数据包(Packet),然后经过数据链路层的封装形成帧(Frame),最后在物理层以二进制比特流的方式完成数据传输。
在传输过程中,每层都会添加一些控制信息组成的首部,那些就是报文头。在接收端,每层都会把自己添加的首部去掉,再还原出原始的数据。
# 3.4 用例子理解流程
发快递——>可以理解为发送数据
- 应用层:用户寄快递时,相当于处在应用层,货物就是报文。
- 运输层:在寄件点填写好目的地信息后形成数据报,处在运输层。
- 网络层:物流中心以自己的方式标记目的地信息形成数据包,处在网络层。
- 数据链路层:物流中心选择运输工具,比如飞机,货车等,数据链路层负责选择运输工具添加帧头帧尾,形成数据帧。
- 物理层:快递需要完成派送,即数据以比特流的形式完成传输。
收快递——>可以理解为接收数据
- 到达目的地后,再逐层去掉自己所在层级添加的首部,也可理解为根据快递地址信息逐层分派,最后送达我们的收件人手上,我们的货物是不变的。
# 3.5 MTU与MSS的设计
**MTU(Maximum Transmission Unit,最大传输单元)**是数据链路层能传输的最大数据帧大小。不同的网络类型有不同的MTU:
| 网络类型 | MTU大小 | 说明 |
|---|---|---|
| 以太网(Ethernet) | 1500字节 | 最常见的局域网 |
| PPPoE(ADSL宽带) | 1492字节 | 比以太网小8字节(PPPoE头部) |
| 802.11(WiFi) | 2304字节 | 无线局域网 |
| 巨型帧(Jumbo Frame) | 9000字节 | 数据中心内部使用 |
| 本地回环(Loopback) | 65535字节 | 本机通信 |
**MSS(Maximum Segment Size,最大段大小)**是TCP层能传输的最大数据量(不含TCP和IP头部):
MSS = MTU - IP头部(20字节) - TCP头部(20字节) = 1460字节(以太网环境下)
疑惑:MSS和MTU是什么关系?为什么要分开设计?
答疑:MTU是链路层的限制,MSS是传输层(TCP)的限制。TCP在建立连接时(三次握手的SYN包中),双方会通过TCP选项字段告知各自的MSS值,取较小值作为本次连接的MSS。这样设计的目的是避免IP层分片。
如果TCP段超过MSS,IP层就会将其分成多个IP片段。IP分片有以下问题:
- 任何一片丢失,整个TCP段都要重传
- 路由器需要缓存分片并重组,增加内存开销
- 防火墙可能丢弃IP分片
所以TCP层主动按照MSS切分数据,避免触发IP分片,这是一种主动适配的设计思想。
回到Bug③的场景:Bug③ 中丢字段的设备用 UDP 上报,UDP 没有 MSS 协商机制。当 2000 字节的 UDP 数据报超过路径 MTU(1492 字节),IP 层将其分成两片。如果第二片因网络原因丢失,整个 2000 字节的数据报就报废了——UDP 没有重传机制。
Bug③的场景还原:
设备 → UDP数据报 2000字节
↓ IP层: 超过MTU=1492,需要分片
分片1: IP头(20B) + 数据(1472B) = 1492B ← 到达 ✓
分片2: IP头(20B) + 数据(528B) = 548B ← 丢失 ✗
结果:接收方只收到分片1,无法重组 → 整个数据报废 → 字段丢失
解决方案:
方案A: 应用层限制UDP消息 < 1472字节(路径MTU - IP/UDP头)
方案B: 改用TCP(自动MSS协商,避免分片)
方案C: 应用层实现分片重传(类似QUIC的做法)
2
3
4
5
6
7
8
9
10
11
12
13
# 3.6 分片与重组机制
虽然TCP通过MSS避免了IP分片,但UDP没有这个机制。当UDP数据报超过MTU时,IP层会自动进行分片。
IP分片的工作原理:
原始IP包(3000字节数据 + 20字节IP头 = 3020字节)
MTU = 1500字节
分片后:
片1:IP头(20B) + 数据(1480B) = 1500B [MF=1, Offset=0]
片2:IP头(20B) + 数据(1480B) = 1500B [MF=1, Offset=185]
片3:IP头(20B) + 数据(40B) = 60B [MF=0, Offset=370]
MF(More Fragments)=1 表示后面还有分片
Offset 是以8字节为单位的偏移量:1480/8=185
2
3
4
5
6
7
8
9
10
分片标识字段:
IP头中有三个字段用于分片和重组:
| 字段 | 长度 | 作用 |
|---|---|---|
| 标识(Identification) | 16位 | 同一数据报的所有分片共享同一标识 |
| 标志(Flags) | 3位 | MF=1表示还有后续分片,DF=1表示不允许分片 |
| 片偏移(Fragment Offset) | 13位 | 该分片在原始数据报中的位置 |
路径MTU发现(PMTUD):
为了找到从源到目的之间所有链路的最小MTU(即路径MTU),TCP使用PMTUD机制:
- 发送方设置IP头的DF(Don't Fragment)标志
- 如果某个路由器发现包超过其出口链路MTU,就丢弃该包并返回ICMP "Fragmentation Needed"消息
- 发送方收到ICMP消息后,降低MSS重新发送
- 重复此过程直到不再收到ICMP消息,此时找到了路径MTU
# 04.数据基础的设计
# 4.1 报文设计思想
我们将位于应用层的信息分组称为报文。报文是网络中交换与传输的数据单元,即站点一次性要发送的数据块。
报文包含了将要发送的完整的数据信息,其长短很不一致,长度不限且可变。
# 4.2 数据报/报文段
现在来到传输层了,传输层要结合以下两种协议来讲:
- 一是我们经常听到的TCP(Transmission Control Protocol)协议,即传输控制协议,它是面向连接的,数据传输的单位是报文段,能够提供可靠的交付。
- 二是UDP(User Datagram Protocol)协议,即用户数据包协议,它是无连接的,数据传输的单位是用户数据报,不保证提供可靠的交付,只能提供"尽最大努力交付"。
如何理解TCP中的报文段?
在应用层交付给传输层的消息(message)。当 message 被交付给传输层时,如果这个 message 的原始尺寸,超出了传输层数据单元的限制(比如超出了 TCP 的 MSS),它就会被划分为多个 segment。这个过程就是分段(segmentation),也是 TCP 层的一个很重要的职责。
# 4.3 数据包的设计
数据包(Packet)是TCP/IP协议通信传输中的数据单位,处于网络层,在局域网中,"包"是包含在"帧"里的。有些资料里会将其称为IP数据报,但只是叫法不同罢了。
IP数据报的结构:
IP数据报是网络层最核心的PDU,其头部设计精妙,每个字段都有明确的用途:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|Version| IHL |Type of Service| Total Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Identification |Flags| Fragment Offset |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Time to Live | Protocol | Header Checksum |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Address |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Destination Address |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Options (可选) |Padding|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data ... |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
关键字段解读:
| 字段 | 长度 | 作用 |
|---|---|---|
| Version | 4位 | IP版本,IPv4=4,IPv6=6 |
| IHL | 4位 | 首部长度,以4字节为单位,最小5(20字节),最大15(60字节) |
| Total Length | 16位 | 整个IP数据报的总长度,最大65535字节 |
| TTL | 8位 | 生存时间,每经过一个路由器减1,为0时丢弃,防止包在网络中无限循环 |
| Protocol | 8位 | 上层协议类型:TCP=6,UDP=17,ICMP=1 |
| Header Checksum | 16位 | 仅校验IP头部,不校验数据部分 |
疑惑:为什么TTL不是用时间而是用跳数来衡量?
答疑:虽然叫"Time to Live"(生存时间),但实际上TTL的含义是"最大跳数"。这是因为在早期设计时,设想TTL以秒为单位递减,但实际实现中路由器处理时间远小于1秒,所以每次只减1。用跳数来限制更加简单可靠——每经过一个路由器减1,到0就丢弃。这样可以有效防止因路由环路导致数据包在网络中永远循环。
Protocol字段的设计意义:
Protocol字段体现了分层设计中多路复用/分解的思想。IP层通过这个字段知道收到的数据应该交给上层的哪个协议处理——TCP还是UDP还是ICMP。这和传输层通过端口号来区分应用程序是同样的设计思想,只是作用在不同的层。
# 4.4 帧Frame的设计
帧是数据链路层的传输单元。将上层传输的数据添加一个头部和尾部,组成了帧。它包含帧头、载荷、帧尾。
以太网帧的结构:
+----------+----------+---------+-----------+-----+
| 前导码 | 目的MAC | 源MAC | 类型/长度 | ... |
| (8字节) | (6字节) | (6字节) | (2字节) | |
+----------+----------+---------+-----------+-----+
↓
+-----------+------------------+-----------+
| 帧头(14B) | 数据(46~1500B) | FCS(4B) |
+-----------+------------------+-----------+
2
3
4
5
6
7
8
各字段说明:
| 字段 | 长度 | 作用 |
|---|---|---|
| 前导码(Preamble) | 7字节 | 用于时钟同步,值为10101010重复 |
| 帧起始定界符(SFD) | 1字节 | 值为10101011,标记帧的开始 |
| 目的MAC地址 | 6字节 | 接收方的物理地址 |
| 源MAC地址 | 6字节 | 发送方的物理地址 |
| 类型/长度 | 2字节 | >1536时表示上层协议类型(如0x0800=IPv4),≤1500时表示数据长度 |
| 数据(Payload) | 46~1500字节 | 承载上层的IP数据报 |
| FCS | 4字节 | 帧校验序列,使用CRC-32校验 |
疑惑:以太网帧的最小数据长度为什么是46字节?
答疑:这与以太网的CSMA/CD(载波侦听多路访问/冲突检测)机制有关。以太网要求帧的最小长度为64字节(含帧头14字节和FCS 4字节),这样数据部分至少需要46字节。
为什么是64字节?因为在10Mbps以太网中,信号在最大网段长度(2500米)上往返一次的时间约为51.2微秒,在这段时间内可以传输512位(64字节)的数据。如果帧长度小于64字节,发送方可能在冲突信号到达之前就已经发完了帧,导致无法检测到冲突。
所以如果上层数据不足46字节,以太网会自动填充(padding)到46字节。这就是为什么抓包时经常看到小数据包后面有一串0x00。
# 4.5 各层PDU对比
将各层的协议数据单元(PDU)放在一起对比,可以看出清晰的设计规律:
| 层次 | PDU名称 | 头部大小 | 数据部分 | 尾部 | 标识上层的字段 |
|---|---|---|---|---|---|
| 应用层 | 报文(Message) | 协议自定义 | 应用数据 | 无 | 无(最高层) |
| 传输层 | 报文段/数据报 | TCP:20~60B, UDP:8B | MSS以内 | 无 | 端口号 |
| 网络层 | 数据包(Packet) | 20~60B | 65535B以内 | 无 | Protocol字段 |
| 数据链路层 | 帧(Frame) | 14B | 46~1500B | 4B(FCS) | 类型字段 |
| 物理层 | 比特(Bit) | 前导码8B | — | — | — |
设计规律总结:
每层都有"标识上层协议"的字段:帧的类型字段标识网络层协议(IPv4/IPv6/ARP),IP的Protocol字段标识传输层协议(TCP/UDP),TCP/UDP的端口号标识应用层协议。这就是多路分解的基础。
头部大小逐层变小:越底层的协议,头部越简洁,处理越快。以太网帧头只有14字节,因为链路层设备(交换机)需要以线速处理。
校验覆盖范围不同:以太网FCS校验整个帧(头+数据+FCS),IP checksum只校验IP头,TCP/UDP checksum校验头+数据+伪头。各层各管各的。
封装是套娃,解封是拆箱:每一层收到上层的PDU后当作自己的载荷,加上自己的头部(有时还有尾部)形成本层的PDU。接收方逐层拆解,最终还原应用数据。
# 05.数据传输中的问题
# 5.1 大端和小端序
疑惑:同样一个数字,不同计算机存储的字节顺序居然不一样?
答疑:是的,这是一个历史问题。不同的CPU架构选择了不同的字节存储顺序。
以整数 0x12345678(十六进制)为例:
内存地址: 0x00 0x01 0x02 0x03
大端序: 12 34 56 78
(Big-Endian) ↑高位字节在低地址
小端序: 78 56 34 12
(Little-Endian) ↑低位字节在低地址
2
3
4
5
6
7
"大端"和"小端"的名字来源:这个命名来自《格列佛游记》,书中两个国家因为鸡蛋应该从大头(Big End)还是小头(Little End)打开而开战。
主流架构的字节序:
| 架构 | 字节序 | 代表 |
|---|---|---|
| x86/x86-64 | 小端 | Intel/AMD处理器、大多数PC |
| ARM | 双端可选,通常小端 | 手机、嵌入式设备 |
| MIPS | 双端可选 | 路由器、嵌入式 |
| PowerPC | 大端 | 早期Mac、游戏主机 |
| SPARC | 大端 | Sun工作站 |
| 网络协议 | 大端 | TCP/IP协议栈 |
回到Bug①:小陈的代码中,设备端用 memcpy 把 int64 deviceID 直接复制到发送缓冲区,没有做字节序转换。x86 设备(小端)和 ARM 设备(小端)发出来的数据在内存布局上碰巧一致,但到了服务端被按网络字节序(大端)解析时,高字节和低字节的"位置"被互换——2882400001(0xABCDEF01 假设)变成了 0x01EFCDAB,也就是 186773531536。
Bug①的根因可视化:
设备(ARM, 小端):
deviceID = 0xABCDEF01
内存中: 01 EF CD AB (低字节在低地址)
网络传输(按大端序):
期望收到: AB CD EF 01
实际收到: 01 EF CD AB ← 服务端按这个解析
结果: 0x01EFCDAB = 186773531536(完全不同的数字)
2
3
4
5
6
7
8
9
10
# 5.2 网络字节序
疑惑:既然不同机器字节序不同,那网络传输时怎么办?
答疑:TCP/IP协议规定了统一的网络字节序(Network Byte Order),即大端序。所有多字节数据在网络上传输时必须转换为大端序。
这意味着:
- 发送方在发送数据前,将主机字节序转换为网络字节序
- 接收方在收到数据后,将网络字节序转换为主机字节序
常用的字节序转换函数:
函数名 作用 方向
htons() host to network short 主机→网络(16位)
htonl() host to network long 主机→网络(32位)
ntohs() network to host short 网络→主机(16位)
ntohl() network to host long 网络→主机(32位)
2
3
4
5
论证:为什么选择大端序作为网络字节序?
- 人类阅读习惯:大端序的高位在前,和人类书写数字的顺序一致。抓包时更容易阅读。比如IP地址192.168.1.1,在大端序中就是
C0 A8 01 01,和我们看到的地址顺序一致。 - 历史惯例:TCP/IP协议诞生时,网络设备多使用大端序处理器(如Motorola 68000),自然采用了大端序。
- 协议解析效率:大端序可以先读到高位字节,某些场景下可以更快地做出路由决策。
实际影响:在x86平台上,每次发送和接收多字节数据(如端口号、IP地址、序列号等)都需要调用字节序转换函数。忘记转换是网络编程中最常见的错误之一,会导致数据被解读为完全不同的值。
# 5.3 数据校验机制
数据在传输过程中可能因为各种原因出错(电磁干扰、硬件故障、信号衰减等),因此每一层都设计了相应的校验机制。
各层校验方式对比:
| 层次 | 校验方式 | 校验范围 | 校验强度 | 计算速度 |
|---|---|---|---|---|
| 数据链路层 | CRC-32 | 整个帧 | 很强 | 硬件实现,极快 |
| 网络层 | IP Checksum | 仅IP头部 | 弱 | 简单求和 |
| 传输层 | TCP/UDP Checksum | 伪头+头+数据 | 中等 | 简单求和 |
| 应用层 | MD5/SHA/HMAC | 自定义 | 很强 | 软件实现,较慢 |
CRC-32校验(数据链路层):
CRC(Cyclic Redundancy Check,循环冗余校验)是一种基于多项式除法的校验算法:
1. 将数据视为一个大的二进制数
2. 用一个预定义的生成多项式去除这个数
3. 余数就是CRC校验码(32位)
4. 接收方用同样的多项式除(数据+CRC),余数为0则数据正确
2
3
4
CRC-32能检测出所有1位、2位错误,所有奇数位错误,以及大多数突发错误(burst error)。它在硬件中可以用移位寄存器实现,速度极快。
TCP/UDP Checksum:
TCP和UDP使用的是简单的16位反码求和校验。有一个特殊设计是引入了伪头部(Pseudo Header):
TCP/UDP校验计算范围:
+------------------+
| 伪头部(12字节) | ← 包含源IP、目的IP、协议号、TCP/UDP长度
+------------------+
| TCP/UDP头部 |
+------------------+
| 数据部分 |
+------------------+
2
3
4
5
6
7
8
疑惑:为什么TCP/UDP校验要包含IP地址(伪头部)?IP层不是已经有自己的校验了吗?
答疑:IP层的校验只覆盖IP头部本身,不覆盖数据部分。如果数据在传输过程中被损坏,IP层的校验无法发现。更重要的是,伪头部的设计是为了防止错误投递——确保数据确实是发给正确的目的地的,而不是因为IP头部被篡改而投递到了错误的地址。
# 5.4 数据完整性保证
在网络传输中,仅靠校验码还不够,还需要一套完整的机制来保证数据的完整性和可靠性。
TCP的可靠传输机制:
可靠传输的三大支柱:
1. 确认机制(ACK)
发送方 ──数据──> 接收方
发送方 <──ACK── 接收方
收到ACK才认为数据送达
2. 超时重传
发送方 ──数据──> (丢失)
... 等待超时 ...
发送方 ──数据──> 接收方(重传)
3. 序号机制
段1(seq=0) 段2(seq=1000) 段3(seq=2000)
接收方通过序号发现缺失的段并请求重传
同时可以对乱序到达的段进行重新排序
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
快速重传与选择性确认:
除了超时重传,TCP还有更高效的机制:
- 快速重传(Fast Retransmit):当发送方连续收到3个重复ACK时,不等超时就立即重传。这是因为3个重复ACK强烈暗示该数据包已经丢失。
- 选择性确认(SACK, Selective ACK):传统ACK只能告诉发送方"我收到了序号X之前的所有数据"。SACK可以告诉发送方"我收到了X之前的,还收到了Y到Z之间的",这样发送方只需要重传真正丢失的部分。
不用SACK:
段1 段2 段3 段4 段5
↑ 丢失
收到ACK=段2 三次 → 重传段2、段3、段4、段5(不知道哪些收到了)
使用SACK:
段1 段2 段3 段4 段5
↑ 丢失
收到ACK=段2, SACK=段3-段5 → 只重传段2
2
3
4
5
6
7
8
9
端到端原则:
数据完整性的设计遵循端到端原则——真正的完整性检查应该由通信的两个端点来完成,中间节点的检查只是优化手段。
即使链路层有CRC校验,传输层仍然需要自己的校验,因为:
- 数据可能在路由器内部的内存中出错(链路层CRC只覆盖单段链路)
- 中间设备可能修改数据(如NAT修改IP和端口)
- 数据可能在主机的协议栈处理过程中出错
这就是为什么应用层往往还会再加一层校验(如文件下载的MD5/SHA256校验),真正确保端到端的数据完整性。
# 06.数据传输的性能
# 6.1 带宽和吞吐量
**带宽(Bandwidth)和吞吐量(Throughput)**是两个经常被混淆的概念:
带宽 ≠ 吞吐量
带宽:链路的理论最大传输速率(如100Mbps、1Gbps)
类比:高速公路的车道总宽度
吞吐量:实际传输数据的速率
类比:实际通过的车流量
2
3
4
5
6
7
为什么吞吐量总是低于带宽?
影响吞吐量的因素:
├── 协议开销(头部占用带宽)
│ 以太网帧效率 = 1500 / (1500 + 14 + 4 + 8 + 12) ≈ 97.4%
│ TCP/IP效率 = (1500-20-20) / 1500 ≈ 97.3%
│ 综合效率 ≈ 94.8%
├── TCP流量控制(接收窗口限制)
├── TCP拥塞控制(慢启动、拥塞避免)
├── 往返延迟(RTT影响窗口利用率)
├── 丢包重传(浪费带宽和时间)
└── 应用层处理延迟
2
3
4
5
6
7
8
9
10
带宽时延积(Bandwidth-Delay Product, BDP):
BDP = 带宽 × RTT,表示在任意时刻网络中"在途"的数据量。TCP的发送窗口至少要等于BDP才能充分利用带宽。
示例:
带宽 = 100 Mbps,RTT = 50ms
BDP = 100,000,000 × 0.05 / 8 = 625,000 字节 ≈ 610 KB
如果TCP窗口只有64KB(旧版默认值),
实际吞吐量 = 64KB / 50ms ≈ 10 Mbps(只利用了10%带宽)
需要开启TCP窗口缩放选项(Window Scale)来支持更大的窗口。
2
3
4
5
6
7
8
# 6.2 延迟的组成
网络延迟(Latency)由四个部分组成:
总延迟 = 处理延迟 + 排队延迟 + 传输延迟 + 传播延迟
处理延迟 排队延迟
(检查头部/查路由) (等待出队)
数据 ──→ [路由器 ════════════ 缓冲队列] ──→
传输延迟 传播延迟
(推入链路) (信号传播)
──═══════──→ ────────→ [下一跳]
2
3
4
5
6
7
8
| 延迟类型 | 含义 | 典型值 | 影响因素 |
|---|---|---|---|
| 处理延迟 | 路由器检查头部、查路由表、校验的时间 | 微秒级 | 路由器性能 |
| 排队延迟 | 在路由器缓冲区中等待的时间 | 微秒~毫秒 | 网络拥塞程度 |
| 传输延迟 | 将数据推入链路的时间 = 数据量/带宽 | 微秒级 | 带宽、数据量 |
| 传播延迟 | 信号在介质中传播的时间 = 距离/信号速度 | 毫秒级 | 物理距离 |
论证:哪种延迟影响最大?
- 局域网:传播延迟几乎为0(距离短),传输延迟占主导。提高带宽效果显著。
- 广域网:传播延迟占主导(光在光纤中速度约20万km/s,北京到纽约约11000km,单向延迟约55ms)。再大的带宽也无法减少传播延迟,这是物理极限。
- 拥塞网络:排队延迟暴增,可能从微秒级跳到几十毫秒。这就是为什么"网络高峰期"会变慢。
# 6.3 数据传输优化
# Nagle算法
疑惑:如果应用程序每次只发送1字节数据(如远程终端每按一个键),TCP是否就要为这1字节数据发送一整个TCP段(至少40字节头部)?
答疑:如果不做处理,确实如此。这就是所谓的小包问题(Small Packet Problem)——大量的协议头部开销远超有效数据。
Nagle算法的解决思路:
Nagle算法规则:
1. 如果有已发送但未确认的数据:
→ 将新的小数据缓存起来,等收到ACK后合并发送
2. 如果没有未确认的数据:
→ 立即发送(即使数据很小)
3. 如果数据量达到MSS:
→ 立即发送(不等ACK)
2
3
4
5
6
7
Nagle算法与Delayed ACK的冲突:
Delayed ACK(延迟确认)是TCP的另一个优化:接收方不是每收到一个段就立即发ACK,而是等待一小段时间(通常40~200ms),看有没有数据可以捎带(piggyback)。
当Nagle和Delayed ACK同时启用时,可能产生严重的延迟:
发送方(Nagle开启) 接收方(Delayed ACK开启)
──── 数据1(小) ────→
等待ACK... 等待数据捎带或超时...
... 200ms后 ...
←────── ACK ──────
──── 数据2(小) ────→
2
3
4
5
6
每次发送小数据都要等200ms,性能急剧下降。解决方案:对实时性要求高的应用,使用 TCP_NODELAY 选项禁用Nagle算法。
# TCP_CORK
与Nagle相似但不同的是 TCP_CORK 选项。它将数据"堵住",直到显式"拔出塞子"或缓冲区满时才发送。适合在发送大量小块数据(如HTTP响应头+文件内容)时使用,可以确保它们被合并成少量大包发送。
Nagle vs TCP_CORK:
┌────────────────────────────────────────────────┐
│ Nagle:有未确认数据时缓存,收到ACK后发送 │
│ 自动触发,适合交互式场景 │
│ │
│ TCP_CORK:无条件缓存,直到拔塞或超时(200ms) │
│ 手动控制,适合批量发送场景 │
└────────────────────────────────────────────────┘
2
3
4
5
6
7
8
# 6.4 零拷贝技术
疑惑:传统的文件发送流程中,数据要在用户态和内核态之间来回拷贝,这对性能有多大影响?
答疑:传统文件发送需要4次数据拷贝和4次上下文切换,开销巨大。
传统发送流程(read + write):
磁盘 ──DMA拷贝──→ 内核缓冲区 ──CPU拷贝──→ 用户缓冲区
│
网卡 ←─DMA拷贝── Socket缓冲 ←─CPU拷贝──────┘
共4次拷贝(2次DMA + 2次CPU),4次上下文切换
2
3
4
5
6
7
零拷贝(Zero Copy)技术就是为了减少这些不必要的拷贝和上下文切换。
sendfile系统调用:
sendfile流程:
磁盘 ──DMA拷贝──→ 内核缓冲区 ──CPU拷贝──→ Socket缓冲区
│
网卡 ←─────────DMA拷贝─────────────────────┘
共3次拷贝(2次DMA + 1次CPU),2次上下文切换
数据不经过用户空间
2
3
4
5
6
7
8
在支持scatter-gather DMA的网卡上,还可以进一步优化:
sendfile + SG-DMA流程:
磁盘 ──DMA拷贝──→ 内核缓冲区
│
网卡 ←───SG-DMA───┘(只传递描述符,不拷贝数据)
共2次拷贝(都是DMA),2次上下文切换
真正的"零CPU拷贝"
2
3
4
5
6
7
8
各种零拷贝方案对比:
| 方案 | 拷贝次数 | 上下文切换 | 适用场景 |
|---|---|---|---|
| read + write | 4次 | 4次 | 通用 |
| mmap + write | 3次 | 4次 | 需要修改数据时 |
| sendfile | 3次(或2次) | 2次 | 静态文件发送 |
| splice | 2次 | 2次 | 管道间传输 |
| O_DIRECT | 跳过页缓存 | 2次 | 数据库等有自己缓存的应用 |
实际应用:
- Nginx:默认使用sendfile发送静态文件
- Kafka:使用sendfile实现高性能消息投递,这是Kafka吞吐量极高的核心原因之一
- 数据库:使用O_DIRECT绕过操作系统的页缓存,自己管理数据缓存
回到Bug⑤:小陈的文件下发代码就是经典的 read(file) → write(socket) 模式,50MB 文件经过了 4 次拷贝。在高频下发场景下(同时给 100 台设备升级),CPU 拷贝成为瓶颈。改用 sendfile 后,从文件到网卡的路径缩短为 2 次 DMA 拷贝,CPU 不再参与数据搬运,耗时从 30 秒降到约 5-8 秒。
# 07.数据序列化设计
# 7.1 为何需要序列化
疑惑:程序中的数据结构(如对象、数组、字典)为什么不能直接在网络上传输?
答疑:程序中的数据结构存在于内存中,包含了指针、内存对齐的填充字节、运行时的元数据等信息。这些信息:
- 与内存布局绑定:不同语言、不同平台的内存布局不同
- 包含无意义数据:填充字节、虚表指针等对另一端没有意义
- 包含指针:指针指向的是本机内存地址,传到另一台机器完全无意义
- 字节序问题:不同机器的字节序可能不同
所以需要序列化(Serialization)——把内存中的数据结构转换为一种与平台无关的、可传输的字节序列。反过来的过程叫反序列化(Deserialization)。
发送方 接收方
数据结构 ──序列化──→ 字节流 ──网络──→ 字节流 ──反序列化──→ 数据结构
(语言A) (通用格式) (通用格式) (语言B)
2
3
# 7.2 常见序列化方案
| 方案 | 格式 | 可读性 | 大小 | 速度 | 跨语言 | 适用场景 |
|---|---|---|---|---|---|---|
| JSON | 文本 | 极好 | 大 | 慢 | 所有语言 | Web API、配置文件 |
| XML | 文本 | 好 | 很大 | 很慢 | 所有语言 | 企业服务(SOAP)、配置 |
| Protobuf | 二进制 | 差 | 很小 | 很快 | 多语言 | 微服务RPC、移动端 |
| MessagePack | 二进制 | 差 | 小 | 快 | 多语言 | 替代JSON的紧凑方案 |
| Thrift | 二进制 | 差 | 小 | 快 | 多语言 | Facebook系RPC |
| Avro | 二进制 | 差 | 小 | 快 | 多语言 | 大数据(Hadoop生态) |
| FlatBuffers | 二进制 | 差 | 小 | 极快 | 多语言 | 游戏、实时系统 |
疑惑:JSON这么方便,为什么还需要二进制序列化方案?
答疑:看一个具体例子就知道了。假设要传输一个整数 123456789:
JSON格式: "123456789" → 9个ASCII字符 = 9字节
加上字段名:"id":123456789 → 16字节
加上花括号:{"id":123456789} → 18字节
Protobuf: 字段标签(1字节) + Varint编码(4字节) = 5字节
2
3
4
5
在大规模微服务架构中,每天可能有几十亿次RPC调用。假设每次调用平均节省100字节:
- 10亿次调用 × 100字节 = 100GB的带宽节省
- 加上编解码速度的提升,整体性能差异可能是数倍
回到Bug②:小陈的 30 万台设备每条消息 2000 字节 JSON,改用 Protobuf 后压缩到约 500 字节,节省 75% 带宽。在 CPU 端,JSON 解析需要逐字符扫描、构建语法树,而 Protobuf 直接按字段编号偏移量读取,速度提升 3-5 倍。
# 7.3 协议缓冲区设计
Protocol Buffers(Protobuf)是Google开源的序列化方案,其编码设计非常精巧。
Varint编码:
Protobuf使用Varint(变长整数)编码来高效存储整数:
Varint编码规则:
- 每个字节的最高位(MSB)是标志位
- MSB=1:后面还有更多字节
- MSB=0:这是最后一个字节
- 每个字节的低7位是数据位
- 采用小端序排列
示例:编码数字 300
300的二进制:100101100
拆分为7位组:0000010 0101100
加MSB标志位(小端序,低位在前):
第一字节:1_0101100 = 0xAC(MSB=1,还有后续)
第二字节:0_0000010 = 0x02(MSB=0,最后一个)
结果:0xAC 0x02(只用2字节,而固定int32要4字节)
小数字的优势更明显:
数字1: 0x01(1字节,比int32节省75%)
数字127:0x7F(1字节)
数字128:0x80 0x01(2字节)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Tag-Length-Value (TLV) 结构:
Protobuf的每个字段由三部分组成:
[Tag] [Length] [Value]
Tag = (field_number << 3) | wire_type
wire_type取值:
0: Varint(int32, int64, bool, enum)
1: 64-bit(fixed64, double)
2: Length-delimited(string, bytes, 嵌套message, repeated字段)
5: 32-bit(fixed32, float)
2
3
4
5
6
7
8
9
关键设计决策:
- 使用字段编号而非字段名:JSON传输字段名(如"userName"每次传输都要传这8个字符),Protobuf只传字段编号(1个Varint,通常1字节)。
- 没有的字段不传:如果某个字段是默认值或未设置,序列化时直接跳过,不占任何空间。
- 向后兼容:新增字段用新编号,老代码遇到不认识的编号直接跳过。这就是为什么Protobuf强调"不要复用已删除的字段编号"。
注意:虽然Protobuf很高效,但它仍然只是序列化数据,不解决消息边界问题(Bug④)。你需要在自己设计的帧格式中,用长度前缀来标识每条Protobuf消息的边界。
# 7.4 序列化性能对比
不同序列化方案在实际场景中的性能差异:
测试数据:包含10个字段的用户信息(string/int/bool/嵌套对象)
序列化后大小(字节):
JSON: 256
XML: 412
MsgPack: 152
Protobuf: 98
FlatBuffers: 88(但不需要反序列化,直接读取)
序列化速度(相对JSON=1.0):
JSON: 1.0x
XML: 0.3x
MsgPack: 2.5x
Protobuf: 5.0x
FlatBuffers: 10x(只是内存拷贝)
反序列化速度(相对JSON=1.0):
JSON: 1.0x
XML: 0.2x
MsgPack: 3.0x
Protobuf: 5.0x
FlatBuffers: ∞(零反序列化,直接访问偏移量)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
如何选择序列化方案?
决策树:
├── 需要人类可读?
│ ├── 是 → JSON(轻量级)或 XML(结构化强)
│ └── 否 → 看下一个问题
├── 需要schema定义和类型安全?
│ ├── 是 → Protobuf 或 Thrift
│ └── 否 → MessagePack(无schema的二进制JSON)
├── 对延迟极度敏感(游戏/实时系统)?
│ ├── 是 → FlatBuffers(零反序列化开销)
│ └── 否 → Protobuf(综合最优选择)
└── 大数据场景(Hadoop/Spark)?
└── 是 → Avro(schema随数据存储,利于演化)
2
3
4
5
6
7
8
9
10
11
12
结果总结:在大多数后端服务场景中,Protobuf是最佳选择——体积小、速度快、有schema约束、支持向后兼容。而在面向Web前端的API中,JSON仍然是首选,因为浏览器原生支持JSON解析。选择序列化方案时,要根据实际场景权衡可读性、性能和生态支持。
# 08.综合案例:文件传输协议的四次进化
前面我们分别讲了数据的分层封装、MTU/MSS 设计、字节序、校验机制、序列化方案、零拷贝等技术。但这些知识点如果是孤立地看,很难形成"设计一个网络协议"的系统思维。
本章用一个贯穿全文的实战案例——从零开始设计一个文件传输协议,让它经历四次进化,从"能跑就行"到"逼近物理极限"。每一代版本的代码都给出了,每一代的瓶颈和解决方案都和前面的章节对应。读完这一节,你应该能形成"设计一个通信协议时知道该关注哪些维度"的能力。
# 8.1 案例背景与目标
假设我们要开发一个远程文件分发服务,需求如下:
- 服务端向客户端下发文件(固件包、配置文件、资源包等)
- 文件大小从几 KB 到 100MB 不等
- 需要支持文件校验(确保传输正确)
- 目标是在千兆局域网内,100MB 文件在 5 秒内完成传输
我们将实现四个版本,逐步进化:
| 版本 | 核心方案 | 问题 | 对应的Bug |
|---|---|---|---|
| V1 | 裸字节流(TCP直接传输) | 无消息边界、无校验 | Bug④ |
| V2 | 文本序列化(JSON帧格式) | 体积大、速度慢 | Bug② |
| V3 | 二进制序列化(Protobuf+CRC) | 仍需用户态拷贝 | — |
| V4 | 零拷贝(sendfile + 二进制帧) | 实现复杂度略高 | Bug⑤ |
# 8.2 第一代:裸字节传输——没有边界的流
最直觉的实现——直接 read(file) → write(socket)。
// V1: 裸字节传输——最朴素的实现
public class FileTransferV1 {
// 发送端
public static void sendFile(Socket socket, String filePath) throws IOException {
File file = new File(filePath);
FileInputStream fis = new FileInputStream(file);
OutputStream out = socket.getOutputStream();
// 先发4字节文件大小(⚠️ 问题1:字节序!x86小端 vs 网络大端)
// 忘记做 htonl 转换!Bug①的根因
int fileSize = (int) file.length();
out.write((fileSize >> 24) & 0xFF); // 手动大端转换
out.write((fileSize >> 16) & 0xFF);
out.write((fileSize >> 8) & 0xFF);
out.write(fileSize & 0xFF);
// 发送文件内容
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
out.flush();
fis.close();
}
// 接收端
public static void receiveFile(Socket socket, String savePath) throws IOException {
InputStream in = socket.getInputStream();
// 读4字节文件大小
byte[] sizeBuf = new byte[4];
in.read(sizeBuf);
int fileSize = ((sizeBuf[0] & 0xFF) << 24) |
((sizeBuf[1] & 0xFF) << 16) |
((sizeBuf[2] & 0xFF) << 8) |
(sizeBuf[3] & 0xFF);
// ⚠️ 问题2:如果发送方紧接着发了第二个文件,TCP字节流中
// 第二个文件的头部会紧跟在第一个文件的数据后面
// 但接收方怎么知道第一个文件的数据在哪结束?
FileOutputStream fos = new FileOutputStream(savePath);
byte[] buffer = new byte[8192];
int remaining = fileSize;
while (remaining > 0) {
int len = in.read(buffer, 0, Math.min(buffer.length, remaining));
if (len == -1) break;
fos.write(buffer, 0, len);
remaining -= len;
}
fos.close();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
问题诊断:
V1 的三个核心缺陷:
缺陷1:字节序陷阱(对应Bug①)
发送端手动做了大端转换(注意那4行位移操作)
如果写成 out.write(intToBytes(fileSize)) 而不转换
→ x86(小端)机器上发出去的高低位是反的
缺陷2:不支持多文件传输(对应Bug④)
发送端发了文件大小+文件内容后,想发第二个文件
但第二个文件的大小头会紧跟在第一个内容后
TCP是字节流,无法区分"第一文件的最后N字节"和"第二文件的头4字节"
缺陷3:无校验(对应第5.3/5.4节)
如果传输过程中数据出错,接收方浑然不知
拿到一个损坏的文件当作正确文件使用
2
3
4
5
6
7
8
9
10
11
12
13
14
15
性能测试:
V1 测试(千兆局域网,100MB文件):
耗时:~2.8秒
CPU:25%(主要是read+write的4次拷贝)
瓶颈:CPU拷贝 + 上下文切换
结论:能传,但有bug隐患。对应第3.2节——TCP流不保留消息边界。
2
3
4
5
6
# 8.3 第二代:文本序列化——可读但慢
用 JSON 来封装帧格式,加上文件名和校验信息。
// V2: JSON帧格式——有边界、有校验,但笨重
public class FileTransferV2 {
static class FileHeader {
String fileName;
long fileSize;
String md5; // 文件MD5校验
int chunkIndex; // 分块序号(支持断点续传)
int totalChunks; // 总分块数
}
public static void sendFile(Socket socket, String filePath) throws IOException {
File file = new File(filePath);
// 构造JSON帧头
FileHeader header = new FileHeader();
header.fileName = file.getName();
header.fileSize = file.length();
header.md5 = computeMD5(filePath); // 计算MD5
header.chunkIndex = 0;
header.totalChunks = 1;
Gson gson = new Gson();
String jsonHeader = gson.toJson(header); // ⚠️ 约200字节的JSON
byte[] headerBytes = jsonHeader.getBytes(StandardCharsets.UTF_8);
OutputStream out = socket.getOutputStream();
// 发送帧:长度前缀(4B) + JSON头 + 文件数据
// 这样接收方就能区分每一条消息!Bug④的解决方案
out.write(intToBigEndian(headerBytes.length));
out.write(headerBytes);
// 发送文件数据
FileInputStream fis = new FileInputStream(file);
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
out.flush();
fis.close();
}
public static void receiveFile(Socket socket, String saveDir) throws IOException {
InputStream in = socket.getInputStream();
// 读长度前缀 → 解决了Bug④!
byte[] lenBuf = new byte[4];
in.read(lenBuf);
int headerLen = bigEndianToInt(lenBuf);
// 读JSON头
byte[] headerBytes = new byte[headerLen];
in.read(headerBytes);
String jsonHeader = new String(headerBytes, StandardCharsets.UTF_8);
Gson gson = new Gson();
FileHeader header = gson.fromJson(jsonHeader, FileHeader.class);
// 读文件数据
String savePath = saveDir + "/" + header.fileName;
FileOutputStream fos = new FileOutputStream(savePath);
byte[] buffer = new byte[8192];
long remaining = header.fileSize;
while (remaining > 0) {
int len = in.read(buffer, 0, (int) Math.min(buffer.length, remaining));
if (len == -1) break;
fos.write(buffer, 0, len);
remaining -= len;
}
fos.close();
// ✅ 校验文件完整性
String receivedMD5 = computeMD5(savePath);
if (!receivedMD5.equals(header.md5)) {
System.err.println("⚠️ 文件校验失败!期望:" + header.md5 + " 实际:" + receivedMD5);
} else {
System.out.println("✅ 文件校验通过:" + header.fileName);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
改进与问题:
V2 的改进:
✅ 解决了Bug④——长度前缀定义了消息边界
✅ 解决了数据完整性——MD5校验确保文件正确
✅ 人类可读——抓包能看到JSON头
V2 的新问题(对应Bug②):
❌ JSON头约200字节,对于100MB文件来说占比低(0.0002%)
但对于大量小文件传输场景(如每次传10KB配置)
头部开销占比 = 200 / 10240 ≈ 2% → JSON解析开销不可忽略
❌ JSON解析需要CPU(Gson/Jackson构建语法树)
2
3
4
5
6
7
8
9
10
性能测试:
V2 测试(千兆局域网,100MB文件):
耗时:~3.2秒(比V1慢0.4秒,JSON开销)
CPU:28%(JSON序列化/反序列化额外开销)
V2 测试(1KB小文件×10000次):
耗时:~8500ms(JSON开销占比巨大)
CPU:45%
每条消息只传1KB数据,但要解析200字节的JSON头
结论:适合大文件,小文件场景JSON开销太大。对应第7.2节——二进制序列化替代方案。
2
3
4
5
6
7
8
9
10
# 8.4 第三代:二进制序列化——高效但有门槛
用 Protobuf 替换 JSON,加上 CRC32 替代 MD5(速度更快)。
// file_transfer.proto
syntax = "proto3";
message FileHeader {
string file_name = 1;
int64 file_size = 2;
fixed32 crc32 = 3; // CRC32校验(比MD5快10倍)
int32 chunk_index = 4;
int32 total_chunks = 5;
}
2
3
4
5
6
7
8
9
10
// V3: Protobuf帧格式——紧凑高效
public class FileTransferV3 {
public static void sendFile(Socket socket, String filePath) throws IOException {
File file = new File(filePath);
// 构造Protobuf帧头
FileTransfer.FileHeader header = FileTransfer.FileHeader.newBuilder()
.setFileName(file.getName())
.setFileSize(file.length())
.setCrc32(computeCRC32(filePath)) // CRC32比MD5快10倍
.setChunkIndex(0)
.setTotalChunks(1)
.build();
byte[] headerBytes = header.toByteArray(); // 约20-50字节!
OutputStream out = socket.getOutputStream();
// 发送帧:长度前缀(4B) + Protobuf头 + 文件数据
out.write(intToBigEndian(headerBytes.length));
out.write(headerBytes);
// 发送文件数据
FileInputStream fis = new FileInputStream(file);
byte[] buffer = new byte[65536]; // 更大的缓冲区
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
out.flush();
fis.close();
}
public static void receiveFile(Socket socket, String saveDir) throws IOException {
InputStream in = socket.getInputStream();
byte[] lenBuf = new byte[4];
in.read(lenBuf);
int headerLen = bigEndianToInt(lenBuf);
byte[] headerBytes = new byte[headerLen];
in.read(headerBytes);
FileTransfer.FileHeader header = FileTransfer.FileHeader.parseFrom(headerBytes);
String savePath = saveDir + "/" + header.getFileName();
FileOutputStream fos = new FileOutputStream(savePath);
byte[] buffer = new byte[65536];
long remaining = header.getFileSize();
while (remaining > 0) {
int len = in.read(buffer, 0, (int) Math.min(buffer.length, remaining));
if (len == -1) break;
fos.write(buffer, 0, len);
remaining -= len;
}
fos.close();
// ✅ CRC32校验(比MD5快10倍)
long receivedCRC = computeCRC32(savePath);
if (receivedCRC != header.getCrc32()) {
System.err.println("⚠️ CRC校验失败!");
} else {
System.out.println("✅ 文件校验通过:" + header.getFileName());
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
核心改进:
V3 相比 V2 的优化:
✅ Protobuf头体积:20-50字节(vs JSON的200字节)→ 节省75%+
✅ 解析速度:5倍于JSON → CPU从28%降到22%
✅ CRC32替代MD5:更快(硬件加速)且足够检测传输错误
✅ 更大的发送缓冲区:65536字节(减少read/write系统调用次数)
残留问题:
❌ 仍然通过 read+write 传输文件数据 → 4次数据拷贝(对应Bug⑤)
2
3
4
5
6
7
8
性能测试:
V3 测试(千兆局域网,100MB文件):
耗时:~2.5秒
CPU:22%
V3 测试(1KB小文件×10000次):
耗时:~2500ms(比V2快3.4倍!)
CPU:18%(比V2节省60%)
结论:Protobuf在大批量小文件场景优势极其明显。对应第7.4节序列化性能对比。
2
3
4
5
6
7
8
9
# 8.5 第四代:零拷贝优化——逼近物理极限
用 sendfile 系统调用替代 read+write,减少数据拷贝。
// V4: 零拷贝 + 二进制帧——逼近物理极限
public class FileTransferV4 {
// 在Java中,通过FileChannel.transferTo()使用sendfile
public static void sendFile(SocketChannel socketChannel, String filePath) throws IOException {
File file = new File(filePath);
// 构造Protobuf帧头(同V3)
FileTransfer.FileHeader header = FileTransfer.FileHeader.newBuilder()
.setFileName(file.getName())
.setFileSize(file.length())
.setCrc32(computeCRC32(filePath))
.setChunkIndex(0)
.setTotalChunks(1)
.build();
byte[] headerBytes = header.toByteArray();
ByteBuffer headerBuffer = ByteBuffer.allocate(4 + headerBytes.length);
headerBuffer.putInt(headerBytes.length); // 4字节长度前缀
headerBuffer.put(headerBytes); // Protobuf头
headerBuffer.flip();
// 先发送帧头(需要CPU拷贝,但只有几十字节,开销极小)
while (headerBuffer.hasRemaining()) {
socketChannel.write(headerBuffer);
}
// ✅ 核心优化:sendfile 零拷贝发送文件内容!
// 数据从磁盘 → DMA → 内核缓冲区 → DMA → 网卡
// 全程不经过用户态CPU拷贝
try (FileChannel fileChannel = FileChannel.open(Paths.get(filePath),
StandardOpenOption.READ)) {
long position = 0;
long count = file.length();
while (count > 0) {
long transferred = fileChannel.transferTo(position, count, socketChannel);
position += transferred;
count -= transferred;
}
}
}
// 接收端
public static void receiveFile(SocketChannel socketChannel, String saveDir) throws IOException {
ByteBuffer lenBuf = ByteBuffer.allocate(4);
while (lenBuf.hasRemaining()) {
socketChannel.read(lenBuf);
}
lenBuf.flip();
int headerLen = lenBuf.getInt();
ByteBuffer headerBuf = ByteBuffer.allocate(headerLen);
while (headerBuf.hasRemaining()) {
socketChannel.read(headerBuf);
}
headerBuf.flip();
FileTransfer.FileHeader header = FileTransfer.FileHeader.parseFrom(
Arrays.copyOfRange(headerBuf.array(), 0, headerLen));
String savePath = saveDir + "/" + header.getFileName();
FileChannel fileChannel = FileChannel.open(Paths.get(savePath),
StandardOpenOption.WRITE, StandardOpenOption.CREATE);
// 接收文件数据(接收端无法用sendfile,因为数据来自网络不是文件)
ByteBuffer buffer = ByteBuffer.allocate(65536);
long remaining = header.getFileSize();
while (remaining > 0) {
buffer.clear();
if (remaining < buffer.capacity()) {
buffer.limit((int) remaining);
}
int read = socketChannel.read(buffer);
if (read == -1) break;
buffer.flip();
fileChannel.write(buffer);
remaining -= read;
}
fileChannel.close();
// ✅ 校验
long receivedCRC = computeCRC32(savePath);
if (receivedCRC != header.getCrc32()) {
System.err.println("⚠️ CRC校验失败!");
} else {
System.out.println("✅ 文件校验通过:" + header.getFileName());
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
零拷贝路径对比:
V3 (read+write): V4 (sendfile):
用户态 用户态
↑ read() ↑ 只有帧头发送用到CPU
│ (CPU拷贝) │
内核态 内核态
↑ DMA ↑ DMA
│ │
磁盘 磁盘 →[DMA]→ 内核缓冲区 →[DMA]→ 网卡
(数据不经过用户态,零CPU拷贝)
→[DMA]→ 内核缓冲区
│
↓ (CPU拷贝)
用户态buffer
│
↓ (CPU拷贝)
Socket缓冲区
│
↓ (DMA)
网卡
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
V4相比V3的核心优化:
✅ 文件数据零CPU拷贝(sendfile减少2次CPU拷贝)
✅ CPU:22% → 8%(解放CPU做其他事)
✅ 传输耗时:2.5秒 → 1.8秒
残留问题:
- 接收端的receive没有零拷贝方案(数据来自网络,不是本地文件)
- 如果文件需要压缩、加密等处理后再发送 → 不能用sendfile(必须经过用户态)
2
3
4
5
6
7
8
# 8.6 四种方案横向对比
| 维度 | V1 裸字节流 | V2 JSON帧 | V3 Protobuf帧 | V4 sendfile |
|---|---|---|---|---|
| 消息边界 | ❌ 无 | ✅ 长度前缀 | ✅ 长度前缀 | ✅ 长度前缀 |
| 数据校验 | ❌ 无 | ✅ MD5 | ✅ CRC32 | ✅ CRC32 |
| 帧头大小 | 4字节 | ~200字节 | ~30字节 | ~30字节 |
| 100MB传输耗时 | 2.8s | 3.2s | 2.5s | 1.8s |
| 10000个1KB小文件 | — | 8500ms | 2500ms | 1800ms |
| CPU占用(100MB) | 25% | 28% | 22% | 8% |
| 数据拷贝次数 | 4次 | 4次 | 4次 | 2次(帧头)+ 0次(文件) |
| 跨语言支持 | 手动处理 | ✅ JSON | ✅ Protobuf | ✅ |
| 人类可读 | ❌ | ✅ | ❌ | ❌ |
| 对应Bug | ①④ | ② | — | ⑤ |
| 对应章节 | 3.2/5.1 | 7.2 | 7.3/5.3 | 6.4 |
性能曲线(文件大小 vs 吞吐量):
吞吐量(GB/s)
│ V4 ────────────
│ /
│ /
│ / V3 ──────
│ / /
│ / V2 ────
│ / /
│ / V1 (小文件场景有bug)
└──────────────────────────────→ 文件大小
1KB 10KB 100KB 1MB 100MB
关键洞察:
大文件场景:V1-V4差距不大(文件数据本身是瓶颈)
小文件批量场景:V3/V4的帧头优化效果极其明显
V4的零拷贝优势在并发场景下更加突出(CPU节约可以做更多事)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 8.7 从案例看主流框架设计
看到这里,你应该明白了——V4 的设计思想在主流项目中广泛使用:
| 框架/系统 | 使用了V4的哪些技术 | 设计亮点 |
|---|---|---|
| Kafka | sendfile + 二进制协议 | Topic消息零拷贝投递到Consumer |
| Nginx | sendfile + 自定义帧格式 | 静态文件直接DMA到网卡 |
| gRPC | Protobuf + 长度前缀帧 | HTTP/2流式传输 + 二进制编码 |
| MySQL复制 | 二进制日志(Binlog Event) + CRC | 主从复制数据的完整性和高性能 |
| Redis | RESP协议(文本+长度前缀) | 兼顾可读性和解析效率 |
| Dubbo | Hessian2/Protobuf + 自定义帧 | RPC调用的高效序列化 |
一个完整的传输协议设计的checklist(来自四代进化的经验):
☑ 1. 字节序:所有多字节字段用网络字节序(大端)→ 第5.1/5.2节
☑ 2. 消息边界:长度前缀或分隔符 → 第3.2节、Bug④
☑ 3. 数据校验:CRC32(性能) 或 SHA256(安全) → 第5.3节
☑ 4. 序列化:Protobuf(高效) 或 JSON(可读) → 第7章
☑ 5. 传输优化:sendfile(静态)、大缓冲区(减少系统调用) → 第6.4节
☑ 6. MTU意识:避免IP分片(大消息用MSS大小的块) → 第3.5节
☑ 7. 向后兼容:协议版本号、字段编号不复用 → 第7.3节
2
3
4
5
6
7
8
9
# 8.8 全文知识图谱回顾
走到这里,我们用"文件传输协议的四次进化"把全文核心串完了。最后用一张图收敛所有知识点:
小陈的五重Bug
│
┌───────┬───────┼───────┬───────┐
│ │ │ │ │
①数字变了 ②CPU爆了 ③丢字段 ④解包失败 ⑤传得慢
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
字节序 序列化 MTU TCP流 零拷贝
大小端 JSON慢 分片 无边界 sendfile
[5.1] [7.2] [3.5] [3.2] [6.4]
[5.2] [7.3] [3.6] [3.3] [8.5]
│ │ │ │ │
└───────┴───────┼───────┴───────┘
│
┌───────────┴───────────┐
│ │
数据校验 [5.3/5.4] 各层PDU [4.5]
CRC/IP/TCP Checksum 帧→包→段→报文
│ │
└───────────┬───────────┘
│
V1 → V2 → V3 → V4
文件传输协议的四次进化 [第8章]
│
▼
Kafka / Nginx / gRPC
主流框架的设计智慧 [8.7节]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
最终的方法论沉淀——设计一个网络传输协议时,都应该问自己三个问题:
- 数据长什么样?(序列化方案 → JSON vs Protobuf → 可读性 vs 效率)
- 数据怎么分?(消息边界 → 长度前缀/分隔符 → TCP流的边界处理)
- 数据对不对?(字节序 → 网络字节序 / 校验 → CRC/checksum → 端到端完整性)
把这三个问题问到位,你就从"能把数据发出去"进化到了"能设计健壮的通信协议"。
# 09.思考题与作业
# 9.1 基础思考题
PDU的逐层包装:一个 1000 字节的应用数据,经过 TCP/IP/以太网 三层封装后,总共有多少字节?其中协议头部占比是多少?如果改用 UDP 呢?
MTU与切片的代价:为什么 TCP 宁可用 MSS 配合 PMTUD 来避免分片,也不直接让 IP 层分片?IP 分片到底有多少额外开销?(提示:从丢包重传、重组超时、防火墙过滤三个角度)
字节序的跨平台坑:如果两台 x86 机器(都是小端)通信,可以直接用主机字节序吗?如果可以,为什么还要用网络字节序?实际工程中为什么都统一转网络字节序?
TCP流 vs UDP数据报:说出至少三个场景分别适合 TCP Stream 和 UDP Datagram,并解释为什么。(提示:文件传输、视频直播、DNS查询、心跳检测)
# 9.2 进阶思考题
ProtoBuf的Varint精妙之处:Varint用每个字节的高位做"是否有后续字节"的标志。这种设计在什么场景下最优?在什么场景下反而是负优化?(提示:小正整数 vs 大整数 vs 负整数)
Bug④的深度思考:如果在 TCP 流中传输 Protobuf 消息,你用"长度前缀"来定界。请问长度字段用几个字节合适?1字节、2字节、4字节各有什么优缺点?Varint编码的长度前缀怎么样?
零拷贝的局限性:sendfile 虽然快,但它有使用限制。请列出至少 3 种"不能用 sendfile 而必须老老实实 read+write"的场景。Java 的
FileChannel.transferTo()在什么情况下会退化为普通的 read+write?设计你自己的协议帧:如果要设计一个通用的 RPC 帧格式,兼顾性能、跨语言和可扩展性,你会怎么设计?画出帧格式的字节布局,解释每一段的作用。和 gRPC 的帧格式(Length-Prefixed-Message)对比一下。
# 9.3 动手作业
作业一(必做):对比序列化方案。
- 用你熟悉的语言,对同一个包含 10 个字段的数据结构(string×3, int×3, double×2, bool×2),分别用 JSON 和 Protobuf 序列化。
- 测量:① 序列化后字节数 ② 序列化+反序列化 100万次的耗时。
- 把实测数据与文中 7.4 节的数据对比,填入下表:
| 方案 | 单条大小 | 100万次序列化耗时 | 100万次反序列化耗时 |
|---|---|---|---|
| JSON | |||
| Protobuf |
作业二(选做):复现第 8 节的文件传输四次进化。
- 实现 V1~V4 四个版本的文件传输协议(发送+接收)。
- 测试 100MB 文件在千兆局域网中的传输耗时。
- 额外测试 10000 个 1KB 小文件的传输场景,看帧头大小对性能的影响。
| 版本 | 100MB传输耗时 | 10000×1KB传输耗时 | CPU使用率 |
|---|---|---|---|
| V1 | |||
| V2 | |||
| V3 | |||
| V4 |
作业三(架构思考):分析一个你熟悉的通信协议。
- 任选一个(HTTP/2、gRPC、Redis RESP、Kafka、MySQL协议、WebSocket……),画出它的帧格式。
- 标注:消息边界如何定义?用什么序列化?有没有校验?头部有多大?
- 和 V4 的帧格式对比,哪些设计是共通的,哪些是特定场景的取舍?
# 参考
https://cloud.tencent.com/developer/article/2301115
https://zhuanlan.zhihu.com/p/705751265