data - movement - buffer - cache
06. 数据搬运、缓冲与缓存
0. 本章先解决什么问题
很多程序慢,不是慢在“算”,而是慢在“搬”。
数据可能要经过:
磁盘
-> 内核页缓存
-> 用户缓冲区
-> 程序数据结构
-> 序列化缓冲区
-> 内核 socket 缓冲区
-> 网卡
-> 网络
-> 对端缓冲区
每经过一层,都可能发生:
- 复制。
- 等待。
- 格式转换。
- 缓冲区排队。
- 缓存命中或失效。
- 用户态/内核态切换。
理解数据搬运,才能理解很多性能问题、IO 问题、网络问题和缓存一致性问题。
这张图把“数据从哪里来,到哪里去”画成路径:磁盘、页缓存、用户缓冲、序列化缓冲、socket buffer、网卡。每一段都可能复制、排队、命中缓存或触发背压。
这张图怎么读
这张图的读法是顺着数据路径问:
数据现在在哪一层?
下一步要搬到哪里?
这一步是复制、引用、映射,还是设备直接传输?
这一层有没有缓冲区?
缓冲区满了怎么办?
有没有缓存副本?
缓存和真实数据是否一致?
很多“性能问题”和“数据不一致问题”都不是算法本身造成的,而是数据在不同层之间移动时产生的:
| 问题 | 数据路径视角 |
|---|---|
| 上传慢 | 数据被分成小块、多次系统调用、多次复制 |
| 下载占 CPU | 文件经过用户态再写回内核,复制和校验成本高 |
| 内存暴涨 | buffer 堆积、反序列化对象过多、缓存没有淘汰 |
| 写入成功但重启丢数据 | 数据停在页缓存或写回缓存,没有持久化 |
| 改了数据但读到旧值 | 缓存副本没有失效 |
| 偶发卡顿 | 缓冲区满、GC、flush、cache miss、批量刷盘 |
所以分析这类问题,第一步不是改循环,而是画出数据走过哪些层。
1. 数据为什么要移动
程序不是孤立运行的。它要从外部拿数据,也要把结果送出去。
常见数据移动:
| 方向 | 例子 |
|---|---|
| 存储 -> 内存 | 读文件、加载程序 |
| 内存 -> 存储 | 写文件、保存日志 |
| 内存 -> CPU | CPU 读取变量、数组元素 |
| 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 慢或内存高时怎么想
如果一个程序处理文件或网络很慢,按这个顺序问:
- 数据从哪里来,到哪里去?
- 经过了哪些缓冲区?
- 有没有重复复制?
- 有没有频繁用户态/内核态切换?
- 是否命中缓存?
- 缓冲区是否满了,是否排队?
- 缓存是否过期或不一致?
- 序列化是否太重?
- 是否能减少数据搬运,而不是只优化计算?
这套问法适用于文件、网络、数据库、日志、图片处理、视频处理等很多场景。
机制深挖:吞吐、延迟和在途数据不是一回事
数据管道里经常有三个量被混在一起:
| 量 | 问的问题 |
|---|---|
| 吞吐 | 单位时间完成多少数据 |
| 延迟 | 一份数据从进入到完成要等多久 |
| 在途数据 | 已经进入系统但还没完成的数据有多少 |
它们之间有一个非常实用的近似关系:
在途数据量 ≈ 吞吐 × 平均停留时间
例如一个管道每秒处理 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 只是把失败延后,并且让已经进入队列的数据等待更久。它可能带来更坏的结果:
| 现象 | 原因 |
|---|---|
| 请求还没失败,但用户已经等很久 | 队列吸收了过载,延迟被隐藏 |
| 内存越来越高 | 在途数据堆积 |
| 超时后仍继续处理 | 队列里的旧任务已经没有意义 |
| 故障恢复慢 | 需要先消化积压 |
真正的稳定设计要给出过载策略:
代码块收起展开
限流,让入口不超过下游能力;
背压,让生产者感知下游慢;
超时,让旧任务及时退出;降级或拒绝,让系统保住核心路径。
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. 学完本章你能解决什么问题
学完这一章,你应该能解决或开始分析这些问题:
- 为什么程序慢可能慢在搬数据,而不是算数据?
- 为什么文件第二次读取可能更快?
- 为什么
send成功不等于对方收到? - buffer 和 cache 到底有什么区别?
- 为什么缓存会有一致性和淘汰问题?
- 为什么缓冲区满了会造成阻塞、丢弃或背压?
- 为什么零拷贝能提高文件传输性能?
- 为什么序列化会带来 CPU、内存和兼容性成本?
如果你以后看到“IO 慢、内存高、缓存不一致、网络发送不符合预期”,能先画出数据移动路径,你就已经抓住了这类问题的主线。