第 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:缓存穿透,数据库被打满
→ 用"布隆过滤器 + 缓存空值"
这道题考察什么:
- 三个概念的精确区分(不是背定义,是理解”失效形态”的不同)
- 每个的解决方案(能不能对上号)
- 有没有真实经验(能不能举出场景)
思考题 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 → 更新时本地缓存靠过期自愈,避免广播失效的复杂度
这道题考察什么:
- 能不能”先算账”(容量、流量、成本)
- 能不能”分层设计”(本地 + Redis + DB)
- 能不能”权衡一致性”(用户资料容忍短暂不一致,所以用 TTL)
- 能不能”防御失效”(穿透、击穿、雪崩)
- 能不能”说出决策依据”(不是堆技术,而是每个选择都有理由)
一页纸总结
┌──────────────────────────────────────────────────────────────┐
│ 第 09 章 · 缓存:用局部性对抗物理定律 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【缓存的前提:局部性】 │
│ 时间局部性(反复访问)+ 空间局部性(访问相邻) │
│ 局部性是程序结构的自然结果,所以缓存才有效 │
│ │
│ 【缓存的本质】 │
│ 用空间换时间,用冗余换速度 │
│ 层次存储:每一层都是下一层的缓存 │
│ (L1→L2→L3→主存→SSD→磁盘→网络→CDN) │
│ │
│ 【命中率的边际递减】 │
│ 缓存越大,命中率越高,但提升递减 │
│ → 缓存不是越大越好,是"命中率 vs 成本"的平衡 │
│ │
│ 【LRU 的失效】 │
│ 扫描污染(一次性访问挤掉热数据) │
│ Linux 双链表 LRU:两次访问才算热(active/inactive) │
│ │
│ 【缓存的宿命:一致性】 │
│ 缓存 = 数据有两份,迟早不一致 │
│ 解法:TTL / Cache-Aside / 双删 / 订阅(按强度递增) │
│ 单机(MESI 强一致)和分布式(弱一致)是同一权衡 │
│ │
│ 【失效三连:穿透/击穿/雪崩】 │
│ 穿透 = 查不存在的 key → 空值缓存/布隆过滤器 │
│ 击穿 = 热点 key 过期 → 互斥锁/逻辑过期 │
│ 雪崩 = 大量 key 同时过期 → TTL 加随机 jitter │
│ │
│ 【何时不用缓存】 │
│ 强一致要求、无局部性、数据量巨大但热点少、复杂度超收益 │
│ │
│ 【一句话】 │
│ 缓存用局部性换速度、用冗余换延迟,代价是一致性和复杂度; │
│ 先测量再缓存,先分析 miss 再优化。 │
│ │
│ 【下一章预告】 │
│ 缓存让"读"变快了,那"写"和"搬数据"呢? │
│ 下一章讲零拷贝与 I/O 多路复用:怎么"少做无用功"。 │
└──────────────────────────────────────────────────────────────┘