data - movement - buffer - cache

06. 数据搬运、缓冲与缓存

0. 本章先解决什么问题

很多程序慢,不是慢在“算”,而是慢在“搬”。

数据可能要经过:

磁盘
-> 内核页缓存
-> 用户缓冲区
-> 程序数据结构
-> 序列化缓冲区
-> 内核 socket 缓冲区
-> 网卡
-> 网络
-> 对端缓冲区

每经过一层,都可能发生:

  • 复制。
  • 等待。
  • 格式转换。
  • 缓冲区排队。
  • 缓存命中或失效。
  • 用户态/内核态切换。

理解数据搬运,才能理解很多性能问题、IO 问题、网络问题和缓存一致性问题。

数据搬运、缓冲与缓存

这张图把“数据从哪里来,到哪里去”画成路径:磁盘、页缓存、用户缓冲、序列化缓冲、socket buffer、网卡。每一段都可能复制、排队、命中缓存或触发背压。

这张图怎么读

这张图的读法是顺着数据路径问:

数据现在在哪一层?
下一步要搬到哪里?
这一步是复制、引用、映射,还是设备直接传输?
这一层有没有缓冲区?
缓冲区满了怎么办?
有没有缓存副本?
缓存和真实数据是否一致?

很多“性能问题”和“数据不一致问题”都不是算法本身造成的,而是数据在不同层之间移动时产生的:

问题数据路径视角
上传慢数据被分成小块、多次系统调用、多次复制
下载占 CPU文件经过用户态再写回内核,复制和校验成本高
内存暴涨buffer 堆积、反序列化对象过多、缓存没有淘汰
写入成功但重启丢数据数据停在页缓存或写回缓存,没有持久化
改了数据但读到旧值缓存副本没有失效
偶发卡顿缓冲区满、GC、flush、cache miss、批量刷盘

所以分析这类问题,第一步不是改循环,而是画出数据走过哪些层。

1. 数据为什么要移动

程序不是孤立运行的。它要从外部拿数据,也要把结果送出去。

常见数据移动:

方向例子
存储 -> 内存读文件、加载程序
内存 -> 存储写文件、保存日志
内存 -> CPUCPU 读取变量、数组元素
CPU -> 内存写回计算结果
进程 -> 内核写文件、发网络
内核 -> 进程读文件、收网络
主机 -> 主机网络传输
内存结构 -> 字节序列化
字节 -> 内存结构反序列化

数据移动越多,成本越高。

1.1 数据移动有三种代价

数据搬运的代价可以拆成:

代价说明
带宽成本每秒能搬多少 byte,受内存、磁盘、网络限制
延迟成本发起一次搬运要等多久,受系统调用、设备、网络影响
协调成本为了搬运要分配 buffer、切换上下文、加锁、排队

这解释了为什么批处理常常比逐条处理快:

  • 逐条处理:每条都付一次协调成本
  • 批量处理:多条共享一次协调成本

但批量也不是越大越好。批太大可能增加内存、延迟和失败重试成本。工程判断要在吞吐和延迟之间取平衡。

2. 数据为什么要复制

复制常见原因:

原因解释
隔离用户程序不能直接乱改内核数据
安全需要检查、过滤、权限边界
格式转换内存布局和外部格式不同
生命周期一份数据要保留,另一份继续变化
连续性算法或硬件需要连续内存
对齐硬件访问对齐数据更高效

复制不一定坏。坏的是你不知道复制发生了,还以为只是“传了个引用”或“发了点数据”。

2.1 复制和共享各有风险

减少复制听起来总是好事,但共享同一份数据也会引入状态风险。

方式好处风险
复制生命周期独立,修改互不影响CPU 和内存成本高
共享引用少复制,速度快谁能修改、何时释放、并发安全更难
只读共享成本低且安全要保证真的不可变
写时复制平时共享,修改时复制实现复杂,首次写入有峰值成本
内存映射减少显式复制页错误、持久化、并发可见性要小心

所以“零拷贝”不是所有场景都应该追求。只要数据需要被解析、修改、加密、压缩或长期持有,就可能必须复制或转换。

3. 一次文件读取可能发生什么

表面上:

read(file)

底层可能是:

应用发起 read
-> 进入内核
-> 查页缓存
-> 如果命中,直接从内存复制给应用
-> 如果未命中,从磁盘读入页缓存
-> 再复制到用户缓冲区
-> 返回应用

所以同一个文件第二次读可能更快,因为第一次已经把内容放进了页缓存。

3.1 页缓存让“磁盘 IO”和“内存 IO”容易混淆

读文件时,如果命中页缓存,数据来自内存;如果未命中,才需要读磁盘。

  • 第一次读慢:可能从磁盘读入页缓存
  • 第二次读快:可能直接从页缓存复制

这会影响性能测试:你以为测的是磁盘速度,实际可能测的是内存速度。可靠测试要区分冷缓存和热缓存。

写文件也类似:

write 返回
-> 数据可能进入页缓存
-> 后台稍后写盘

如果需要崩溃后仍然可靠,必须理解 flush / fsync / rename 这类持久化边界。否则“写成功”只是进入某层 buffer。

4. 一次网络发送可能发生什么

表面上:

send(bytes)

底层可能是:

应用缓冲区
-> 内核 socket 缓冲区
-> 协议栈封装
-> 网卡队列
-> 物理链路发送

注意:

send 返回成功,不一定代表对方已经收到。

它可能只代表数据进入了本机的发送缓冲区。

这就是抽象泄漏的典型例子。

4.1 socket buffer 是排队点

发送数据时,应用通常先把数据交给内核 socket buffer。之后协议栈和网卡慢慢发送。

如果对端读得慢,或者网络慢,本机发送缓冲区可能逐渐变满:

应用生产数据速度 > 网络发送速度
-> socket buffer 增长
-> send 阻塞或返回暂时不可写
-> 延迟升高或内存压力增加

这就是背压的来源。背压不是坏事,它是在告诉上游:

下游吃不下了,别再无节制生产。

如果系统忽略背压,继续把数据堆进内存队列,最终可能内存暴涨、延迟爆炸,甚至进程崩溃。

5. 缓冲 buffer:吸收速度差

缓冲用于吸收生产者和消费者速度不一致。

生产者 -> buffer -> 消费者

例子:

场景缓冲作用
键盘输入人输入慢,程序可以批量读取
文件写入应用先写缓冲,系统择机刷盘
网络发送应用写入 socket buffer,网卡逐步发送
视频播放提前缓冲一段内容,抵抗网络抖动
打印任务任务先排队,打印机慢慢处理

缓冲能平滑短期波动,但不能解决长期速度不匹配。

长期生产速度 > 消费速度
-> buffer 迟早满

缓冲区满了之后,常见结果:

  • 写入阻塞。
  • 写入失败。
  • 丢弃数据。
  • 触发背压。
  • 延迟越来越高。

5.1 背压是缓冲设计的核心

一个可靠的数据管道必须回答:

消费者处理不过来时,生产者怎么办?

常见策略:

策略适合场景风险
阻塞生产者不能丢数据,允许等待线程被占住,可能级联阻塞
返回失败调用者可以稍后重试需要清楚错误语义
丢弃低优先级实时流、监控采样必须知道丢失边界
降采样/合并指标、日志、视频帧精度下降
扩容 buffer短期突发长期瓶颈会变成内存问题
限流保护下游需要公平性和优先级

缓冲区大小不是越大越好。大 buffer 可以吸收突发,但也会隐藏瓶颈,让请求在队列里等很久。

一个经验判断:

buffer 用来吸收短抖动;
backpressure 用来处理持续过载。

6. 缓存 cache:减少重复成本

缓存用于保存更近、更快的副本。

如果将来还会用,就先留一份。

常见缓存:

缓存缓存什么
CPU Cache最近访问的内存块
OS 页缓存最近访问的文件页
DNS 缓存域名解析结果
浏览器缓存静态资源
应用缓存热点数据或计算结果

缓存有效依赖局部性:

局部性含义
时间局部性刚用过的数据未来还可能用
空间局部性附近的数据未来还可能用

如果数据只用一次,缓存收益就小。如果访问完全随机,缓存命中率也会低。

6.1 缓存要看命中率和代价

缓存是否值得,取决于:

命中节省的成本 * 命中次数
是否大于
维护缓存的成本 + 失效风险 + 空间成本

一个缓存至少要考虑:

问题说明
key 怎么设计key 不稳定会导致命中率低或串数据
value 多大大 value 会吃内存和复制成本
何时失效TTL、主动失效、版本号
未命中怎么办回源、计算、降级
热点怎么办单个 key 被大量访问
冷数据怎么办淘汰策略
错误是否缓存缓存错误可能放大故障

缓存不是“加一层 map”。缓存是一份副本系统。

7. 缓冲和缓存的区别

对比缓冲 buffer缓存 cache
目的吸收速度差减少重复访问
关注点满了还是空了命中还是未命中
典型问题排队、阻塞、背压失效、一致性、淘汰
例子socket buffer、写缓冲CPU cache、页缓存、DNS cache

一句话:

buffer 解决速度不匹配。
cache 解决重复访问成本。

7.1 一个组件可能同时像 buffer 和 cache

现实里边界不总是绝对的。比如 OS 页缓存:

  • 读文件时:像 cache,保存最近读过的文件页
  • 写文件时:像 buffer,先接收写入,之后再刷盘

所以不要只看名字,要看它当前承担什么角色:

观察点更像 buffer更像 cache
主要问题满了、排队、背压命中、失效、淘汰
数据方向从生产者流向消费者从慢层复制到快层
失败风险堆积、阻塞、丢弃旧值、不一致、穿透
关键指标队列长度、等待时间命中率、淘汰率、回源成本

8. 缓存一致性

缓存是副本。副本就有一致性问题。

原始数据改了
缓存里的旧数据还在

常见策略:

策略含义
失效数据变化后让缓存作废
更新数据变化时同步更新缓存
TTL过一段时间自动过期
版本号用版本判断新旧
写穿写缓存时同时写底层
写回先写缓存,之后再写底层

越强的一致性,通常成本越高。

例子:

写回缓存性能好
但如果还没写到底层就崩溃,数据可能丢

可靠性和性能又发生了 trade-off。

8.1 缓存常见事故:穿透、击穿、雪崩

应用缓存里常见三类问题:

问题含义典型处理
缓存穿透查询根本不存在的数据,每次都打到底层缓存空结果、布隆过滤器、参数校验
缓存击穿一个热点 key 过期,大量请求同时回源单飞请求、互斥重建、提前刷新
缓存雪崩大量 key 同时失效或缓存层故障TTL 加随机、分批预热、降级限流

这些问题都不是“缓存语法”问题,而是副本系统和流量模式交互后的结果。

再提醒一次:缓存里不只有值,还有状态:

存在 / 不存在
新鲜 / 过期
正在重建 / 已重建
可用 / 降级

状态没设计清楚,就会出现并发重建、旧值覆盖新值、错误被缓存等问题。

9. 淘汰策略

缓存空间有限。满了要淘汰。

常见思想:

策略直觉
FIFO最早进入的先淘汰
LRU最近最少使用的先淘汰
LFU使用频率最低的先淘汰
Random随机淘汰
TTL到期淘汰

没有万能策略。访问模式不同,适合的策略不同。

比如:

  • 最近访问的数据未来还会访问:LRU 常有效。
  • 高频热点更重要:LFU 更合理。
  • 只需要简单低成本:Random 也可能够用。

9.1 淘汰策略要匹配访问模式

几个典型访问模式:

访问模式特征策略注意
热点稳定少量 key 被反复访问LRU/LFU 通常有效
周期扫描一批数据被顺序扫一遍LRU 可能被冷数据污染
突发热点某个 key 突然爆热要防击穿和单点压力
大对象混入少量大 value 挤掉很多小 value要按容量成本淘汰
只写不读命中率低缓存可能不值得

所以优化缓存前先看命中率、value 大小、淘汰原因、回源成本,而不是盲目加容量。

10. 零拷贝的直觉

零拷贝不是“真的没有任何复制”,而是减少不必要的数据复制和上下文切换。

普通文件发送可能是:

磁盘
-> 内核页缓存
-> 用户缓冲区
-> 内核 socket 缓冲区
-> 网卡

优化后可能更像:

磁盘
-> 内核页缓存
-> 网卡

核心思想:

如果应用只是把文件转发出去,不需要真的把文件内容搬进应用内存。

这能减少:

  • 数据复制。
  • 内存占用。
  • CPU 消耗。
  • 用户态/内核态切换。

10.1 零拷贝适合“搬运”,不适合所有处理

零拷贝适合这种场景:

应用不需要理解内容,只是把 bytes 从一个地方送到另一个地方。

例如静态文件发送、代理转发、大文件下载。

但如果需要:

需求为什么不能简单零拷贝
解压/压缩必须读懂内容并转换
加密/解密需要计算和改写字节
修改字段需要解析结构
过滤敏感内容必须检查内容
生成新格式需要重新序列化

这时瓶颈可能从“复制”变成“计算/解析/转换”。优化方向也要变。

11. 序列化和反序列化

内存里的结构不能直接原样跨文件或网络传输。

通常要:

内存结构
-> 字节序列
-> 文件/网络
-> 字节序列
-> 内存结构

这就是序列化和反序列化。

成本包括:

  • 编码和解析。
  • 字段校验。
  • 内存分配。
  • 字节复制。
  • 版本兼容。
  • 字节序和编码处理。

所以“协议格式”不只是写法问题,它会影响性能、兼容性和可靠性。

11.1 文本协议和二进制协议的取舍

协议格式常见两类:

格式优点代价
文本/自描述易读、易调试、兼容性好体积大、解析成本高、类型边界要额外处理
二进制/紧凑体积小、解析快、字段明确不易读、版本兼容更难、字节序/对齐要清楚

没有绝对答案。调试友好、开发速度、网络带宽、CPU、兼容性都要一起看。

如果数据量小、排查频繁,文本格式可能更划算;如果高吞吐、大量数据、字段固定,二进制格式可能更合适。

关键是:格式选择也是成本模型的一部分。

12. 联系实际:看到 IO 慢或内存高时怎么想

如果一个程序处理文件或网络很慢,按这个顺序问:

  1. 数据从哪里来,到哪里去?
  2. 经过了哪些缓冲区?
  3. 有没有重复复制?
  4. 有没有频繁用户态/内核态切换?
  5. 是否命中缓存?
  6. 缓冲区是否满了,是否排队?
  7. 缓存是否过期或不一致?
  8. 序列化是否太重?
  9. 是否能减少数据搬运,而不是只优化计算?

这套问法适用于文件、网络、数据库、日志、图片处理、视频处理等很多场景。

机制深挖:吞吐、延迟和在途数据不是一回事

数据管道里经常有三个量被混在一起:

问的问题
吞吐单位时间完成多少数据
延迟一份数据从进入到完成要等多久
在途数据已经进入系统但还没完成的数据有多少

它们之间有一个非常实用的近似关系:

在途数据量 ≈ 吞吐 × 平均停留时间

例如一个管道每秒处理 1000 个任务,如果每个任务平均停留 50ms:

1000/s × 0.05s = 50

系统里大约有 50 个任务在路上。现在如果下游变慢,平均停留时间变成 2s,而入口吞吐暂时仍然是 1000/s:

1000/s × 2s = 2000

在途任务会暴涨。表现可能是:

队列长度升高
内存升高
超时升高
GC 或回收压力升高
日志里看起来每一步都没错,但整体越来越慢

所以“加 buffer”只能容纳更多在途数据,并不会提升下游消费能力。真正要问的是:

瓶颈层的服务速率是多少?
入口是否被它限制住?
过载时是等待、拒绝、丢弃,还是降级?

反例:把 buffer 调大不等于系统更稳

假设生产者每秒写入 1200 条,消费者每秒只能处理 1000 条。

如果 buffer 容量是 1000:

每秒净增加 200 条
大约 5 秒填满

如果把 buffer 改成 100000:

每秒仍然净增加 200 条
大约 500 秒填满

看起来“稳定了很久”,但本质没有变:

生产速度 > 消费速度

更大的 buffer 只是把失败延后,并且让已经进入队列的数据等待更久。它可能带来更坏的结果:

现象原因
请求还没失败,但用户已经等很久队列吸收了过载,延迟被隐藏
内存越来越高在途数据堆积
超时后仍继续处理队列里的旧任务已经没有意义
故障恢复慢需要先消化积压

真正的稳定设计要给出过载策略:

代码块JAVA · 3 行收起展开
限流,让入口不超过下游能力;
背压,让生产者感知下游慢;
超时,让旧任务及时退出;

降级或拒绝,让系统保住核心路径。

buffer 是减震器,不是发动机。它能吸收短期抖动,不能修复长期供需失衡。

排障卡:区分 buffer 满和 cache miss

看到慢,不要把 buffer 和 cache 混成一个词。

现象更像什么应该查什么
写入越来越慢,队列越来越长buffer 满生产/消费速度、背压、队列长度
第一次读慢,第二次读快cache hit页缓存、热点数据、预热
改了数据但读到旧值cache 一致性失效、TTL、版本、写穿/写回
内存高但吞吐没上去buffer 堆积或重复复制缓冲大小、复制次数、序列化
CPU 高在解析/编码序列化成本协议格式、字段校验、分配

一句实用判断:

buffer 的问题是“来得太快或走得太慢”;
cache 的问题是“命中、淘汰、失效、一致性”。

把两者分开,才能决定是限流/背压、扩大缓冲、改异步,还是做缓存失效和版本控制。

问题:大文件下载为什么占满内存?

学完本章,可以分析一个很常见的问题:

服务端提供大文件下载。
文件只有几百 MB,但并发一高,进程内存暴涨。

先画数据路径:

磁盘 -> 页缓存 -> 用户态大 byte array -> 响应序列化 -> socket buffer -> 网卡

危险点:

问题后果
一次性读完整文件到内存每个并发请求都占一份大 buffer
客户端下载慢socket buffer 堆积,上游继续读
没有背压生产速度超过网络发送速度
重复复制页缓存到用户态,再到内核 socket
没有限制并发总内存随连接数线性增长

更稳的方向:

分块流式读取
遵守 socket 可写状态和背压
限制单连接/总并发
能直接发送静态文件时使用更少复制的路径
设置超时,清理慢连接

你会发现,解决方案不是“把内存调大”,而是减少不必要的数据驻留,并让数据流动速度受下游控制。

13. 学完本章你能解决什么问题

学完这一章,你应该能解决或开始分析这些问题:

  1. 为什么程序慢可能慢在搬数据,而不是算数据?
  2. 为什么文件第二次读取可能更快?
  3. 为什么 send 成功不等于对方收到?
  4. buffer 和 cache 到底有什么区别?
  5. 为什么缓存会有一致性和淘汰问题?
  6. 为什么缓冲区满了会造成阻塞、丢弃或背压?
  7. 为什么零拷贝能提高文件传输性能?
  8. 为什么序列化会带来 CPU、内存和兼容性成本?

如果你以后看到“IO 慢、内存高、缓存不一致、网络发送不符合预期”,能先画出数据移动路径,你就已经抓住了这类问题的主线。

延伸阅读