08 缓存:局部性、多级缓存、替换策略、一致性

开篇提问

先问你一个能测出”真懂还是假懂”的问题:

计算机里到处都是缓存——CPU 有 L1/L2/L3,操作系统有页缓存,数据库有 buffer pool,Web 有 CDN 和浏览器缓存,应用有 Redis。为什么”缓存”这个模式能在这么多完全不同的层次上,一遍又一遍地被发明出来?

提示:它们缓存的东西完全不同(指令、内存页、磁盘块、网页、数据库记录),介质也完全不同(SRAM、DRAM、SSD、远程内存),但结构却惊人地一致。

想清楚这个问题,你就理解了缓存的本质:缓存不是一种技术,而是”利用某种普遍存在的规律”的必然结果。

那个规律叫什么,我先不说——往下看。


子主题一:缓存成立的根基——局部性原理

答案:局部性原理(Principle of Locality)。它是缓存能生效的唯一理由,也是贯穿整个计算机体系的最重要经验规律之一。

它有两层含义:

时间局部性(Temporal Locality):如果一个数据现在被访问,它很可能在不远的将来再次被访问。

  • 例子:循环里的变量、刚被查询的用户资料、刚打开的热点新闻

空间局部性(Spatial Locality):如果一个数据被访问,它附近的数据很可能也会被访问。

  • 例子:数组顺序遍历、读取一个结构体后读它的相邻字段、读一个网页后加载它的图片

为什么局部性会存在?因为程序的行为不是随机的——它有循环、有顺序、有热点、有结构。人类造出来的数据和代码,天然带有聚集性。

这是缓存能成立的唯一赌注:我们赌”未来会访问的东西,和现在访问的相似”。 如果访问模式是完全随机的(每个数据被访问的概率相同),那么任何缓存策略的命中率都只会等于”缓存容量/总数据量”——缓存就没意义了。

所以缓存设计的第一件事,不是选什么替换算法,而是分析”我的访问模式有没有局部性”。局部性强,随便什么策略都能有高命中率;局部性弱,再好的算法也救不了。

这也是为什么”缓存”会出现在那么多层次:局部性是一个跨层次的普遍规律——指令有局部性、内存访问有局部性、磁盘访问有局部性、网络请求有局部性。所以每个层次都可以做一次缓存。


子主题二:多级缓存——为什么不是”一层大的”

既然缓存好,为什么不直接做一层超大的缓存,而要搞 L1/L2/L3/内存/磁盘这么多层?

因为速度和容量是一对无法调和的矛盾:

层次典型容量访问延迟
寄存器几百字节~0.3ns
L1 Cache32~64KB~1ns
L2 Cache256KB~1MB~4ns
L3 Cache几 MB~几十 MB~10-40ns
内存几 GB~几十 GB~100ns
SSD几百 GB~100μs
机械硬盘几 TB~10ms

注意这个跨度:从 L1 到机械硬盘,延迟差了 1000 万倍。 而容量也差了 1 亿倍。没有任何一种技术能同时做到”又快又大又便宜”——这是物理和经济约束,不是工程问题。

多级缓存的本质,就是用”分层”来逼近”既要快又要大”:

  • 越靠近计算的层:越快、越小、越贵
  • 越远离计算的层:越慢、越大、越便宜
  • 通过让”大部分访问命中在快的层”,整体的平均访问时间就接近快层,而容量接近慢层

这里有个关键量化——平均访问时间 = 命中率 × 快层延迟 + (1-命中率) × 慢层延迟。

因为慢层比快层慢 1000 万倍,所以命中率的微小提升,对平均延迟的影响是巨大的:命中率从 90% 提到 99%,平均延迟能降一个数量级。

这就是为什么”提升命中率”是缓存领域永恒的主题——不是因为它线性重要,而是因为快慢层差距太大,命中率的每一点提升都被杠杆放大了。


子主题三:替换策略——当缓存满了,踢掉谁

缓存满了,新数据要进来,得踢掉一个旧的。踢谁?这就是替换策略。

LRU(Least Recently Used,最近最少使用):踢掉最久没被访问的。

  • 直观、符合时间局部性假设
  • 实现:哈希表 + 双向链表,O(1)
  • 问题:对”顺序扫描”极不友好——一次全表扫描会把热数据全冲掉(缓存污染)
  • 还有一个隐藏弱点:它需要维护访问顺序,有额外开销

LFU(Least Frequently Used,最少使用):踢掉访问次数最少的。

  • 更关注”长期热度”
  • 问题:历史包袱——一个曾经很热但现在冷了的数据,因为累计计数高,会长期占着位置
  • 需要”老化”机制来衰减历史计数

FIFO / 先进先出:简单,但完全不看访问模式,命中率通常不如 LRU。

随机替换:你没看错,随机替换在很多场景下表现居然”够好”——因为它实现极简、无开销,且能避免 LRU 在扫描场景下的最坏情况。这是”用一点命中率换零开销”的典型取舍。

现代工业方案:分段 LRU / ARC / TinyLFU 真实系统(数据库 buffer pool、CDN、Redis)往往用更复杂的策略。核心思路是识别并保护”真正的热数据”,同时让”扫描型冷数据”快速流过而不污染缓存:

  • 分段 LRU:分成”试用段”和”保护段”,数据要先在试用段证明自己被多次访问,才升级到保护段
  • TinyLFU:用极小的概率数据结构(前面讲的 Count-Min Sketch)统计频率,用它来决定”新来的能不能取代老的”——用近似频率信息做准入控制,这是近似思想和缓存思想的漂亮结合

选策略的判断标准:你的访问模式是什么样? 有明显热点 → LRU/LFU 系;大量顺序扫描 → 需要抗污染的策略;无所谓 → 简单策略甚至随机就够。


子主题四:缓存一致性——缓存带来的最大麻烦

缓存最大的副作用,开篇提到过、这里展开:一旦数据有了副本(缓存里一份、源里一份),就出现了”谁是对的”这个问题。

这就是缓存一致性问题。它有三种经典解法:

Write-through(写穿):写缓存的同时也写源存储。

  • 一致性好(缓存和源始终一致)
  • 代价:每次写都要等慢的源存储确认,写的延迟高
  • 适合:写少读多、对一致性要求高

Write-back(写回):只写缓存,标记为”脏”,等被替换时才写回源。

  • 写极快(只碰快的层)
  • 代价:一致性差(源里是旧值),而且有丢失风险(缓存挂了,脏数据还没写回就丢了)
  • 适合:写密集、能容忍短暂不一致或已有持久化兜底(比如数据库的 buffer pool + WAL)

Write-around / Cache-aside(旁路缓存):写的时候绕过缓存直接写源,读的时候才加载进缓存。

  • 这是应用层缓存(Redis/Memcached)最主流的模式
  • 写路径简单,但首次读会 miss(要回源加载)
  • 需要处理”缓存和源不一致”:用**失效(invalidate)**而不是更新——写源之后删除缓存,下次读重新加载

为什么”删除缓存”比”更新缓存”好? 这是个经典的工程智慧,原因有两条:

  1. 避免并发写导致的不一致:两个并发写,更新缓存的顺序可能和写库的顺序不同,导致缓存里留下旧值。而”删除”是幂等的——不管顺序如何,最终都是”缓存没有了,下次重新加载”。
  2. 避免”写了但没人读”的浪费:更新缓存要付出序列化的成本,但这个数据可能再也没人读。删除的成本几乎为零。

子主题五:缓存的经典问题与应对

问题一:缓存穿透(Penetration)——查一个根本不存在的数据。

  • 后果:每次都 miss,每次都打到数据库,缓存形同虚设
  • 应对:缓存空值(存一个”不存在”的标记,短 TTL)、布隆过滤器提前挡掉

问题二:缓存击穿(Breakdown)——一个热点 key 过期,瞬间大量请求打到数据库。

  • 后果:热门 key 失效的瞬间,数据库被打爆
  • 应对:互斥重建(只让一个请求去回源,其他等待)、逻辑过期(物理永不过期,后台异步更新)、热点 key 永不过期

问题三:缓存雪崩(Avalanche)——大量 key 同时过期。

  • 后果:数据库瞬间承受全部流量
  • 应对:过期时间加随机抖动(避免同时失效)、多级缓存、限流熔断兜底

问题四:缓存与源的一致性延迟。

  • 这是本质问题,无法彻底解决,只能管理
  • 应对:设置合理 TTL、写后失效、订阅 binlog 异步更新(如 Canal)

顺便把三个名字钉死,避免面试时说串:

问题一句话本质关键特征核心思路
穿透 Penetration查 根本不存在 的数据缓存里永远不会有,miss 是永久的让”不存在”也变成可缓存的事实(空值/布隆)
击穿 Breakdown单个热点 key 失效一个 key,瞬时并发,回源一次就够控制”回源并发数”(互斥重建/逻辑过期)
雪崩 Avalanche大量 key 同时失效一批 key,瞬时总量,回源量巨大打散失效时间 + 限流兜底

注意”击穿”和”雪崩”经常被混用,但它们的量纲不同:击穿是”并发度问题”(同一把钥匙被一万个人抢),雪崩是”总量问题”(一万把钥匙同时到期)。所以击穿的解法是”加锁/单飞”(让一万个人排成一队),雪崩的解法是”错峰+限流”(把一万个人分散到不同时间,剩下的直接拒绝)。用错解法就无效:给雪崩加互斥锁,一万个 key 每个都重建一次,数据库一样挂。


子主题六:局部性到底从哪来——把”赌注”拆开看

前面说”缓存是赌未来和现在相似”。这句话太玄了。一个干过五年后端的工程师应该追问一句:我凭什么敢赌?这个赌注的胜率由谁决定?

答案是:局部性不是上天的恩赐,它是被”程序和数据是怎么被造出来的”决定的。我们把来源逐个拆开,你会发现每一条都对应着你写过的某类代码。

来源一:循环(Loop)——时间局部性的最大制造机

一个循环体执行一万次,它访问的指令地址和数据地址就在那一小段里反复出现。指令的局部性几乎全部来自循环,而且它强得离谱:程序 90% 的时间在执行 10% 的代码,这就是指令 cache 能存在的全部理由。

数据侧同理:循环里的累加器、索引变量、局部数组,被反复读写。这是时间局部性的教科书来源。

来源二:顺序访问(Sequential Access)——空间局部性的最大制造机

数组遍历、文件顺序读、日志追加写、视频流播放。这类访问的特征是”地址单调递增”。它为什么重要?因为它是唯一能被硬件预测的访问模式。

现代 CPU 里有硬件预取器(Hardware Prefetcher),它专门识别”固定步长(stride)“的访问模式:你访问了地址 0、64、128,它就猜下一个是 192,提前把后面的行搬进缓存,你真正要用时它已经在那儿了。

反过来说:链表和树遍历是预取器的天敌。指针指向的地址没有规律,预取器无从猜测,只能等 miss 发生后再去取——这就是”指针追逐(pointer chasing)“慢的根源。一个存 100 万个元素的 std::list 和 std::vector,遍历一遍的耗时能差 5~10 倍,差的不是算法复杂度(都是 O(n)),差的是空间局部性。

记住这条:数据结构选型时,cache 友好性往往比渐进复杂度更影响实际性能。O(n) 的数组扫描常常打赢 O(1) 的哈希查找——因为哈希表每次访问都是随机地址,每访问一个元素就是一次 cache miss(~100ns),而数组顺序扫描每 16 个 int 才 miss 一次。这就是为什么工业界有那么多”用数组代替哈希表”的优化(比如把小 map 换成排序数组做二分)。

来源三:热点数据(Hot Data)——时间局部性的社会学来源

不是所有数据的局部性都来自代码结构。真实世界的数据访问服从幂律分布(Zipf 分布):微博热搜、爆款商品、顶流主播的直播间,访问量集中在极少数对象上,剩下的长尾几乎没人看。

Zipf 分布的经验规律大致是:第 k 热的对象,访问频率正比于 1/k。这意味着前 1% 的对象可能吃掉 30%~50% 的流量。

这个数字对缓存设计极其重要:它说明缓存不需要很大就能很有效。你只要能精准地把那 1% 挑出来放进去,就能挡掉三分之一的流量。缓存优化的第一优先级不是”做得更大”,而是”识别得更准”。 这是后面所有准入控制策略(TinyLFU)存在的经济学基础。

来源四:结构化数据(Structured Data)——空间局部性的被动来源

结构体、数组、对象——我们把”逻辑上相关”的东西放在”物理上相邻”的位置。读了一个用户的 name,大概率马上要读他的 avatar 和 level;如果这些字段在同一个结构体里,它们很可能落在同一条缓存行上,第一次访问就把整行搬进来,后续字段访问全部免费。

这是空间局部性最”白捡”的来源。但它是把双刃剑——

反例:AoS vs SoA。 假设有个 struct User { int age; char name[64]; char address[256]; },共 324 字节。现在要统计 100 万个用户的平均年龄。你只需要 age 这 4 个字节,但每访问一个用户,缓存都要把 324 字节(约 6 条缓存行)搬进来。 有效数据占比 1.2%,缓存里 98.8% 是被浪费的。

改成 SoA(Structure of Arrays,把结构体拆成几个平行数组):int[] ages; char[][] names; ...,扫描 ages 时只碰 ages 数组,有效容量直接提升几十倍。

这不是理论游戏——列式存储(Parquet、ClickHouse、HBase 的列族)的全部性能优势,本质上就是 SoA 在磁盘/内存层次上的同样优化。 同一种思想,从 CPU 缓存行一路贯彻到数据仓库。多漂亮的统一。

好,现在把四种来源合起来,得出一条可操作的判断规则:

要评估一个系统能不能靠缓存救,就问三个问题:有没有循环(时间局部性)?访问是顺序还是随机(空间局部性)?数据是不是幂律分布(热点)? 三个都答”是”,随便什么策略都有高命中率;三个都答”否”,那你面对的根本不是缓存问题,而是容量问题——加缓存只会让事情更复杂。


子主题七:缓存行与伪共享——空间局部性的”反噬”

空间局部性说”相邻的数据大概率一起被访问”,所以硬件一次搬 64 字节而不是 4 字节。这个设计在单线程下几乎纯赚。

但多核一来,它立刻变成陷阱。

先看基本事实:缓存行(Cache Line)是 CPU 缓存的最小管理单位。x86-64 上是 64 字节(部分 ARM/Apple Silicon 用 128 字节)。缓存的一致性、传输、失效,全部以行为单位。哪怕你只读一个 int(4 字节),硬件也会把包含它的 64 字节整行搬进来。

现在,设想这段代码:

struct Counters {
    long a;  // 线程 0 疯狂自增
    long b;  // 线程 1 疯狂自增
};

a 和 b 在逻辑上毫无关系——两个线程各写各的,谁也不读对方的数据。按常识,这应该是完美的并行,没有任何共享,不需要加锁,性能应该随着核数线性扩展。

但实际测下来可能比单线程还慢 5~10 倍。 为什么?

因为它们物理上落在同一条缓存行里。而一致性协议(下下节讲的 MESI)的工作单位是”行”,不是”变量”。于是:

  1. 核 0 写 a → 它必须独占这一行 → 发消息让核 1 的行副本失效(Invalid)
  2. 核 1 写 b → 它需要这一行 → 发消息让核 0 的行副本失效,自己拿过来
  3. 核 0 再写 a → 行又被核 1 抢走了,再失效一次
  4. 如此往复……

这条缓存行在两个核之间像打乒乓球一样来回搬(cache line ping-pong)。每次搬运是几十到上百纳秒的跨核/跨插槽通信,而自增本身只要 1 纳秒。你为了做 1 纳秒的活,付出了 100 纳秒的通信代价。

这就是伪共享(False Sharing):

逻辑上没有共享,物理上被迫共享。两个无关的写者,因为被打包进同一个”一致性单位”,互相把对方的工作成果作废。

生活化一点:两个人合租一套两居室,各自在自己房间里各干各的,门锁得严严实实,理论上互不干扰。但这套房子的电表是共用的,一个人开空调,另一个人的台灯就闪一下,他得去配电箱把自己的闸推上去——而这一推,又让对方那边断电。两个人明明各过各的,却把全部时间花在了推电闸上。

怎么解决?缓存行填充(Padding)。

思路直白得近乎粗暴:既然冲突是因为挤在同一行,那就把它们撑开到不同行去。

struct alignas(64) PaddedCounter {
    long value;
    char padding[64 - sizeof(long)];  // 填满一行剩下的 56 字节
};
// 或者更简单:alignas(64) 让每个计数器都对齐到行首

Java 里的对应物是 @Contended 注解(JDK 8 在 sun.misc,JDK 9+ 是 jdk.internal.vm.annotation,需要用 -XX:-RestrictContended 才能给用户类用)。java.util.concurrent.atomic.LongAdder 的内部 Cell 类就用了这个注解——不这么做的话,LongAdder 在高并发下会比 AtomicLong 还慢。

C++ 里标准做法是 std::hardware_destructive_interference_size 配合 alignas。Go 的 runtime 里到处是 pad [64]byte(比如 per-P 的调度结构)。Disruptor 的 Sequence 类用 7 个 long 填充到 64 字节——这些都是同一个套路。

但要分清两个层次的问题,别乱用 padding:

层次一:伪共享——两个线程写两个不同的变量。 用 padding 撑开,问题解决。这是”消除虚假的依赖”。

层次二:真共享——N 个线程写同一个变量。 这时候 padding 一点用都没有,因为你没法把”同一个变量”拆成两个。真正的解法是分片(Sharding):

AtomicLong 是所有人抢一个厕所;LongAdder 是给每个线程发一个专属厕所(Cell),读数的时候把所有厕所的数字加起来。

看出来了吗?LongAdder = 分片 + 每个分片 padding。 先分片消除真共享(用求和换并行度),再 padding 消除伪共享(防止相邻 Cell 落一行)。两招叠加,才有了 LongAdder 在高并发下比 AtomicLong 快一个数量级的表现。

padding 不是免费的,别滥用。 每个计数器从 8 字节膨胀到 64 字节,意味着同样的缓存容量只能装 1/8 的数据——你用缓存容量换并行度。只对满足这两个条件的数据做 padding:① 被多个核高频写(只读不写不会有失效风暴,因为 MESI 的 Shared 状态可以多个核同时持有);② 写频率高到通信开销已经超过计算开销。

反过来,还有一个”过度 padding 综合征”:给每个字段都加上 padding,结果对象的内存 footprint 暴涨,cache miss 率飙升,整体反而更慢。空间局部性的收益和伪共享的代价,是在同一个 64 字节上做权衡——这是一枚硬币的两面。

最后留一个思考:AoS vs SoA 和 伪共享,其实是同一个 tradeoff 的两个方向。 SoA 说”把会一起访问的字段放一起”(利用空间局部性);padding 说”把会被不同核写的变量分开”(规避伪共享)。什么时候聚、什么时候散?判断标准只有一个:访问者是不是同一个。 同一个线程顺序访问 → 聚;不同核并发写 → 散。


子主题八:多级缓存的包含策略——L1 的数据一定在 L2 里吗

这个问题你平时不会想,但它决定了几件很实际的事:总有效容量有多大、一致性检查要查几层、淘汰一个数据时要不要”回头”去清理内部。

三种策略:

1. Inclusive(包含):内层是外层的子集。L1 ⊆ L2 ⊆ L3。

优点非常实在:一致性检查只需要看最外层。 别的核要找一份数据有没有副本,只需查 L3(L3 有,说明内层可能有,再往里追;L3 没有,内层一定没有,直接结束)。所以 L3 天然充当了嗅探过滤器(snoop filter),能砍掉大量无意义的一致性广播。Intel 的服务器级处理器(Skylake-SP 等)长期用 inclusive L3,就是为了这个。

淘汰路径也简单:L3 淘汰一个行,需要**反向失效(back-invalidation)**把它从 L1/L2 里也踢掉,保证包含关系不被破坏。

缺点:容量浪费。 一份数据在 L1、L2、L3 各存一份,L3 的几十 MB 里有一部分是内层数据的冗余副本。你的”总有效容量”其实只有 L3 那么大,而不是 L1+L2+L3。

2. Exclusive(互斥):一个数据只存在于一层。

优点:总有效容量 = L1 + L2 + L3 全部用满,一分不浪费。老 AMD(Bulldozer 等)用过这个设计。

缺点很烦:L1 miss 时不能在 L2 里留副本,得把数据从 L2 搬到 L1(L2 腾出的位置再被 L1 淘汰下来的数据填),每次 miss 都是一串连锁的搬运操作,数据通路复杂、延迟路径长。而且一致性检查要逐层查,没法用外层当 filter。

3. Non-Inclusive Non-Exclusive(NINE,非包含非互斥):不保证包含,也不保证互斥。

这是现代主流——Intel 客户端处理器(Skylake 起)和 AMD Zen 的 L3 都是 NINE。它留了冗余的可能性,但不强制。

为什么它能成为主流?因为它拿到了 inclusive 最关键的好处(L3 里仍保留大部分行的目录信息,可以当近似的 snoop filter 用,虽然不是精确的),又不必付出”强制冗余”的容量代价。工程上这是典型的**“用一点精度换大量容量”**——而这个取舍方向,我们在 Count-Min Sketch、Bloom Filter 里已经见过一次了。

策略总有效容量一致性检查淘汰复杂度典型代表
Inclusive= 最外层容量查最外层即可(精确 filter)需反向失效Intel Skylake-SP L3
Exclusive= 各层之和逐层查需层间搬移老 AMD Bulldozer
NINE介于两者之间近似 filter较低AMD Zen、Intel 客户端

这套东西和分布式缓存有一一对应的关系,特别值得对照:

你的”本地进程内缓存 + Redis”是什么关系?绝大多数人没想过这个问题,但它决定了失效逻辑有多难写。

  • 如果你保证”本地缓存的数据一定也在 Redis 里”(inclusive),那么失效就极其简单:只删 Redis 的 key,再通过广播(Pub/Sub 或 MQ)通知所有实例删本地缓存。 失效路径唯一、可集中审计、出问题时有单一排查入口。你用一点内存冗余,买到了”失效路径的唯一性”。 这是绝大多数系统的实际选择,而且是对的。

  • 如果你为了省内存搞 exclusive(本地缓存直接从 DB 加载,Redis 只存另一批数据),那你就得为”只存在于本地缓存里的那批 key”单独设计一套失效广播机制,而且这批 key 没有任何集中的地方能查到。复杂度和出错概率都上去了。

结论很干脆:在分布式缓存里,inclusive 几乎总是赢。因为缓存之间的一致性成本(人写的代码、出的 bug、排查的时间)远高于内存成本(几 GB 内存的钱)。 CPU 里这个权衡是反过来的(硬件实现成本 vs SRAM 面积),所以 CPU 走了 NINE/exclusive,分布式走了 inclusive。同一个问题,因为”什么更贵”不同,答案相反。 这就是体系结构的魅力。

还有一个 CPU 和分布式的共同坑,值得单独拎出来说:

inclusive 依赖”外层淘汰时反向失效内层”。那 Redis 淘汰一个 key 的时候,会通知你的本地缓存吗?

不会。 Redis 的淘汰(maxmemory 触发)是静默的——它自己把 key 删了,不会发任何事件。你的本地缓存里那个 key 的副本就此变成孤儿,一直活到 TTL 到期。

这就是为什么本地缓存必须配一个很短的 TTL 作为兜底,而不能纯靠失效广播。广播解决”快速一致”,TTL 解决”广播失败时的最终一致”。可靠的系统永远是”通知 + 超时”双保险,任何一层单独都不够。


子主题九:替换策略的深水区——LRU 为什么会输给一次全表扫描

子主题三讲过 LRU 的直觉。现在把它拆到底:怎么实现、为什么会被打爆、工业界怎么救。

LRU 的实现:哈希表 + 双向链表

教科书实现:

  • 哈希表:key → 链表节点,O(1) 定位
  • 双向链表:维护访问顺序,头 = 最近使用,尾 = 最久未用
  • get(key):哈希定位 → 把节点移到链表头
  • put(key, val):新节点插到头 → 超容量就删尾节点 + 删哈希表项

教科书说这是 O(1)。但”O(1)“这个说法掩盖了一个致命的事实:

LRU 把”读操作”变成了”写操作”。

每次 get 都要修改链表指针(改 4 个指针 + 改头节点)。在内存层面,这意味着每次读缓存都要写内存。在多线程层面,这意味着读操作必须持有写锁,所有读请求串行化——你精心设计的无锁读路径、读写分离、RCU,全被一个 LRU 链表毁掉了。

这是 LRU 在工程中真正的成本,比算法复杂度重要得多。 一个每秒百万 QPS 的缓存,光是”每次读都要抢一把全局锁去挪链表”就够你喝一壶。

所以工业界的第一反应不是”优化链表”,而是干脆不要精确的 LRU:

Redis 的做法——采样近似 LRU。 Redis 不为所有 key 维护链表(那要维护一个巨大的字典 + 链表,内存和锁开销都受不了)。它只在每个对象的 lru 字段里记一个时间戳(24 bit 的 LRU_CLOCK,精度约 1 秒),淘汰时随机采样 N 个 key(maxmemory-samples,默认 5),把其中 idle 时间最长的踢掉。Redis 3.0 之后加了 eviction pool(一个 16 候选的池子,跨多次采样累积候选并按 idle 排序),效果进一步逼近真 LRU。

用随机采样近似全局最优。 你算一下就知道有多划算:采样 5 个,命中率损失通常只有一两个百分点,但省掉了全局链表的内存和锁。这是”近似算法”思想在缓存领域最经典的一次落地——和我们在 HyperLogLog、Count-Min Sketch 里见到的是同一种世界观。

LRU 的致命伤:顺序扫描污染

现在看 LRU 真正被打爆的场景。

假设你的缓存能装 2000 个页面,其中有 1000 个是真正的热数据(被反复访问)。现在有人跑了一次全表扫描:SELECT * FROM huge_table,顺序访问 10 万个页面。

这 10 万个页面每个都只被访问一次,但它们的”最近访问时间”是最新的。按 LRU 规则,它们会一个一个把真正的热数据挤出去。等扫描结束,缓存里 100% 是再也不会被访问第二次的垃圾。

命中率从 99% 直接掉到 0,然后需要很长时间重新”爬”回来。这段恢复期就是业务能感知到的抖动——监控图上那个突刺。

类比:图书馆把最常借的书摆在门口的架子上,按”最近被谁翻过”排序。某天来了个写论文的学生,为了找资料,把书库里十万本书按编号从头到尾翻了一遍。管理员忠实地执行”最近翻过的最重要”→ 门口最显眼的位置,现在摆满了《××县土壤志(1987)》这类从没人看过的书,而热门书全被挤进了地下室。接下来一个月,每个读者都要等管理员去地下室跑一趟。

这就是 LRU 被扫描打爆的样子。

问题的本质是什么?LRU 只记录”最后一次访问的时间”,它无法区分”访问过一次”和”访问过一千次”。 在 LRU 眼里,一个刚被扫描进来的一次性数据,和一个被访问了一万次的热数据,只要前者更晚被摸过,前者就赢。

这是信息量的缺失,不是算法实现的缺陷。 想救,就得给淘汰决策喂更多的信息——这就是为什么后面所有变体都在”增加信息维度”。

救法一:分段 LRU(真实落地在 MySQL InnoDB)

MySQL InnoDB 的 buffer pool 用的就是分段 LRU,而且它的参数暴露得很直白,值得背下来:

  • LRU 链分成 young(5/8,热端) 和 old(3/8,冷端) 两段,分界点由 innodb_old_blocks_pct 控制(默认 37)
  • 新读入的页面不是插到链表头,而是插到 old 区的头部(midpoint insertion)
  • 一个页面在 old 区停留超过 innodb_old_blocks_time(默认 1000ms)后再次被访问,才晋升到 young 区头部

看懂这个”1000ms”的杀伤力了吗? 一次全表扫描顺序读每个页面,同一个页面在扫描中只会被访问一次(或者即使被多次访问,间隔也远小于 1 秒)。所以扫描数据永远待在 old 区,熬不过那 1 秒的门槛,升不到 young 区,热数据被完整保护住了。

这就是 LRU-K(K=2)思想的教科书级工程实现,而且它就跑在你每天用的 MySQL 里。

救法二:LRU-K 与 2Q——把”访问次数”纳入决策

LRU-K:为每个对象记录最近 K 次访问的时间戳,用”倒数第 K 次访问的时间”作为优先级(称为 K-distance)。

  • K=1 时,退化成标准 LRU
  • K=2 意味着”至少被访问过两次的数据才值得留下”。一次性的扫描数据 K-distance 是无穷大,最先被踢

优点:能干净地区分一次性和重复性访问。缺点:要为每个对象维护 K 个时间戳的历史,而且淘汰时要在一堆 K-distance 里找最小值——需要一个优先队列,O(log n) 且内存开销大。工程上几乎不会直接用。

2Q:LRU-K 的实用近似,用两个队列换掉优先队列:

  • A1(FIFO 队列):首次访问进入。只记 key(或记数据但容量小)。FIFO 在这里不是偷懒,而是用”入队时间”廉价地近似”首次访问距离”
  • Am(LRU 队列):在 A1 里被再次命中的对象,升级到 Am,这里才是受保护的正式缓存

用一个 FIFO 就把 O(log n) 压回了 O(1),效果却接近 LRU-K。工程上的漂亮妥协。

救法三:ARC——会自己调参数的缓存

**ARC(Adaptive Replacement Cache,IBM,2003)**的思路让人拍案:我不知道该信最近性(recency)还是频率(frequency),那就让缓存自己学。

它维护 4 个链表:

  • T1:只见过一次的(recency 侧的实际缓存)
  • T2:见过多次的(frequency 侧的实际缓存)
  • B1:从 T1 被淘汰的 ghost,只记 key 不记数据
  • B2:从 T2 被淘汰的 ghost

关键在 ghost 列表。它们提供的是反事实信息:

  • 来了一个新请求,命中了 B1(说明”这东西当初不该被踢”)→ 证据表明 T1 太小了 → 把 T1 的目标大小 p 调大
  • 命中了 B2 → 证据表明 T2 太小了 → 把 p 调小

用”如果当初没淘汰它会怎样”这个假设的反馈,在线调节两个策略的权重。

这个思想非常值得记住:缓存是一个在线学习系统。 你不需要先验地知道访问模式是”重最近”还是”重频率”,让”淘汰决策的后悔程度”来驱动参数就行。而且 ARC 是 O(1)、无需人工调参、天然抗扫描(扫描数据进 T1,被淘汰后 ghost 命中率一直很低,参数不会往 T1 倾斜,也就挤不走 T2)。

它的缺点是专利(IBM)和实现复杂度(4 个链表的一致性维护),所以开源界用得不如 W-TinyLFU 广。

救法四:TinyLFU 与 W-TinyLFU——准入控制比淘汰更重要

TinyLFU 换了一个根本性的角度:

与其纠结”缓存满了踢谁”,不如在门口就决定”你配不配进来”。

它用一个极小的**频率草图(Count-Min Sketch,用 4 位左右的计数器,每个 key 约 0.5 字节)**记录访问频率。当新对象想进来时,拿它的频率和”将被淘汰的受害者”比,频率低就直接拒绝准入。

这一步为什么有效?因为扫描数据的频率是 1,热数据的频率是几十上百——准入控制能在污染发生之前就把它挡在门外,根本不给它污染缓存的机会。这是”预防”和”治疗”的区别。

关键创新是”老化(aging)”。 纯 LFU 最大的问题是历史包袱:一个三个月前爆火、现在没人看的 key,累计计数高达十万,会永远赖在缓存里,把今天的新热点挡在外面。缓存得有”遗忘”的能力。

TinyLFU 的做法极其简单粗暴:定期重置。每发生 W 次访问(W 通常设为缓存容量的 10 倍),就把草图里所有计数器减半(或直接清零)。

用”滑动窗口的近似”代替精确计数——一次除法操作,就把 LFU 的历史包袱卸掉了。这个手法我们在指数衰减、滑动窗口计数里都见过:遗忘不需要精确,只需要有。

W-TinyLFU(Caffeine 的实现,目前 Java 界事实标准)补上了 TinyLFU 的一个短板:TinyLFU 对突发的新热点反应迟钝(新热点频率还没攒起来,进不来)。所以它在前面加了一个小的 Window LRU:

请求 → Window LRU(约 1%,接住突发流量)
         ↓ 淘汰候选
      TinyLFU 准入仲裁(比频率,赢的留下)
         ↓
      Main 区(SLRU 分段 LRU):
        Probation(约 20%,试用段)
           ↕ 再次命中则晋升 / 淘汰则降级
        Protected(约 80%,保护段)

三层结构,每层解决一个具体问题:Window 抗突发、TinyLFU 抗污染、SLRU 提供频率保护。实测命中率在多种真实 trace 上显著优于 LRU 和 ARC,能逼近理论最优(Belady 算法)的 90% 以上。

到底该选哪个

场景特征推荐策略理由
有明显热点、无批量扫描LRU,甚至随机替换简单策略已经够用,别过度设计
有周期性全量扫描/批处理分段 LRU(InnoDB 思路)/ 2Q必须抗污染
访问模式未知或随时间变化ARC自适应,无需调参
读极多、内存紧张、扫描频繁W-TinyLFU准入控制 + 频率记忆,命中率最高
并发极高、实现成本敏感采样近似 LRU(Redis 思路)用一点命中率换掉锁和内存

最后一句狠的:选替换策略之前,先确认你的问题真的是替换策略的问题。 很多时候命中率低是因为缓存太小(容量问题,加内存解决)、key 设计不合理(粒度问题,拆分 key 解决)、或者根本没有局部性(架构问题,换思路)。用 TinyLFU 去救一个”访问完全随机”的系统,就像用更好的渔网去捞一片没有鱼的沙漠。


子主题十:分布式缓存的四层拓扑——客户端、CDN、本地、Redis

前面讨论的替换策略和一致性,主要是在”一层缓存”内部。真实的互联网系统从来不止一层——是四层叠起来的。

从用户到数据,依次是:

用户 ──→ ① 客户端缓存(浏览器 / App 本地)
      ──→ ② CDN(边缘节点)
      ──→ ③ 本地进程内缓存(Caffeine / Guava / Ehcache)
      ──→ ④ 分布式缓存(Redis / Memcached)
      ──→ ⑤ 数据库

四层各自的特征差别极大,必须量化地看清:

层典型容量访问延迟共享性一致性难度典型命中率
① 客户端缓存MB ~ 百 MB(单设备)~0(本地磁盘/内存)单用户独占★★★★★ 副本发出去就收不回单用户视角很高,全局视角低
② CDNTB 级(全网)10~50ms(省掉的是回源 RTT)地域共享★★★★ 只能靠 TTL + 主动刷新静态资源 90%+,动态 <30%
③ 本地进程内百 MB ~ 几 GB / 实例~100ns ~ 1μs单实例独占★★★ 跨实例不一致60~90%
④ 分布式 Redis几十 GB ~ TB0.2~1ms(同机房 RTT)全局共享★★ 单点权威,可精确失效80~95%
⑤ 数据库TB5~50ms全局— 权威源—

洞察一:多层是相乘的,不是相加的

四层串联时,最终打到数据库的流量 = 总流量 × 各层 miss 率的乘积。

算一笔账:假设本地缓存命中 70%,Redis 对剩下的流量命中 90%:

打到本地缓存:100% → 命中 70%,miss 30%
    ↓ 30%
打到 Redis:命中 90% → 拦下总流量的 27%,miss 3%
    ↓ 3%
打到数据库:总流量的 3%

每加一层,后端压力就降一个数量级。 这就是为什么”多级缓存”不是一个可选项,而是高 QPS 系统的必然结构。

洞察二:越靠近用户,命中率越低,但收益越大

这看起来矛盾,其实很好理解:

  • 客户端缓存命中率最低(单用户重复访问同一资源的概率不高),但它省掉的是整个网络往返 + 服务端全部处理,收益最大
  • Redis 命中率最高,但它省掉的只是”一次 DB 查询”

所以这四层不是”选一个最优的”,而是”叠加”——每一层拦截不同规模、不同性质的流量,各司其职。

洞察三:层的”共享性”决定了一致性难度——这是最关键的一条

看表格倒数第二列,一致性难度从上到下递减。为什么?

因为”共享性”决定了你能不能精确失效。

  • Redis 是全局唯一的权威副本。所有实例读同一份,你删一次,所有人都看不到旧值了。它是”一致性的锚点”。
  • 本地缓存是单实例独占的。Redis 里删了,10 台机器的内存里还有 10 份旧值。你要么广播(Pub/Sub、MQ)通知它们删,要么等 TTL。
  • 客户端缓存和 CDN 最惨——副本已经发到用户设备/边缘节点上了,你根本收不回来。 你连”哪些用户缓存了旧版本”都不知道,更别说主动清除。所以只能靠:
    • TTL(被动过期,最常用)
    • 版本号 / ETag / Last-Modified(让用户下次带上来校验,返回 304)
    • 文件名哈希(cache busting):app.a3f9c2.js,内容变了文件名就变,等于强制失效。这是前端最可靠的失效手段
    • CDN 主动 purge:厂商提供 API,但通常有分钟级延迟且不保证 100% 生效

由此得出一条分层的一致性分工原则:

能容忍”过期几秒”的 → 放 CDN 和客户端(靠 TTL 兜底) 需要精确失效的 → 放 Redis(唯一权威副本,可主动删除) 极热且能容忍”过期几秒钟”的 → 放本地缓存(靠短 TTL + 广播双保险)

洞察四:本地缓存是最危险的一层

它命中率高、延迟极低,是性能利器,但一致性最差。这个矛盾必须用一个数字量化清楚:

一致性窗口 = 本地缓存的 TTL 代价 = 回源到 Redis 的 QPS

举例:一个 key 的全局 QPS 是 10 万。

  • 本地缓存 TTL = 5 分钟 → 每实例每 5 分钟回源一次 → 10 台机器 → Redis QPS ≈ 0.3
  • 本地缓存 TTL = 5 秒 → 每实例每 5 秒回源一次 → 10 台机器 → Redis QPS = 200

如果你想把一致性窗口从 5 分钟压到 5 秒,Redis 的 QPS 涨了几百倍。

这笔账你必须自己算清楚。没有免费的午餐:一致性和性能在本地缓存这一层是直接用 TTL 兑换的。 而且注意这个公式和机器数成正比——你的实例越多,本地缓存的一致性越差(更多人持有副本),这是水平扩展带来的隐藏代价。

两个常见错误

错误一:只上 Redis 不上本地缓存。

理由是”Redis 已经够快了”。但 Redis 再快也要走一次网络 RTT(0.2~1ms)。对于首页配置、活动信息、爆款商品详情这类 QPS 十万+ 的极热数据,本地缓存能省掉 99% 的网络往返——这是数量级的差别,不是百分比的差别。 而且它顺带解决了”热点 key 打爆单个 Redis 分片”的问题(本地缓存把绝大部分读挡在了 Redis 之前)。

错误二:本地缓存和 Redis 存同样的东西、配同样的 TTL。

这是假的多级缓存——两层完全重叠,内存翻倍,命中率毫无提升,还多了一层一致性风险。

真正的多级缓存,三要素必须逐层递减配合:

本地缓存Redis
存什么极热的小集合(几千个 key)全量热数据(百万级 key)
TTL秒级(1~10s)分钟 ~ 小时级
一致性短 TTL + 失效广播精确失效(写后删除)

上层小、快、短;下层大、慢、长。 这样两层才形成互补,而不是重复。


子主题十一:一致性协议的深水区——MESI 与分布式缓存的思想对照

分布式缓存的一致性我们聊过了(cache-aside、写后失效、TTL 兜底)。现在看另一个层次:CPU 是怎么在硬件层面做缓存一致性的? 看完你会发现,思想惊人地相似,而差异的原因更有意思。

MESI:四种状态

MESI 是 x86/ARM 上最经典的一致性协议。每条缓存行有 4 种状态:

状态含义与内存一致?其他核有副本?
M Modified已修改(脏)✗ 不一致✗ 独占
E Exclusive独占(干净)✓ 一致✗ 独占
S Shared共享(干净)✓ 一致✓ 可能多个
I Invalid无效——

状态流转(挑最关键的几条):

  • 读 miss(I) → 发 BusRd(总线读)→ 如果没人有,从内存取,进 E;如果别人有,从别人的缓存/内存取,双方都进 S
  • 写 E → 直接改成 M,不需要任何总线事务(因为独占,没人需要被通知)。这是 E 状态存在的全部价值:省掉一次广播
  • 写 S → 发 BusRdX(总线读并独占)或 BusUpgr(升级)→ 所有其他核的副本全部置 I → 自己进 M
  • 别人要读我的 M → 我先把脏数据写回内存,然后双方变 S(MESI 要求写回;MOESI 加了个 O(Owned) 状态,允许脏数据在多核间共享而不必立刻写回内存,由 O 状态的持有者负责最终写回——AMD 用这个)

看懂伪共享的根源了吗? 核 0 写 a(S→M,广播让核 1 失效),核 1 写 b(核 0 的 M 被抢走,失效),循环往复。每一次”写共享态的行”,都要广播一次失效。 这就是那 100 纳秒的来源。

从嗅探到目录:规模问题的经典解法

MESI 的广播依赖”所有核共享一条总线,都能看到所有事务”——这叫总线嗅探(Snooping)。

核少的时候很好。但核数上到几十上百,总线就成了瓶颈:每个事务都要广播给所有人,带宽和功耗都撑不住。

解法是目录协议(Directory-based):用一个集中的目录记录”每个缓存行被哪些核持有”。要失效的时候,只给真正持有副本的那几个核发消息,而不是广播给所有人。

这个转变,和分布式系统里的转变一模一样:

  • CPU:总线嗅探(广播)→ 目录协议(精确路由)
  • 分布式:Redis keyspace notification 全量订阅(广播)→ Canal 订阅 binlog,按 key 路由给相关的应用实例(精确投递)

同一条规律:规模一大,广播就必须退化成精确的路由。 中间那层”目录/路由表”,就是规模化的代价。

一个必须澄清的经典混淆:一致性 ≠ 顺序

这是很多五年经验工程师都会踩的坑:

MESI 保证的是「缓存一致性(Cache Coherence)」,不是「内存一致性/内存模型(Memory Consistency / Memory Model)」。

  • Coherence(一致性):对同一个内存地址,所有核看到的写操作顺序是一致的(不会出现核 0 看到 x=1 然后 x=2,核 1 看到 x=2 然后 x=1)
  • Consistency(内存模型/顺序):对不同内存地址的写,其他核以什么顺序看到

MESI 完美地保证了前者,但对后者什么都没保证。

原因是现代 CPU 为了性能加了两样东西:

  • Store Buffer(写缓冲):核 0 执行 x = 1 时,不会傻等到失效广播完成,而是先把写丢进 store buffer 就继续执行下一条指令
  • Invalidate Queue(失效队列):核 1 收到失效消息时,不会立刻处理,而是丢进队列,回个 ACK 就继续干活,稍后才真正处理

这两个优化让 MESI 的”强一致”在时间上被延迟了。结果就是经典的乱序观察:

// 初始 x = 0, y = 0
// 核 0                    // 核 1
x = 1;                     y = 1;
r1 = y;                    r2 = x;
// 结果可能 r1 == 0 且 r2 == 0 —— 两个核都还没"看见"对方的写

这在顺序一致性下是不可能的,但在 x86(TSO 模型)下真实会发生。

这就是内存屏障(Memory Barrier)存在的理由:mfence 排空 store buffer,lfence 排空 invalidate queue。Java 的 volatile、C++ 的 std::atomic(默认 memory_order_seq_cst),底层就是这些指令。

这条结论太重要了,值得单独框起来:

缓存一致性协议不解决可见性的”时机”问题,它只解决”最终值”问题。可见性时机由内存模型规定,靠内存屏障来强制。

与分布式缓存一致性的思想对照

现在把两个层次并排放:

维度CPU 缓存(MESI + 目录)分布式缓存(Redis + 应用)
一致性目标强一致:任意时刻单地址全局有序最终一致:允许短暂不一致窗口
两大充要条件写传播 + 事务串行化(两者都严格保证)写传播靠失效广播,串行化基本放弃
传播方式总线嗅探 → 目录协议(点对点)全量订阅 → binlog 按 key 路由(点对点)
传播延迟纳秒级(几十 ns)毫秒 ~ 秒级(差 5~6 个数量级)
失效单位缓存行(64B),硬件自动key,应用代码显式发起
失败模型核不会”分区”,硬件不坏实例会挂、网络会分区
兜底手段不需要(协议本身是完备的)必须靠 TTL 兜底(协议不完备)
透明性完全透明,程序员写代码时感知不到缓存存在完全显式,每个读都要写 miss 回填逻辑

为什么 CPU 能做到强一致,分布式做不到?三个物理原因:

  1. 延迟量级不同。 跨核通信几十纳秒,“每次写都同步广播失效”可以接受。分布式是毫秒级——如果每次写都要等所有副本确认,写延迟会涨 1000 倍,没有人能接受。
  2. 规模不同。 CPU 几十个核,目录能精确追踪每行的持有者。分布式上千个实例,追踪每个 key 的持有者,元数据本身就比数据还大。
  3. 故障模型不同。 CPU 核永远在线、永远可达。分布式必须容忍部分不可达——而”所有副本都确认”这个要求和”容忍部分不可达”在数学上就是冲突的(这正是 CAP 的物理根源,不是设计偷懒)。

所以:分布式放弃强一致,不是因为工程师懒,是因为物理约束不同。 理解这一点,你就不会再纠结”为什么不能做一个强一致的分布式缓存”了。


子主题十二:预热与降级——缓存上线与崩溃时的实操

前面所有讨论都假设缓存”正常工作”。现实里最要命的两个时刻恰恰是:它刚启动(空的) 和 它挂了。

冷启动空窗期:缓存是空的时候会发生什么

场景:新集群上线、大促前扩容、误操作 FLUSHALL、Redis 主从全切。

缓存空了 → 所有请求直穿到 DB。而且更糟的是——数据库的 buffer pool 也是冷的(它的内存缓存里也没有这批数据),磁盘 I/O 直接打满。

双重打击:DB 要承受全量 QPS,而这个 QPS 的规模是按”90% 被缓存挡住”来规划的——实际压力是设计容量的 10 倍。同时它自己的缓存也失效了,单次查询从毫秒级退化到几十毫秒。

结果就是经典的冷启动雪崩:DB 被打挂 → 查询超时 → 应用重试 → 压力更大 → DB 彻底起不来,即使重启也立刻再挂。

类比:一家新开的超市,货架全空,所有顾客都挤在后仓门口等着一件件从仓库搬。而仓库自己还没拆包上架。这不是”慢一点”的问题,是整个系统进入无法自愈的死锁。

预热的五种手段

1. 离线热点统计 + 启动时灌入

从历史访问数据(Nginx access log、Redis --hotkeys、DB 慢查询日志、监控埋点)离线算出 top N 热点 key,启动时批量加载。

为什么这招性价比极高? 因为前面讲过 Zipf 分布:top 1% 的 key 往往占 30%~50% 的访问量。你只需要灌入 1% 的数据,就能挡掉三分之一的流量。这是全章最划算的一笔买卖。

2. 灰度放量 / 流量爬坡

新集群先接 1% 流量跑几分钟,让缓存自然填充,再逐步 5% → 20% → 50% → 100%。

这是最稳的做法,本质是把”瞬间全量回源”摊平成”一段时间内的渐进回源”。代价是上线周期变长。大促扩容时这招几乎是标配。

3. 异步刷新(refresh-after-write)

Caffeine 的 refreshAfterWrite 是这里的关键武器。它和 expireAfterWrite 的区别必须分清:

  • expireAfterWrite:过期即不可用 → 请求阻塞等待回源 → 会有抖动
  • refreshAfterWrite:过期后旧值仍可用,返回旧值同时异步刷新 → 请求永不阻塞

对热点数据用 refreshAfterWrite,等于同时解决了击穿(不会有并发回源)和预热(数据始终”热着”)两个问题。这是本地缓存最实用的一条配置。

4. 缓存副本切换(扩容的正确姿势)

扩容 Redis 集群时:先起新集群 → 用旧集群的 RDB/AOF 或主从复制把数据同步过来 → 确认预热完成 → 再切流量。

绝对不要”起个空集群直接切流量”。 这是扩容事故的头号原因。

5. 常驻基线数据

把配置类、字典类、基础元数据设成永不过期(或超长 TTL + 主动刷新),保证缓存里永远有一层”底座”。即使热数据全丢,这层底座还在,能挡住相当一部分流量。

预热的四个坑(每一条都有人踩过)

坑一:预热即雪崩。 批量灌入的 key 用了同一个 TTL → 到期时集体失效 → 你亲手制造了一场雪崩。

预热也必须加随机 TTL 抖动。 这是全章最容易被忘掉的一条。

坑二:预热把热数据挤掉了。 预热量超过 maxmemory,触发淘汰策略,把已经在正常工作的热数据挤走了。预热量控制在容量的 50%~70%。

坑三:预热了旧数据/错数据。 从离线快照灌进去的数据可能是几小时前的。更糟的是——因为缓存里有值,回源逻辑永远不会被触发,这个错误会被”缓存住”,直到 TTL 到期。所以预热后必须有数据校验和快速回滚手段(比如保留一份”预热批次版本号”,出问题能一键清空这批)。

坑四:预热本身打爆 DB。 开几千个线程并发回源 → DB 直接挂。预热必须限速(令牌桶控制回源 QPS)。本质是”用更长的预热时间,换 DB 不被打爆”——预热慢一点没关系,DB 挂了就全完了。

降级:缓存挂了以后怎么办

第一原则,先钉死:

缓存是加速器,不是数据源。 理论上缓存整体挂掉,系统应该”变慢但可用”(所有请求回源),而不是”直接不可用”。

如果你的系统在 Redis 挂掉时直接返回 500 了,说明架构有设计缺陷——你把缓存当成数据库用了。

(例外:如果某些数据只存在缓存里,比如 Session、分布式锁、幂等去重标记,那缓存就是唯一数据源,必须做持久化 + 多副本。这时候它已经是数据库了,只是用了 Redis 这个名字。)

但”变慢但可用”有个致命前提:DB 扛得住全量流量吗?

现实是扛不住——DB 的容量规划本来就是按”90% 被挡住”做的。所以降级本质上是一道算术题:

降级方案的核心问题:放行多少流量回源,DB 才不会挂?

降级策略的六个层级(从轻到重,按序启用):

① 超时与熔断

  • Redis 调用超时设 50~200ms(绝不能设几秒——否则所有线程都被拖在等待上,线程池瞬间耗尽,整站雪崩)
  • 错误率超阈值(如 50%)触发熔断,快速失败而不是等待
  • 熔断后进入半开状态,定期放一个请求试探恢复

② 限流 / 削峰

  • 只放行 DB 能承受的 QPS(比如平时的 20%),其余请求快速失败或排队
  • 铁律:宁可拒绝一部分,也不要全挂。 部分可用 > 全部不可用

③ 降级到本地缓存

  • Redis 挂掉时,本地缓存(Caffeine/Guava)充当最后的缓冲区
  • 虽然数据旧、命中率只有 30%~50%,但能挡住相当一部分流量
  • 这是多级缓存在故障时刻的真正价值——它不只是性能优化,它是一层容灾设施。

④ 返回兜底数据

  • 静态兜底页、默认推荐列表、上一次成功结果的快照(存本地文件/对象存储)
  • 有损服务 > 无服务

⑤ 功能降级

  • 关闭非核心功能:推荐、个性化、排行榜、评论数、浏览量
  • 只保核心链路:商品详情、加购、下单、支付
  • 这是”丢卒保车”,需要在代码里预埋开关(feature flag / 降级开关)

⑥ 写路径降级

  • 缓存写失败不能让整个写请求失败
  • 做法:写 DB 成功即返回,缓存失效失败就**记录到补偿队列(MQ 或本地磁盘日志)**异步重试
  • 这是最终一致的兜底,也是”写后删除缓存”这个方案必须要有的保险

三个概念必须分清

概念定义触发方式
熔断 Circuit Breaking检测到下游故障后主动断开,快速失败被动(基于错误率/超时统计)
限流 Rate Limiting主动限制通过的流量主动(按配额)
降级 Degradation用有损的方式继续服务主动(开关)或被动(熔断触发)

三者通常串起来:熔断触发 → 进入降级逻辑 → 降级逻辑里对回源限流。

一个反直觉的提醒:最危险的不是读,是写

大家都盯着”读回源会不会打爆 DB”,但真正致命的是:

如果写路径依赖缓存做「分布式锁」或「幂等去重」,缓存一没,超卖和重复扣款就来了。

这类问题是正确性问题,不是性能问题,而且会造成资损,比服务不可用严重得多。

所以有一条原则值得单独背下来:

不要把正确性建立在缓存上。 分布式锁要用可靠的方案(且要有过期和 fencing token); 幂等要靠数据库的唯一索引; 缓存只做加速,不做裁决。

缓存可以是系统的加速器,但永远不能是系统的裁判。


子主题十三:终极对照——CPU 缓存体系 vs 分布式缓存体系

最后把整章串起来。你会发现这两个层次,是同一套思想在两种不同物理约束下的两次实现。

结构对照表

维度CPU 缓存体系分布式缓存体系
最内层L1(32~64KB,~1ns,单核私有)进程内缓存(几百 MB,~100ns,单实例私有)
中间层L2(256KB~1MB,~4ns,单核私有)本地 SSD 缓存 / sidecar(可选层)
共享层L3(几~几十 MB,~10-40ns,多核共享)Redis 集群(几十 GB~TB,~0.2-1ms,全局共享)
权威层主存 DRAM(GB,~100ns)数据库(TB,~5-50ms)
更远层SSD / 磁盘(~100μs ~ 10ms)对象存储 / 离线数仓(~100ms+)
传输粒度缓存行 64 字节Key-Value / 对象(几十 B ~ 几 MB)
局部性时间 + 空间(地址邻近,白捡)时间 + 空间需人工设计
替换策略伪 LRU / RRIP(硬件,纳秒预算)LRU 采样 / W-TinyLFU(软件,微秒预算)
一致性MESI/MOESI + 目录,强一致TTL + 失效广播,最终一致
透明性完全透明,程序员无感知完全显式,每个读都要写回填逻辑
失效传播总线/目录,纳秒级Pub/Sub / MQ / Canal,毫秒~秒级
失败语义缓存不会”不可用”(硬件不坏)缓存会挂、会分区 → 必须降级
分层动机SRAM 快而贵 / DRAM 慢而便宜内存快 / 网络慢 / 磁盘更慢

看出来了?每一行的左半边和右半边,在”结构”上几乎完全一致。 这就是开篇那个问题的答案:因为约束一样(快而小 vs 慢而大),所以解法一样(分层 + 局部性)。

三个深层差异(比相似性更有价值)

差异一:为什么 CPU 透明,分布式显式?

CPU 缓存对程序员完全不可见。你写代码时从不关心某个变量在不在 L1,也不需要在读内存前先”回填”一下——硬件全干了。它给你一个承诺:有缓存的执行结果,和没有缓存时逐条等价。

分布式缓存完全显式。每个读请求你都得写:查缓存 → miss → 查 DB → 回填 → 返回。每个写请求你都得想:先删缓存还是先写库?并发了怎么办?

为什么不能让分布式缓存也自动透明? 因为”透明”要求硬件能拦截所有访问路径并保证语义等价——CPU 有这个能力(所有内存访问都走同一条指令路径,硬件能全看到)。但在分布式系统里,“数据访问”散落在成千上万行业务代码里,没有任何一个统一的拦截点能全看到。你只能让应用自己管。

结论:透明性来自”统一的访问路径”。CPU 有,分布式没有。 这就是为什么 ORM 的二级缓存、Spring 的 @Cacheable 这类”半透明”方案总是有各种坑——它们在试图用一个不完整的拦截点,去模拟一个完整的拦截点。

差异二:为什么 CPU 用简单策略,分布式用复杂策略?

L1 用的是伪 LRU(每行一个 bit 的 tree-PLRU)或 RRIP(2 bit),简单到近乎粗暴。而分布式缓存可以跑 W-TinyLFU 这种三层结构 + 频率草图的复杂算法。

原因是时间预算:

  • L1 一次命中只有 ~1ns(约 3~4 个时钟周期)。替换决策必须在纯组合逻辑里几拍内完成,根本没有时间跑算法
  • Redis 一次访问 ~500μs(含网络往返)。软件有充足的时间跑复杂逻辑,W-TinyLFU 的全部决策开销在微秒级,占比可以忽略

结论:替换策略的复杂度上限,由”一次缓存访问的延迟预算”决定。

顺着这条往下推,会得到一个非常漂亮的规律:

越快的缓存,替换策略越简单;越慢的缓存,替换策略越复杂。

因为越快,决策开销占总延迟的比例越高,你越”跑不起”复杂算法。这条规律可以解释整个缓存技术栈的策略分布——从 L1 的一位伪 LRU,到 Redis 的采样 LRU,到 Caffeine 的 W-TinyLFU,再到 CDN 用离线大数据分析做内容预取(CDN 的延迟预算是小时级,所以它能用最重的算法)。

差异三:分布式缓存缺失了”空间局部性”这一半——最容易被忽略

这是全章最值得带走的一条。

CPU 缓存靠”地址邻近”免费获得空间局部性:读一个 int,硬件顺手把相邻 60 字节也搬进来,后续 15 次访问全免。还有硬件预取器自动识别步长、提前搬运。

Redis 呢? key 是字符串。user:1001 和 user:1002 在物理上毫无邻近性——它们被哈希到不同的槽,很可能落在不同的分片、不同的机器上。你要取 1000 个用户,就得发 1000 次网络请求。

所以:分布式缓存天然只有时间局部性,没有空间局部性。

这个差异导致两个推论:

推论 A:CPU 里的”顺序扫描”能被预取器部分救回来;分布式里的”批量扫描”是纯粹的污染,没有任何自动机制能救。 这也解释了为什么分布式缓存对抗污染的动机比 CPU 强得多——它用的替换策略(W-TinyLFU)比 CPU 的复杂那么多。

推论 B:分布式缓存里的空间局部性,是设计出来的,不是白捡的。 怎么设计?

  • MGET / Pipeline —— 相当于”一次取一整条缓存行”。把 1000 次 RTT 压成 1 次
  • 聚合 key(Hash 结构) —— 相当于”把结构体的相邻字段打包在一起”。一个 user:1001 的 hash 里放 name/age/avatar/level,一次 HGETALL 全拿到
  • 批量接口设计 —— 服务间调用优先提供 batch 接口,而不是让调用方循环调用单条接口(N+1 查询问题在缓存层同样致命)

但这里立刻出现粒度的两难,和 CPU 里一模一样:

  • 聚合 key 太大 → 改一个字段就要传输/序列化/失效整个 hash,浪费带宽(这就是分布式版的”伪共享”)
  • 聚合 key 太小 → 拿不到批量收益,网络往返次数爆炸(这就是分布式版的”有效数据占比低”)

同一个 64 字节的两难,在 Redis 的 key 上又演了一遍。 判断标准也完全一样:看访问者是不是同一批。 总是一起读的字段 → 打包;会被独立更新的字段 → 拆开。

最后一个对照:伪共享 ↔ 热点 key

把这条收个尾,你会发现两章内容是同一个思想的两次出现:

CPU 里:N 个核同时写一个计数器 → 全抢同一个缓存行 → 一致性风暴 → 性能崩塌。 解法:分片(LongAdder 给每个线程一个 Cell)+ padding(Cell 之间撑开到不同行)→ 读时求和。

分布式里:某个爆款商品的 key QPS 达到 10 万 → 所有请求都打到同一个 Redis 分片(单分片的 QPS 上限通常几万)→ 这个分片成为瓶颈,而其他分片闲着。 解法:拆分热 key —— 把 item:1001 拆成 item:1001:0 ~ item:1001:15 共 16 个子 key,用哈希/随机分摊到不同分片;读时随机选一个子 key(或聚合求和)。

完全同一个思想:把一个争用点拆成 N 个,用”求和”换”并行度”。

在 CPU 里,代价是”读的时候要遍历所有 Cell 求和”;在分布式里,代价是”读的时候要多发一次请求或做一次聚合”。两者都是用一点点读的复杂度,换掉了写路径上的串行化。

看到这里,你应该能回答开篇那个问题了——

为什么”缓存”能在这么多层次上一遍遍被发明出来?因为每一层都面对同一个物理约束(快而小 vs 慢而大),而每一层的上层行为都服从同一个统计规律(局部性)。约束相同,规律相同,解法当然相同。

所谓”计算机领域的通用问题”,说的就是这种:换个介质、换个尺度、换个名字,但骨子里是同一道题。


演进脉络

  • 1960s:虚拟内存和 Cache 的概念出现,LRU 替换策略被提出。
  • 1980s-90s:CPU 多级缓存体系成熟;数据库 buffer pool 成为标配。
  • 2000s:分布式缓存(Memcached)兴起,应用层缓存成为架构标配。
  • 2010s:Redis 成为事实标准,支持丰富数据结构;CDN 大规模普及。
  • 现代:更智能的准入/替换策略(TinyLFU、ARC);多级缓存自动化;缓存一致性方案(binlog 订阅、缓存预热)产业化。
  • 当下前沿:W-TinyLFU 成为应用层缓存的事实标准(Caffeine);硬件侧从嗅探协议转向目录协议以应对众核;伪共享成为高性能库(Disruptor、LongAdder)的标配考量;多级缓存的”包含策略”从硬件设计话题外溢到分布式架构决策。

主线:缓存的每一次演进,都是围绕”提高命中率”和”管理一致性”两条线——前者靠更聪明的策略(从 LRU 到 LRU-K/2Q/ARC/TinyLFU/W-TinyLFU),后者靠更精细的失效协议(从 TTL 到事件驱动)。而这一切的前提,始终是那个朴素的赌注:局部性。

还有一条隐藏主线值得单独点出:决策的”信息量”在不断增加。 LRU 只用”最后一次访问时间”这一个比特的信息;LRU-K 加了”访问次数”;ARC 加了”淘汰后的反事实反馈”;TinyLFU 加了”全局频率的近似统计”。每一次进化,都是给淘汰决策喂进更多维度的信息,让它能更准确地区分”真热”和”假热”。替换策略的历史,就是一部”用更多信息对抗不确定性”的历史。


本章思考题

【思考题】

  1. 缓存为什么能在这么多不同层次上被发明出来?它依赖的根本规律是什么?如果访问模式完全随机,缓存还有效吗?

  2. 为什么不做”一层超大缓存”而要多级缓存?命中率从 90% 提到 99%,为什么影响这么大?

  3. 写路径上,“删除缓存”为什么通常比”更新缓存”更好?

  4. 缓存穿透、击穿、雪崩的区别是什么?各怎么应对?

  5. 程序的局部性从哪来?请举出至少四种来源,并说明各自主要贡献时间局部性还是空间局部性。为什么说”数据结构选型时,cache 友好性往往比渐进复杂度更影响实际性能”?

  6. 什么是伪共享(False Sharing)?为什么两个逻辑上毫无关系的变量,被两个线程各自写入,会拖慢程序?缓存行填充(padding)的原理是什么?它和 LongAdder 的”分片”是同一回事吗?

  7. 多级缓存的 inclusive / exclusive / non-inclusive 三种包含策略各自的取舍是什么?现代 CPU 主流是哪种?如果把这个概念搬到”本地缓存 + Redis”上,为什么 inclusive 几乎总是更好的选择?

  8. LRU 用哈希表 + 双向链表实现,教科书说是 O(1)。这个说法掩盖了什么真实成本?为什么 Redis 不用精确 LRU 而用”随机采样”?一次全表扫描是怎么把 LRU 打爆的?

  9. MySQL InnoDB 的 buffer pool 怎么解决”全表扫描污染热数据”的问题?请解释 innodb_old_blocks_pct 和 innodb_old_blocks_time 这两个参数各自的作用,以及它体现了哪种替换策略思想。

  10. LFU 的”历史包袱”具体指什么?TinyLFU 用什么手段解决它?为什么 W-TinyLFU 要在 TinyLFU 前面加一个 Window LRU?ARC 的”ghost 列表”提供了什么信息、解决了什么问题?

  11. 客户端缓存 / CDN / 本地进程内缓存 / 分布式缓存(Redis)这四层,各自的延迟、共享性、一致性难度有什么差别?为什么”越靠近用户的层一致性越难”?本地缓存的 TTL 和回源 QPS 之间是什么关系?

  12. MESI 四个状态分别是什么含义?MESI 保证的是”缓存一致性(coherence)“还是”内存模型/顺序(consistency)“?既然有 MESI,为什么还需要内存屏障(memory barrier)?

  13. 什么是”冷启动空窗期”?缓存预热有哪些常用手段?预热有哪四个典型坑?缓存整体不可用时,降级的正确顺序是什么?为什么”最危险的不是读,而是写”?

  14. 对比 CPU 缓存体系与分布式缓存体系:它们在结构上有哪些相似性?最根本的三个差异是什么(透明 vs 显式、策略复杂度的决定因素、空间局部性的有无)?为什么说”热点 key 打爆单个 Redis 分片”和 CPU 的”伪共享”是同一个问题?

【参考答案】

  1. 因为局部性原理是跨层次的普遍规律——程序行为有循环、顺序、热点、结构,所以指令、内存、磁盘、网络请求都有局部性。缓存就是赌”未来访问的与现在访问的相似”。如果访问模式完全随机(每个数据等概率),命中率只会等于”缓存容量/总数据量”,缓存就失去意义——所以局部性是缓存成立的唯一根基。

  2. 因为速度和容量无法兼得(L1 到机械盘延迟差 1000 万倍、容量差上亿倍),没有任何单一技术能又快又大又便宜。多级缓存用分层逼近”既要快又要大”:热点在快层、冷数据在慢层。平均访问时间 = 命中率×快层延迟 + (1-命中率)×慢层延迟,因快慢层差距达 1000 万倍,命中率的每一点提升都被杠杆放大,所以从 90% 提到 99% 能让平均延迟降一个数量级。

  3. 两条原因:一是避免并发写导致不一致——两个并发写更新缓存的顺序可能与写库顺序不同,留下旧值;而”删除”是幂等的,最终都是”缓存没了,下次重新加载”。二是避免浪费——更新缓存要付序列化成本,但这个数据可能再也没人读,删除成本几乎为零。

  4. 穿透:查根本不存在的数据,每次都 miss 打到数据库(应对:缓存空值、布隆过滤器)。击穿:单个热点 key 过期,瞬间大量请求打到数据库(应对:互斥重建、逻辑过期、热点永不过期)。雪崩:大量 key 同时过期,数据库瞬间承受全量(应对:TTL 加随机抖动、多级缓存、限流熔断兜底)。三者量纲不同:穿透是”永久 miss”,击穿是”并发度问题”(同一 key 上万人抢,解法是排队/单飞),雪崩是”总量问题”(上万 key 同时到期,解法是错峰+限流)。

  5. 四种来源:①循环——时间局部性(程序 90% 时间执行 10% 代码);②顺序访问——空间局部性(地址单调递增,是唯一能被硬件预取器预测的模式);③热点数据——时间局部性(真实访问服从 Zipf 幂律,前 1% 对象常占 30%~50% 流量);④结构化数据——空间局部性(相关字段物理相邻,一次缓存行加载后续字段免费)。关于复杂度:O(1) 的哈希表每次访问都是随机地址,每访问一个元素一次 cache miss(~100ns);O(n) 的数组顺序扫描每 16 个 int 才 miss 一次,且能被预取器提前搬运。渐进复杂度只算”访问次数”,不算”每次访问的代价”,而 cache miss 的代价是命中代价的 100 倍,这个常数因子经常压倒复杂度差异。

  6. 伪共享:两个逻辑上无关的变量落在同一条 64 字节缓存行里,被不同核各自写入;一致性协议以”行”为单位,一方写就要让另一方的行副本失效,导致缓存行在核间来回搬运(ping-pong),每次跨核通信几十到上百纳秒,而自增本身只要 1 纳秒,性能可能比单线程还慢数倍。padding 原理:把变量之间填满或按 alignas(64) 对齐到行首,让它们各自独占不同的缓存行,各行独立走 MESI,消除虚假失效。它和 LongAdder 的分片不是一回事,而是两个层次:padding 解决”伪共享”(两个不同变量被打包),分片解决”真共享”(N 个线程写同一个变量,padding 救不了,只能拆成 N 个 Cell 读时求和)。LongAdder = 先分片消除真共享 + 每 Cell 再 padding 消除伪共享,两招叠加。

  7. Inclusive(L1⊆L2⊆L3):总有效容量只等于最外层容量(内层冗余浪费),但一致性检查只需看最外层,外层天然充当精确的 snoop filter,淘汰时需反向失效内层;典型是 Intel 服务器级处理器的 inclusive L3。Exclusive:总有效容量等于各层之和,零浪费,但每次 miss 都要层间搬移、数据通路复杂,且一致性检查必须逐层查;老 AMD Bulldozer 用过。NINE(非包含非互斥):不保证包含也不保证互斥,允许冗余但不强制,能拿到 inclusive 的近似 snoop filter 好处又不付出强制冗余的容量代价,是现代主流(AMD Zen、Intel 客户端 Skylake 起)。搬到”本地缓存+Redis”:选 inclusive 几乎总是对的,因为如果本地缓存的数据一定也在 Redis 里,失效路径就唯一(删 Redis key + 广播删本地),可集中审计;若搞 exclusive(本地直接从 DB 加载、Redis 存另一批),就得为”只存在于本地缓存的 key”单独设计广播机制,且无处集中查询。CPU 里硬件实现成本和 SRAM 面积更贵所以走 NINE/exclusive,分布式里人写的代码和出的 bug 更贵所以走 inclusive——同一个问题,“什么更贵”不同,答案相反。

  8. 被掩盖的真实成本是:LRU 把”读操作”变成了”写操作”。每次 get 都要修改双向链表指针,多线程下意味着读操作必须持有写锁,所有读请求串行化,无锁读路径和读写分离全被一个链表毁掉。Redis 用随机采样(maxmemory-samples,默认 5)+ eviction pool(16 候选按 idle 排序)近似 LRU,只牺牲一两个百分点命中率,就省掉了全局链表的内存和锁开销。全表扫描打爆 LRU 的过程:扫描顺序访问 10 万个页面,每个只访问一次但”最近访问时间”最新,于是逐个把真正的热数据挤出缓存;扫描结束后缓存里 100% 是再也不会被访问第二次的垃圾,命中率从 99% 掉到 0,需要很长时间爬回来。根因是 LRU 只记录”最后一次访问时间”这一个维度,无法区分”访问过一次”和”访问过一万次”——这是信息量缺失,不是实现缺陷。

  9. InnoDB 把 LRU 链表分成 young(热端,约 5/8)和 old(冷端,约 3/8)两段:innodb_old_blocks_pct(默认 37)控制分界点位置,新读入的页面不插链表头,而是插到 old 区头部(midpoint insertion);innodb_old_blocks_time(默认 1000ms)规定一个页面必须在 old 区停留超过 1 秒后再次被访问,才晋升到 young 区。一次全表扫描中同一页面只被访问一次(或两次间隔远小于 1 秒),因此扫描数据永远熬不过 1 秒门槛、升不到 young 区,热数据被完整保护。这体现了 LRU-K(K=2)的思想——“至少被访问两次(且间隔足够长)才值得留下”,是 LRU-K 在工业界最经典的落地。

  10. LFU 的历史包袱:一个曾经爆火、现已冷却的 key,累计计数极高,会长期赖在缓存里,把今天的新热点挡在门外——缓存没有”遗忘”能力。TinyLFU 用极简单的定期重置(Reset)解决:每发生 W 次访问(W 通常设为缓存容量的 10 倍),就把 Count-Min Sketch 里所有计数器减半或清零,用”滑动窗口的近似”代替精确计数,一次操作卸掉历史包袱。W-TinyLFU 加 Window LRU(约 1%)是因为 TinyLFU 对突发的新热点反应迟钝——新热点频率还没攒起来,准入时被判负进不来;Window 先用 LRU 接住这波突发流量,它淘汰的候选再和 Main 区候选由 TinyLFU 仲裁,兼顾突发响应和抗污染。ARC 的 B1/B2 ghost 列表提供的是”反事实信息”:命中 B1 说明当初不该从 T1 踢它、T1 太小,就把 T1 的目标大小 p 调大;命中 B2 则调小。这让缓存变成一个在线学习系统——不需要先验知道访问模式是重最近性还是重频率,用”淘汰决策的后悔程度”反馈自适应,O(1)、免调参、天然抗扫描。

  11. 延迟:客户端缓存 0;CDN 1050ms(省掉的是回源 RTT);本地进程内 100ns1μs;Redis 0.21ms(同机房 RTT);DB 550ms。共享性:客户端缓存单用户独占,CDN 地域共享,本地缓存单实例独占,Redis 全局共享。一致性难度从上到下递减(客户端 ★★★★★ → Redis ★★),因为”共享性”决定能否精确失效:Redis 是全局唯一权威副本,删一次所有人都看不到旧值,是一致性的锚点;本地缓存删除后各实例还留副本,要广播或等 TTL;客户端和 CDN 的副本已经发出去、收不回来,只能靠 TTL、ETag/版本号、文件名哈希(cache busting)、CDN purge API(分钟级且不保证)。TTL 与回源 QPS 的关系:一致性窗口 = 本地缓存 TTL,代价 = 回源 Redis 的 QPS,且 QPS 与实例数成正比——TTL 从 5 分钟降到 5 秒,Redis QPS 可能涨几百倍,实例越多一致性越差。水平扩展的隐藏代价。

  12. MESI 四态:M(Modified,脏、与内存不一致、独占)、E(Exclusive,干净、与内存一致、独占)、S(Shared,干净、可能多核持有)、I(Invalid,无效)。写 E 状态无需任何总线事务(独占,无人需通知),写 S 需发 BusRdX/BusUpgr 让其他副本全部置 I。MESI 保证的是缓存一致性(coherence)——对同一个地址,所有核看到的写顺序一致;它不保证内存模型/顺序(consistency)——对不同地址的写,其他核以什么顺序看到。因为现代 CPU 为性能引入了 store buffer(写完不等失效广播完成就继续执行)和 invalidate queue(失效队列,收到失效先入队回 ACK,稍后处理),使得 MESI 的强一致在时间上被延迟,出现两个核互相”看不见”对方写的乱序观察。所以需要内存屏障(mfence 排空 store buffer、lfence 排空 invalidate queue)来强制可见性时机。一句话:一致性协议解决”最终值”,内存模型 + 屏障解决”可见性时机”。

  13. 冷启动空窗期:新集群上线/扩容/FLUSHALL/主从全切后缓存为空,所有请求直穿 DB,而 DB 的 buffer pool 也是冷的、磁盘 I/O 打满,实际压力是设计容量(按 90% 被挡住规划)的 10 倍,进入”打挂→超时→重试→更压”的无法自愈死锁。预热手段:①离线热点统计后启动时批量灌入(Zipf 分布下灌 1% 的 key 能挡 30%~50% 流量,性价比最高);②灰度放量/流量爬坡(把瞬间全量回源摊平);③异步刷新 refreshAfterWrite(过期后旧值仍可用、返回旧值并异步刷新,请求永不阻塞,同时解决击穿和预热);④用旧集群 RDB/AOF 或主从复制预热新集群后再切流量;⑤配置/字典类基线数据设永不过期。四个坑:①预热即雪崩——灌入的 key 用同一 TTL 会集体失效,预热也必须加随机 TTL 抖动;②预热量超 maxmemory 触发淘汰、挤掉正在工作的热数据,应控制在容量 50%70%;③灌入离线快照的旧/错数据,因缓存有值导致回源永不触发、错误被缓存住,必须有校验和按批次版本号快速回滚;④并发批量回源打爆 DB,预热必须令牌桶限速。降级顺序(轻→重):超时设 50200ms + 错误率熔断快速失败 → 对回源限流(只放 DB 能承受的 QPS,宁可拒绝部分也不要全挂)→ 降级到本地缓存当最后缓冲区 → 返回兜底数据(静态页/默认列表/上次快照)→ 功能降级(关推荐、个性化等非核心,保详情、下单、支付)→ 写路径降级(写 DB 成功即返回,缓存失效失败记补偿队列异步重试)。“最危险的不是读是写”:读可以回源,但写路径若依赖缓存做分布式锁或幂等去重,缓存一没就出现超卖、重复扣款,这是正确性/资损问题,比性能问题严重得多。所以原则是”不要把正确性建立在缓存上”——锁要有过期和 fencing token,幂等靠数据库唯一索引,缓存只做加速、不做裁决。

  14. 相似性:两者都面对”快而小 vs 慢而大”的同一物理约束,都用分层逼近(CPU:L1/L2/L3/内存/磁盘 ↔ 分布式:本地缓存/Redis/DB/对象存储),都靠局部性成立,都有替换策略和写策略(write-through/write-back ↔ cache-aside/写后失效),都有包含与排他策略,甚至都有同一套”粒度错配”问题。三个根本差异:①透明 vs 显式——CPU 缓存完全透明,硬件承诺”有缓存与无缓存执行结果逐条等价”;分布式完全显式,每个读都要手写 miss 回填。根源是透明性要求”统一的访问路径”(所有内存访问都走同一条指令路径,硬件能全看到),分布式的数据访问散落在成千上万行业务代码里,没有统一拦截点。②策略复杂度的决定因素是”一次访问的延迟预算”——L1 命中仅 1ns(34 个时钟周期),决策必须在纯组合逻辑几拍内完成,只能用 1 bit 的伪 LRU 或 2 bit 的 RRIP;Redis 一次访问 500μs,软件有充足时间跑 W-TinyLFU。规律:越快的缓存策略越简单、越慢的缓存策略越复杂(CDN 延迟预算是小时级,所以能用最重的离线大数据预取)。③空间局部性的有无——CPU 靠地址邻近白捡空间局部性(读一个 int 顺手搬来相邻 60 字节,还有预取器识别步长);Redis 的 key 是字符串,user:1001 与 user:1002 无任何物理邻近性、可能在不同分片,取 1000 个用户就是 1000 次网络请求,天然只有时间局部性。所以分布式里的空间局部性必须人工设计:MGET/Pipeline 相当于”一次取一整行”,Hash 聚合 key 相当于”把结构体的相邻字段打包”;但聚合太大则改一个字段要失效整个 hash(分布式版伪共享),太小则网络往返爆炸(分布式版有效数据占比低),判断标准与 CPU 完全相同——“访问者是不是同一批”。伪共享与热点 key 是同一个问题:CPU 里 N 核抢同一缓存行,分布式里 N 请求打到同一 Redis 分片(单分片 QPS 有上限,其他分片闲着);解法也相同——CPU 用 LongAdder 分片 + Cell padding,Redis 把 item:1001 拆成 item:1001:0item:1001:15 分摊到不同分片、读时聚合。两者都是”把一个争用点拆成 N 个,用求和换并行度”,代价都是读时多一次聚合。