加载中...

这一讲比前三讲好懂,但有三处一带而过的地方值得停下来:

  • 缓存命中率下降到某个阈值就要扩容” —— 阈值在哪?为什么是个悬崖?
  • 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_cachememcached = 把 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#xPython 的 @lru_cache 天生就是 group
值不可变解决一致性:没有"改"这个动作就没有新旧版本,无从不一致;顺带连"失效时间"这个概念都不需要了。
“鼓励函数式”= 你的 F 必须是纯函数,实践上意味着 key 要带版本用户42@v17)。代价是版本号从哪来——得从存储读,又是一次不带缓存的读。一致性问题被从缓存层踢回了业务层。

8.持久性:RDB 是定期全量快照,AOF 默认 appendfsync everysec 最多丢 1 秒的写——也就是说 Redis 默认配置下那个 ok 可能是假的(对比 36 讲的底线、37 讲"写要确保至少一个从收到");而同机房多副本挡不住机房级故障域,要达标必须多机房多副本,条件太苛刻。
重试友好性SET 幂等,但 LPUSHINCR 不幂等——网络超时后重试就多塞了几个元素。原文说它"对用户很友好,但对重试并不友好"。

9. 问一句话:数据本来存在吗?缓存为什么没有它?
穿透 = 数据不存在,根本缓存不了 → 每次都打后端(解法:缓存空值、布隆过滤器);
击穿 = 数据存在,单个热点 key 刚好没了 → 那一瞬间并发同时回源(解法:单飞、逻辑过期);
雪崩 = 数据存在,大面积 key 同时没了 → 后端整体被打穿(解法:过期抖动、过载保护、多级缓存)。

10. 因为 Write Behind 是"先写缓存立刻返回、攒一批再刷存储"——数据只在缓存里、还没落存储。这时缓存挂了数据真的丢了。而"缓存允许丢数据、所以通常单副本"这个前提就不成立了:你必须给它多副本、必须保证持久性——它已经变成存储了。


下一步

40 讲:服务端开发的架构建议——服务端篇的收口。

回看:
36 讲 · 存储中间件的由来(四堵墙 · ok 不许是假的——第 6.2 节撞的就是它)
带读 11 · 37 讲事务补写、缓存补读 · 跨分片事务——第 4.4 节全靠它)
带读 12 · 38 讲去可变那一刀——第 5.3 节和 groupcache 是同一刀)

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关