第 09 章 缓存:用局部性对抗物理定律

前面两部,我们讲的是”正确性”——资源怎么管理、并发怎么协调。 从这一章开始,我们进入”性能”——怎么让系统跑得快。

而性能优化的第一站,几乎永远是同一个词:缓存。

缓存不是某一个技术,是一整个思想体系:CPU 缓存、TLB、page cache、Redis、CDN、浏览器缓存、本地缓存……它们长得完全不一样,但骨子里是同一个东西。

这一章,我们把这个”同一个东西”挖出来。


引子:为什么你的程序,90% 的时间在访问 10% 的内存?

先看一个让你脊背发凉的事实:

一个典型的 Go 服务:
  - 代码可能几十 MB
  - 数据(堆 + 栈)可能几个 GB
  - 但它在任何时刻,真正"热"的数据,可能只有几十 MB

换句话说:90% 的时间,在访问 10% 的内存。

这不是巧合,这是一个叫**局部性原理(Principle of Locality)**的物理规律。

而缓存,就是”利用局部性,把 90% 的时间花在 10% 的内存上,而那 10% 的内存,恰好放在最快的存储里”。

这一章,我们讲清楚:

  • 局部性为什么存在(以及为什么它是”物理定律”而不是”编程技巧”)
  • 缓存的层次结构(为什么层层叠叠)
  • 缓存什么时候失效(扫描污染、伪共享、穿透击穿雪崩)
  • 以及,最重要的:缓存给你的工程系统带来的那个致命难题——一致性。

给你一句总纲:

缓存是”用空间换时间,用冗余换速度”。

它的前提是”局部性”(访问不是均匀的,而是集中的)。 它的代价是”一致性”(有两份数据,就迟早会不一致)。

所以缓存设计永远在回答两个问题:怎么让”命中”尽量多?怎么让”不一致”尽量少?


本章路线图

第一幕:局部性 —— 缓存的前提
  Q1  什么是局部性原理?为什么说它是"物理定律"?
  Q2  缓存的层次结构是怎么设计的?为什么层层叠叠?

第二幕:缓存的工作机制
  Q3  缓存命中、未命中、淘汰,这些词到底指什么?
  Q4  LRU 是什么?它在什么情况下会"失效"?
  Q5  Linux 的 page cache 用了什么淘汰策略?为什么不用纯 LRU?

第三幕:缓存的一致性
  Q6  缓存一致性问题是所有缓存的宿命——为什么?
  Q7  单机的"缓存一致性"和分布式的"缓存一致性"是一回事吗?

第四幕:工程实战
  Q8  缓存穿透、击穿、雪崩,到底分别是什么?怎么防?
  Q9  什么时候缓存是有害的?怎么判断"该不该缓存"?
  Q10 设计一个缓存,你要回答哪几个问题?

第一幕:局部性 —— 缓存的前提

Q1 什么是局部性原理?为什么说它是”物理定律”?

局部性原理:程序在任意时刻,倾向于访问”一小块集中的内存”,而不是均匀地访问所有内存。

它有两个维度:

时间局部性(temporal locality):

如果一个数据被访问了,它"很快"还会被再次访问。

例子:
  - 循环变量 i,循环里被访问 N 次
  - 热点数据(某个频繁读的配置)
  - 计数器(反复自增)

空间局部性(spatial locality):

如果一个数据被访问了,它"附近"的数据很快也会被访问。

例子:
  - 遍历数组:访问 a[i],马上访问 a[i+1]
  - 遍历结构体:访问一个字段,可能访问相邻字段
  - 顺序读文件:读第 N 块,马上读第 N+1 块

为什么说局部性是”物理定律”而不是”编程技巧”?

因为它是程序结构的自然结果,不是人为的选择:

1. 循环(时间局部性):程序的本质是"重复执行"
2. 顺序访问(空间局部性):数据结构的本质是"连续存储"(数组、字符串)
3. 函数调用(时间局部性):一段代码反复执行

除非你故意写"随机访问"的程序,否则局部性自然存在。

局部性有多强?一个经典的实证:

一个程序的地址访问轨迹,往往符合"80/20 定律"(甚至更极端):
  80% 的访问,集中在 20% 的地址
  甚至:90% 的访问,集中在 10% 的地址

这就是为什么"缓存"如此有效——你只需要缓存那一小部分热点,
就能覆盖绝大部分访问。

工程启示:局部性是你做所有性能优化的”底层杠杆”。

如果你能让数据的访问”更局部”(比如把热数据打包、把相关的数据放一起),缓存就会自动生效。反之,如果你的访问模式是”随机、均匀”的,那缓存几乎没用。

这就是为什么:

  • 数据库要建索引(让访问集中)
  • 对象存储要按 hash 分片(让访问分散到不同节点,但每个节点内集中)
  • 高并发系统要”热数据分离”(热数据单独缓存)

Q2 缓存的层次结构是怎么设计的?为什么层层叠叠?

现代计算机的存储,是一个”金字塔”,越往上越快、越小、越贵:

        容量          延迟          层次
        ─────        ─────         ─────
寄存器   ~1KB         ~0.3ns        CPU 寄存器
L1 缓存  ~64KB        ~1ns          CPU 片内
L2 缓存  ~512KB       ~4ns          CPU 片内
L3 缓存  ~32MB        ~12ns         CPU 片内/片外
主存     ~GB          ~100ns        内存条
SSD      ~TB          ~100μs        固态盘
机械盘   ~10TB        ~10ms         磁盘
网络     ~无限        ~1-100ms      分布式

为什么层层叠叠?为什么不直接”全用最快的”?

因为快的存储贵:

L1 缓存(SRAM):成本极高,只能做几十 KB
主存(DRAM):成本中等,能做几个 GB
磁盘:成本低,能做几个 TB

如果"全用 L1",那 64KB 就要花掉你一台电脑的钱。
所以必须"分层":用少量快的 + 大量慢的,组合成"看起来又快又大"。

这个分层的设计原则:

每一层,都是”下一层的缓存”。

  • L1 是 L2 的缓存
  • L2 是 L3 的缓存
  • L3 是主存的缓存
  • 主存是磁盘的缓存(page cache)
  • 磁盘是网络的缓存(本地缓存)
  • Redis 是数据库的缓存
  • CDN 是源站的缓存

这个”层层缓存”的思想,是理解整个计算机系统性能的钥匙。

而每一层的缓存,都遵循同一个工作模式:

1. 访问数据时,先查最快的层
2. 命中(hit)→ 直接返回(快)
3. 未命中(miss)→ 去下一层找,找到后"缓存"到当前层
4. 当前层满了 → 按某种策略"淘汰"一些数据

工程启示:你在设计任何系统时,都可以套用这个”分层缓存”的思维。

  • 接口层:内存缓存 → 本地缓存 → Redis → 数据库
  • 网络层:CDN → 边缘节点 → 源站
  • 计算层:预计算结果 → 实时计算

关键问题是:每一层的”命中率”和”一致性”怎么平衡。 这个后面展开。


第二幕:缓存的工作机制

Q3 缓存命中、未命中、淘汰,这些词到底指什么?

这几个词是缓存的基本概念,用”图书馆”来类比最清楚:

你(CPU)要查一本书(数据):

命中(hit):
  你要的书,就在你手边的书架上(缓存里)→ 直接拿,快

未命中(miss):
  你要的书,不在手边书架 → 去大书库(主存/磁盘)拿
  拿回来后,放到手边书架(缓存起来,因为可能还要用)

淘汰(eviction):
  手边书架满了,放不下新书 → 把"最不常用的书"拿走(淘汰),腾地方

几个关键指标:

命中率(hit rate)= 命中次数 / 总访问次数
  - 命中率 90%,意味着 90% 的访问走缓存(快),10% 走底层(慢)

未命中率(miss rate)= 1 - 命中率

平均访问时间 = 命中率 × 命中时间 + 未命中率 × 未命中时间

例:
  命中率 95%,命中 1ns,未命中 100ns
  平均 = 0.95 × 1 + 0.05 × 100 = 0.95 + 5 = 5.95ns
  (比没有缓存的 100ns 快了 17 倍)

淘汰策略(eviction policy):

缓存满了,淘汰谁?常见策略:

LRU(Least Recently Used):淘汰"最久没被访问"的
LFU(Least Frequently Used):淘汰"访问次数最少"的
FIFO(First In First Out):淘汰"最早进来"的
Random:随机淘汰

理论上,LRU/LFU 更聪明,但实现更贵。

一个关键的认识:命中率的”边际递减”。

缓存从 0 开始增大:
  - 缓存很小:命中率低,但每增加一点,命中率涨很快
  - 缓存适中:命中率较高,增加缓存,命中率缓慢涨
  - 缓存很大:命中率接近"理论极限",再增加,几乎不涨

所以:缓存不是越大越好,而是"命中率 vs 成本"的平衡。

这个”边际递减”的规律,你在工程里天天遇到:

  • Redis 缓存从 1GB 加到 2GB,命中率可能从 90% 涨到 95%
  • 但从 10GB 加到 20GB,命中率可能只从 99% 涨到 99.2%

多花一倍的成本,只换来 0.2% 的命中率,值不值? 这是缓存设计里必须算的账。

Q4 LRU 是什么?它在什么情况下会”失效”?

LRU(Least Recently Used)是最常用的淘汰策略:淘汰”最久没被访问”的数据。

它的实现(经典:哈希表 + 双向链表):

哈希表:O(1) 查找
双向链表:维护"访问顺序",最近访问的在头部,最久在尾部

访问数据 → 移到链表头部(O(1))
淘汰 → 删链表尾部(O(1))

LRU 在什么情况下失效?

失效场景一:扫描污染(scan pollution)。

场景:一次全表扫描,把缓存"洗"了一遍。

例:
  缓存 100 个条目,全是热数据
  突然来了一个"扫描"(遍历一个 1000 条的大文件,每个都读一次)
  
  LRU 的后果:
    扫描的 1000 条数据,会不断把"热数据"挤出去
    (因为扫描的数据"最近被访问",LRU 认为它们是"热的")
    扫描结束后,缓存里全是"扫描过但不会再访问"的冷数据
    真正的热数据被挤掉了 → 后续访问全部 miss
  → 缓存被"污染"了

失效场景二:循环访问(cyclic access)。

场景:访问模式是"循环"的,循环长度 > 缓存大小。

例:
  缓存能存 100 条,访问模式是循环访问 101 条数据:
  1, 2, 3, ..., 101, 1, 2, 3, ..., 101, ...

  LRU 的后果:
    访问 1 时,淘汰最久没访问的(正好是下一次要访问的 1)
    → 每次都 miss!命中率 0%

失效场景三:突发访问(burst)。

场景:平时访问均匀,突然来一波"一次性"的热点。

例:
  缓存满了,来了一批"只会访问一次"的突发流量
  这批数据挤掉了真正的热数据
  突发过去后,热数据全 miss 了

失效场景四:冷启动(cold start)。

场景:服务重启,缓存空了。

例:
  Redis 重启,缓存清空
  流量打进来,全部 miss,全打到数据库
  → 数据库被"打穿"

这些”失效”的共同点:

LRU 假设”最近访问的,未来还会访问”(时间局部性)。当这个假设不成立时,LRU 就失效了。

  • 扫描污染:扫描的数据”最近访问”但不”未来访问”
  • 循环访问:循环长度 > 缓存,LRU 每次都淘汰”即将访问的”

所以没有”万能”的淘汰策略,只有”匹配访问模式”的策略。

Q5 Linux 的 page cache 用了什么淘汰策略?为什么不用纯 LRU?

第 1 章提过,Linux 用双链表 LRU(active/inactive 两链表)。这里详细讲它为什么”不用纯 LRU”。

纯 LRU 的问题:无法抵抗”扫描污染”。

纯 LRU:访问一个页,就把它移到"最近使用"的位置。
问题:一次全盘扫描,会污染缓存(Q4 讲的扫描污染)。

Linux 的双链表 LRU:

两个链表:
  active_list(活跃链表):最近被访问过"多次"的页(热页)
  inactive_list(非活跃链表):只被访问过"一次"的页(候选淘汰)

规则:
  1. 新访问的页 → 放 inactive_list 头部
  2. inactive 里的页,再次被访问 → 提升到 active_list
  3. active_list 太长了 → 尾部的页降级到 inactive
  4. 淘汰 → 从 inactive_list 尾部淘汰

为什么这样能抵抗扫描污染?

关键:"访问一次"只进 inactive,不直接进 active。

扫描污染的场景:
  扫描读取 1000 个页,每个都访问一次
  → 它们都进 inactive_list(没有"第二次访问",不会提升到 active)
  → 扫描结束时,这些页在 inactive_list 里,很快被淘汰
  → active_list 里的热数据(被访问过多次的)不受影响

核心思想:一次访问不足以证明”热度”,多次访问才算。

这个”两次访问才算热”的思想,是缓存设计的经典智慧。

它解决了”如何区分’真正热的数据’和’碰巧被访问一次的数据’”。

这个思想在工程里的应用:

  • Redis 的 LFU 策略(4.0+):用计数器 + 衰减,区分”热”和”温”
  • MySQL 的 buffer pool:young/old 两个区域,新页进 old,存活一段时间才进 young
  • Guava Cache、Caffeine:用频率计数 + 时间衰减

核心:别让”一次性访问”污染你的热缓存。 这是第 3 章讲 page cache 时已经埋下的伏笔。


第三幕:缓存的一致性

Q6 缓存一致性问题是所有缓存的宿命——为什么?

这是缓存最核心的难题,也是这一章最重要的一问。

问题的根源:缓存 = 数据有”两份”,两份就会不一致。

主数据:数据库、磁盘、源站(权威)
缓存:Redis、page cache、CDN(副本)

当主数据变了,缓存里的副本"过时"了 → 不一致

例:
  1. 用户改了自己的昵称,写到了数据库(主数据)
  2. 但 Redis 缓存里,还是旧昵称
  3. 另一个请求读昵称,读到 Redis 的旧值
  → 用户看到自己的昵称"没改"(其实是缓存没更新)

为什么”迟早会不一致”?

因为”更新两份数据”不可能完美原子:

方案一:先更新缓存,再更新数据库
  → 缓存更新了,但数据库更新失败 → 缓存是新的,数据库是旧的
  → 不一致(而且是"数据库落后"这种严重情况)

方案二:先更新数据库,再更新缓存
  → 数据库更新了,但缓存更新失败 → 数据库是新的,缓存是旧的
  → 不一致(缓存落后,但通常可通过过期自愈)

方案三:先删缓存,再更新数据库
  → 删了缓存,但更新数据库失败 → 缓存没了(回源重新加载旧的),数据库旧的
  → 短暂不一致

无论哪种顺序,都存在"中间状态",而这个中间状态可能被观察到。

所以缓存一致性是”概率问题”还是”必然问题”?

答案:在"没有额外机制"的情况下,不一致是"必然发生"的,
只是"发生概率"和"持续时间"不同。

而所有"缓存一致性方案",本质都是在:
  1. 降低不一致的概率
  2. 缩短不一致的持续时间
  3. 或者让不一致"可接受"(最终一致)

经典的解法(按一致性强度递增):

1. 过期时间(TTL):缓存设过期,到期自动失效
   → 不一致最多持续 TTL 这么久
   → 最简单,最常用

2. 更新时失效(Cache-Aside):更新数据库时,顺便删缓存
   → 不一致窗口缩短到"更新和删除之间的瞬间"

3. 延迟双删:更新数据库 → 删缓存 → 延迟一会 → 再删一次
   → 处理"更新后、删除前,有读请求把旧值写回缓存"的竞态

4. 强一致(分布式锁/事务):更新数据库和缓存用同一个事务/锁
   → 一致性强,但性能和复杂度高

5. 最终一致(binlog 订阅):监听数据库变更,异步更新缓存
   → 一致窗口短,且能自愈

工程现实:大多数场景用”TTL + 更新时失效”就够了。

因为大多数业务能容忍”几十毫秒到几秒”的缓存不一致(比如昵称、商品详情、文章内容)。

真正需要强一致的(余额、库存、订单状态),往往不用缓存,或者用特殊的方案(如读主库)。

缓存一致性的本质,是”用一致性换性能”,你能容忍多少不一致,就能获得多少性能。

Q7 单机的”缓存一致性”和分布式的”缓存一致性”是一回事吗?

这是个很好的概念辨析,能帮你把知识串起来。

单机的”缓存一致性”(cache coherence):

指:多核 CPU 的缓存之间,保证"对同一个地址的读,看到一致的值"。
(第 7 章讲的 MESI 协议)

关注:硬件层面的、单个内存地址的一致性。

分布式的”缓存一致性”(cache consistency):

指:应用层的缓存(Redis、CDN)和主数据(数据库、源站)之间的一致性。

关注:应用层面的、业务数据的一致性。

它们的关系:

相同点:
  - 都是"副本和主数据"的一致性问题
  - 都是"分布式状态"的一致性问题
  - 核心矛盾都是"性能(延迟)vs 一致性(正确)"

不同点:
  - 单机:硬件保证(MESI),纳秒级,强一致
  - 分布式:软件保证(TTL、失效策略),毫秒级,弱一致/最终一致

更深的对应(呼应第 7 章的思考题 4):

单机缓存一致性(MESI):
  - 强一致(硬件保证)
  - 但引入了 store buffer 等优化(导致可见性延迟)
  - 需要内存屏障来"恢复强一致"

分布式缓存一致性:
  - 弱一致(软件选择)
  - TTL、异步更新等优化(导致延迟)
  - 需要"失效、双删、订阅"来"缩短不一致窗口"

两者本质是同一个权衡:要快,还是要一致?

一个统一的理解框架:

“一致性”是一个光谱,从”强一致”(任何时刻都一致)到”最终一致”(最终会一致,中间可以不一致)。

而”性能”(延迟、吞吐)往往和”一致性”成反比。

单机缓存(MESI)选择了”强一致 + 用硬件扛住性能损耗”, 分布式缓存选择了”弱一致 + 用性能换一致性”。

你设计任何系统,都要在这条光谱上选一个点。


第四幕:工程实战

Q8 缓存穿透、击穿、雪崩,到底分别是什么?怎么防?

这是面试和实战的高频考点,三个词容易混。逐个讲清楚。

缓存穿透(cache penetration):

定义:查询一个"不存在"的数据,缓存里没有,数据库里也没有。

问题:
  每次查询都穿透缓存,打到数据库
  如果攻击者故意用"不存在的 key"打你,
  数据库被大量无效查询打垮

例:
  用户 ID 是自增的(1, 2, 3...),攻击者查 ID = -1、99999999
  → 这些 ID 不存在,缓存没存(因为没存过),数据库也查不到
  → 每次都是 miss,每次打数据库

防御:

1. 缓存空值:把"不存在的 key"也缓存起来,值设为"空"(短 TTL)
   → 下次查同样的 key,命中缓存(空值),不打数据库

2. 布隆过滤器(Bloom Filter):
   - 提前判断"这个 key 一定不存在"(存在性过滤)
   - 布隆过滤器能"确定不存在",但"存在"可能是误判
   - 把"不存在的 key"直接拦截,不打缓存和数据库

3. 参数校验:明显非法的参数(负数、超范围)直接拒绝

缓存击穿(cache breakdown / hot key invalid):

定义:一个"热点 key"过期了,瞬间大量请求同时打到数据库。

问题:
  热点 key(比如爆款商品)缓存过期的那一刻,
  海量请求同时发现缓存 miss,同时去查数据库
  → 数据库瞬间被打垮

和穿透的区别:
  穿透是"查不存在的 key"(每个 key 都不存在)
  击穿是"查同一个热点 key"(这个 key 存在,只是缓存刚好过期)

防御:

1. 互斥锁(mutex):同一时刻,只让一个请求去查数据库,其他等待
   - 查数据库的请求,查完更新缓存,其他请求直接读缓存

2. 逻辑过期(不物理删除):
   - 缓存"逻辑上过期",但物理上还在
   - 发现过期,先返回旧值,同时"异步"去更新缓存
   - 避免"过期瞬间的缓存空洞"

3. 热点 key 永不过期:
   - 热点 key 不设过期,或者设置很长的过期
   - 用"主动更新"代替"过期失效"

缓存雪崩(cache avalanche):

定义:大量 key 同时过期,缓存瞬间"大面积失效",流量全打到数据库。

问题:
  如果大量 key 设置了相同的过期时间(比如都是 1 小时),
  到期那一刻,这些 key 同时失效,
  海量请求同时 miss,数据库瞬间被打垮

和击穿的区别:
  击穿是"一个热点 key 失效"
  雪崩是"大量 key 同时失效"

防御:

1. 过期时间加随机值(jitter):
   - 基础 TTL + 随机偏移(比如 1 小时 ± 10 分钟)
   - 让 key 的过期时间"错开",避免同时失效

2. 多级缓存:本地缓存 + Redis + 数据库
   - Redis 失效了,本地缓存还能顶一会

3. 熔断 + 限流:
   - 数据库扛不住时,熔断,快速失败(第 16 章背压)

4. 预热 + 不失效:
   - 热点数据提前加载,且不设置过期

三者的总结对比:

穿透击穿雪崩
查询对象不存在的 key一个热点 key大量 key
触发查不存在的 key热点 key 过期大量 key 同时过期
后果数据库被无效查询打垮数据库被热点查询打垮数据库被全量查询打垮
防御空值缓存、布隆过滤器互斥锁、逻辑过期过期加随机、多级缓存

这三个词背后的统一逻辑:

都是”缓存失效导致流量直达数据库”,只是”失效的形态”不同。

  • 穿透:缓存”从来没生效过”(因为 key 不存在)
  • 击穿:缓存”暂时失效”(热点 key 过期)
  • 雪崩:缓存”大面积失效”(大量 key 同时过期)

防御的统一思路:让缓存”尽量不失效”,失效了”尽量不直达数据库”。

Q9 什么时候缓存是有害的?怎么判断”该不该缓存”?

缓存不是银弹,用错了反而有害。这是很多人忽略的。

缓存有害的场景:

场景一:数据变化极频繁,且要强一致。

例:股票实时价格、实时库存

问题:
  数据每秒变几百次,缓存刚更新就过期
  而且要求"读到的一定是最新"(强一致)
  → 缓存要么频繁失效(无用),要么不一致(有害)
  → 这种情况,别缓存,直接读主数据

场景二:缓存命中率极低(访问无局部性)。

例:查一个随机 ID 的详情(每个 ID 只查一次)

问题:
  访问是"均匀随机"的,没有局部性
  缓存命中率极低(因为每个数据只访问一次)
  → 缓存只增加了延迟和复杂度,没带来收益

场景三:缓存数据量巨大,但热点极少。

例:海量冷数据,只有极少数热点

问题:
  缓存整个数据集,成本极高(内存)
  但命中率提升很小(因为大部分是冷数据)
  → 只缓存热点,或者不缓存

场景四:缓存引入的复杂度超过收益。

例:简单的配置读取,量不大

问题:
  加缓存 → 要处理一致性、过期、失效、穿透击穿雪崩
  → 复杂度暴涨,但性能收益微小
  → 不值得

怎么判断”该不该缓存”?问这几个问题:

1. 数据有"读多写少"的特征吗?
   - 读多写少 → 适合缓存
   - 读写都频繁 → 缓存一致性成本高,慎用

2. 访问有"局部性"吗?
   - 有热点(80/20)→ 适合缓存
   - 均匀随机 → 缓存没用

3. 能容忍"不一致"吗?
   - 能容忍 TTL 内的不一致 → 适合缓存
   - 必须强一致 → 别缓存,或用特殊方案

4. 数据量 vs 内存成本,划算吗?
   - 数据量小、内存够 → 缓存
   - 数据量巨大、命中率低 → 不划算

5. 不缓存的代价是什么?
   - 数据库扛得住 → 可以不缓存(简单)
   - 数据库扛不住 → 必须缓存

核心原则:缓存是”用复杂度和一致性,换性能和吞吐”。只有当”性能和吞吐”的收益大于”复杂度和一致性”的成本时,才值得缓存。

别一上来就加缓存。先测量,确认”这里确实是瓶颈”,再加缓存。 这是性能优化的第一原则。

Q10 设计一个缓存,你要回答哪几个问题?

这是这一章的”落地”总结。设计任何一个缓存,都要回答这几个问题:

问题一:缓存什么粒度?

整个对象?还是字段?还是查询结果?

粒度越粗(整个对象):
  - 简单,但失效时影响面大
粒度越细(字段):
  - 精确,但复杂度高

例:缓存"用户信息",是整个 User 对象,还是只缓存"昵称"?

问题二:缓存放在哪一层?

本地(进程内):快(纳秒),但各实例不一致
Redis(共享):慢一点(毫秒),但多实例共享
多级(本地 + Redis):兼顾,但复杂度最高

选择取决于:一致性要求 + 性能要求 + 实例数量

问题三:过期策略是什么?

固定 TTL:简单,但可能"过期瞬间打穿"
滑动过期:访问就续期,热点 key 不过期
主动更新:数据变化时主动更新缓存(或删除)

选择取决于:数据的更新频率和一致性要求

问题四:失效策略是什么?

更新时失效(Cache-Aside):更新数据库,删缓存
延迟双删:防竞态
binlog 订阅:异步更新

选择取决于:一致性要求

问题五:命中率怎么保证?

key 设计:让"相关数据"命中同一个缓存条目
预热:启动时预加载热点
淘汰策略:LRU/LFU,匹配访问模式

问题六:失效了怎么办(兜底)?

穿透、击穿、雪崩的防御(Q8)
数据库熔断、限流(第 16 章)
降级:缓存挂了,是"直连数据库"还是"快速失败"?

一个完整的缓存设计示例(Go + Redis):

func GetUser(ctx context.Context, id int64) (*User, error) {
    // 1. 先查缓存
    key := fmt.Sprintf("user:%d", id)
    if data, err := redis.Get(ctx, key); err == nil {
        var u User
        json.Unmarshal([]byte(data), &u)
        return &u, nil
    }
    
    // 2. 缓存 miss,加锁(防击穿)
    lockKey := "lock:" + key
    if ok := redis.SetNX(ctx, lockKey, 1, 10*time.Second); ok {
        defer redis.Del(ctx, lockKey)
        // 拿到锁,查数据库,回填缓存
        u, err := db.GetUser(ctx, id)
        if err != nil { return nil, err }
        if u != nil {
            data, _ := json.Marshal(u)
            redis.Set(ctx, key, data, 5*time.Minute)  // TTL + 随机
        } else {
            redis.Set(ctx, key, "null", 1*time.Minute)  // 缓存空值,防穿透
        }
        return u, nil
    }
    
    // 3. 没拿到锁,稍等重试(或者返回降级)
    time.Sleep(50 * time.Millisecond)
    return GetUser(ctx, id)  // 重试
}

这一章的最终落脚点:

缓存是整个计算机系统的”性能基石”,从 CPU 到 CDN,无处不在。

它的核心是”用局部性换速度,用冗余换延迟”。 它的代价是”一致性”和”复杂度”。

而一个优秀的工程师,不是”会加缓存”,而是知道缓存的前提(局部性)、代价(一致性)、和边界(什么时候有害)。


思考题

思考题 1

面试官问:“缓存雪崩、击穿、穿透的区别是什么?分别怎么解决?“请完整回答,并举出你实际遇到过的场景。

参考答案

核心区分:三者都是”缓存失效导致流量直达数据库”,区别在”失效的形态”。

缓存穿透:

场景:查询"一定不存在"的数据
例:查一个不存在的用户 ID

过程:
  请求 → 缓存(miss,因为从没存过)→ 数据库(查不到)→ 返回空
  每次请求都穿透,数据库被无效查询打垮

攻击:恶意用"不存在的 key"刷接口

解决方案:

// 1. 缓存空值
func GetUser(id int64) (*User, error) {
    key := fmt.Sprintf("user:%d", id)
    // 查缓存
    if v, ok := cache.Get(key); ok {
        if v == "null" {
            return nil, nil  // 缓存了"空",拦截穿透
        }
        return v.(*User), nil
    }
    // 查数据库
    u, err := db.GetUser(id)
    if err != nil { return nil, err }
    if u == nil {
        cache.Set(key, "null", 1*time.Minute)  // 缓存空值,短 TTL
        return nil, nil
    }
    cache.Set(key, u, 5*time.Minute)
    return u, nil
}
 
// 2. 布隆过滤器(拦截"一定不存在"的 key)
// 3. 参数校验(非法参数直接拒绝)

缓存击穿:

场景:一个"热点 key"恰好过期,瞬间大量请求打数据库
例:爆款商品详情,缓存过期的那一刻

过程:
  热点 key 过期 → 海量请求同时 miss → 同时查数据库 → 数据库被打垮

解决方案:

// 1. 互斥锁(防击穿的核心)
func GetHotItem(id int64) (*Item, error) {
    key := fmt.Sprintf("item:%d", id)
    if v, ok := cache.Get(key); ok {
        return v.(*Item), nil
    }
    // miss,加分布式锁,只让一个请求查库
    lockKey := "lock:item:" + strconv.FormatInt(id, 10)
    if ok := redis.SetNX(lockKey, 1, 10*time.Second); ok {
        defer redis.Del(lockKey)
        item, _ := db.GetItem(id)
        cache.Set(key, item, 10*time.Minute)
        return item, nil
    }
    // 没拿到锁,等一会重试
    time.Sleep(100 * time.Millisecond)
    return GetHotItem(id)
}
 
// 2. 逻辑过期:热点 key 不物理删除,发现过期先返回旧值 + 异步更新
// 3. 热点 key 永不过期 + 主动更新

缓存雪崩:

场景:大量 key 同时过期,缓存大面积失效
例:一批 key 都设置了 1 小时过期,1 小时后同时失效

过程:
  大量 key 同时过期 → 海量请求同时 miss → 数据库被全量请求打垮

解决方案:

// 1. 过期时间加随机值(jitter)
ttl := 1*time.Hour + time.Duration(rand.Intn(600))*time.Second
// 让 key 过期时间错开,避免同时失效
 
// 2. 多级缓存:本地缓存 + Redis + DB
//    Redis 失效了,本地缓存顶一会
 
// 3. 熔断 + 限流:数据库扛不住时,快速失败
 
// 4. 预热:启动时预加载热点数据

实际遇到的场景(举例):

1. 秒杀活动:热点商品详情被海量请求,缓存过期瞬间打穿
   → 用"互斥锁 + 热点永不过期"

2. 定时任务批量写缓存:一个定时任务每 1 小时刷新一批 key,
   设置了相同 TTL,1 小时后雪崩
   → 用"TTL 加随机 jitter"

3. 攻击者刷不存在的 ID:缓存穿透,数据库被打满
   → 用"布隆过滤器 + 缓存空值"

这道题考察什么:

  1. 三个概念的精确区分(不是背定义,是理解”失效形态”的不同)
  2. 每个的解决方案(能不能对上号)
  3. 有没有真实经验(能不能举出场景)

思考题 2

你设计了一个缓存,命中率 95%。但老板说”不够,要 99%“。你会怎么做?从”缓存容量、过期策略、key 设计、数据结构”四个角度分析,哪些手段能提升命中率,代价是什么?

参考答案

先泼冷水:命中率从 95% 到 99%,可能代价巨大,甚至做不到。

命中率的提升是”边际递减”的(Q3 讲过)。95% → 99%,看起来只差 4 个百分点,但难度可能是”从 0 到 95%“的好几倍。

角度一:缓存容量。

原理:缓存越大,能容纳的热点越多,命中率越高
但:边际递减(第 3 章讲过)

提升命中率:
  增大缓存(比如 Redis 从 10GB 到 50GB)

代价:
  内存成本 5 倍
  而且如果热点数据本身就只占 10GB,加到 50GB 命中率几乎不涨

关键:先搞清楚"热点数据有多大"
  - 如果热点数据 8GB,你缓存 10GB,命中率已经接近极限
  - 再加容量,只能缓存"温数据",命中率提升很小

角度二:过期策略。

原理:key 过期 = 缓存失效 = miss

提升命中率:
  1. 延长 TTL(key 不那么快过期)
  2. 热点 key 永不过期(或者滑动过期)
  3. 用"主动更新"代替"被动过期"

代价:
  TTL 越长,一致性越差(脏数据存活越久)
  热点永不过期 = 要保证主动更新机制可靠(否则数据永远旧的)

角度三:key 设计。

原理:key 设计决定"一次查询能命中多少相关数据"

提升命中率:
  1. key 的粒度:缓存"聚合结果"而不是"单条"
     - 例:缓存"列表页的前 100 条"而不是"每条单独缓存"
     - 一次列表查询,命中一个 key,而不是 miss 100 个 key

  2. 关联数据合并:把"经常一起访问的"合并成一个 key

代价:
  粒度越粗,失效影响越大(改一条,整个 key 失效)
  一致性管理更复杂

角度四:数据结构。

原理:缓存的数据结构,影响"命中后的读取效率"和"淘汰策略的有效性"

提升命中率:
  1. 预热:把"预测会热"的数据提前加载
  2. 淘汰策略优化:LRU → LFU(区分"热"和"温")
  3. 分级缓存:热数据用单独的缓存池

代价:
  预热要"预测"(可能预测错)
  LFU 实现复杂(频率计数 + 衰减)
  分级缓存管理复杂

我的建议(务实的回答):

第一步:先搞清楚"95% 是怎么来的"
  - 那 5% 的 miss,是什么?是"真冷数据"还是"本可命中的"?
  - 用监控/日志分析 miss 的原因:
    a. 冷数据(第一次访问,必然 miss)→ 无法提升,除非预热
    b. 过期导致(TTL 太短)→ 延长 TTL
    c. 淘汰导致(缓存不够大)→ 增大缓存
    d. key 设计问题(一次查 100 条,100 个 miss)→ 改 key 粒度

第二步:针对 miss 的原因,精准优化
  - 而不是盲目"加大缓存、加长 TTL"

第三步:评估代价
  - 95% → 99% 可能需要:5 倍内存 + 一致性变差 + 复杂度暴涨
  - 值不值?取决于:那 4% 的 miss 打数据库,数据库扛不扛得住?
    - 扛得住 → 95% 够了,别优化
    - 扛不住 → 优化(或加数据库层缓存/读写分离)

关键洞察(这道题的核心):

命中率不是”越高越好”的孤立指标,而是”命中率 vs 成本”的平衡。

提升命中率的手段,都有代价(内存、一致性、复杂度)。 盲目追求 99%,可能花了 5 倍成本,换来 4% 的提升,还引入了新的问题。

正确的思路:先分析”miss 的原因”,再针对性地、有成本意识地优化。

一个更深的追问:那 5% 的 miss,一定需要优化吗?

不一定。如果那 5% 是”必然的冷数据访问”(比如用户第一次查自己的新数据),那 95% 就是理论极限,再优化也没用。

而如果那 5% 是”热点数据的过期 miss”,那才是值得优化的。

所以,回答”怎么提升到 99%“之前,先回答”那 5% 是什么”。


思考题 3

“CPU 缓存、TLB、page cache、Redis、CDN,本质是同一个东西。“你认同吗?请说明它们共同的设计模式,以及各自的”局部性”具体指什么。

参考答案

认同,但需要精确化:它们共享同一个”设计模式”,但”局部性”的载体不同。

共同的设计模式:

1. 都是"层次存储"的一层
   CPU 缓存(L1/L2/L3)→ 主存 → page cache → 磁盘 → Redis → 数据库 → CDN → 源站

2. 都遵循"访问 → 命中/未命中 → 缓存/淘汰"的循环
   未命中 → 去下一层取 → 缓存到当前层 → 满了淘汰

3. 都面临"一致性"问题
   CPU 缓存:MESI 保证一致
   page cache:writeback 的脏页
   Redis:TTL + 失效策略
   CDN:缓存刷新

4. 都依赖"局部性"才有效
   没有局部性(均匀随机访问),任何缓存都失效

各自的”局部性”具体指什么:

缓存局部性的载体具体含义
CPU 缓存指令/数据的访问模式循环(时间)、数组遍历(空间)
TLB虚拟地址翻译页的访问(时间)、连续地址(空间)
page cache文件访问反复读同一文件(时间)、顺序读(空间)
Redis业务数据访问热点 key(时间)、关联数据(空间)
CDN用户地域同一地区反复访问(时间)、附近用户(空间)

细微的区别(让”本质相同”更精确):

1. "局部性"的尺度不同
   CPU 缓存:指令级(纳秒)
   Redis:业务级(毫秒)
   CDN:地理级(秒)

2. "一致性"的处理不同
   CPU 缓存:硬件强一致(MESI)
   Redis:软件弱一致(TTL)
   CDN:人工/定时刷新

3. "淘汰"的代价不同
   CPU 缓存:淘汰一个缓存行,代价纳秒级
   Redis:淘汰一个 key,代价微秒级
   CDN:淘汰一个文件,代价秒级

一个更深的观察:

它们本质相同,是因为”层次存储 + 局部性 + 缓存”是解决”快慢存储差异”的唯一方案。

无论你是 CPU 设计者(面对 L1 vs 主存的速度差),还是架构师(面对 Redis vs 数据库的速度差),你面对的是同一个问题:“快的存储太贵太小,慢的存储便宜但太慢,怎么组合?”

答案都是:用少量快的做缓存,用大量慢的做存储,靠局部性让缓存高效。

所以,学懂了一个缓存(比如 CPU 缓存),你就理解了所有缓存的设计逻辑:

1. 命中率 = 缓存能覆盖多少"热访问"
2. 一致性 = 缓存和主数据"错位"的程度
3. 淘汰 = 缓存满时"牺牲谁"
4. 局部性 = 缓存有效的前提

这道题的价值:

它考察你能否”抽象出共性”。看到 CPU 缓存、Redis、CDN 背后的同一个模式,说明你理解了”缓存”这个概念的本质,而不是记住了几个具体技术。

而”抽象出共性”的能力,正是”举一反三”的关键——第 12 章讲”摊销”时,你会再次用到这个能力。


思考题 4(开放题)

你负责一个”用户资料”服务:读多写少,用户量 1 亿,日活 1000 万,每个用户资料约 1KB。你会怎么设计缓存?请给出完整的方案(缓存层级、key 设计、过期、失效、一致性、容量估算),并说明你的决策依据。

参考答案

这是一道完整的架构设计题,考察你能否把这一章的知识综合运用。

第一步:算账(容量和流量)。

数据量:
  1 亿用户 × 1KB = 100GB(全量数据)

日活 1000 万,假设峰值 QPS:
  1000 万 / 86400 秒 ≈ 115 QPS(平均)
  峰值(按 10 倍):~1000 QPS

缓存容量:
  全量 100GB 缓存 → 需要 100GB+ 的 Redis(贵)
  只缓存活跃用户(1000 万日活 × 1KB)= 10GB(合理)
  → 结论:只缓存"活跃用户",不缓存全量

第二步:缓存层级。

第一层:本地缓存(进程内,比如 Go 的 bigcache/freecache)
  - 缓存"超热点"用户(日活里的头部用户)
  - 容量:几百 MB
  - 延迟:纳秒级

第二层:Redis(共享缓存)
  - 缓存"活跃用户"
  - 容量:10-20GB
  - 延迟:毫秒级

第三层:数据库(主数据)
  - 全量 100GB

为什么用两级?

本地缓存:
  - 快(纳秒),但各实例不一致
  - 适合"超热点"(访问极频繁,且能容忍实例间短暂不一致)

Redis:
  - 稍慢(毫秒),但多实例共享
  - 适合"活跃用户"(各实例都能命中)

两级配合:本地缓存挡住"超热点"的绝大部分流量,
Redis 挡住"活跃用户"的流量,数据库只承受"冷用户"的流量。

第三步:key 设计。

key = "user:{user_id}"
value = JSON 序列化的用户资料(或者更紧凑的格式)

考虑:
  1. key 的哈希分布(避免热点 key)
  2. value 的序列化(JSON 简单但大,protobuf 紧凑但复杂)
  3. 大 key 问题(用户资料 1KB,不算大,但如果一个 key 几 MB 就是大 key)

第四步:过期和失效策略。

过期(TTL):
  活跃用户:7 天 TTL(滑动续期,访问就续)
  或者:LRU 自动淘汰(设 maxmemory + allkeys-lru)

失效(更新时):
  用户更新资料 → 写数据库 → 删缓存(Cache-Aside)
  → 下次读取,回源重建缓存

一致性:
  - 能容忍"短暂不一致"(用户改昵称,别人晚几秒看到,可接受)
  - 用 TTL 兜底(即使删缓存失败,7 天后也过期了)

第五步:防穿透、击穿、雪崩。

穿透:用户 ID 不存在
  → 缓存空值(短 TTL)+ 参数校验(ID 范围)

击穿:热点用户(比如大 V)缓存过期
  → 互斥锁(单飞),只让一个请求回源

雪崩:大量用户同时过期
  → TTL 加随机 jitter

第六步:完整的读写流程。

func GetUser(ctx context.Context, id int64) (*User, error) {
    // 1. 本地缓存
    if u, ok := localCache.Get(id); ok {
        return u, nil
    }
    
    // 2. Redis
    key := fmt.Sprintf("user:%d", id)
    if data, err := redis.Get(ctx, key); err == nil {
        var u User
        json.Unmarshal([]byte(data), &u)
        localCache.Set(id, &u)  // 回填本地缓存
        return &u, nil
    }
    
    // 3. Redis miss,加锁回源(防击穿)
    lockKey := "lock:" + key
    if ok := redis.SetNX(ctx, lockKey, 1, 10*time.Second); ok {
        defer redis.Del(ctx, lockKey)
        u, err := db.GetUser(ctx, id)
        if err != nil { return nil, err }
        if u == nil {
            redis.Set(ctx, key, "null", 1*time.Minute)  // 空值缓存
            return nil, nil
        }
        data, _ := json.Marshal(u)
        ttl := 7*24*time.Hour + time.Duration(rand.Intn(3600))*time.Second  // TTL + jitter
        redis.Set(ctx, key, data, ttl)
        localCache.Set(id, u)
        return u, nil
    }
    
    // 4. 没拿到锁,稍等重试
    time.Sleep(50 * time.Millisecond)
    return GetUser(ctx, id)
}

第七步:更新流程。

func UpdateUser(ctx context.Context, id int64, updates map[string]interface{}) error {
    // 1. 更新数据库(主数据)
    if err := db.UpdateUser(ctx, id, updates); err != nil {
        return err
    }
    
    // 2. 删缓存(Cache-Aside)
    redis.Del(ctx, fmt.Sprintf("user:%d", id))
    
    // 3. 本地缓存怎么办?
    //    - 方案 A:也删(但各实例的本地缓存不好删,要广播)
    //    - 方案 B:靠本地缓存的短 TTL 自愈
    //    - 实际:本地缓存设短 TTL(比如 30 秒),靠过期自愈
    return nil
}

第八步:容量和成本估算。

Redis:20GB(活跃用户 1000 万 × 1KB × 2 倍冗余)
  成本:一台 32GB 内存的机器(或云 Redis 20GB 规格)

本地缓存:500MB × N 个实例
  成本:包含在应用实例的内存里

数据库:100GB 全量
  成本:正常数据库容量

决策依据总结:

1. 只缓存活跃用户(1000 万)而不是全量(1 亿)→ 缓存成本从 100GB 降到 10GB
2. 两级缓存 → 本地缓存挡超热点,Redis 挡活跃,分层降低延迟
3. Cache-Aside + TTL → 一致性够用(用户资料容忍短暂不一致)
4. 防穿透/击穿/雪崩 → 保证缓存失效时数据库不被打垮
5. 本地缓存短 TTL → 更新时本地缓存靠过期自愈,避免广播失效的复杂度

这道题考察什么:

  1. 能不能”先算账”(容量、流量、成本)
  2. 能不能”分层设计”(本地 + Redis + DB)
  3. 能不能”权衡一致性”(用户资料容忍短暂不一致,所以用 TTL)
  4. 能不能”防御失效”(穿透、击穿、雪崩)
  5. 能不能”说出决策依据”(不是堆技术,而是每个选择都有理由)

一页纸总结

┌──────────────────────────────────────────────────────────────┐
│  第 09 章 · 缓存:用局部性对抗物理定律                            │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  【缓存的前提:局部性】                                         │
│    时间局部性(反复访问)+ 空间局部性(访问相邻)                   │
│    局部性是程序结构的自然结果,所以缓存才有效                     │
│                                                              │
│  【缓存的本质】                                                 │
│    用空间换时间,用冗余换速度                                    │
│    层次存储:每一层都是下一层的缓存                               │
│    (L1→L2→L3→主存→SSD→磁盘→网络→CDN)                         │
│                                                              │
│  【命中率的边际递减】                                            │
│    缓存越大,命中率越高,但提升递减                               │
│    → 缓存不是越大越好,是"命中率 vs 成本"的平衡                   │
│                                                              │
│  【LRU 的失效】                                                 │
│    扫描污染(一次性访问挤掉热数据)                               │
│    Linux 双链表 LRU:两次访问才算热(active/inactive)            │
│                                                              │
│  【缓存的宿命:一致性】                                          │
│    缓存 = 数据有两份,迟早不一致                                  │
│    解法:TTL / Cache-Aside / 双删 / 订阅(按强度递增)            │
│    单机(MESI 强一致)和分布式(弱一致)是同一权衡                 │
│                                                              │
│  【失效三连:穿透/击穿/雪崩】                                    │
│    穿透 = 查不存在的 key → 空值缓存/布隆过滤器                    │
│    击穿 = 热点 key 过期 → 互斥锁/逻辑过期                        │
│    雪崩 = 大量 key 同时过期 → TTL 加随机 jitter                  │
│                                                              │
│  【何时不用缓存】                                                │
│    强一致要求、无局部性、数据量巨大但热点少、复杂度超收益           │
│                                                              │
│  【一句话】                                                     │
│    缓存用局部性换速度、用冗余换延迟,代价是一致性和复杂度;          │
│    先测量再缓存,先分析 miss 再优化。                             │
│                                                              │
│  【下一章预告】                                                  │
│    缓存让"读"变快了,那"写"和"搬数据"呢?                        │
│    下一章讲零拷贝与 I/O 多路复用:怎么"少做无用功"。              │
└──────────────────────────────────────────────────────────────┘