找回密码
 立即注册

缓存一致性全景:从 CPU 到分布式系统的九重迷局

2026-9-15 22:07| 发布者: 李鹏| 查看: 31| 评论: 0

摘要: 对缓存有使用经验、但还没有把"CPU 缓存一致性、内存模型、分布式缓存失效、HTTP/CDN 缓存"串成一条线的工程师与学习者。读完你会得到一张完整的认知地图:缓存为什么存在、一致性为什么难、业界有哪些手段、以及真 ...
 缓存一致性全景:从 CPU 到分布式系统的九重迷局

对缓存有使用经验、但还没有把"CPU 缓存一致性、内存模型、分布式缓存失效、HTTP/CDN 缓存"串成一条线的工程师与学习者。读完你会得到一张完整的认知地图:缓存为什么存在、一致性为什么难、业界有哪些手段、以及真实系统里怎么取舍。
先做一个小实验。你随手在浏览器里打开一个网页,到内容渲染出来,中间至少经过了:DNS 缓存、浏览器 HTTP 缓存、CDN 边缘缓存、负载均衡层缓存、Redis 或本地缓存、操作系统页缓存、CPU 的 L1/L2/L3 缓存——可能还有数据库自己的 Buffer Pool。每一层都在缓存,每一层都有"副本",而副本一旦多于一份,"它们之间一不一致"就成了问题。
缓存一致性(Cache Coherence / Consistency)因此是计算机科学里少有的、从纳米秒到毫秒、从单颗芯片到全球网络都绕不开的主题。本文不打算只讲某一种缓存,而是沿着"局部—单机—分布式—全球网络"这条尺度线,把九层迷局逐一展开:每一层的一致性问题长什么样、业界用什么机制解决、代价是什么。
在开始之前,先把最重要的结论放在这里,方便你带着主线往下读:一致性从来不是"有"或"没有"的开关,而是一条光谱。光谱的一端是线性一致(Linearizability),代价是性能与可用性;另一端是最终一致(Eventual Consistency),代价是用户可能短暂看到旧数据。绝大多数工程问题的实质,不是"如何做到绝对一致",而是"在业务允许的窗口内,用可接受的成本,把不一致的概率和时长压到最低"。全文的每一章,都是在给这句话加注脚。
  1. 缓存的本质:为什么我们需要缓存

  1. 局部性:缓存存在的物理前提

缓存能成立,靠的不是"缓存技术",而是一条客观规律:局部性原理(Principle of Locality)。它分两种:
  • 时间局部性(Temporal Locality):刚被访问的数据,很快还会再被访问。程序里的循环变量、热点商品、正在追的剧,都是典型。
  • 空间局部性(Spatial Locality):刚被访问的数据附近的数据,很快也会被访问。数组遍历、连续的内存分配、一个分类下的商品列表,都是典型。
局部性意味着:如果把"最近被访问过的一小部分数据"放在更靠近消费者的地方,绝大多数访问都能从那里命中,而不必每次都走慢速的"远端"。衡量这一点的核心指标是命中率(Hit Ratio)——缓存系统 90% 的工程问题,最后都能归结为"怎么把命中率做上去、把未命中(miss)的代价降下来"。
  1. 存储层级金字塔:速度、容量与成本的三角

计算机的存储从来不是"一种设备",而是一个层层叠叠的金字塔:越往上越快、越贵、越小;越往下越慢、越便宜、越大。缓存就是人为在相邻层级之间插入的"加速垫"。
暂时无法在飞书文档外展示此内容
这张图的读法是:每一层相对下一层,容量大两到三个数量级,延迟慢一到两个数量级。CPU 从寄存器读数据是亚纳秒级,从主存读是百纳秒级——差两百倍;从网络存储读则是毫秒级——差百万倍。整个缓存体系的存在意义,就是把最热的数据尽可能"顶"到金字塔上方。
  1. 一组必须记住的延迟数字

1985 年以来,硬件性能提升了几个数量级,但"相对差距"几乎没变。Jeff Dean 整理的《Latency Numbers Every Programmer Should Know》至今仍是系统设计的第一课,下面这组经典量级你应该刻进脑子(数值为量级参考,不同架构和年代略有出入):
操作
典型延迟
直观换算
L1 缓存读取
约 0.5 ns
CPU 一个周期左右
L2 缓存读取
约 7 ns
约 14 倍于 L1
主存(DRAM)读取
约 100 ns
约 200 倍于 L1
SSD 随机读 4K
约 150 µs
比主存慢约 1500 倍
同数据中心内一次网络往返
约 500 µs
一个"分布式缓存 miss"的典型代价
跨大洲网络往返
约 100~150 ms
用户可感知的延迟
看这组数字你就能明白两件事:第一,为什么"多一次缓存命中"和"少一次缓存 miss"在性能上差别巨大——一次 Redis 命中的代价(几百微秒)比一次 DB 查询(几毫秒甚至更久)低一个数量级以上;第二,为什么 CPU 宁可花极大的电路面积去做缓存一致性协议,也不肯让多核各自为政——因为跨核共享数据一旦出错,代价是"百万倍延迟级别的数据访问"直接变成"内存损坏级别的逻辑错误"。
  1. 命中与未命中:缓存的两个基本动作

任何缓存系统,无论多复杂,都只做两件事:
  • 读(Lookup):根据 key 查找副本,命中则返回,未命中则从慢速层加载并回填(fill)。
  • 写(Update / Invalidate):数据变化时,更新副本,或者更常见地——把副本删掉(invalidate),让下一次读重新加载。
这里埋着全文最重要的一个伏笔:CPU 缓存选择"更新 + 一致性协议",分布式缓存通常选择"删除 + 让读自行修复"。为什么?因为分布式环境里"同步更新所有副本"的成本高到不可接受,而"删掉让下次读补"只需要一次操作的代价。这个差异,正是后面所有分布式一致性方案的出发点。
  1. 单机硬件:CPU 缓存一致性

  1. 缓存行与伪共享(False Sharing)

CPU 缓存的最小管理单位是缓存行(Cache Line),x86 上通常 64 字节。这意味着缓存不是"按变量"管理的,而是"按一块 64 字节的内存"管理的。问题随之而来:如果两个线程各自频繁写两个不同的变量,而这两个变量恰好落在同一条缓存行里,那么任何一个线程的写入,都会让对方的副本失效、重新同步——缓存行在两个核之间来回"乒乓"(ping-pong),性能可以退化几个数量级。这就是著名的伪共享:明明没有共享数据,却承担了共享的全部代价。
public class FalseSharing {
    // 线程 1 只写 a,线程 2 只写 b
    public volatile long a;
    public volatile long b;
}
解决办法是在变量之间填充(padding)到 64 字节对齐,或者用 JDK 的 @Contended 注解、用 LongAdder 这类按缓存行分桶的原子累加器。这个例子的价值不在性能技巧本身,而在于它第一次揭示了一条全文反复出现的规律:一致性协议的成本,与"共享范围"成正比。你能把共享面做小,协议就不必付出那么多代价。
  1. MESI 协议:四状态的缓存一致性

多核 CPU 上,每个核都有私有的 L1/L2。如果核 0 改了变量 x,核 1 的缓存里还留着旧 x,程序就错了。硬件怎么保证不错?靠的是一致性协议在"总线"(或片上互联)上做嗅探(Snooping):任何核想独占写某块数据,都要先在总线上广播,让所有持有副本的核先失效。
最常见的教学协议是 MESI,它给每条缓存行定义四个状态:
暂时无法在飞书文档外展示此内容
用一个双核交替访问变量 x 的例子走一遍状态迁移(假设最初两核都没有 x):
  • 核 0 读 x:发总线读(BusRd),总线上没有其他副本 → 核 0 的 x 进入 E(独占、干净);
  • 核 0 写 x:本地写即可,无需总线事务 → E 变 M(修改、脏);
  • 核 1 读 x:发 BusRd,核 0 嗅探到后把脏数据写回内存并把状态降为 S,核 1 装入 x 为 S(共享);
  • 核 1 写 x:发总线读独占(BusRdX),核 0 的 S 副本被失效 → 核 1 的 x 变 M。
注意其中的关键设计:脏数据(M)只有在被别人需要时才会写回内存,平时它只躺在 L1 里。这就是"写回(Write-back)"缓存与"写直达(Write-through)"缓存的区别——后者每次写都穿透到内存,简单但慢;前者省内存流量,但引入"哪个副本才是最新"的问题,MESI 就是回答这个问题的。
  1. 从 MESI 到 MOESI、目录协议

MESI 有个效率痛点:M 状态的脏数据被其他核读走时,必须先写回内存再共享,白白多一次内存访问。AMD 等厂商的 MOESI 协议增加了 O(Owned)状态:数据可以保持脏,但"借"给其他核做 S 副本,由拥有者负责最终写回,省掉一次内存往返。Intel 也有类似机制(实际实现是 MESI 的增强变体,并支持"转发")。
总线嗅探还有一个更根本的天花板:广播风暴。当核数从 4 变成 64、甚至多路 CPU(多颗物理芯片)时,每次失效都广播给所有人,互联带宽会先撑不住。所以服务器级系统普遍使用目录协议(Directory-based):由一个目录结构记录"这条缓存行在哪些核里有副本",只向持有者定向发失效消息。你可以把它理解为"从群发邮件升级为通讯录定向通知"——这是分布式系统里"用元数据换带宽"的经典模板,后面讲分布式缓存时你会再次遇到同样的思路。
  1. 写缓冲、失效队列与内存屏障

如果你以为"MESI 保证所有核任何时候看到的值都一样",那就掉进了最经典的坑。协议保证的是最终收敛,而 CPU 为了性能,在路径上插了两个"作弊器":
  • 写缓冲(Store Buffer):CPU 执行写指令后不等待失效确认,先把值放进自己的写缓冲,异步完成总线事务。好处是写指令不阻塞;代价是"自己写完了,别人还没看到"。
  • 失效队列(Invalidate Queue):收到失效消息后不立即生效,而是排队异步处理。好处是读指令不被失效拖慢;代价是"已经答应失效,实际还没失效"。
这两个"作弊器"合起来,让 CPU 出现了指令重排(Memory Reordering)的观感。看一个经典例子(两个核并行执行):
核 0:  A = 1;         核 1:  while (B == 0) { }   // 等待
      B = 1;                        print(A);        // 可能打印 0!
直觉上核 1 等到 B 变成 1 后,A 必然已经是 1。但在弱内存序(如 ARM 默认)下,由于写缓冲的存在,B=1 可能先于 A=1 被其他核看到;即便 x86 的 TSO 模型不会重排两个普通写,它也会允许"先看到的写"覆盖一些直觉。结论是:硬件只保证"最终一致",不保证"你直觉中的顺序"。
修复手段是内存屏障(Memory Barrier / Fence):mfence、lfence、sfence,或者 Java 里的 volatile、C++ 里的原子变量。屏障的本质是"把写缓冲和失效队列清空/等齐"——它不增加正确性,只把"该被看到的写"强制暴露出去。这引出一个贯穿全文的通用结论:任何一致性机制,本质上都是"牺牲一点并行度,换取一份顺序承诺"。MESI 如此,数据库的隔离级别如此,分布式系统的同步复制亦如此。
  1. 顺序一致性 vs 弱内存模型

理论计算机科学给"最强的一致性承诺"起了个名字:顺序一致性(Sequential Consistency)——所有核看到的执行结果,等价于各核指令按某种全局顺序交错执行,且每个核自己的指令保持程序顺序。顺序一致性是人类直觉的模型,也是多线程程序最容易推理的模型;但它要求所有操作全局可见,性能代价巨大。
于是现实世界几乎都是"弱于顺序一致"的:x86 采用 TSO(Total Store Order),只允许写-读重排;ARM/POWER 更弱,允许更多重排。为了让程序在弱模型下依然正确,语言层面设计了内存模型(Memory Model),把"你需要什么级别的顺序承诺"以语言原语的形式交还给程序员——这就是下一章的内容。
  1. 语言与运行时:内存模型

  1. Java 内存模型:volatile 与 happens-before

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 是"读 + 比较 + 写"的硬件原子指令,它把"乐观更新"变成了缓存一致性协议可以直接支持的原子动作。
  1. C++ 内存序:把一致性承诺精确到比特

C++11 起的内存模型把选择权交还给了程序员:std::atomic 的每一个操作都可以指定 memory_order,从最弱到最强依次是:
  • relaxed:只保证原子性,不承诺任何顺序——适合计数器等"顺序无关"场景;
  • acquire / release:成对使用,制造"发布-订阅"关系——release 之前的写,对 acquire 之后的读可见;这是无锁队列、自旋锁的标准配方;
  • acq_rel:读改写操作(如 CAS)同时具备两者;
  • seq_cst:最强,等价于全局顺序一致,是默认值,也是最慢的。
std::atomic<int> flag{0};
int data = 0;

// 线程 1:发布
data = 42;                                  // 普通写
flag.store(1, std::memory_order_release);   // 发布

// 线程 2:获取
while (flag.load(std::memory_order_acquire) == 0) {}
use(data);                                  // 必然读到 42
这个例子与上一章的"重排示例"完全同构,区别只是:在 C++ 里,顺序承诺不再是 CPU 厂商的默许,而是由你在每一行代码上显式购买。这是全文方法论的第一次显式出现:一致性是有标价的商品,按需购买,而不是默认配齐。
  1. 锁与无锁:从缓存一致性看并发原语

有了上面的基础,再看锁就有了一层新理解。一把被多个线程争抢的自旋锁,其底层实现就是"对一个缓存行反复做 CAS/测试-设置",而这会带来两件事:
  • 串行化:任何时刻只有一个线程持有锁,临界区并行度归零;
  • 缓存行乒乓:锁变量所在缓存行在争抢线程之间反复失效、同步,比临界区本身的代码还贵。这也是为什么高并发自旋锁要引入"排队"(ticket lock / MCS lock)来让每个线程只轮询自己的缓存行。
无锁(Lock-free)编程的野心是"用 CAS 循环替代锁",让读操作永不阻塞。但它把复杂度转移给了算法本身:ABA 问题(A 被改成 B 又改回 A,CAS 误判无人修改)、内存回收(怎么安全释放被并发读的节点)、以及指令级的顺序承诺——每一样都比锁更难推理。工程上的诚实结论是:99% 的场景锁就够了,无锁是给 1% 的场景准备的"性能期货"。判断要不要上无锁,先量化争抢:如果锁的争抢率很低,无锁优化毫无意义。
  1. 单机软件缓存:页缓存、Buffer Pool 与写回

  1. 操作系统页缓存:第一次"软"一致

离开 CPU,缓存一致性的主角从"硬件协议"变成了"软件策略"。操作系统把磁盘文件读进内存页缓存(Page Cache),读文件变成"先查页缓存"。写文件呢?默认是写回(Write-back):数据先进页缓存标记为脏页,后台线程择机落盘。这带来两个后果:
  • 性能极好——写操作不碰磁盘;
  • 机器断电时,还没落盘的脏页直接丢失。所以数据库、消息队列这类"不能丢数据"的软件,必须显式调用 fsync 把日志强制落盘,这正是"同步点"思想在操作系统层的体现。
注意这里的一致性问题已经发生了质变:CPU 缓存的一致性由硬件协议保证,页缓存的一致性却是"丢数据 vs 性能"的取舍——同样的取舍,稍后会在分布式缓存里以更剧烈的形式重演。
  1. 数据库 Buffer Pool 与 WAL

数据库是"缓存 + 一致性"的集大成者。以 MySQL InnoDB 为例:数据页缓存在 Buffer Pool 里,读写都先碰内存;脏页由后台线程(checkpoint)落盘。那么问题来了——Buffer Pool 还没落盘时数据库崩溃了,内存里改过的数据不就丢了吗?
答案是 WAL(Write-Ahead Logging):每次事务提交前,先把 redo log 顺序写盘(顺带 fsync),数据页的修改可以慢慢落盘。崩溃后,数据库用 redo log 重放,把 Buffer Pool 里丢失的修改找回来。这就是"先写日志、后改数据"的经典套路,它把"随机写数据页"变成了"顺序写日志",同时把一致性从"内存状态"变成了"日志+状态可重放"。理解这一点,对后面理解 binlog 订阅做缓存同步非常有帮助——日志不只是崩溃恢复工具,它还是"变更的事实来源"(Source of Truth)。
  1. 一致性从这里开始变"软"

把前三章串起来,你会看到一条清晰的脉络:CPU 层的一致性靠硬件协议(强、快、透明);操作系统和数据库层的一致性靠软件策略(写回、WAL、checkpoint,允许短暂不一致但可恢复);而接下来进入分布式世界后,连"可恢复"都变得昂贵——网络分区、机器宕机、消息丢失成为常态,一致性从此只能靠"机制 + 概率 + 时间窗口"来管理。这是理解全文后半部分的钥匙:越往上走,一致性越是一种工程管理,而不是一种数学保证。
  1. 分布式缓存:四种读写模式

  1. 为什么不能照搬单机经验

很多第一次设计分布式缓存的人,会天然地把 CPU 缓存的思路带过来:"缓存和数据库之间做一致性协议不就行了?"答案是不行,原因有三个硬约束:
  • 没有共享总线:CPU 缓存之间有高速互联做嗅探,而缓存(Redis)与数据库(MySQL)之间是网络,一次往返几百微秒,无法为每个访问做全局广播;
  • 没有事务边界:CPU 的一致性协议是一条原子指令的事,而"写数据库 + 更新缓存"是两个独立系统、两次独立操作,中间任何一个失败都会留下不一致;
  • 节点会故障:CPU 缓存不会宕机,Redis 会、MySQL 会、网络会。一致性方案必须把"故障"当默认情况而不是异常情况设计。
于是分布式缓存收敛出四种读写模式,它们的差异本质上是三个问题的不同回答:谁负责加载数据?写操作走缓存还是走库?同步还是异步?
  1. 四种模式:旁路、读穿透、写穿透、写回

暂时无法在飞书文档外展示此内容
  • Cache-Aside(旁路缓存):应用自己管理两套系统——读 miss 时查库并回填,写时直写数据库然后删缓存。这是 Redis + MySQL 场景的绝对主流,优点是灵活、缓存故障不影响主流程;缺点是一致性完全靠应用自觉,任何一次"忘了删缓存"都会留下脏数据。
  • Read-Through(读穿透):把"加载数据库并回填"的逻辑收进缓存组件(如本地缓存框架、网关缓存层)。应用只见缓存,代码更简单;但 miss 时的加载压力集中在组件内部,热点击穿时更危险。
  • Write-Through(写穿透):写请求先写缓存,缓存同步透传到数据库。缓存永远"热",读性能稳定;但写的延迟被数据库拖累,且缓存与数据库的"同步原子性"依然要靠组件保证,写放大明显。
  • Write-Behind(写回):只写缓存,后台异步批量落库。写吞吐极高、可合并写,是最快也是风险最高的模式——缓存一崩,未落库的数据就丢了;落库延迟窗口内,读到的也是旧值。
  1. 对比与选型

模式
读路径
写路径
选型建议
Cache-Aside
miss 时应用查库回填
直写库 + 删缓存
通用业务默认选择;读多写少、可容忍短暂不一致
Read-Through
缓存组件加载回填
同 Cache-Aside
多语言团队想收敛加载逻辑;需配套防击穿
Write-Through
缓存命中为主
写缓存同步透传库
配置中心、元数据等"缓存必须常新"的场景
Write-Behind
缓存命中为主
异步批量落库
计数、浏览记录、日志聚合等可容忍丢失的场景
选型没有"最好",只有"最匹配"。一个反直觉但重要的规律是:写少读多的系统,删缓存比更新缓存更优。删除只需要一次操作,更新则要求"读-改-写"与数据库事务对齐,复杂度和风险都更高。这就是为什么业界最流行的组合是"Cache-Aside + 写后删除",而不是"写后更新"。
  1. 缓存为什么会不一致:问题全景

  1. 双写竞态:一切问题的根源

分布式缓存不一致的最基本来源,是"写数据库"和"写/删缓存"这两个操作无法原子。看两个经典时序:
  • 先写库、再更新缓存:写库成功、更新缓存失败 → 缓存里永远是旧值,直到 TTL 到期;
  • 先更新缓存、再写库:缓存成功、写库失败 → 缓存里出现库里不存在的新值,更危险。
更隐蔽的是并发交错:线程 A 写库(新值 V2),线程 B 同时读到旧值 V1 并回填缓存,随后 A 才删缓存——但 B 的回填发生得比 A 的删除晚,V1 就"赢"了,缓存里长期残留旧值。这就是"先删缓存再写库"与"先写库再删缓存"都各自有坑的原因,也是下一章延迟双删、版本号、binlog 订阅等方案要解决的共同敌人。记住这个敌人:不是"缓存更新失败",而是"回填与删除的先后交错"。
  1. 穿透、击穿、雪崩:三种"绕过缓存"的故障

除了数据不一致,缓存系统还有三种高频故障模式,它们的共同后果是"请求绕过缓存直接打到数据库",但成因、范围、对策完全不同:
暂时无法在飞书文档外展示此内容
  • 穿透(Penetration):查询的 key 根本不存在,缓存永远 miss,恶意或低频无效请求直击数据库。对策:布隆过滤器前置拦截、对空值做短 TTL 缓存、入口参数校验。
  • 击穿(Hotspot Invalidation):单个热点 key 恰好过期,瞬间大量并发 miss。对策:互斥锁只允许一个请求回源重建(其他等待)、逻辑过期(值里带过期时间,读到再异步刷新)、热点 key 不设物理 TTL 而靠后台刷新。
  • 雪崩(Avalanche):大面积 key 同时过期,或缓存集群整体宕机/重启,海量请求全部落到数据库。对策:过期时间加随机抖动打散、缓存集群高可用(多副本/分片容灾)、限流降级 + 本地小缓存兜底。
这三者的对策之所以不同,是因为它们的"根因位置"不同:穿透是数据问题(key 不存在),击穿是时间问题(一个 key 的瞬时),雪崩是容量问题(一片 key 或整个集群)。判断口诀:穿透查"不存在",击穿是"一个热点突然没了",雪崩是"一大片同时没了"。
  1. 副本、分区与网络:分布式独有的不一致来源

即使读写逻辑完美,分布式系统还有三类"结构性"的不一致来源:
  • 副本延迟:Redis 主从复制是异步的,主库写完、从库还没同步,读从库的请求就拿到旧值;
  • 分片不原子:数据分片后,一次事务涉及的多个 key 可能落在不同分片,跨分片没有原子操作,部分成功部分失败就留痕了;
  • 网络分区(脑裂):缓存集群与数据库之间断连,两侧各自为政,恢复后数据已分叉——这正是分布式系统最著名的"不可能三角"的由来。
  1. CAP 与一致性级别

分布式系统的任何取舍,都逃不开 CAP:网络分区(P)发生时,你必须在一致性(C)和可用性(A)之间二选一。注意 CAP 不是"三选二",而是"分区时二选一"——大多数业务系统选择 AP(保证可用),然后用下面的手段把"最终一致"收敛得足够快。
一致性从强到弱的光谱,工程上常这样分级:
级别
含义
典型手段
代价
线性一致
所有操作像按一个全局时钟顺序执行
单点串行 / 分布式事务 / Raft 复制
性能与可用性损失最大
顺序一致
各进程操作交错后等价于某个全局顺序
锁、同步协议
需要全局协调
因果一致(较弱)
有因果关系的操作顺序可见,无关操作可乱
向量时钟 / 版本化
实现复杂
单调读
读到的值不回退(不会先新后旧)
会话粘滞、版本比对
需绑定会话
最终一致
停止写入后,副本最终收敛
TTL、异步同步、补偿
窗口期内读到旧值
本文接下来所有方案,几乎都落在"最终一致"到"单调读"之间。把它们读明白后你会得到一个判断力:当业务提出"数据绝对不能错"时,正确答案往往不是"把缓存做一致",而是"这个数据根本不该放缓存"——下一章会详细展开。
  1. 一致性保障方案谱系

把上一章的问题收集起来,业界已经沉淀出一整套由弱到强的方案谱系。它们的共同目标只有一个:在业务容忍的时间窗口内,让缓存收敛到数据库的当前值,并把不一致的概率与时长压到工程上可接受。下面按"投入成本"从低到高逐一展开。
  1. TTL:最简单的最终一致

给每个缓存 key 设过期时间(TTL),是所有方案的地基。它不解决"当下不一致",只承诺"最多不一致 TTL 时长"。别看它朴素,它有三个不可替代的价值:
  • 兜底:无论上面任何方案出错(删缓存失败、消息丢失、双写交错),TTL 到期后缓存必然重建,脏数据不会永存;
  • 自愈:很多"忘了删缓存"的 bug,靠 TTL 自动愈合,给修复争取时间;
  • 简单:零额外组件、零代码复杂度,成本几乎为零。
代价是显而易见的:TTL 越长,不一致窗口越大;TTL 越短,命中率越低、回源越多。工程上常见做法是给 TTL 加随机抖动(如 base + random(0~1/5 base)),避免大量 key 同时到期触发雪崩——这是"用随机性买稳定性"的典型。
  1. Cache-Aside 的读修复:写后删 + 读回填

如果只能记住一个操作规范,那就是:读 miss 回填(Read Repair),写后删除(Write-through Invalidate)。前者让缓存永远只积累被真实访问过的数据;后者让写入路径最少只有一个操作。这条规范配合 TTL 兜底,已经能覆盖 80% 的业务场景。它的全部缺陷集中在第六章的双写竞态上——回填与删除的先后交错——于是有了下面的升级方案。
  1. 延迟双删:压缩不一致窗口

暂时无法在飞书文档外展示此内容
延迟双删的思路直击双写竞态:第一次删除后等一小段时间(N 毫秒),再删一次。目的是确保"旧值回填"发生在两次删除之间,让第二次删除把它清掉。要点与局限都很明确:
  • N 怎么定:必须大于"读请求从 miss 到回填"的最坏耗时上界,经验值 500ms~2s。取小了等于没删;取大了缓存空窗变长、命中率下降;
  • 删失败了怎么办:删除操作本身可能失败,需要消息队列或定时任务补偿重删,否则旧值会一直留着;
  • 局限:延迟双删是"概率压缩",不是"保证消除"。它把不一致窗口从"可能很长"压到"N 毫秒内",但极端调度下仍可能漏网。追求更强保证,看后面的版本号与订阅方案。
还有两个变体值得一提:异步化双删(第二次删除丢给 MQ,避免写请求被 N 毫秒阻塞)和串行化双删(对同一 key 的写操作加分布式锁串行执行,从源头消除交错)。变体的本质都是"把时间窗口换成队列或锁",用复杂度换确定性。
  1. 版本号与 CAS:让旧写不覆盖新值

双删的所有问题,都可以归结为一句话:旧值回填覆盖了新值,且系统无法区分新旧。那么最直接的解法是——给数据带版本号,写入时比较版本,旧版本直接丢弃。数据库行可以带 version 字段(每次 UPDATE 自增),缓存里同步存版本,回填/更新时用 Redis 的 Lua 脚本做原子比较:
-- KEYS[1] = 缓存 key,ARGV[1] = 新值,ARGV[2] = 新版本号
local cur = redis.call('GET', KEYS[1])
if cur and tonumber(cur) >= tonumber(ARGV[2]) then
    return 0      -- 已有更新版本,丢弃旧消息
end
redis.call('SET', KEYS[1], ARGV[1])
return 1
这套"比较并交换(Compare-And-Set)"的思路,就是把 CPU 的 CAS 原语搬到了分布式缓存里。它的好处是顺序不再依赖时序,而依赖版本:哪怕消息乱序到达,旧版本也覆盖不了新版本。业界成熟的工程化形态,就是上一条"写库 + binlog 订阅"链路里,消费端用版本号做幂等与去重——两者天然互补。
  1. binlog 订阅 + MQ:业务零侵入的最终一致

暂时无法在飞书文档外展示此内容
如果说延迟双删和版本号是"应用内自救",binlog 订阅则是"架构级根治"。以阿里开源的 Canal 为代表:Canal 把自己伪装成 MySQL 的从库,实时订阅主库的 binlog,把每一条行变更解析成事件,投递到 MQ,再由消费服务更新 Redis、搜索引擎或其他读副本。
  • 业务零侵入:写接口不需要再记着"删缓存",缓存更新逻辑集中在消费端,彻底消灭"忘了删"这类人为错误;
  • 可重放可追溯:binlog 是数据库的事实来源,事件在 MQ 里可留存、可重放,修复数据只需补推消息;
  • 天然有序 + 可幂等:按主键分区保证同一 key 的事件有序;配合上一节的版本号比对,乱序与重复都能被挡掉。
它的代价同样明显:新增 Canal、MQ 两条链路和两个故障点,消费延迟就是不一致窗口,binlog 有保留期和容量成本。工程判断:当"缓存更新逻辑散落在多个服务、反复出人为事故"时,才值得上订阅架构;小型系统用它属于杀鸡用牛刀。
  1. 分布式锁与串行化:用复杂度换确定性

如果业务对"同一 key 的并发写"要求强一致,另一个思路是让这些写操作串行执行:用分布式锁(Redis SETNX + 过期、ZooKeeper 临时节点、etcd 租约)把同一 key 的写串起来,或直接通过消息队列把写操作排队。串行化的好处是把并发交错变成线性顺序,双写竞态从根上消失;代价是吞吐下降、锁本身的可用性成为新风险——锁服务挂了,业务也要跟着降级。关于分布式锁(尤其 Redis 锁在 GC 停顿、主从切换下会失效的问题),业界有大量争论,这里只给一条实用结论:锁只用于关键

路过

雷人

握手

鲜花

鸡蛋

最新评论

QQ|Archiver|手机版|小黑屋|凡尘乡音—重庆市云阳县凡尘街坊—凡尘论坛—云阳论坛

GMT+8, 2026-11-8 13:02 , Processed in 0.123424 second(s), 23 queries .

© 2026 凡尘乡音|凡尘街坊便民信息交流平台 版权所有

返回顶部