cache - layers
03. 缓存分层:同一个思想从 CPU 到 CDN
0. 本章先解决什么问题
CPU 有 L1/L2/L3,TCP 有收发缓冲区,操作系统有 page cache,MySQL 有 buffer pool,MyBatis 有一二级缓存,应用前面摆着 Redis,浏览器和 CDN 还各有一层。
这些东西散在五门课和四个组件的文档里,本章把它们全部收进一个字典:每一站是什么结构、多大、怎么淘汰、怎么保一致、坏了是什么现象,逐个讲透。
统一的主线只有一句:
访问有局部性,上下游有速度差。
用一小块快存储挡住对慢存储的大部分访问(缓存),
或者吸收两端的速度差(缓冲)。
这张图怎么读
从下往上延迟递增:CPU 缓存纳秒级,内存百纳秒级,SSD 百微秒级,跨机房网络毫秒级。每相邻两层之间都插着一层缓存或缓冲,命中就不必往下走。右侧标注每层由谁管理:硬件、内核、数据库、应用自己。
1. 先分清两个词:缓存和缓冲
这两个词经常混用,但角色不同,分清了后面每一站的定位才准:
- 缓存 cache:存”将来还会再要”的数据副本,目的是避免重复的慢访问。评价指标是命中率,核心难题是一致性和淘汰。
- 缓冲 buffer:存”正在路上”的数据,目的是吸收两端速度差、攒批搬运。评价指标是水位,核心难题是背压(满了怎么办)。
同一个组件可以同时干两件事。
page cache 读的时候是缓存(同一块磁盘数据第二次读直接命中),写的时候是缓冲(先收下脏页,攒着慢慢刷盘)。
TCP 的收发缓冲区是纯缓冲。
buffer pool 名字里带 buffer,实际两个角色都全职。
带着这对概念,下面从最快的一层开始逐站过。
2. 全景:延迟阶梯和每站的容量
先把数量级摆在桌面上,这是所有缓存存在的物理理由:
| 存储 | 延迟量级 | 典型容量 | 管理者 |
|---|---|---|---|
| L1 缓存 | ~1 ns | 每核 32~64 KB | 硬件 |
| L2 缓存 | ~4 ns | 每核 256 KB~1 MB | 硬件 |
| L3 缓存 | ~10-40 ns | 全核共享 8~64 MB | 硬件 |
| 内存 | ~100 ns | GB 级 | OS |
| SSD 随机读 | ~100 us | TB 级 | OS/驱动 |
| 机械盘寻道 | ~10 ms | TB 级 | OS/驱动 |
| 同机房网络往返 | ~0.5 ms | 无限 | 网络 |
| 跨城网络往返 | ~10 ms 起 | 无限 | 网络 |
相邻层普遍差两三个数量级。只要访问有局部性(刚访问过的、和它挨着的,大概率马上又要),把热的一小撮放进上一层,平均延迟就砍掉一个数量级。
存储层次结构 推导过这套逻辑,下面看它在每一站的具体形态。
3. 第一站:CPU 缓存,L1/L2/L3、缓存行与 MESI
3.1 结构
每个核私有 L1(还分指令和数据两块)和 L2,所有核共享 L3。CPU 访问内存前逐级查:L1 没有查 L2,L2 没有查 L3,都没有才走内存总线,代价从 1 ns 一路涨到 100 ns。
搬运单位是上一章讲过的缓存行,64 字节。读一个 int(4 字节),硬件会把它所在的整 64 字节拉进来。这就是”数组遍历比链表遍历快一个量级”的机制来源:数组下一个元素几乎总在已经拉进来的行里,链表的下一个节点在内存里天各一方,每跳一次就是一次 100 ns 的缓存未命中。
3.2 一致性:MESI 和它漏到 Java 里的部分
多个核各自缓存了同一行数据,一个核改了,其他核的副本就旧了。硬件用 MESI 协议自动处理:每个缓存行标着四种状态(已修改、独占、共享、失效),一个核要写某行,先通过总线消息把其他核的该行标成失效,其他核下次读会重新拉取。
对 Java 后端来说,重点是这套硬件机制不直接等于”写了别人立刻可见”:写操作会先进核内的 store buffer 再异步刷出去,编译器和 CPU 还会重排指令。
可见性于是从硬件问题漏成语言层问题,volatile、synchronized、final 的内存语义就是 Java 给这个漏洞打的补丁。
这条线在 可见性 & Volatile 里有完整展开,本章只钉住定位:JMM 是对 CPU 缓存层一致性的语言级封装。
3.3 伪共享:缓存行粒度的误伤
两个毫不相关的变量恰好落在同一个 64 字节行里,两个线程分别高频写这两个变量,MESI 会让这一行在两个核之间反复失效弹跳,性能掉一个数量级。这叫伪共享。
代码块收起展开
// LongAdder 的 Cell 为什么要加 @Contended(本质是填充到独占一个缓存行)
@sun.misc.Contended
static final class Cell {
volatile long value; // 每个线程往自己的 Cell 累加
// 若多个 Cell 挤在同一缓存行,分散累加的努力会被行弹跳吃掉
}LongAdder源码 的分段累加设计要配合缓存行填充才成立,这是”底层块尺寸决定并发类库写法”的直接证据。
3.4 TLB:给地址翻译也配一层缓存
虚拟地址到物理地址要查多级页表,每次访存都查一遍页表等于访存翻几倍。硬件把最近的翻译结果缓存在 TLB 里,命中就免查页表。
TLB 容量很小(几百到几千条),每条覆盖一页 4 KB,所以大内存应用(数据库、JVM 大堆)会用大页(2 MB)让每条 TLB 覆盖面扩大 512 倍。
看到”数据库建议开 huge page”的调优建议,背后就是这站。
4. 第二站:TCP 收发缓冲区,滑动窗口住的地方
这一站是纯缓冲,但它是后端排查网络问题时最常撞见的一层,必须完整讲。
4.1 为什么必须有这两块缓冲
应用调用 write 发数据时,数据并没有直接上网线:
- 发送方向:应用 write 把数据拷进内核的发送缓冲区,write 就返回了。之后由内核协议栈决定什么时候真正发包。
- 接收方向:网卡收到包先进内核的接收缓冲区,应用 read 再从那里拷走。
两块缓冲各有存在的硬理由:
- 发送缓冲区
- 应用产生数据的速度和网络发送速度不同步,要有地方攒。
- 已发出但未收到 ACK 的数据必须留底,丢了要重传。重传的底就押在发送缓冲区里,ACK 到了才能释放。
- 接收缓冲区
- 网卡到包的时机和应用 read 的时机不同步。
- 乱序到达的段要在这里排队重组,凑齐了才能交给应用。
4.2 滑动窗口:缓冲区余量的实时广播
TCP 流量控制 讲过滑动窗口的协议机制,这里把它和缓冲区钉在一起,因为窗口不是抽象概念,它就是接收缓冲区的空闲量:
- 接收方每个 ACK 都捎带
rwnd,意思是”我的接收缓冲区还剩多少字节”。 - 发送方保证:已发送未确认的数据量 <= min(rwnd, 拥塞窗口)。
- 接收应用消费慢,接收缓冲就堆积,rwnd 随之缩小,发送方被迫减速。
- rwnd 缩到 0,发送方停发,定期发零窗口探测包等它恢复。
所以流量控制的本质是:接收端把自己缓冲区的水位实时广播给发送端,让上游按下游的消化能力供货。
4.3 背压链条:从对端一路顶回你的代码
把两块缓冲串起来,就得到一条完整的背压传导链,这条链是”下游慢导致上游堵”类故障的标准路径:
- 对端应用消费慢。
- 对端接收缓冲区满,rwnd 变 0。
- 本端发送缓冲区的数据发不出去,越堆越满。
- 本端应用再 write:阻塞模式下 write 卡住不返回,非阻塞模式下返回 EAGAIN(Java NIO 里是 channel.write 返回 0)。
- 应用必须自己决定:等待、丢弃、还是断开。
Netty 把最后一步产品化成了高低水位机制。
发送队列堆过高水位,Channel 的 isWritable 变 false 并触发回调,业务代码应该停止写入;落回低水位再恢复。
不理会这个信号继续写,数据全堆在 Netty 的出站缓冲里,OOM 就是这么来的。
这是”理解内核缓冲区”直接换成”会用框架 API”的地方。
4.4 排查入口
ss -t 状态列旁的两个数字:
- Recv-Q 堆积:本进程 read 太慢,先查线程池是不是满了、处理是不是慢。
- Send-Q 堆积:对端收不动,或者网络丢包在等重传。
- 两头都不堆但是慢:问题不在缓冲区,去查 RTT 和丢包。
5. 第三站:OS page cache,磁盘前面的内存大堤
5.1 读路径:缓存加预读
Linux 把空闲内存几乎全部拿来缓存磁盘数据,free 命令里 buff/cache 那一大块就是它。读文件时:
一次 read 有两条分支:
- 页缓存命中:直接从内存拷贝返回,通常是微秒级。
- 页缓存未命中:先触发磁盘 I/O,把数据读入页缓存,再拷给应用。
内核检测到顺序读后还会预读,提前把后面几十到几百 KB 搬进页缓存,因此顺序读取大文件时后续请求几乎都能命中。
“文件第二次读快得多”和”程序重启后一段时间磁盘特别忙”都是这一站的日常表现:前者是命中,后者是缓存冷了在重建。
5.2 写路径:这时它是缓冲
write 默认只把数据写进页缓存并标记为脏页就返回,落盘由内核线程延后批量执行(脏页比例或时限触发)。
好处是写聚合、顺序化;代价是掉电丢数据窗口。所以数据库在提交点必须显式 fsync 强制刷盘,日志先行 一章里”提交时唯一必须等的 IO”等的就是这次 fsync。
5.3 谁在用它,谁绕开它
- 重度依赖它的是 Kafka:自己不管缓存,顺序读写全交给页缓存,再用 sendfile 让数据从页缓存直达网卡,不过应用内存。
- 绕开它的是 MySQL:对数据文件用 O_DIRECT。原因一是 buffer pool 已经缓存了页,页缓存再存一份纯浪费内存;原因二是数据库比内核更懂哪页该留,它知道这是索引根还是全表扫描。
同一层缓存,一个组件当命根子,一个组件嫌碍事,判断依据是”上层有没有更聪明的自管缓存”。
5.4 页回收:内存不够时 OS 淘汰谁
page cache 几乎吃掉全部空闲内存,应用要内存时 OS 就得往外吐,这一层同样有一套完整的淘汰机制,和 Redis、buffer pool 是同一道题的第三个答案:
- 结构:页分两条链表,active(活跃)和 inactive(不活跃)。新页和降级页进 inactive,在 inactive 里再次被访问才升 active。
- 又是”两段式防污染”,和 buffer pool 的 old/young 异曲同工。一次性扫过的大文件在 inactive 走一遭就被回收。
- 谁来回收:内存水位低时后台线程 kswapd 慢慢回收。低到分配请求等不及时发生直接回收(direct reclaim),分配内存的那个线程被拉去干回收的活。
- 应用突然的延迟尖刺,很多就是撞上了直接回收。
- 回收谁:文件页(page cache)干净的直接丢,脏的先刷盘再丢;匿名页(堆栈)只能换出到 swap。
- swappiness 参数调两者的倾向,服务器常调低,宁丢缓存别换堆。
- 兜底:全都不够就 OOM killer 挑进程杀,JVM 大堆进程经常是它的头号目标。
三套淘汰放一起看:Redis 采样近似 LRU、buffer pool 中点插入、OS 双链表加二次机会,全是”教科书 LRU 防不住扫描和维护成本”这同一个教训的三种工程答案。
6. 第四站:InnoDB buffer pool,数据库的自管内存
6.1 结构:三条链表管一池页
buffer pool 是 MySQL 进程里一大块自管内存(innodb_buffer_pool_size,生产常配到物理内存的 50%~70%),切成 16 KB 的帧,每帧装一个数据页,配套三条链表:
- free list:还空着的帧。新页读入时从这里拿。
- LRU list:所有在池的页按冷热排队。free 拿光了就从 LRU 尾淘汰。
- flush list:所有脏页按修改先后排队。后台线程从这里挑页刷盘。
一个页可以同时在 LRU(参与冷热竞争)和 flush(等着落盘)里,两条链管两件事,互不代替。
6.2 中点插入 LRU:两道闸防污染
朴素 LRU 有个致命弱点:一次全表扫描会把整池热页刷成冷数据。InnoDB 的修正是把 LRU 切成两段并设两道闸:
链表前 5/8 是热区(young),后 3/8 是冷区(old)。
- 第一道闸:新读入的页只插到冷区头部,不碰热区。
- 第二道闸:冷区的页被再次访问时,距首次读入超过 1 秒(innodb_old_blocks_time)才允许晋升热区。
第二道闸专门防的是全表扫描的访问模式。
扫描对每页”读入后立刻多次访问”(读该页的每一行),只按”访问过就晋升”根本拦不住。
加上时间闸,扫描在 1 秒内扫完一页就走,永远留在冷区,随扫随淘汰,热区安然无恙。
预读进来但从没被访问的页同理在冷区自生自灭。
6.3 和日志的配合
改页只改 buffer pool 里的副本(脏页),靠 redo log 保证不丢,脏页由后台按 checkpoint 节奏批量刷盘。
这个”缓存吸收随机写、日志兜底持久性”的搭配是第 4 章的主线,两章在这里互相咬合:buffer pool 敢延迟刷盘,是因为 redo 已经落盘;redo 敢只记增量,是因为页的完整状态在池里。
命中率看 SHOW ENGINE INNODB STATUS 的 Buffer pool hit rate,生产健康线通常在 99% 以上,掉到 95% 就该查是池太小还是有大扫描。完整参数在 InnoDB引擎。
7. 第五站:应用进程内缓存
7.1 MyBatis 一级、二级缓存
- 一级缓存:SqlSession 级,默认开。
- 同一个 session 内同样的查询第二次直接返回上次结果,任何写操作清空。
- Spring 集成下每次请求通常是新 session,所以实际存在感很弱。
- 二级缓存:namespace(Mapper)级,默认关。跨 session 共享,按 Mapper 隔离。
- 默认关的原因是多表关联场景下,A Mapper 缓存的结果涉及 B 表数据,B 表被 B Mapper 更新时 A 的缓存不失效,直接产生脏读。
- 开它之前必须确认表的写路径全部收敛在同一个 namespace 里。
机制源码在 SqlSession 与缓存。实践里这两层经常让位给下一站的 Redis,因为进程内缓存在多实例部署下天然各存各的,失效没法广播。
7.2 本地缓存 Caffeine
进程内通用缓存的现役标准件。两个记忆点:
Caffeine 可以从两个层面理解:
- 位置:它挡在 Redis 前面,把一次约
0.5 ms的网络往返缩短到纳秒级的进程内访问。代价是多实例之间可能短暂不一致,因此只适合配置、字典等“短暂旧了也无妨”的数据。 - 淘汰策略
W-TinyLFU:由三个零件拼成。- 频率素描(Count-Min Sketch):用二维计数器阵列近似记录热度。一个 key 经过 4 个哈希后在 4 行分别加一,查询频率取 4 个计数的最小值。它只会少量高估、几乎不会低估,用固定几 MB 就能记住海量 key 的近似热度;计数器还会定期整体减半,让老热点的历史优势逐步衰减。
- 准入判断:缓存满时,新 key 先和即将被淘汰的“受害者”比较热度,赢了才准入。全表扫描产生的一次性 key 频率通常只有 1,很难挤掉常驻热点,因此从机制上避开了 LRU 的扫描污染。
- Window 区:保留一小块普通 LRU,兜住刚出现、频率还没积累起来的突发热点。
Redis 用采样近似节省内存,Caffeine 用频率素描换取精度;两者都在回避“精确 LFU 必须给每个 key 完整记账”的成本。
8. 第六站:Redis,应用架构层的缓存
这一站语义最丰富,问题也最多,因为一致性从硬件和内核的事变成了你的事。逐个展开。
8.1 旁路缓存:标准读写路径和它的第一个坑
- 读:先查 Redis,命中就返回。未命中就查 MySQL,把结果写回 Redis(带 TTL),再返回。
- 写:先更新 MySQL,再删除 Redis 里的旧 key。
写路径两个决策都有讲究。
为什么是”删缓存”而不”改缓存”。
并发写下,改缓存会出现”后算出的旧值覆盖先算出的新值”的交错:写 A 先更库、写 B 后更库,B 先回填缓存、A 后回填,缓存里留下 A 的旧值,且没有 TTL 之外的纠错机会。
删除让下一个读自己去取最新值,等于把”算新值”的责任推迟给读方,交错窗口小得多。
附带好处是很多写根本不会再被读,删比改省一次无用计算。
为什么是”先更库,再删缓存”这个顺序。
反过来”先删缓存再更库”,删完到库更新完成之间,任何读都会把旧值捞回缓存,旧值一直活到下次写。
而”先更库再删缓存”的坏情况需要四个事件精确交错(读未命中、读到旧库值、写更库、写删缓存、读回填旧值),且要求读的回填晚于写的删除。
读通常比写快,这个交错概率低一个量级。
低概率兜底手段,按投入排序:
- 给缓存设 TTL:最坏情况旧值活不过 TTL,多数业务够用。
- 延迟双删:更库、删缓存、隔几百毫秒再删一次,把上面那个低概率交错留下的旧值补掉。
- 订阅 binlog 异步删(canal):库的变更流驱动删除,删除动作不依赖业务代码记得写,最终一致的收敛由数据流保证。
没有方案能在旁路缓存模式下做到强一致,选择的实质是”业务能容忍多久的旧”。要强一致就别拿 Redis 当缓存,走分布式锁或者读也过库。
8.2 穿透:查不存在的数据
缓存穿透的病理链路是:请求的 key 在数据库里根本不存在,缓存因而永远无法命中,每次请求都会落到 MySQL;攻击者批量扫描不存在的 ID 就可能直接打垮数据库。
- 空值缓存:查库确认不存在后,向 Redis 写一个短 TTL(例如 2 分钟)的空标记。成本很低,但海量随机 key 攻击仍会向缓存塞入大量垃圾空值。
- 布隆过滤器:启动时把全量合法 ID 写入“大位图 +
k个哈希函数”组成的过滤器,请求先经过它。- 写入元素时,把
k个哈希位置置为 1。 - 查询时,只要任一位置为 0,就能断定“一定不存在”;全部为 1 只能说明“可能存在”,因此存在误判率。
- 它只会错放(说可能存在但实际不存在),不会错杀(判定不存在就一定不存在),所以可以安全地挡在数据库前面。
- 普通布隆过滤器不支持直接删除,因为多个元素可能共享同一个位;数据变化后通常要定期重建。
- 写入元素时,把
8.3 击穿:热 key 过期的瞬间
缓存击穿发生在一个高并发热 key 恰好到期时:成千上万请求同时未命中,又同时去数据库重建同一份数据,数据库会被同一条查询瞬间打穿。
- 互斥重建:未命中的请求先抢分布式锁(
SETNX),只有抢到锁的请求查库并回填,其余请求短暂等待后重读缓存。它保证拿到新数据,但等待请求延迟会上升,锁本身也必须设置超时以防死锁。 - 逻辑过期:key 不设置物理 TTL,而是在值里保存逻辑过期时间。读到逻辑已过期的数据时,抢锁成功的请求异步重建,当前请求和其他请求先返回旧值。它避免排队,但会产生一段可控的旧数据窗口。
一致性要求高时选互斥重建,宁可等待也不返回旧值;可用性优先时选逻辑过期,宁可短暂读旧也不阻塞请求。
8.4 雪崩:一大片同时失效
- 病理 A:大批 key 设了相同 TTL(比如缓存预热时统一 30 分钟),同一时刻集体过期,流量整片砸向库。
- 病理 B:Redis 实例整个宕机,所有请求全量穿透。
- 解 A:TTL 加随机抖动(30 分钟 ± 随机 5 分钟),把过期时刻摊开。
- 解 B:高可用架构(哨兵/集群,第 6 章的领域)+多级缓存(本地 Caffeine 还能顶一阵)+限流降级兜底,宁可拒绝一部分请求,保住库不死。
三种病的共同病理值得抽出来:缓存层的命中被突然抽走,而下层从未按全量流量做过容量规划。
所以兜底思路永远是两笔账:怎么让命中别被突然抽走(抖动、逻辑过期、高可用),以及抽走了怎么保住下层(互斥、限流、降级)。
案例完整版在 mid - 缓存穿透 & 击穿 & 雪崩。
8.5 内存满了:八种淘汰策略和近似 LRU
maxmemory 到顶后由 maxmemory-policy 决定谁滚蛋:
| 策略 | 淘汰谁 |
|---|---|
noeviction | 不淘汰,写请求直接报错。默认值,缓存场景基本不用 |
allkeys-lru | 全体 key 里挑最久未访问的。纯缓存场景的标准答案 |
volatile-lru | 只在设了 TTL 的 key 里挑 |
allkeys-lfu | 全体里挑访问频率最低的(4.0+) |
volatile-lfu | 带 TTL 的里挑频率最低 |
allkeys-random / volatile-random | 随机踢 |
volatile-ttl | 挑剩余 TTL 最短的 |
Redis 的 LRU 是近似实现。
全局访问链表太贵(每次访问都要摘链挂链,内存和 CPU 都不划算),它只在每个对象头里记一个 24 位时钟,淘汰时随机采样 5 个 key(maxmemory-samples),踢掉其中最久未访问的,并维护一个淘汰候选池让结果逐步逼近真 LRU。
LFU 同理用 8 位计数器(对数概率递增,防高频 key 秒满)加时间衰减(防历史热点永远赖着)。
设计哲学和 buffer pool 的中点插入殊途同归:教科书算法在工程里都要为”不老实的访问模式”和”维护成本”让步。
源码级展开在 过期与淘汰。
9. 第七站:HTTP 缓存,浏览器和 CDN
离用户最近的一层,规则全写在 HTTP 头里,分强缓存和协商缓存两级。
9.1 强缓存:不发请求
Cache-Control 指令 | 含义 |
|---|---|
max-age=31536000 | 本地副本一年内直接用,请求都不发 |
no-cache | 可以存,但每次用前必须去协商(走 9.2) |
no-store | 完全不许存,敏感数据用 |
private / public | 仅浏览器可存 / 中间代理和 CDN 也可存 |
s-maxage=600 | 专管共享缓存(CDN)的寿命,覆盖 max-age |
immutable | 承诺此 URL 内容永不变,连刷新都不发请求 |
老头 Expires 是绝对时间戳,依赖客户端时钟,被 max-age 取代,如今只做兜底。
命中强缓存时 DevTools 显示 from memory cache(本次会话摸过,在内存)或 from disk cache(历史会话留下的,在磁盘),请求根本没出浏览器。
9.2 协商缓存:发请求但可能不传 body
强缓存过期后,浏览器带着凭证去问服务器”我这份还能用吗”:
浏览器常用两类验证凭证:
Last-Modified/If-Modified-Since:服务器返回文件修改时间,浏览器下次请求时带回来比较。它只有秒级精度,一秒内修改两次可能识别不出;反过来,文件只被touch、内容没变,也会因为修改时间变化而白白失效。ETag/If-None-Match:服务器按内容计算指纹,内容不变时指纹不变,判断更精确。两组凭证同时存在时,ETag优先。
服务器确认资源未变化后返回 304 Not Modified,不再传输响应体;这省下了带宽,但没有省掉本次网络 RTT。
9.3 工程标准姿势
- 静态资源(JS/CSS/图片):文件名带内容指纹(app.3f2a1b.js),配 max-age=1 年 + immutable。内容一变文件名就变,等于用”换 URL”绕开了失效问题,缓存想设多久设多久。
- HTML:no-cache(或很短的 max-age),每次协商,保证用户总能拿到引用了新指纹的入口。
这套姿势的本质:把”怎么让缓存及时失效”这个难题,转化成”根本不失效,直接换名字”。
和 Redis 旁路缓存的”删了让读方重建”是同一个方向的两种走法。
9.4 CDN:把强缓存铺到离用户最近的机房
CDN 边缘节点是一层地理分布的共享 HTTP 缓存:用户请求被 DNS 调度到最近的边缘,边缘命中直接回,未命中逐级回源站取并存下。
s-maxage 管边缘副本寿命;发版要主动清(刷新指定 URL)或预热(提前推热资源上边缘)。
边缘节点的 stale-while-revalidate 行为(先回旧的,后台悄悄更新)解释了”明明部署成功了,线上过了几分钟才变”这类现象。
9.5 DNS 缓存:顺路的一层
域名解析结果在浏览器、操作系统、运营商 resolver 逐级缓存,寿命由权威 DNS 设的 TTL 决定。TTL 长则解析快、切换慢(故障转移要等全网缓存过期),TTL 短则反之。它是”翻译表也是缓存”的最普及案例,第 8 章间接层还会回来引用它。
10. 串起来:一次商品页请求经过的所有站
- 浏览器:静态资源强缓存命中,HTML 协商拿 304。
- CDN:图片在边缘命中,未命中的回源。
- NGINX/OpenResty:本地缓存热点页面片段。
- 应用:Caffeine 命中类目配置,未命中查 Redis。
- Redis:商品详情命中;库存 key 逻辑过期,异步重建中先回旧值。
- MySQL:漏下来的查询,buffer pool 里 99% 命中页。
- page cache:buffer pool 未命中的页读文件,可能还在页缓存。
- 磁盘:漏斗最底,真正的磁盘 IO 已是涓流。
全程 TCP 收发缓冲区在每一跳吸收速度差,rwnd 防止谁被灌爆。
工程要点两条。
一是命中率要逐层监控,上层命中率崩了,下层会在毫无准备时接到全量。
二是越下层越要按”上层全崩”的最坏情况留余量或配限流。
多级缓存的完整改造案例在 mid - OpenResty & 多级缓存 & 请求链路改造。
11. 统一检查框架
面对任何一层缓存,五个问题按序问,答不上的就是隐患:
- 命中率多少?收益 = 命中率 x (慢路径成本 - 快路径成本),
命中率过低时缓存是负资产(白占内存还添一致性风险) - 单位是什么?(缓存行/页/key/URL,决定了它的粒度和浪费形态)
- 淘汰策略防得住扫描和历史热点这两类”不老实”访问吗?
- 写路径怎么保一致?能接受多长的不一致窗口?兜底是什么?
- 这层突然全失效,下一层扛得住吗?谁限流?
12. 学完本章你能解决什么问题
- 缓存和缓冲的区别是什么,page cache 为什么两个都算?
- 伪共享是怎么发生的,LongAdder 为什么要填充缓存行?
- rwnd 和接收缓冲区是什么关系,背压怎么一路传导回应用代码?
- Netty 的高低水位在把哪个内核机制翻译给业务代码?
- MySQL 为什么用 O_DIRECT 绕开 page cache,Kafka 为什么反着来?
- buffer pool 的两道闸分别拦住全表扫描的哪个行为特征?
- 旁路缓存为什么选”先更库再删缓存”,三种兜底各补哪个洞?
- 穿透、击穿、雪崩各自的病理和解法是什么,共同病理是什么?
- 强缓存和协商缓存怎么配合,指纹文件名把难题转化成了什么?
- Redis 近似 LRU 和 Caffeine 的 W-TinyLFU 各自在修 LRU 的什么死穴?
核心一句话:数据停留的每一站都在做同一道题(放什么、淘汰谁、怎么保一致、失效了谁兜底),层越高语义越丰富,一致性越从硬件的事变成你的事;缓冲则永远盯水位和背压。