这一讲比前三讲好懂,但有三处一带而过的地方值得停下来:
- “缓存命中率下降到某个阈值就要扩容” —— 阈值在哪?为什么是个悬崖?
- “FastF(x) 并没有被包装成一个原子的读操作” —— 会出什么事?为什么这事注定解不彻底?
- “groupcache 鼓励我们用函数式编程” —— 为什么值不可变就能消灭一致性问题?
而最好的抓手是:这一讲通篇讲的东西,就是 Python 的
@lru_cache。
怎么读这份带读
| 这一部分是 | 建议 | |
|---|---|---|
| 第一部分 · 地基(一) | 把整讲压成一个 Python 装饰器 | 先读这个,后面会轻松很多 |
| 第二部分 · 原文(二~六) | 38 讲本身讲了什么,把省掉的推导补上 | 主体 |
| 第三部分 · 引申(七~八) | 实战里的缓存问题,原文只讲了三分之一 | 标了 ⚠ 非原文 |
只想搞懂某一件事:
缓存为什么"不完美" → 四 | 雪崩和命中率 → 二 · 三 | groupcache 凭什么完美 → 五 | Redis 能不能当存储 → 六
这一讲的演示脚本(推荐按这个顺序跑):
| # | 脚本 | 演什么 | 配合 |
|---|---|---|---|
| 1 | 代码/缓存就是lru_cache.py |
缓存 = memoize · 纯函数那条前提 · 缓存污染 | 一 · 五 |
| 2 | 代码/命中率是个杠杆.py |
后端压力的非线性 · 雪崩的正反馈 · 扩容暴击 | 二 · 三 |
| 3 | 代码/缓存不一致.py |
那条永久不一致的时间线 · 删缓存 vs 更新缓存 · 四种写法 | 四 · 八 |
第一部分 · 地基
一 · ★ 缓存就是 @lru_cache
python3 代码/缓存就是lru_cache.py
1.1 两种加速方式,只有第二种是本讲的主题
| 加速方式 | 原理 | 例子 | 本讲讲不讲 |
|---|---|---|---|
| 更高速的硬件 | 快介质挡在慢介质前面 | SSD 挡 SATA、内存挡外存 | 一句话带过 |
| 更短的路径 | 把 y = F(x) 的结果记下来,下次跳过整个过程 |
x => y 的内存缓存 |
全讲都在讲这个 |
★ 第二种在 Python 里有名字:记忆化(memoize),标准库就是
functools.lru_cache。
1.2 原文那段 Go 代码,逐行就是 lru_cache
缓存 = {}
def FastF(x):
if x in 缓存: # ← 对应 val, err := memcaches[i].Get(key)
return 缓存[x]
y = F(x) # ← 对应 y = F(x)
缓存[x] = y # ← 对应 memcaches[i].Set(key, val)
return y
| 原文的 memcached 版 | Python 版 |
|---|---|
key := toBytes(x) |
缓存的 key 就是参数 x |
i := hash % countOf(memcaches) |
只有这一行是分布式带来的 |
memcaches[i].Get(key) |
if x in 缓存 |
y = F(x) |
y = F(x) |
memcaches[i].Set(key, val) |
缓存[x] = y |
★ memcached = 把
lru_cache那个 dict 搬到别的机器上,并且切成几片。
机制一模一样,只是那个 dict 不在本进程里了。
整讲后面所有的麻烦,都来自"不在本进程里"这一件事。
脚本实测(20 次请求、4 个不同的 x、每次 F 花 10ms):
不加缓存: 真的算了 20 次,耗时 243 ms
手写缓存: 真的算了 4 次,耗时 44 ms 快 6 倍
@lru_cache: 真的算了 4 次,耗时 46 ms
CacheInfo(hits=16, misses=4, maxsize=128, currsize=4)
1.3 所以接口只需要三个方法
func (cache *Cache) Get(key []byte) (val []byte, err error)
func (cache *Cache) Set(key, val []byte) (err error)
func (cache *Cache) Delete(key []byte) (err error)
缓存的数据结构就是一个 KV 存储——37 讲那个"KV 是一切的基础"在这里又出现一次。
1.4 ★ lru_cache 那条前提,就是整讲的全部麻烦
Python 官方文档对 lru_cache 有一条警告:它只适用于纯函数(同样的输入永远得到同样的输出)。脚本演示:
数据库 = {"张三": 5000}
@lru_cache
def 查工资(名字):
return 数据库[名字]
查工资("张三") # → 5000
数据库["张三"] = 8000 # 张三涨薪了
查工资("张三") # → 5000 ✗ 还是旧的!
★ 这就是原文说的一致性问题——注意它在单机上就已经存在了,根本不需要分布式。
而 F 只要去查数据库,它就不是纯函数(数据库会被改)。
原文说的"不太完美的补丁",指的就是这件事(第四节详细算账)。
groupcache 的解法是干脆要求你的 F 必须是纯函数(第五节)。
第二部分 · 原文这一讲
二 · memcached:把那个 dict 搬到别的机器上
python3 代码/命中率是个杠杆.py
2.1 分片 —— 又是 37 讲那个函数
多个 memcached 实例组成集群,靠 Hash 分片或 Range 分片把缓存数据分布上去。
★ 这就是 37 讲的「该找哪台(key)」,一字不改。
缓存天生是扁平 KV,所以它天生适配分片——不需要像 38 讲那样先"去目录"。
2.2 ★ 命中率是个杠杆:"某个阈值"其实是悬崖
原文说"一旦缓存命中率趋势下降,且下降到某个阈值,就要考虑给缓存集群扩容"。阈值在哪? 关键是这个式子:
后端压力 = 前端 QPS × (1 - 命中率)
前端 10000 QPS、后端最多扛 1000 QPS:
| 命中率 | 后端承受 | |
|---|---|---|
| 99% | 100 | ✓ 安全 |
| 95% | 500 | ✓ 安全 |
| 90% | 1,000 | 刚好顶到上限 |
| 80% | 2,000 | ✗ 过载 2 倍 |
| 50% | 5,000 | ✗ 过载 5 倍 |
| 0% | 10,000 | ✗ 过载 10 倍 |
这不是线性的,因为决定后端压力的是未命中率,而它是 1 减出来的:
99% → 95% 压力 ×5 ← 只掉了 4 个百分点,压力五倍
95% → 90% 压力 ×2
90% → 80% 压力 ×2
★ 命中率越高的系统,对命中率下滑越敏感。
一个靠 99% 命中率活着的系统,掉到 95% 就已经被打穿了——而 95% 听起来还很体面。
所以那个"阈值"不是一条线,是一个悬崖:你要盯的不是命中率的绝对值,是它的下滑趋势。
2.3 ★ 为什么缓存扩容比数据库扩容凶险
原文说"为了避免 memcached 集群扩容导致缓存命中率大幅降低,一般我们不会用简单哈希分片,而是用一致性哈希"。37/38 讲已经算过这两个数字了,但在缓存这里含义完全变了:
3 个实例 → 4 个实例,有多少 key 换了家?
简单哈希 hash(key) % 实例数 75.5%
一致性哈希 key 和实例都映到环上 25.4%
| "换了家"意味着什么 | |
|---|---|
| 在存储里(37/38 讲) | 这条数据要搬迁——慢、费带宽,但数据不会丢 |
| 在缓存里 | 这条数据在新分片上根本不存在 → 直接 cache miss,请求当场落到后端 |
假设后端留了 6 倍余量(能扛 3000 QPS,正常只用 500):
用简单哈希 扩容:命中率 95% → 23%,后端压力 7,674 ✗ 过载 2.6 倍
用一致性哈希扩容:命中率 95% → 71%,后端压力 2,918 ✓ 刚好扛住
★ 在数据库那边,简单哈希只是"扩容很麻烦";在缓存这边,简单哈希是"扩容这个动作本身会把后端打死"。
你为了缓解压力而扩容,结果扩容当场把系统搞崩。而且注意:一致性哈希也只是"刚好"扛住。所以真实的缓存扩容还要两道保险:
一次只加一个节点、新节点先预热再接流量。
一致性哈希把伤害从 75% 降到 25%,没有降到 0。
三 · 缓存 vs 存储:允许丢数据的那一类
3.1 “既是也不是”
| 维度 | 缓存 | 一般存储 |
|---|---|---|
| 维持业务状态 | 是(这点像存储) | 是 |
| 副本 | 通常单副本,允许丢 | 多副本,绝不允许丢 |
| 丢了怎么办 | 回源重算就行 | 没有"重算"这回事 |
★ "允许丢数据"是缓存区别于存储的根本点。
而 38 讲第 1.2 节说过,盘和机器天天在坏。缓存的处理方式是不管——
反正丢了就回源。代价是那一刻后端要多承受压力,这就通向雪崩。
3.2 ★ 雪崩不是"压力大",是【正反馈】
原文给了链条,但没说清"为什么起不来"。脚本把它量化了:
| 时刻 | 命中率 | 后端压力 | |
|---|---|---|---|
| 正常运行 | 95% | 500 | ✓ |
| 一个缓存实例宕机 | 70% | 3,000 | ✗ 过载 |
| 后端过载,响应变慢 | 70% | 3,000 | ✗ |
| 超时重试涌入,QPS 翻倍 | 70% | 6,000 | ✗ |
| 后端挂掉 | 70% | 6,000 | ✗ |
| 后端重启,缓存全是冷的 | 0% | 20,000 | ✗ |
★ 最后一行是关键:后端重启起来那一刻,它面对的压力比出事前大 40 倍。
为什么?因为缓存是冷的。刚重启时缓存里什么都没有,命中率 = 0,后端要独自承受 100% 的流量——而正常运行时它只承受 5%。
所以它必然再挂一次,然后无限循环。这才叫雪崩。
中间还有一个放大器:超时重试。请求变慢 → 客户端超时重试 → QPS 翻倍 → 更慢。
3.3 过载保护:宁可丢 90%
原文的解法:
“一旦并发的请求超过预期,就要丢弃部分请求,以减少压力。”
翻译成人话:宁可让 90% 的请求立刻失败,也要保住 10% 能成功。
★ 为什么?因为只有让一部分请求成功,缓存才能慢慢热起来,命中率才能爬回去。
不丢请求,就一个都活不下来。这条判断有点反直觉,但它是所有过载保护(限流、熔断、降级)背后的同一个道理:
系统过载时,“公平地都慢一点"等于"全部死”;"不公平地救一部分"才能恢复。
四 · ★ 为什么说它是"不完美的补丁"
python3 代码/缓存不一致.py
4.1 补丁:事务补写,缓存补读
原文这段是全讲最漂亮的判断:
| 补丁 | 改善存储在哪方面的"业务场景匹配性" | 手法 |
|---|---|---|
| 事务(37 讲) | 写操作 | 把一个复杂操作包装成原子操作 |
| 缓存(本讲) | 读操作 | 提升高频读的效率 |
“如果存储本身非常匹配业务场景的话,它不应该需要缓存在它前面挡一道,内部自己就有缓存。”
★ 也就是说:你在存储前面挂一层缓存,本身就是在承认"这个存储和我的业务读场景不匹配"。
缓存不是加分项,是补丁。
4.2 ★ "不是原子读"是什么意思
FastF(x) 是三步:
val = cache.Get(key) ← 第 1 步
if 没命中:
y = F(x) ← 第 2 步(去后端存储读)
cache.Set(key, y) ← 第 3 步
三步之间有两条缝,别人可以插进来。“原子"的意思就是"中间没有缝”。
脚本跑的那条时间线(读请求 R + 写请求 W,W 用最标准的"先写存储、再删缓存"):
存储 缓存
R 查缓存 → 未命中 k:v1 (空)
R 读存储 → 拿到 v1 k:v1 (空)
W 写存储 k=v2 k:v2 (空)
W 删缓存 k(本来就是空的,删了个空) k:v2 (空)
R 回填缓存 = 它刚才读到的 v1 k:v2 k:v1 ← ✗
★ W 的"删缓存"发生在 R 的"回填缓存"之前——它删掉的是一个空缓存,然后 R 把过期的 v1 写了进去。
而且是【永久】不一致:不是"缓存慢了一会儿",是缓存从此稳定地返回一个错的值,直到下一次有人写 k 为止——中间可能是几天。
原文说"如果我们有一些业务逻辑是基于 FastF(x) 得到的值,就有可能会出现逻辑错乱"——
就是拿这个 v1 去做判断(够不够余额、有没有权限),错的是业务结果。常见的"缓解"是给缓存加 TTL。注意那只是把"永久错"变成"错 5 分钟",不是修好了。
4.3 顺带解决一个常见疑问:为什么是"删缓存"而不是"更新缓存"
有人会想:写的时候顺手把缓存也改成新值,不就不用删了?看看两个并发的写:
做法:写存储 + 更新缓存
W1 写存储 k=v1 存储 k:v1 缓存(空)
W2 写存储 k=v2 存储 k:v2 缓存(空)
W2 更新缓存 k=v2 存储 k:v2 缓存 k:v2
W1 更新缓存 k=v1 ← 它慢了一步 存储 k:v2 缓存 k:v1 ← ✗
做法:写存储 + 删缓存(同样的交错)
W1 写存储 k=v1 / W2 写存储 k=v2
W2 删缓存 k 存储 k:v2 缓存(空)
W1 删缓存 k 存储 k:v2 缓存(空) ← ✓
★ 区别在于幂等:
"更新缓存"带着一个值,值有新旧,所以有顺序问题——谁后写谁赢。
"删缓存"不带值,删两次和删一次一样——幂等,没有顺序问题。所以工程默认是写存储 + 删缓存(叫 Cache Aside)。它挡不住 4.2 那个读写交错,但至少挡住了写写交错。
(幂等这个概念第 6.3 节还会再出现一次。)
4.4 ★ 为什么这个问题【注定】解不彻底
想彻底解决,需要"改存储"和"改缓存"原子地发生。但它们在两个不同的系统里:
要跨系统原子 = 分布式事务(37 讲第七节那个 2PC)
= 每次读写都要加一轮网络协调
= 缓存比不用缓存还慢
★ 所以这不是"还没人想出好办法",是目标本身自相矛盾:
你加缓存是为了快,而修好一致性要付出的代价是慢,而且慢过没缓存。这就是原文那句话的完整含义——“缓存其实应该被认为是存储的补丁,而且是理论上来说不太完美的补丁”。
不完美不是实现问题,是定义问题。
五 · groupcache:不是修补丁,是拆掉产生问题的前提
5.1 改动一 · group:缓存污染
原文说"F(x)、G(x) 在同一个内存缓存集群就意味着它们相互之间会淘汰对方,这里面的淘汰规则不是我们能够控制的"。脚本把它跑出来了:
场景:F = 首页热门列表(只有 2 个 key,被疯狂请求)
G = 用户历史查询(key 千奇百怪,每个只查一次)
每一轮:F 请求它的 2 个 key,然后 G 刷进来 4 个全新的 key
── 共用一个池子(memcached 的处境,容量 4)──
总命中 0 / 60 → 命中率 0%
✗ 每轮 G 的 4 个新 key 正好把池子灌满,F 的热 key 每轮都被挤出去
── 分成两个 group,各 2 的容量(总容量还是 4)──
group F:命中 18 / 20 → 90% ← F 被保护住了
group G:命中 0 / 40 → 0% ← G 本来就没法命中
总命中率 30%
有了 group 之后的两个好处:
| 好处 | |
|---|---|
| 独立的内存上限 | 每个 group 有自己的 cacheBytes,只淘汰自己组内的数据 |
| 不用手工拼 key | 直接记 x => y,不再需要 F#x / G#x 这种字符串前缀模拟命名空间 |
★ 最有意思的是:Python 的
@lru_cache天生就是 group。@lru_cache(maxsize=2) def F(x): ... # 这就是 group F @lru_cache(maxsize=2) def G(x): ... # 这就是 group G每个函数一个独立缓存,各有自己的 maxsize,互不淘汰。
所以不是 groupcache 发明了什么新东西——是 memcached 那种"全世界共用一个池子 + 手工拼 key 前缀"本来就是个退化的设计,groupcache 只是把它修回正常的样子。
5.2 改动二 · 值不可变:一致性问题直接消失
“一旦某个 x 值 Get 到的值为 y,那么就一直为 y。”
由此推出两个连带结果,原文都提到了:
| 结果 | 为什么 |
|---|---|
| 不需要"失效时间"这个概念 | 值不会过时,它只会因为内存不足被淘汰 |
| 一致性问题消失 | 既然值不可修改,就不存在"缓存里的值和存储里的值不一致" |
5.3 ★ 这跟 38 讲对象存储的第三刀是同一刀
groupcache 没有解决第四节那个问题,它取消了这个问题:
值不可修改 → 没有"改"这个动作 → 没有新旧版本 → 无从不一致
而 38 讲第十二节说过,对象存储砍的第三刀正是去可变:
| 砍掉什么 | 换来什么 | |
|---|---|---|
| 对象存储(38 讲) | 去可变(Put 是整体覆盖) | 不需要分布式事务 → 能扩到 EB 级 |
| groupcache(本讲) | 值不可变 | 不需要一致性协议 → 理论完美 |
★ 都不是把难题解开,而是把产生难题的那个前提砍掉。
这是这几讲里反复出现的同一个手法(去目录 / 去关系 / 去可变 / 值不可变)——
把做不到的需求砍掉,剩下的就能做对。
5.4 代价:为什么"函数式"这条路难走
原文:
“groupcache 是一个理论完美的内存缓存系统……但是 groupcache 对使用者来说是有挑战的,
某种意义上来说,它鼓励我们用函数式编程的方式来实现业务逻辑。”
"鼓励函数式"翻译成大白话就是:你的 F 必须是纯函数。
而第 1.4 节演示过,只要 F 去查一次会被改的数据库,它就不是纯函数。要让它是纯函数,你得把 key 设计成带版本的:
# 不是纯函数:数据库一改,同样的 key 就该返回不同的值
groupF.Get("用户42的资料")
# 是纯函数:版本变了 key 就变了,老 key 的值永远正确
groupF.Get("用户42的资料@v17")
★ 于是负担转移了:现在你必须自己维护"当前版本号是多少",而且这个版本号从哪来?
得从存储里读——又变成一次不带缓存的读。这就是"挑战并不低"的具体含义:groupcache 把一致性问题从缓存层踢回了业务层,
业务层能不能优雅地接住,取决于你的领域模型能不能自然地表达成"不可变 + 版本"。
而这,正是函数式编程的思维方式。
六 · Redis:缓存还是存储
6.1 它为什么让人分不清
| 当缓存看 | 当存储看 |
|---|---|
| 能设内存上限,满了执行淘汰算法 | key => document:值可以是字符串、哈希表、列表、集合、有序集合(支持 Range 查询) |
| 只需要是个简单 KV 就够了 | 还支持 Lua 脚本做存储过程 |
★ 原文的判断很准:如果只把它当缓存,那它只需要是个简单 KV——多出来的那些数据结构和 Lua,全都是在往"数据库"的方向长。
而一旦你开始把它当数据库用,就得回答两个"存储系统可不是闹着玩的"问题。
6.2 坑一 · 持久性:那个 ok 可能是假的
原文说"定期持久化的策略对于一个服务端的存储系统来说是不合格的"。展开说,Redis 有两种持久化:
| 方式 | 怎么做 | 宕机丢多少 |
|---|---|---|
| RDB | 定期做全量快照 | 上次快照之后的所有写 |
| AOF | 把每条写命令追加到日志 | 取决于 appendfsync 配置 ↓ |
appendfsync 的三档:
| 配置 | 行为 | 宕机丢多少 |
|---|---|---|
always |
每条命令都 fsync | 不丢,但很慢 |
everysec(默认) |
每秒 fsync 一次 | 最多丢 1 秒的写 |
no |
交给操作系统 | 可能丢几十秒 |
★ 接回 36 讲那条底线:“服务端不允许 ok 是假的。”
数据库的做法是返回成功之前就已经落盘(WAL)。
Redis 默认配置的做法是先返回成功,1 秒内再落盘。
所以 Redis 默认配置下,那个 ok 是可能假的。主从复制也是异步的:主写完就返回,从还没收到。主一挂、切到从,那些写就没了——
而 37 讲第 4.3 节说过,正经的主从要求"写操作应确保至少一个从节点收到了最新数据"。
而原文那个"多机房"的论证:
同机房多副本 → 挡不住机房整体断电
机房整体断电 → "并不是一个太小概率的事件"
所以要达标 → 必须多机房多副本
但 会有多少企业仅仅为了部署 Redis 去搞多个机房?
用 38 讲的语言说:Redis 的多副本没有跨越"机房"这一级故障域。
6.3 坑二 · 重试友好性:幂等(接 29 讲)
网络是不可靠的。你发了一条命令,没收到回复——它到底执行了没有? 你只能重试。于是命令是否幂等变成了关键:
| 命令 | 幂等吗 | 重试 3 次的后果 |
|---|---|---|
SET k v |
✓ 幂等 | 还是 v,没事 |
LPUSH k v |
✗ 不幂等 | 列表里多了 3 个 v |
INCR k |
✗ 不幂等 | 加了 3 次 |
SADD k v |
✓ 幂等 | 集合去重,还是一个 v |
★ 这正好和第 4.3 节呼应:那里说"删缓存比更新缓存好,因为删除是幂等的";
这里说"Redis 的坑在于很多命令不幂等"。同一个概念,在这一讲出现了两次。原文说"在 Redis 的协议中,有不少请求用户很友好,但是对重试并不友好"——
LPUSH读起来多顺啊,可它就是不能安全重试。实战里的三条出路:① 改用幂等的结构(
SADD而不是LPUSH);
② 用唯一请求 ID 去重;③ 用 Lua 脚本把"检查 + 执行"包成一个原子操作。
6.4 一句话定位
“它和 memcached 都是实用型的瑞士军刀,很有用,但是我们站在分布式系统的理论角度看时,它们都有那么一点不完美的地方。”
| 定位 | |
|---|---|
| 当缓存用 | 很好,而且比 memcached 好用(数据结构丰富、有持久化能兜底冷启动) |
| 当唯一的存储用 | 两道硬门槛:多机房多副本、命令不幂等。除非你真的搞了多机房,否则别把它当唯一真源 |
| 常见的正确用法 | Redis 当缓存/会话/排行榜,真源在关系型数据库或对象存储里 |
第三部分 · 引申
⚠ 这一部分不是原文内容。 原文的缓存问题只讲了雪崩一个,实战里是三个;
缓存的写法原文只演示了一种,实战里有四种。
七 · 实战里的缓存问题
7.1 缓存三兄弟:穿透 / 击穿 / 雪崩
三个名字很像,但区分它们只需要问一个问题:数据本来存在吗?缓存为什么没有它?
| 名字 | 数据本来存在吗 | 缓存为什么没有 | 后果 |
|---|---|---|---|
| 穿透 Penetration | 不存在 | 根本缓存不了——查了也没东西可存 | 每一次请求都打后端 |
| 击穿 Breakdown | 存在 | 单个热点 key 刚好过期/被淘汰 | 那一瞬间大量并发同时打后端 |
| 雪崩 Avalanche | 存在 | 大面积 key 同时消失(实例宕机、集中过期) | 后端整体被打穿(第三节那个) |
各自的解法:
| 解法 | 原理 | |
|---|---|---|
| 穿透 | ① 缓存空值(把"这个 key 没有"也记下来,短 TTL) ② 布隆过滤器(先问"可能存在吗",不可能就直接返回) |
让"不存在"这件事也能被缓存住 |
| 击穿 | ① 单飞 singleflight:同一个 key 的并发 miss 只放一个请求回源,其他的等它 ② 逻辑过期:值不真删,标记为"旧",一个请求去刷新、其他人先用旧值 |
把"N 个并发 miss"收敛成1 次回源 |
| 雪崩 | ① 过期时间加随机抖动(别让一批 key 同时到期) ② 过载保护(原文的解法,见 3.3) ③ 多级缓存(本地内存 + 远程缓存,一层挂了还有一层) |
避免"同时",以及接受损失一部分 |
★ 顺带一个原文没提但很值得知道的事:击穿的那个"单飞",groupcache 里就自带一个
(这份代码后来被抽出来成了 Go 官方的golang.org/x/sync/singleflight)。
也就是说 groupcache 不只是"理论完美",它把这个实战问题也一起解了。
7.2 四种缓存写法
| 名字 | 谁负责写缓存 | 一致性 | 特点 |
|---|---|---|---|
| Cache Aside(旁路缓存) | 业务代码 | 弱 | 写存储 + 删缓存。原文演示的就是这个,也是默认选它 |
| Read Through | 缓存层自己回源 | 弱 | 业务只调 cache.Get,miss 时缓存自己去读存储。groupcache 是这一类 |
| Write Through | 缓存层同步写存储 | 强一些 | 写缓存时缓存同步写存储,一起成功/失败;慢 |
| Write Behind(回写) | 缓存层异步写存储 | 最弱 | 先写缓存立刻返回,攒一批再刷存储;最快 |
★ 注意 Write Behind 那一行——它已经不是"缓存"了。
数据只在缓存里、还没落存储,这时缓存挂了数据真的丢了。
原文说"缓存允许数据发生丢失,所以缓存通常是单副本的"——
一旦你用了 Write Behind,这个前提就不成立了:你必须给它多副本,它已经变成存储了。
选型一句话:
绝大多数场景 → Cache Aside(写存储 + 删缓存)+ 给缓存加 TTL 兜底
能接受函数式 → groupcache 那条路,一致性问题从根上消失
想要强一致 → 【别用缓存】,让存储自己带缓存
最后一条正是原文的意思:
“如果存储本身非常匹配业务场景的话,它不应该需要缓存在它前面挡一道,内部自己就有缓存。”
八 · 收口:本讲所有问题都是同一个问题
回头看,这一讲的每个麻烦都是同一件事的不同侧面:
★ 缓存和存储是【两个系统】,而你希望它们表现得像【一个】。
| 这一讲的问题 | 其实是 |
|---|---|
| 一致性(四节) | 两个系统的状态可能不同步,而跨系统原子操作太贵 |
| 雪崩(三节) | 一个系统倒了,另一个承受不住 |
| 命中率(二节) | 两个系统之间的流量分配 |
| 扩容 miss(2.3) | 一个系统重新分片,另一个立刻承压 |
| Redis 定位模糊(六节) | 它试图同时当这两个系统 |
8.1 三个系统的三种态度
| 态度 | 代价 | |
|---|---|---|
| memcached | 承认不完美,尽量实用 | 一致性问题留给你自己扛 |
| groupcache | 砍掉可变性,换理论完美 | 代价推给业务层(必须写成纯函数 + 版本化 key) |
| Redis | 想同时当缓存和存储 | 两边的要求都得满足,于是两边都有点勉强 |
三种态度对应本讲的三段,也对应一个通用的工程选择:
是把难题留在原地(memcached)、砍掉产生难题的前提(groupcache)、还是同时满足两套要求(Redis)。
38 讲对象存储选的是第二条(去目录、去关系、去可变)。
8.2 那句最该记住的判断
★ 缓存不是性能优化的第一选项,是最后选项。
先看能不能改存储选型 / 改查询 / 改数据模型,都不行才挂缓存——
因为一旦挂上去,你就得永远背着一致性这笔债,而第 4.4 节说了,这笔债理论上还不清。原文的原话更含蓄,但意思一样:缓存是存储的补丁,而且是理论上不完美的补丁。
验收
地基(一)
| # | 问题 | 答案在 |
|---|---|---|
| 1 | 缓存的两种加速方式是什么?本讲主要讲哪一种?它在 Python 里叫什么? | 一 |
| 2 | @lru_cache 有一条前提,那条前提失效会怎样?为什么它就是整讲的全部麻烦? |
一 |
原文(二~六)
| # | 问题 | 答案在 |
|---|---|---|
| 3 | 为什么"命中率下降到某个阈值"其实是个悬崖?为什么缓存扩容比数据库扩容凶险? | 二 |
| 4 | 雪崩为什么"怎么启动也启动不了"?过载保护为什么要主动丢请求? | 三 |
| 5 | FastF(x) 为什么不是原子读?会造成什么后果?为什么这个问题注定解不彻底? |
四 |
| 6 | 为什么是"删缓存"而不是"更新缓存"? | 四 |
| 7 | groupcache 那两个改动各解决什么?"鼓励函数式编程"具体是什么意思、代价在哪? | 五 |
| 8 | 把 Redis 当存储用有哪两道硬门槛? | 六 |
引申(七~八)
| # | 问题 | 答案在 |
|---|---|---|
| 9 | 穿透 / 击穿 / 雪崩怎么一句话区分? | 七 |
| 10 | 为什么说 Write Behind"已经不是缓存了"? | 七 |
答案
1. ① 更高速的硬件(SSD 挡 SATA、内存挡外存);② 更短的路径(把 y=F(x) 的结果记下来)。本讲全篇讲的是第②种,它在 Python 里叫记忆化 memoize,标准库就是 functools.lru_cache。memcached = 把 lru_cache 那个 dict 搬到别的机器上并切成几片。
2. 前提是 F 必须是纯函数(同样输入永远同样输出)。失效的话缓存会稳定地返回旧值——脚本里改了数据库,查工资("张三") 还是返回 5000。而只要 F 去查一次会被改的数据库,它就不是纯函数。这就是原文说的一致性问题,它在单机上就已经存在了,根本不需要分布式——分布式只是让它更难修。
3. 因为 后端压力 = QPS × (1 - 命中率),决定压力的是未命中率,它是 1 减出来的:命中率 99%→95% 只掉 4 个百分点,未命中率却从 1% 变成 5%,压力五倍。所以命中率越高的系统对下滑越敏感,要盯的是趋势不是绝对值。
缓存扩容更凶险,因为"key 换了家"在存储里只是搬迁(慢但不丢),在缓存里是当场 miss(请求立刻落到后端)——简单哈希扩容 75% 的 key 换家,等于把后端压力瞬间放大到过载,扩容动作本身触发雪崩。
4. 因为缓存是冷的。后端重启那一刻缓存里什么都没有,命中率 = 0,它要独自承受 100% 的流量,而正常时只承受 5%——脚本算出来是出事前的 40 倍,所以必然再挂,无限循环。(还有超时重试在放大 QPS。)
过载保护要主动丢请求,因为只有让一部分请求成功,缓存才能慢慢热起来、命中率才能爬回去。不丢请求,就一个都活不下来——“公平地都慢一点"等于"全部死”。
5. 它是三步(查缓存 / 读存储 / 回填缓存),三步之间有缝。后果是:读请求读到 v1 之后被写请求插队(写存储 v2 + 删了个空缓存),读请求再把过期的 v1 回填进缓存 → 缓存 v1、存储 v2,永久不一致,直到下次有人写。TTL 只是把"永久错"变成"错 5 分钟"。
注定解不彻底,因为要原子就得跨"缓存 + 存储"两个系统做分布式事务 → 每次读写加一轮网络协调 → 比不用缓存还慢。你加缓存是为了快,修好一致性的代价是慢——目标本身自相矛盾。
6. 因为幂等。"更新缓存"带着一个值,值有新旧,两个并发写的顺序可能和存储里相反(W1 慢一步就把旧值写进缓存);"删缓存"不带值,删两次和删一次一样,没有顺序问题。删缓存挡不住读写交错,但至少挡住了写写交错。
7. ① group 解决缓存污染:没有 group 时 F 和 G 挤在一个池子里互相淘汰(脚本里 F 的命中率被打到 0%),分成两个 group 各设内存上限后 F 恢复到 90%;顺带不用再手工拼 F#x。Python 的 @lru_cache 天生就是 group。
② 值不可变解决一致性:没有"改"这个动作就没有新旧版本,无从不一致;顺带连"失效时间"这个概念都不需要了。
“鼓励函数式”= 你的 F 必须是纯函数,实践上意味着 key 要带版本(用户42@v17)。代价是版本号从哪来——得从存储读,又是一次不带缓存的读。一致性问题被从缓存层踢回了业务层。
8. ① 持久性:RDB 是定期全量快照,AOF 默认 appendfsync everysec 最多丢 1 秒的写——也就是说 Redis 默认配置下那个 ok 可能是假的(对比 36 讲的底线、37 讲"写要确保至少一个从收到");而同机房多副本挡不住机房级故障域,要达标必须多机房多副本,条件太苛刻。
② 重试友好性:SET 幂等,但 LPUSH、INCR 不幂等——网络超时后重试就多塞了几个元素。原文说它"对用户很友好,但对重试并不友好"。
9. 问一句话:数据本来存在吗?缓存为什么没有它?
穿透 = 数据不存在,根本缓存不了 → 每次都打后端(解法:缓存空值、布隆过滤器);
击穿 = 数据存在,单个热点 key 刚好没了 → 那一瞬间并发同时回源(解法:单飞、逻辑过期);
雪崩 = 数据存在,大面积 key 同时没了 → 后端整体被打穿(解法:过期抖动、过载保护、多级缓存)。
10. 因为 Write Behind 是"先写缓存立刻返回、攒一批再刷存储"——数据只在缓存里、还没落存储。这时缓存挂了数据真的丢了。而"缓存允许丢数据、所以通常单副本"这个前提就不成立了:你必须给它多副本、必须保证持久性——它已经变成存储了。
下一步
40 讲:服务端开发的架构建议——服务端篇的收口。
回看:
36 讲 · 存储中间件的由来(四堵墙 · ok 不许是假的——第 6.2 节撞的就是它)
带读 11 · 37 讲(事务补写、缓存补读 · 跨分片事务——第 4.4 节全靠它)
带读 12 · 38 讲(去可变那一刀——第 5.3 节和 groupcache 是同一刀)
