编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 计算机原理

  • 网络协议

    • README
    • 通过看新闻熟悉网络
    • 通过购物熟悉加密
    • 从0到1部书电商网站
    • 请求网络的通用流程
    • 网络编程模型的概念
    • 传输协议TCP和UDP
    • Socket的发展和设计
    • 传输数据的设计思想
      • 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 数据传输优化
        • Nagle算法
        • TCP_CORK
        • 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 动手作业
        • 参考
    • 网络域名解析的流程
    • HTTP服务设计流程
    • HTTP协议设计思想
    • HTTPS协议设计策略
    • HTTP连接和跳转
    • HTTP代理和缓存设计
    • 如何去排查网络故障
    • WebSocket实时通信
    • HTTP3与QUIC协议
  • 操作系统

  • 数据库原理

  • 计算机
  • 网络协议
杨充
2023-06-08
目录

传输数据的设计思想

# 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/天的无效传输
1
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校验在这里
    ↓ ⑦ 比特流
物理传输
1
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 数据的发送处理

  1. 应用程序处理

启动应用程序新建邮件,将收件人邮箱填好,再由键盘输入"早上好",鼠标点击"发送"按钮就可以开始 TCP/IP 的通信了。

首先,应用程序会对邮件内容进行编码处理,例如:UTF-8,GB2312 等。这些编码相当于 OSI 的表示层功能。

应用在发送邮件的那一刻建立 TCP 连接,从而利用这个 TCP 连接发送数据。它的过程首先是将应用的数据发送给下一层的 TCP,再做实际的转发处理。

  1. TCP 模块处理

TCP根据应用的提示,负责建立连接,发送数据以及断开连接。TCP 提供将应用层发来的数据顺利发送至对端的可靠传输。

为了实现 TCP 的这一功能,需要在应用层数据的前端附加一个 TCP 的首部。TCP 的首部中包括源端口号和目标端口号、序号。随后将附加了 TCP 首部的包再发送给 IP。

  1. IP 模块处理

IP 将 TCP 传过来的 TCP 首部和 TCP 数据合起来当做自己的数据,并在 TCP 首部的前端加上自己的 IP 首部。IP 首部中包含接收端 IP 地址,发送端 IP 地址。随后 IP 包将被发送给连接这些路由器或主机网络接口的驱动程序,以实现真正的发送数据。

  1. 网络接口(以太网驱动)的处理

从 IP 传过来的 IP 包,对于以太网卡来说就是数据。给这些数据附加上以太网首部并进行发送处理。以太网首部中包含接收端 MAC 地址,发送端 MAC 地址,以太网类型,以太网数据协议。根据上述信息产生的以太网数据将被通过物理层传输给接收端。

# 2.3 数据接收处理

  1. 网络接口(以太网驱动)的处理

主机收到以太网包以后,首先从以太网的包首部找到 MAC 地址判断是否为发给自己的包。如果不是发给自己的则丢弃数据,如果是发给自己的则将数据传给处理 IP 的子程序。

  1. IP 模块处理

IP 模块收到 IP 包首部以及后面的数据部分以后,也做类似的处理。如果判断得出包首部的 IP 地址与自己的 IP 地址匹配,则可接受数据并从中查找上一层的协议。并将后面的数据传给 TCP 或者 UDP 处理。对于有路由的情况下,接收端的地址往往不是自己的地址,此时需要借助路由控制表,从中找出应该送到的主机或者路由器以后再进行转发数据。

  1. TCP 模块处理

在 TCP 模块中,首先会校验数据是否被破坏,然后检查是否在按照序号接收数据。最后检查端口号,确定具体的应用程序。数据接收完毕后,接收端则发送一个"确认回执"给发送端。数据被完整地接收以后,会传给由端口号识别的应用程序。

  1. 应用程序处理

接收端应用程序会直接接收发送端发送的数据。通过解析数据可以获知邮件的内容信息。

# 2.4 为何要层层封装

疑惑:为什么不直接把应用层的数据扔到网线上,而要一层一层地包装?

答疑:层层封装的本质是关注点分离。每一层只关心自己该做的事情,不关心其他层的细节。

类比寄快递:

  • 你(应用层)只关心信的内容
  • 快递员(传输层)关心的是包裹的收件人和寄件人
  • 物流中心(网络层)关心的是从哪个城市到哪个城市
  • 运输工具(数据链路层)关心的是这一段路用卡车还是飞机
  • 公路/航线(物理层)负责实际的运输

这种分层设计的核心优势:

  1. 替换灵活:数据链路层从以太网换成WiFi,上面的协议都不需要改动
  2. 组合自由:应用层可以选择TCP或UDP,传输层不关心上面跑的是HTTP还是FTP
  3. 分工明确:每层有自己的头部格式和处理逻辑,开发和调试都更容易
  4. 标准化:不同厂商的设备只要遵循同一层的协议标准就能互通

回到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)
1
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)
1

一个 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 用例子理解流程

发快递——>可以理解为发送数据

  1. 应用层:用户寄快递时,相当于处在应用层,货物就是报文。
  2. 运输层:在寄件点填写好目的地信息后形成数据报,处在运输层。
  3. 网络层:物流中心以自己的方式标记目的地信息形成数据包,处在网络层。
  4. 数据链路层:物流中心选择运输工具,比如飞机,货车等,数据链路层负责选择运输工具添加帧头帧尾,形成数据帧。
  5. 物理层:快递需要完成派送,即数据以比特流的形式完成传输。

收快递——>可以理解为接收数据

  1. 到达目的地后,再逐层去掉自己所在层级添加的首部,也可理解为根据快递地址信息逐层分派,最后送达我们的收件人手上,我们的货物是不变的。

# 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字节(以太网环境下)
1

疑惑:MSS和MTU是什么关系?为什么要分开设计?

答疑:MTU是链路层的限制,MSS是传输层(TCP)的限制。TCP在建立连接时(三次握手的SYN包中),双方会通过TCP选项字段告知各自的MSS值,取较小值作为本次连接的MSS。这样设计的目的是避免IP层分片。

如果TCP段超过MSS,IP层就会将其分成多个IP片段。IP分片有以下问题:

  1. 任何一片丢失,整个TCP段都要重传
  2. 路由器需要缓存分片并重组,增加内存开销
  3. 防火墙可能丢弃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的做法)
1
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
1
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机制:

  1. 发送方设置IP头的DF(Don't Fragment)标志
  2. 如果某个路由器发现包超过其出口链路MTU,就丢弃该包并返回ICMP "Fragmentation Needed"消息
  3. 发送方收到ICMP消息后,降低MSS重新发送
  4. 重复此过程直到不再收到ICMP消息,此时找到了路径MTU

# 04.数据基础的设计

# 4.1 报文设计思想

我们将位于应用层的信息分组称为报文。报文是网络中交换与传输的数据单元,即站点一次性要发送的数据块。

报文包含了将要发送的完整的数据信息,其长短很不一致,长度不限且可变。

# 4.2 数据报/报文段

现在来到传输层了,传输层要结合以下两种协议来讲:

  1. 一是我们经常听到的TCP(Transmission Control Protocol)协议,即传输控制协议,它是面向连接的,数据传输的单位是报文段,能够提供可靠的交付。
  2. 二是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 ...                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
1
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)   |
+-----------+------------------+-----------+
1
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 — — —

设计规律总结:

  1. 每层都有"标识上层协议"的字段:帧的类型字段标识网络层协议(IPv4/IPv6/ARP),IP的Protocol字段标识传输层协议(TCP/UDP),TCP/UDP的端口号标识应用层协议。这就是多路分解的基础。

  2. 头部大小逐层变小:越底层的协议,头部越简洁,处理越快。以太网帧头只有14字节,因为链路层设备(交换机)需要以线速处理。

  3. 校验覆盖范围不同:以太网FCS校验整个帧(头+数据+FCS),IP checksum只校验IP头,TCP/UDP checksum校验头+数据+伪头。各层各管各的。

  4. 封装是套娃,解封是拆箱:每一层收到上层的PDU后当作自己的载荷,加上自己的头部(有时还有尾部)形成本层的PDU。接收方逐层拆解,最终还原应用数据。

# 05.数据传输中的问题

# 5.1 大端和小端序

疑惑:同样一个数字,不同计算机存储的字节顺序居然不一样?

答疑:是的,这是一个历史问题。不同的CPU架构选择了不同的字节存储顺序。

以整数 0x12345678(十六进制)为例:

内存地址:    0x00  0x01  0x02  0x03

大端序:       12    34    56    78
(Big-Endian)  ↑高位字节在低地址
              
小端序:       78    56    34    12
(Little-Endian) ↑低位字节在低地址
1
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(完全不同的数字)
1
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位)
1
2
3
4
5

论证:为什么选择大端序作为网络字节序?

  1. 人类阅读习惯:大端序的高位在前,和人类书写数字的顺序一致。抓包时更容易阅读。比如IP地址192.168.1.1,在大端序中就是 C0 A8 01 01,和我们看到的地址顺序一致。
  2. 历史惯例:TCP/IP协议诞生时,网络设备多使用大端序处理器(如Motorola 68000),自然采用了大端序。
  3. 协议解析效率:大端序可以先读到高位字节,某些场景下可以更快地做出路由决策。

实际影响:在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则数据正确
1
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头部      |
+------------------+
|  数据部分         |
+------------------+
1
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)
   接收方通过序号发现缺失的段并请求重传
   同时可以对乱序到达的段进行重新排序
1
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
1
2
3
4
5
6
7
8
9

端到端原则:

数据完整性的设计遵循端到端原则——真正的完整性检查应该由通信的两个端点来完成,中间节点的检查只是优化手段。

即使链路层有CRC校验,传输层仍然需要自己的校验,因为:

  1. 数据可能在路由器内部的内存中出错(链路层CRC只覆盖单段链路)
  2. 中间设备可能修改数据(如NAT修改IP和端口)
  3. 数据可能在主机的协议栈处理过程中出错

这就是为什么应用层往往还会再加一层校验(如文件下载的MD5/SHA256校验),真正确保端到端的数据完整性。

# 06.数据传输的性能

# 6.1 带宽和吞吐量

**带宽(Bandwidth)和吞吐量(Throughput)**是两个经常被混淆的概念:

带宽 ≠ 吞吐量

带宽:链路的理论最大传输速率(如100Mbps、1Gbps)
      类比:高速公路的车道总宽度

吞吐量:实际传输数据的速率
      类比:实际通过的车流量
1
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影响窗口利用率)
├── 丢包重传(浪费带宽和时间)
└── 应用层处理延迟
1
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)来支持更大的窗口。
1
2
3
4
5
6
7
8

# 6.2 延迟的组成

网络延迟(Latency)由四个部分组成:

总延迟 = 处理延迟 + 排队延迟 + 传输延迟 + 传播延迟

                   处理延迟          排队延迟
                  (检查头部/查路由)   (等待出队)
  数据 ──→ [路由器 ════════════ 缓冲队列] ──→ 
                                       传输延迟        传播延迟
                                      (推入链路)       (信号传播)
                                       ──═══════──→ ────────→ [下一跳]
1
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)
1
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(小) ────→
1
2
3
4
5
6

每次发送小数据都要等200ms,性能急剧下降。解决方案:对实时性要求高的应用,使用 TCP_NODELAY 选项禁用Nagle算法。

# TCP_CORK

与Nagle相似但不同的是 TCP_CORK 选项。它将数据"堵住",直到显式"拔出塞子"或缓冲区满时才发送。适合在发送大量小块数据(如HTTP响应头+文件内容)时使用,可以确保它们被合并成少量大包发送。

Nagle vs TCP_CORK:
┌────────────────────────────────────────────────┐
│ Nagle:有未确认数据时缓存,收到ACK后发送        │
│        自动触发,适合交互式场景                  │
│                                                │
│ TCP_CORK:无条件缓存,直到拔塞或超时(200ms)     │
│        手动控制,适合批量发送场景                │
└────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8

# 6.4 零拷贝技术

疑惑:传统的文件发送流程中,数据要在用户态和内核态之间来回拷贝,这对性能有多大影响?

答疑:传统文件发送需要4次数据拷贝和4次上下文切换,开销巨大。

传统发送流程(read + write):

  磁盘 ──DMA拷贝──→ 内核缓冲区 ──CPU拷贝──→ 用户缓冲区
                                              │
  网卡 ←─DMA拷贝── Socket缓冲 ←─CPU拷贝──────┘

  共4次拷贝(2次DMA + 2次CPU),4次上下文切换
1
2
3
4
5
6
7

零拷贝(Zero Copy)技术就是为了减少这些不必要的拷贝和上下文切换。

sendfile系统调用:

sendfile流程:

  磁盘 ──DMA拷贝──→ 内核缓冲区 ──CPU拷贝──→ Socket缓冲区
                                              │
  网卡 ←─────────DMA拷贝─────────────────────┘

  共3次拷贝(2次DMA + 1次CPU),2次上下文切换
  数据不经过用户空间
1
2
3
4
5
6
7
8

在支持scatter-gather DMA的网卡上,还可以进一步优化:

sendfile + SG-DMA流程:

  磁盘 ──DMA拷贝──→ 内核缓冲区
                     │
  网卡 ←───SG-DMA───┘(只传递描述符,不拷贝数据)

  共2次拷贝(都是DMA),2次上下文切换
  真正的"零CPU拷贝"
1
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 为何需要序列化

疑惑:程序中的数据结构(如对象、数组、字典)为什么不能直接在网络上传输?

答疑:程序中的数据结构存在于内存中,包含了指针、内存对齐的填充字节、运行时的元数据等信息。这些信息:

  1. 与内存布局绑定:不同语言、不同平台的内存布局不同
  2. 包含无意义数据:填充字节、虚表指针等对另一端没有意义
  3. 包含指针:指针指向的是本机内存地址,传到另一台机器完全无意义
  4. 字节序问题:不同机器的字节序可能不同

所以需要序列化(Serialization)——把内存中的数据结构转换为一种与平台无关的、可传输的字节序列。反过来的过程叫反序列化(Deserialization)。

发送方                              接收方
数据结构 ──序列化──→ 字节流 ──网络──→ 字节流 ──反序列化──→ 数据结构
(语言A)              (通用格式)              (通用格式)      (语言B)
1
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字节
1
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字节)
1
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)
1
2
3
4
5
6
7
8
9

关键设计决策:

  1. 使用字段编号而非字段名:JSON传输字段名(如"userName"每次传输都要传这8个字符),Protobuf只传字段编号(1个Varint,通常1字节)。
  2. 没有的字段不传:如果某个字段是默认值或未设置,序列化时直接跳过,不占任何空间。
  3. 向后兼容:新增字段用新编号,老代码遇到不认识的编号直接跳过。这就是为什么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: ∞(零反序列化,直接访问偏移量)
1
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随数据存储,利于演化)
1
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();
    }
}
1
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节)
  如果传输过程中数据出错,接收方浑然不知
  拿到一个损坏的文件当作正确文件使用
1
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流不保留消息边界。
1
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);
        }
    }
}
1
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构建语法树)
1
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节——二进制序列化替代方案。
1
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;
}
1
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());
        }
    }
}
1
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⑤)
1
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节序列化性能对比。
1
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());
        }
    }
}
1
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)
          网卡
1
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(必须经过用户态)
1
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节约可以做更多事)
1
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节
1
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节]
1
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

最终的方法论沉淀——设计一个网络传输协议时,都应该问自己三个问题:

  1. 数据长什么样?(序列化方案 → JSON vs Protobuf → 可读性 vs 效率)
  2. 数据怎么分?(消息边界 → 长度前缀/分隔符 → TCP流的边界处理)
  3. 数据对不对?(字节序 → 网络字节序 / 校验 → CRC/checksum → 端到端完整性)

把这三个问题问到位,你就从"能把数据发出去"进化到了"能设计健壮的通信协议"。

# 09.思考题与作业

# 9.1 基础思考题

  1. PDU的逐层包装:一个 1000 字节的应用数据,经过 TCP/IP/以太网 三层封装后,总共有多少字节?其中协议头部占比是多少?如果改用 UDP 呢?

  2. MTU与切片的代价:为什么 TCP 宁可用 MSS 配合 PMTUD 来避免分片,也不直接让 IP 层分片?IP 分片到底有多少额外开销?(提示:从丢包重传、重组超时、防火墙过滤三个角度)

  3. 字节序的跨平台坑:如果两台 x86 机器(都是小端)通信,可以直接用主机字节序吗?如果可以,为什么还要用网络字节序?实际工程中为什么都统一转网络字节序?

  4. TCP流 vs UDP数据报:说出至少三个场景分别适合 TCP Stream 和 UDP Datagram,并解释为什么。(提示:文件传输、视频直播、DNS查询、心跳检测)

# 9.2 进阶思考题

  1. ProtoBuf的Varint精妙之处:Varint用每个字节的高位做"是否有后续字节"的标志。这种设计在什么场景下最优?在什么场景下反而是负优化?(提示:小正整数 vs 大整数 vs 负整数)

  2. Bug④的深度思考:如果在 TCP 流中传输 Protobuf 消息,你用"长度前缀"来定界。请问长度字段用几个字节合适?1字节、2字节、4字节各有什么优缺点?Varint编码的长度前缀怎么样?

  3. 零拷贝的局限性:sendfile 虽然快,但它有使用限制。请列出至少 3 种"不能用 sendfile 而必须老老实实 read+write"的场景。Java 的 FileChannel.transferTo() 在什么情况下会退化为普通的 read+write?

  4. 设计你自己的协议帧:如果要设计一个通用的 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

上次更新: 2026/06/09, 15:47:57
Socket的发展和设计
网络域名解析的流程

← Socket的发展和设计 网络域名解析的流程→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式