| 缓存一致性全景:从 CPU 到分布式系统的九重迷局 对缓存有使用经验、但还没有把"CPU 缓存一致性、内存模型、分布式缓存失效、HTTP/CDN 缓存"串成一条线的工程师与学习者。读完你会得到一张完整的认知地图:缓存为什么存在、一致性为什么难、业界有哪些手段、以及真实系统里怎么取舍。 先做一个小实验。你随手在浏览器里打开一个网页,到内容渲染出来,中间至少经过了:DNS 缓存、浏览器 HTTP 缓存、CDN 边缘缓存、负载均衡层缓存、Redis 或本地缓存、操作系统页缓存、CPU 的 L1/L2/L3 缓存——可能还有数据库自己的 Buffer Pool。每一层都在缓存,每一层都有"副本",而副本一旦多于一份,"它们之间一不一致"就成了问题。 缓存一致性(Cache Coherence / Consistency)因此是计算机科学里少有的、从纳米秒到毫秒、从单颗芯片到全球网络都绕不开的主题。本文不打算只讲某一种缓存,而是沿着"局部—单机—分布式—全球网络"这条尺度线,把九层迷局逐一展开:每一层的一致性问题长什么样、业界用什么机制解决、代价是什么。 在开始之前,先把最重要的结论放在这里,方便你带着主线往下读:一致性从来不是"有"或"没有"的开关,而是一条光谱。光谱的一端是线性一致(Linearizability),代价是性能与可用性;另一端是最终一致(Eventual Consistency),代价是用户可能短暂看到旧数据。绝大多数工程问题的实质,不是"如何做到绝对一致",而是"在业务允许的窗口内,用可接受的成本,把不一致的概率和时长压到最低"。全文的每一章,都是在给这句话加注脚。
缓存能成立,靠的不是"缓存技术",而是一条客观规律:局部性原理(Principle of Locality)。它分两种:
局部性意味着:如果把"最近被访问过的一小部分数据"放在更靠近消费者的地方,绝大多数访问都能从那里命中,而不必每次都走慢速的"远端"。衡量这一点的核心指标是命中率(Hit Ratio)——缓存系统 90% 的工程问题,最后都能归结为"怎么把命中率做上去、把未命中(miss)的代价降下来"。
计算机的存储从来不是"一种设备",而是一个层层叠叠的金字塔:越往上越快、越贵、越小;越往下越慢、越便宜、越大。缓存就是人为在相邻层级之间插入的"加速垫"。 暂时无法在飞书文档外展示此内容 这张图的读法是:每一层相对下一层,容量大两到三个数量级,延迟慢一到两个数量级。CPU 从寄存器读数据是亚纳秒级,从主存读是百纳秒级——差两百倍;从网络存储读则是毫秒级——差百万倍。整个缓存体系的存在意义,就是把最热的数据尽可能"顶"到金字塔上方。
1985 年以来,硬件性能提升了几个数量级,但"相对差距"几乎没变。Jeff Dean 整理的《Latency Numbers Every Programmer Should Know》至今仍是系统设计的第一课,下面这组经典量级你应该刻进脑子(数值为量级参考,不同架构和年代略有出入):
看这组数字你就能明白两件事:第一,为什么"多一次缓存命中"和"少一次缓存 miss"在性能上差别巨大——一次 Redis 命中的代价(几百微秒)比一次 DB 查询(几毫秒甚至更久)低一个数量级以上;第二,为什么 CPU 宁可花极大的电路面积去做缓存一致性协议,也不肯让多核各自为政——因为跨核共享数据一旦出错,代价是"百万倍延迟级别的数据访问"直接变成"内存损坏级别的逻辑错误"。
任何缓存系统,无论多复杂,都只做两件事:
这里埋着全文最重要的一个伏笔:CPU 缓存选择"更新 + 一致性协议",分布式缓存通常选择"删除 + 让读自行修复"。为什么?因为分布式环境里"同步更新所有副本"的成本高到不可接受,而"删掉让下次读补"只需要一次操作的代价。这个差异,正是后面所有分布式一致性方案的出发点。
CPU 缓存的最小管理单位是缓存行(Cache Line),x86 上通常 64 字节。这意味着缓存不是"按变量"管理的,而是"按一块 64 字节的内存"管理的。问题随之而来:如果两个线程各自频繁写两个不同的变量,而这两个变量恰好落在同一条缓存行里,那么任何一个线程的写入,都会让对方的副本失效、重新同步——缓存行在两个核之间来回"乒乓"(ping-pong),性能可以退化几个数量级。这就是著名的伪共享:明明没有共享数据,却承担了共享的全部代价。 解决办法是在变量之间填充(padding)到 64 字节对齐,或者用 JDK 的 @Contended 注解、用 LongAdder 这类按缓存行分桶的原子累加器。这个例子的价值不在性能技巧本身,而在于它第一次揭示了一条全文反复出现的规律:一致性协议的成本,与"共享范围"成正比。你能把共享面做小,协议就不必付出那么多代价。
多核 CPU 上,每个核都有私有的 L1/L2。如果核 0 改了变量 x,核 1 的缓存里还留着旧 x,程序就错了。硬件怎么保证不错?靠的是一致性协议在"总线"(或片上互联)上做嗅探(Snooping):任何核想独占写某块数据,都要先在总线上广播,让所有持有副本的核先失效。 最常见的教学协议是 MESI,它给每条缓存行定义四个状态: 暂时无法在飞书文档外展示此内容 用一个双核交替访问变量 x 的例子走一遍状态迁移(假设最初两核都没有 x):
注意其中的关键设计:脏数据(M)只有在被别人需要时才会写回内存,平时它只躺在 L1 里。这就是"写回(Write-back)"缓存与"写直达(Write-through)"缓存的区别——后者每次写都穿透到内存,简单但慢;前者省内存流量,但引入"哪个副本才是最新"的问题,MESI 就是回答这个问题的。
MESI 有个效率痛点:M 状态的脏数据被其他核读走时,必须先写回内存再共享,白白多一次内存访问。AMD 等厂商的 MOESI 协议增加了 O(Owned)状态:数据可以保持脏,但"借"给其他核做 S 副本,由拥有者负责最终写回,省掉一次内存往返。Intel 也有类似机制(实际实现是 MESI 的增强变体,并支持"转发")。 总线嗅探还有一个更根本的天花板:广播风暴。当核数从 4 变成 64、甚至多路 CPU(多颗物理芯片)时,每次失效都广播给所有人,互联带宽会先撑不住。所以服务器级系统普遍使用目录协议(Directory-based):由一个目录结构记录"这条缓存行在哪些核里有副本",只向持有者定向发失效消息。你可以把它理解为"从群发邮件升级为通讯录定向通知"——这是分布式系统里"用元数据换带宽"的经典模板,后面讲分布式缓存时你会再次遇到同样的思路。
如果你以为"MESI 保证所有核任何时候看到的值都一样",那就掉进了最经典的坑。协议保证的是最终收敛,而 CPU 为了性能,在路径上插了两个"作弊器":
这两个"作弊器"合起来,让 CPU 出现了指令重排(Memory Reordering)的观感。看一个经典例子(两个核并行执行): 直觉上核 1 等到 B 变成 1 后,A 必然已经是 1。但在弱内存序(如 ARM 默认)下,由于写缓冲的存在,B=1 可能先于 A=1 被其他核看到;即便 x86 的 TSO 模型不会重排两个普通写,它也会允许"先看到的写"覆盖一些直觉。结论是:硬件只保证"最终一致",不保证"你直觉中的顺序"。 修复手段是内存屏障(Memory Barrier / Fence): mfence、lfence、sfence,或者 Java 里的 volatile、C++ 里的原子变量。屏障的本质是"把写缓冲和失效队列清空/等齐"——它不增加正确性,只把"该被看到的写"强制暴露出去。这引出一个贯穿全文的通用结论:任何一致性机制,本质上都是"牺牲一点并行度,换取一份顺序承诺"。MESI 如此,数据库的隔离级别如此,分布式系统的同步复制亦如此。
理论计算机科学给"最强的一致性承诺"起了个名字:顺序一致性(Sequential Consistency)——所有核看到的执行结果,等价于各核指令按某种全局顺序交错执行,且每个核自己的指令保持程序顺序。顺序一致性是人类直觉的模型,也是多线程程序最容易推理的模型;但它要求所有操作全局可见,性能代价巨大。 于是现实世界几乎都是"弱于顺序一致"的:x86 采用 TSO(Total Store Order),只允许写-读重排;ARM/POWER 更弱,允许更多重排。为了让程序在弱模型下依然正确,语言层面设计了内存模型(Memory Model),把"你需要什么级别的顺序承诺"以语言原语的形式交还给程序员——这就是下一章的内容。
Java 开发者通常感知不到 MESI 和内存屏障,因为 JMM(Java Memory Model,JSR-133)把这些都抽象掉了。JMM 的核心是一张关系表——happens-before(先行发生):只要 A happens-before B,那么 A 对共享变量的写入,对执行 B 的线程可见。 其中与本文最相关的一条是:对 volatile 变量的写,happens-before 于对该变量的后续读。从底层看,volatile 读写在 x86 上会被编译成带 lock 前缀的指令或内存屏障——它做的正是上一章说的"把写缓冲清空、把失效队列等齐",从而在硬件层面兑现"可见性"。而 synchronized、Lock、ConcurrentHashMap 这些高级原语,本质上都是"在临界区边界上插屏障"的语法糖。值得强调一个常见误解:volatile 不解决复合操作。它保证可见性和有序性,但不保证"读-改-写"的原子性—— count++ 哪怕 count 是 volatile,两个线程并发执行依然会丢更新。这就是 AtomicInteger 用 CAS(Compare-And-Swap)存在的原因:CAS 是"读 + 比较 + 写"的硬件原子指令,它把"乐观更新"变成了缓存一致性协议可以直接支持的原子动作。
C++11 起的内存模型把选择权交还给了程序员: std::atomic 的每一个操作都可以指定 memory_order,从最弱到最强依次是:
这个例子与上一章的"重排示例"完全同构,区别只是:在 C++ 里,顺序承诺不再是 CPU 厂商的默许,而是由你在每一行代码上显式购买。这是全文方法论的第一次显式出现:一致性是有标价的商品,按需购买,而不是默认配齐。
有了上面的基础,再看锁就有了一层新理解。一把被多个线程争抢的自旋锁,其底层实现就是"对一个缓存行反复做 CAS/测试-设置",而这会带来两件事:
无锁(Lock-free)编程的野心是"用 CAS 循环替代锁",让读操作永不阻塞。但它把复杂度转移给了算法本身:ABA 问题(A 被改成 B 又改回 A,CAS 误判无人修改)、内存回收(怎么安全释放被并发读的节点)、以及指令级的顺序承诺——每一样都比锁更难推理。工程上的诚实结论是:99% 的场景锁就够了,无锁是给 1% 的场景准备的"性能期货"。判断要不要上无锁,先量化争抢:如果锁的争抢率很低,无锁优化毫无意义。
离开 CPU,缓存一致性的主角从"硬件协议"变成了"软件策略"。操作系统把磁盘文件读进内存页缓存(Page Cache),读文件变成"先查页缓存"。写文件呢?默认是写回(Write-back):数据先进页缓存标记为脏页,后台线程择机落盘。这带来两个后果:
注意这里的一致性问题已经发生了质变:CPU 缓存的一致性由硬件协议保证,页缓存的一致性却是"丢数据 vs 性能"的取舍——同样的取舍,稍后会在分布式缓存里以更剧烈的形式重演。
数据库是"缓存 + 一致性"的集大成者。以 MySQL InnoDB 为例:数据页缓存在 Buffer Pool 里,读写都先碰内存;脏页由后台线程(checkpoint)落盘。那么问题来了——Buffer Pool 还没落盘时数据库崩溃了,内存里改过的数据不就丢了吗? 答案是 WAL(Write-Ahead Logging):每次事务提交前,先把 redo log 顺序写盘(顺带 fsync),数据页的修改可以慢慢落盘。崩溃后,数据库用 redo log 重放,把 Buffer Pool 里丢失的修改找回来。这就是"先写日志、后改数据"的经典套路,它把"随机写数据页"变成了"顺序写日志",同时把一致性从"内存状态"变成了"日志+状态可重放"。理解这一点,对后面理解 binlog 订阅做缓存同步非常有帮助——日志不只是崩溃恢复工具,它还是"变更的事实来源"(Source of Truth)。
把前三章串起来,你会看到一条清晰的脉络:CPU 层的一致性靠硬件协议(强、快、透明);操作系统和数据库层的一致性靠软件策略(写回、WAL、checkpoint,允许短暂不一致但可恢复);而接下来进入分布式世界后,连"可恢复"都变得昂贵——网络分区、机器宕机、消息丢失成为常态,一致性从此只能靠"机制 + 概率 + 时间窗口"来管理。这是理解全文后半部分的钥匙:越往上走,一致性越是一种工程管理,而不是一种数学保证。
很多第一次设计分布式缓存的人,会天然地把 CPU 缓存的思路带过来:"缓存和数据库之间做一致性协议不就行了?"答案是不行,原因有三个硬约束:
于是分布式缓存收敛出四种读写模式,它们的差异本质上是三个问题的不同回答:谁负责加载数据?写操作走缓存还是走库?同步还是异步?
暂时无法在飞书文档外展示此内容
选型没有"最好",只有"最匹配"。一个反直觉但重要的规律是:写少读多的系统,删缓存比更新缓存更优。删除只需要一次操作,更新则要求"读-改-写"与数据库事务对齐,复杂度和风险都更高。这就是为什么业界最流行的组合是"Cache-Aside + 写后删除",而不是"写后更新"。
分布式缓存不一致的最基本来源,是"写数据库"和"写/删缓存"这两个操作无法原子。看两个经典时序:
更隐蔽的是并发交错:线程 A 写库(新值 V2),线程 B 同时读到旧值 V1 并回填缓存,随后 A 才删缓存——但 B 的回填发生得比 A 的删除晚,V1 就"赢"了,缓存里长期残留旧值。这就是"先删缓存再写库"与"先写库再删缓存"都各自有坑的原因,也是下一章延迟双删、版本号、binlog 订阅等方案要解决的共同敌人。记住这个敌人:不是"缓存更新失败",而是"回填与删除的先后交错"。
除了数据不一致,缓存系统还有三种高频故障模式,它们的共同后果是"请求绕过缓存直接打到数据库",但成因、范围、对策完全不同: 暂时无法在飞书文档外展示此内容
这三者的对策之所以不同,是因为它们的"根因位置"不同:穿透是数据问题(key 不存在),击穿是时间问题(一个 key 的瞬时),雪崩是容量问题(一片 key 或整个集群)。判断口诀:穿透查"不存在",击穿是"一个热点突然没了",雪崩是"一大片同时没了"。
即使读写逻辑完美,分布式系统还有三类"结构性"的不一致来源:
分布式系统的任何取舍,都逃不开 CAP:网络分区(P)发生时,你必须在一致性(C)和可用性(A)之间二选一。注意 CAP 不是"三选二",而是"分区时二选一"——大多数业务系统选择 AP(保证可用),然后用下面的手段把"最终一致"收敛得足够快。 一致性从强到弱的光谱,工程上常这样分级:
本文接下来所有方案,几乎都落在"最终一致"到"单调读"之间。把它们读明白后你会得到一个判断力:当业务提出"数据绝对不能错"时,正确答案往往不是"把缓存做一致",而是"这个数据根本不该放缓存"——下一章会详细展开。
把上一章的问题收集起来,业界已经沉淀出一整套由弱到强的方案谱系。它们的共同目标只有一个:在业务容忍的时间窗口内,让缓存收敛到数据库的当前值,并把不一致的概率与时长压到工程上可接受。下面按"投入成本"从低到高逐一展开。
给每个缓存 key 设过期时间(TTL),是所有方案的地基。它不解决"当下不一致",只承诺"最多不一致 TTL 时长"。别看它朴素,它有三个不可替代的价值:
代价是显而易见的:TTL 越长,不一致窗口越大;TTL 越短,命中率越低、回源越多。工程上常见做法是给 TTL 加随机抖动(如 base + random(0~1/5 base)),避免大量 key 同时到期触发雪崩——这是"用随机性买稳定性"的典型。
如果只能记住一个操作规范,那就是:读 miss 回填(Read Repair),写后删除(Write-through Invalidate)。前者让缓存永远只积累被真实访问过的数据;后者让写入路径最少只有一个操作。这条规范配合 TTL 兜底,已经能覆盖 80% 的业务场景。它的全部缺陷集中在第六章的双写竞态上——回填与删除的先后交错——于是有了下面的升级方案。
暂时无法在飞书文档外展示此内容 延迟双删的思路直击双写竞态:第一次删除后等一小段时间(N 毫秒),再删一次。目的是确保"旧值回填"发生在两次删除之间,让第二次删除把它清掉。要点与局限都很明确:
还有两个变体值得一提:异步化双删(第二次删除丢给 MQ,避免写请求被 N 毫秒阻塞)和串行化双删(对同一 key 的写操作加分布式锁串行执行,从源头消除交错)。变体的本质都是"把时间窗口换成队列或锁",用复杂度换确定性。
双删的所有问题,都可以归结为一句话:旧值回填覆盖了新值,且系统无法区分新旧。那么最直接的解法是——给数据带版本号,写入时比较版本,旧版本直接丢弃。数据库行可以带 version 字段(每次 UPDATE 自增),缓存里同步存版本,回填/更新时用 Redis 的 Lua 脚本做原子比较:这套"比较并交换(Compare-And-Set)"的思路,就是把 CPU 的 CAS 原语搬到了分布式缓存里。它的好处是顺序不再依赖时序,而依赖版本:哪怕消息乱序到达,旧版本也覆盖不了新版本。业界成熟的工程化形态,就是上一条"写库 + binlog 订阅"链路里,消费端用版本号做幂等与去重——两者天然互补。
暂时无法在飞书文档外展示此内容 如果说延迟双删和版本号是"应用内自救",binlog 订阅则是"架构级根治"。以阿里开源的 Canal 为代表:Canal 把自己伪装成 MySQL 的从库,实时订阅主库的 binlog,把每一条行变更解析成事件,投递到 MQ,再由消费服务更新 Redis、搜索引擎或其他读副本。
它的代价同样明显:新增 Canal、MQ 两条链路和两个故障点,消费延迟就是不一致窗口,binlog 有保留期和容量成本。工程判断:当"缓存更新逻辑散落在多个服务、反复出人为事故"时,才值得上订阅架构;小型系统用它属于杀鸡用牛刀。
如果业务对"同一 key 的并发写"要求强一致,另一个思路是让这些写操作串行执行:用分布式锁(Redis SETNX + 过期、ZooKeeper 临时节点、etcd 租约)把同一 key 的写串起来,或直接通过消息队列把写操作排队。串行化的好处是把并发交错变成线性顺序,双写竞态从根上消失;代价是吞吐下降、锁本身的可用性成为新风险——锁服务挂了,业务也要跟着降级。关于分布式锁(尤其 Redis 锁在 GC 停顿、主从切换下会失效的问题),业界有大量争论,这里只给一条实用结论:锁只用于关键 |
|Archiver|手机版|小黑屋|凡尘乡音—重庆市云阳县凡尘街坊—凡尘论坛—云阳论坛
GMT+8, 2026-11-8 13:02 , Processed in 0.123424 second(s), 23 queries .
© 2026 凡尘乡音|凡尘街坊便民信息交流平台 版权所有