☰
删掉缓存不等于读到新值:数据库与缓存一致性的竞态全景、延迟双删边界与日志驱动的最终一致
2026/10/19 1:59:03 网站建设 项目流程

目录

一、不一致从哪里来,能被消灭吗?

(一)两个副本,没有共同的事务

(二)先给「一致」下可验收的定义

1、三种可谈的口径

2、把目标改写成两个数字

(三)为什么不用分布式事务一把梭

二、四种基础时序:先动谁、怎么动

(一)先更新缓存,再更新数据库:最先排除

(二)先更新数据库,再更新缓存:两处硬伤

1、并发写的乱序覆盖

2、写而不读的无效计算

(三)先删除缓存,再更新数据库:竞态最宽

(四)先更新数据库,再删除缓存:默认答案及其剩余风险

1、竞态需要四个条件同时成立

2、剩余风险有多大

三、延迟双删:给「先删缓存」打的补丁

(一)第二次删除删的是谁

(二)窗口 N 怎么标定

1、读回填链路的高分位耗时

2、主从复制延迟

2.1 复制延迟为什么是变量

2.2 用观测值而不是拍脑袋

(三)三条边界:为什么它只是过渡方案

四、把删除变成可靠动作:失败之后的补偿链

(一)为什么是删除而不是更新:幂等性红利

(二)重试链路的完整形态

1、本地消息表:让「写库成功必有消息」

2、消费端:退避、幂等与死信

(三)两个容易漏掉的细节

1、删除必须在事务提交之后

2、缓存不可用时降级的是失效还是读

五、订阅变更日志:把失效从业务代码里搬出去

(一)链路构成与角色分工

(二)它买到了什么

(三)它带来的四个新问题

1、窗口变长且可变

2、顺序性要自己保证

3、批量变更的删除风暴

4、一套要养的设施

(四)适用判据

六、另一族方案:读写穿透与异步回写

(一)读穿透与写穿透

(二)异步回写的真实边界

七、最终一致的完整防线与工程细节

(一)四层防线

(二)版本号与条件写:让数据自证新旧

(三)与击穿防护的交叉:删除之后的重建竞争

八、选型:把方案放回业务要求里

(一)决策路径

(二)五条路线对比

(三)四类典型数据的落法

1、商品详情类:读极多、写少、可容忍秒级陈旧

2、账户余额与库存类:正确性优先

3、配置与字典类:变更极少、全局引用

4、计数与热榜类:写极频、精确性要求低

九、可观测与验收:一致性要能被测量

(一)四个核心指标

(二)上线前的三类演练

十、常见误区清单

参考资料


干货分享,感谢您的阅读!

「数据库与缓存的一致性到底怎么保证」是一道被讨论了十几年、至今仍在面试与评审现场反复出现的问题。常见的回答——先删缓存、先更新库、延迟双删、订阅变更日志、最终一致——然后在追问「延迟窗口取多少」「第二次删除失败怎么办」「订阅链路积压了算谁的」时哑火。

名词背得再熟,也替代不了对时序的推演:每一种方案的差别,本质上只是把「删除」这个动作放在时间轴的哪个位置、交给谁来执行、失败之后由谁兜底。

这次我们按这个思路从头推一遍:先钉死问题的边界——两个副本、没有共同事务,不一致注定是常态,能做的是让它有界;再把四种基础时序逐一放进泳道图里看竞态,解释为什么「先更新库再删缓存」成为默认答案而「先删缓存」的洞最大;然后专门处理延迟双删——它补的是哪个洞、窗口怎么标定、三条边界为什么决定了它只配做过渡方案;接着把「删除」升级成一个可靠动作,给出消息重试与本地消息表的完整补偿链;再用一整章拆解日志驱动的失效链路——它买到了什么、又带来哪四个新问题;最后把过期兜底、异步补偿、对账巡检叠成四层防线,给出按业务容忍度过筛的选型路径、四类典型数据的落法与一套可验收的观测指标。

方案的优劣不在于它在顺利时有多一致,而在于它在失败时能把不一致收敛到多小、多短、多可见。

一、不一致从哪里来,能被消灭吗?

(一)两个副本,没有共同的事务

所有讨论的起点是一个再朴素不过的结构:为了让读多写少的负载不必每次都落到数据库上,系统在数据库前面放了一层内存缓存。从这一刻起,同一份数据在系统里有了两个副本——缓存里一份,数据库里一份。数据库有事务,缓存也有自己的原子操作,但横跨两者的复合动作没有任何事务语义可言:更新数据库和操作缓存是两次独立的调用,中间隔着网络、进程调度与随时可能发生的失败。

这个结构决定了三件无法回避的事实。第一,缓存不参与数据库事务,提交与失效之间必然存在时间差,哪怕代码写得再紧凑,两个动作之间也隔着至少一次网络往返。第二,读路径会自发回填:旁路型缓存的标准读法是「未命中则回源读库、再把结果写进缓存」,这意味着旧值有机会在失效动作完成之后被重新装回去——很多竞态的根源不在写路径,而在这条看似无辜的读路径上。第三,任何一步都可能失败:删除请求可能超时,进程可能在写完库、还没来得及删缓存的间隙崩溃,缓存集群可能正好在抖动。

把这三件事实摆在一起,结论已经出来了:只要缓存与数据库是两套独立系统,不一致就不是需要被消灭的事故,而是这套结构的固有属性。所有方案的真实目标,是让不一致的窗口有界、可测量、可收敛——而不是让它不存在。想明白这一点,后面所有的方案对比才有统一的评价尺度。

(二)先给「一致」下可验收的定义

「保证一致性」这个说法太含糊,含糊到无法验收。在动手选方案之前,必须先把「一致」翻译成可以判真伪的命题。

1、三种可谈的口径

第一种口径是任意时刻相等:任何读请求在任何时刻,从缓存读到的值都等于数据库里已提交的最新值。这是最强的口径,也是旁路缓存结构下事实上无法达成的口径——两个副本的更新不同步,总存在一个瞬间两边不等。声称达成它的方案,要么偷换了定义,要么把读写全部串行化到失去缓存的意义。

第二种口径是读己之写:一个用户改完自己的数据,随后自己的读请求必须看到新值;别的用户晚几百毫秒看到,无所谓。这个口径贴合大量真实业务——用户改了昵称,刷新页面必须变,别人看到旧昵称再挂几秒没人在意。它可以用「写后短时间内该用户的读走主库或带版本校验」这类局部手段达成,不需要全局强一致。

第三种口径是有界陈旧:任何读请求读到的值,陈旧程度不超过一个明确的时间上界。这是绝大多数业务对缓存的真实要求,也是本文所有方案共同追逐的目标。「有界」两个字是关键:窗口可以是一百毫秒也可以是五分钟,但必须是一个写得进监控告警的数字,而不是「一般很快」。

2、把目标改写成两个数字

有界陈旧的「界」,在工程上应当拆成两个数字分开管理。软上界是失效链路正常工作时的收敛时间:同步删除是毫秒级,消息补偿是亚秒级,日志订阅链路是数百毫秒到秒级。硬上界是所有失效机制全部失手时的最后底线,它只由一个东西决定——键的过期时间。软上界衡量方案的日常表现,硬上界衡量方案的最坏表现;评审一个一致性设计,先问这两个数字,比听十分钟方案描述更有效。

(三)为什么不用分布式事务一把梭

每次讨论到这里,总有人提出:既然问题出在「两个副本不在一个事务里」,那把它们放进一个分布式事务不就完了?这个方向值得正面回答一次,因为答案里藏着对整个问题的定位。

从可行性看,主流内存缓存并不实现两阶段提交的参与者协议——它没有「预备写入、等待协调者指令」的语义,强行包一层要自研补齐,等于自己造一个分布式事务参与者。从代价看,两阶段提交给每次写增加两轮协调往返与协调者日志落盘,写路径时延成倍增长;准备与提交之间资源处于锁定状态,热点键上的锁竞争会把并发写放大成排队。最伤的是可用性反转:协调者或任何一个参与者故障,写路径整体阻塞——缓存本来是一个可选的加速层,摘掉它系统只是变慢;捆进事务之后,它变成了能拖死主链路的强依赖,这与引入缓存的初衷背道而驰。

而这些代价换到的收益却小得可怜:即便两个副本在提交瞬间严格一致,读请求在下一毫秒读到的仍然可能是任意一个副本上被后续写覆盖前的值——业务真正关心的「读到已提交的最新值」并没有自动成立。反过来,绝大多数业务对缓存的要求只是陈旧有上界,这用「删除加过期加补偿」三件普通零件就能满足,成本低一个数量级。至于确实需要强一致的少数读——扣减库存前的余额校验、幂等判断——正确做法是让这一小撮读绕开缓存直读主库,为百分之一的读付百分之一的代价,而不是为了它们把整条链路拖进分布式事务。

二、四种基础时序:先动谁、怎么动

写操作要同时安置两个副本,立刻产生两个选择:对缓存是「更新成新值」还是「直接删除」;先动缓存还是先动数据库。两两组合得到四种基础时序,把它们放进同一张矩阵,再逐一推演竞态,是理解一切进阶方案的地基。

(一)先更新缓存,再更新数据库:最先排除

这个时序几乎不需要竞态分析就能排除,因为它连单线程都不安全。缓存先被写成新值,随后的数据库更新一旦失败,缓存里就躺着一个数据库中从未存在过的值。更麻烦的是这个值不可回收:在缓存被写入与数据库失败被发现之间,任何读请求都可能已经读走了这个值并基于它做了决策——展示给了用户、参与了计算、写进了下游。没有任何机制能撤销一次已经发生的读。数据库回滚只能救数据库自己,救不了已经泄漏出去的幻影值。因此这个时序在任何严肃的设计里都应当直接划掉。

(二)先更新数据库,再更新缓存:两处硬伤

把顺序换过来,先落库再把缓存更新成新值,看起来稳妥了许多——至少缓存里的值总是数据库里存在过的。但它有两处硬伤,使得「更新缓存」在绝大多数场景下都输给「删除缓存」。

1、并发写的乱序覆盖

设想两个写请求 A 与 B 先后更新同一行,数据库靠行锁保证了提交顺序:A 先提交,B 后提交,库里最终是 B 的值。但两者随后各自去更新缓存,这两次缓存写入之间没有任何顺序约束——A 的进程可能刚好发生了一次垃圾回收停顿,或者网络多抖了一下,导致 A 的缓存更新在 B 之后到达。结果是:数据库里是 B,缓存里是 A,而且这个错误状态会稳定存在到下一次写或过期。写并发越高,这个乱序的概率越大。要修复它,需要给每次写带上版本号并在缓存侧做条件写入,复杂度立刻上了一个台阶。而「删除」天然免疫这个问题:两次删除无论谁先谁后,结果都是键不存在,下一次读会从库里取回正确的值——删除是幂等且交换的,更新不是。

2、写而不读的无效计算

更新缓存意味着每次写都要构造一遍缓存值。如果缓存的值是一个聚合结果——一个拼了七八张表的商品详情、一段渲染好的页面片段——那么每次写库都要重算一遍这个聚合,而这个新值未必会被读到:写密集、读稀疏的键,算出来的值还没被读过一次就被下一次写覆盖了。删除则把重建的成本推迟到第一次真正的读到来时才支付,天然是按需计算。只有当一个键重建代价极高、且写后几乎必然马上被读时,「更新缓存」才值得考虑,而且必须配上版本条件写。

(三)先删除缓存,再更新数据库:竞态最宽

于是主流做法收敛到「删除」。下一个问题是删除放在更新数据库之前还是之后。先看放在之前的版本,它曾经非常流行,理由听起来也直观:先把缓存清掉,就算后面更新库失败了,缓存里也不会有错值,最多是一次多余的回源。但把读请求放进时间轴,问题立刻显形。

推演这条时序:写请求先删除了缓存键;就在删除生效之后、数据库事务提交之前,一个普通的读请求到达,未命中,按标准流程回源——此刻库里还是旧值,它读到旧值;随后写请求的事务提交,库里变成新值;最后读请求把手里的旧值回填进缓存。终态是:数据库新值、缓存旧值,而且没有任何后续动作会来纠正它——写请求认为自己已经删过缓存了,任务完成。这份旧值将服务后续所有读请求,直到键过期或下一次写碰巧再来删除。如果过期时间设的是几小时,陈旧就持续几小时。

这个竞态的危险之处在于成立条件极其宽松:只需要一次未命中读落在「删除之后、提交之前」这个窗口里。这个窗口有多宽?删除生效到事务提交,中间隔着更新语句的执行与提交的持久化,通常是几毫秒到几十毫秒;如果写事务里还有别的语句、或者碰上锁等待,窗口能拉到数百毫秒。在读多写少的系统里,几十毫秒内出现一次对同键的未命中读是家常便饭——尤其这个键刚刚被删掉,接下来的第一个读必然未命中。也就是说,这个方案几乎是在主动制造竞态:删除动作本身就把「必然未命中的读」召唤了过来。

(四)先更新数据库,再删除缓存:默认答案及其剩余风险

把删除挪到更新之后,就得到了旁路缓存模式的标准写法,也是微软架构模式文档与各大工程实践里的默认答案:写请求先更新数据库并提交,然后删除缓存。它同样存在理论上的竞态,但成立条件苛刻得多。

1、竞态需要四个条件同时成立

推演这条时序里唯一的危险交错:一个读请求恰好未命中(条件一:键刚过期或刚被淘汰,注意这次不是被写请求删的,而是自然消失),回源读到旧值;在它把旧值回填回缓存之前,一个完整的写流程——更新语句、事务提交、删除缓存——全部执行完毕(条件二与条件三:读请求从回源到回填的收尾,比整个写事务加删除还慢);且在旧值回填之后,没有其他写请求再来触发删除(条件四:这是个低频写的键)。四个条件同时满足,旧值才会驻留。

关键在条件三:一次内存级别的回填动作,要慢过「网络往返加语句执行加提交落盘加另一次网络往返」的完整写链路。正常情况下前者是亚毫秒级,后者是几毫秒到几十毫秒级,慢一个量级以上。要让前者反超后者,读请求必须在回源之后、回填之前被卡住——一次长时间的垃圾回收停顿、一次线程调度饥饿、一次异常的网络重传。这些事件本身发生概率就低,还要恰好卡在那几毫秒的窗口里,还要恰好撞上同键的并发写,还要这个键恰好低频写。

2、剩余风险有多大

做一次量级估算:假设某键的读未命中率是百分之二,写频率是每秒一次,读请求收尾被卡住超过写链路时长的概率按十万分之一计——这已经是相当保守的估计——那么单键出现此竞态的概率在每秒千万分之几的量级,而且即便发生,还有过期时间兜底。与先删缓存那个「几乎主动制造」的竞态相比,两者概率差了至少四五个数量级。这就是为什么工程界把「先更新库、再删缓存」当作默认答案:不是它没有竞态,而是它的竞态又窄又浅,窄到可以交给下一层防线兜底。

但「默认答案」不等于「就此完事」。它还留着两个真实的口子:其一,删除动作本身可能失败——这是第四章的主题;其二,若读回源走的是从库,主从复制延迟会把「读到旧值」从小概率事件变成常态——这个问题与延迟双删共用一个根源,放在下一章一起处理。

三、延迟双删:给「先删缓存」打的补丁

延迟双删是面试里出镜率最高的名词之一,但多数讲述停留在「删两次、中间睡一觉」的口诀层面。要判断它值不值得用、怎么用对,必须回答三个问题:第二次删除删的是谁?中间那个延迟窗口怎么标定?它在什么情况下会失效?

(一)第二次删除删的是谁

延迟双删的完整动作序列是:删除缓存、更新数据库并提交、等待一个窗口 N、再删除一次缓存。两次删除各守一个洞。第一次删除清掉的是写之前就存在的旧缓存,让写期间的读尽量走库——这是「先删缓存」路线的本意。第二次删除针对的正是第二章推演过的那个竞态:在「删除生效到提交完成」的窗口里,未命中的读请求会把旧值回填进缓存;这些在途的读迟早会收尾,等它们全部回填完毕之后再删一次,就能把混进来的旧值清掉。

理解了「第二删是在收拾第一删召来的回填」,窗口 N 的含义就清楚了:N 必须大于所有可能回填旧值的读请求的收尾时间。等得不够久,还有旧值在路上,第二删扑了空;等得太久,不一致窗口被人为拉长,双删的意义被自己抵消。

(二)窗口 N 怎么标定

窗口的下界由两条链路中较慢的那条决定,再加安全余量。

1、读回填链路的高分位耗时

第一条链路是单机房、读主库场景下的:一次读请求从发起回源查询到把结果写回缓存的全程耗时,包含数据库查询、业务侧的反序列化与对象组装、写缓存的网络往返。这条链路的均值通常在几毫秒,但标定窗口必须用高分位值——第九十九点九分位或更高——因为竞态恰恰发生在那些被垃圾回收、调度延迟拖慢的长尾请求上。用均值定窗口,等于只防住了本来就不太可能出事的快请求。

2、主从复制延迟

第二条链路在读写分离的架构下出现,而且几乎总是更大的那一项:读请求回源读的是从库,主库提交的新值要经过日志传输与从库回放才可见。窗口若没有盖过复制延迟,读请求即便在第二删之后才回源,读到的照样是从库上的旧值,回填的照样是旧值——双删直接失效。

2.1 复制延迟为什么是变量

复制延迟不是常数。正常时它可能只有几十毫秒,但从库承接大查询、主库执行大事务或批量写入、网络出现抖动时,它能在几秒到几分钟之间波动;半同步复制退化为异步、从库单线程回放遇到热点行时,恶化得更快。用一个静态窗口去覆盖一个动态变量,注定顾此失彼。

2.2 用观测值而不是拍脑袋

因此窗口的标定必须建立在观测之上:持续采集复制延迟的高分位值与读回填链路的高分位值,取两者较大者再乘一个安全系数作为 N,并且在复制延迟突破窗口时告警——这时应当接受双删已暂时失效的事实,依靠过期时间与补偿链路兜底,而不是假装窗口仍然有效。网上流传的「延迟五百毫秒到一秒」只是某些系统在某些时期的经验值,照抄等于把别人的观测当成自己的承诺。

(三)三条边界:为什么它只是过渡方案

把上面的分析收拢,延迟双删有三条绕不过去的边界。

第一条边界:窗口是估计值。估短了漏删,旧值照样驻留到过期;估长了不一致窗口被拉大。复制延迟抖动时,任何固定窗口都会阶段性失效。它给出的从来不是保证,而是「在延迟分布平稳时大概率有效」。

第二条边界:第二次删除本身会失败。第二删绝不能用请求线程同步睡眠来实现——那会把写接口的时延直接加上整个窗口值,吞吐立刻塌下来;正确做法是投一条延迟消息或注册一个时间轮任务异步触发。但异步就意味着这条消息可能丢:进程重启、消息未持久化、缓存恰好抖动,任何一环断掉,双删就退化成单删,而单删(先删缓存)恰恰是四种基础时序里洞最大的那个。要堵住这个口子,需要持久化的重试队列——而这套设施与第四章的可靠删除完全同构,复杂度并没有省下来。

第三条边界:它对时序的补救是概率性的,与「先更新库再删缓存」相比没有任何一处更强。既然如此,一个自然的问题是:为什么不直接用后者?答案通常只有一个——存量代码已经写成了「先删缓存」,且分散在几十处改不动。这就是延迟双删的真实定位:存量「先删缓存」代码在完成改造或建成日志订阅链路之前的过渡手段。新系统直接从「先更新库、删缓存、异步补偿」起步,不需要经过这一站。

四、把删除变成可靠动作:失败之后的补偿链

前两章反复出现同一个尾巴:「删除失败怎么办」。这一章正面处理它。目标是把删除从一次「尽力而为」的调用,升级成一个「最终必达」的动作——这是「最终一致」四个字里「最终」的第一层含义。

(一)为什么是删除而不是更新:幂等性红利

在设计补偿链之前,先把第二章埋下的一个结论说透,因为它决定了补偿链能做多简单:失效动作选「删除」而不是「更新缓存」,最大的红利在于删除是幂等的、且任意乱序的删除序列结果等价。删一个不存在的键是空操作;同一个键删一次和删十次终态相同;两条针对同键的删除消息谁先谁后无所谓。这意味着补偿链可以放心大胆地重试、重放、甚至重复消费,不需要去重、不需要排序、不需要担心「旧的删除盖掉新的删除」——因为删除没有值,无所谓新旧。反观「更新缓存」的补偿:重试必须防止旧值覆盖新值,于是要引入版本号与条件写,链路上每个环节都要保序或去重,复杂度翻着倍地涨。选择删除,等于把分布式系统里最难的两个问题——顺序与重复——在失效这件事上直接豁免了。

(二)重试链路的完整形态

完整的补偿链分五跳:业务写库并提交;同步删除缓存,成功即结束——这条主路径覆盖百分之九十九以上的写;同步删除失败时,投递一条失效消息;消费者拿着消息重试删除,指数退避,直到成功;超过重试上限的消息进入死信队列并触发告警,由人工或对账任务收尾。

1、本地消息表:让「写库成功必有消息」

这条链路里最隐蔽的洞不在重试,而在投递:如果「写库」和「发消息」是两个独立动作,进程恰好在两者之间崩溃,消息就永远不会出现——库改了,缓存没人删,补偿链形同虚设。堵住它的标准做法是本地消息表:把「待删除缓存键」作为一条记录,与业务更新写进同一个数据库事务——事务提交,消息记录必然存在;事务回滚,消息记录必然不存在。后台任务扫表投递,投递成功后标记完成。这利用数据库自己的事务,把「写库」与「有消息」焊成了原子。若消息中间件支持事务消息(半消息加事务回查),也能达到同等效果,代价是引入对中间件特性的依赖。

2、消费端:退避、幂等与死信

消费端的纪律有三条。重试要带指数退避与抖动,避免缓存故障恢复的瞬间被积压的删除请求打出第二次故障。消费要按幂等设计——前面说过删除天然幂等,这里只需保证消费框架的重复投递不会引发业务报错。死信必须有人看:一条进了死信的失效消息,意味着某个键的陈旧已经只剩过期时间这最后一道防线,告警的严重级别应当按这个后果来定,而不是当成普通的消费失败。

(三)两个容易漏掉的细节

1、删除必须在事务提交之后

一个常见的实现错误是把「删除缓存」写在事务内部——代码顺序上在更新语句之后,但事务尚未提交。此时删除已生效,其他读请求未命中回源,由于隔离性读到的还是旧值,回填旧值;随后事务才提交。这等于亲手把「先更新库再删缓存」改造回了「先删缓存」的竞态结构。正确的挂载点是事务提交之后的回调(如事务同步器的提交后钩子),并且要意识到:提交后进程崩溃、回调没执行的情形,正是上一节消息表要兜住的场景。

2、缓存不可用时降级的是失效还是读

缓存集群整体抖动时,同步删除会大面积失败,补偿队列快速堆积。此时要预先想清楚降级顺序:失效动作可以降级为「只记消息、不同步删」,让写路径不被缓存故障拖慢;但读路径的降级要谨慎——缓存不可用时读全部回源,数据库是否扛得住,属于容量规划要提前回答的问题。两者共同的前提仍然是:所有键都有过期时间,缓存恢复后即便有漏删,陈旧也有硬上界。

五、订阅变更日志:把失效从业务代码里搬出去

前面所有方案有一个共同前提:每一处修改数据库的代码,都记得在合适的时机去删缓存。这个前提在小系统里成立,在真实的中大型系统里会被一点点蚀空——后台管理系统直接改库,数据订正脚本批量更新,别的团队的服务共享同一张表,运营工具走了另一条写路径。任何一个入口忘了删缓存,就多了一类「改了不生效」的客诉。日志驱动的失效方案,就是对这个前提的釜底抽薪。

(一)链路构成与角色分工

链路的核心思想是:不再依赖业务代码通知「我改了数据」,而是直接订阅数据库自己的变更记录。以最常见的形态为例:数据库把每一行的变更写入二进制日志——这本来是为主从复制准备的;一个订阅组件伪装成从库连上来,拉取日志并解析成结构化的变更事件(哪张表、哪个主键、变更前后的值);事件按主键哈希投进消息队列的分区,保证同一行的变更顺序不乱;末端的失效服务消费事件,映射出缓存键并执行删除。开源生态里这一层有成熟的选择——阿里开源的 Canal、红帽主导的 Debezium 都是这条链路上被广泛使用的订阅组件,前者深耕 MySQL 生态,后者覆盖多种数据库并与 Kafka Connect 体系深度集成。

这条链路上有一个不起眼但决定可靠性的细节:消费位点。订阅组件与消费者各自持久化自己处理到的日志位置,任何一环崩溃重启,都从上次的位点继续——事件不会丢,只会迟到或重复。配合删除的幂等性,重复恰好无害。「失效动作最终必达」在这条链路上是结构性的保证,而不是靠业务代码自觉。

(二)它买到了什么

第一是覆盖所有写入口。触发源从「业务代码记得调」变成「数据库真的变了」:后台直改、脚本订正、其他服务直写,只要动了库,日志里就有记录,失效就会发生。那类「某个入口忘了删」的整族问题被连根拔掉。

第二是与业务代码彻底解耦。失效逻辑集中到一个消费者里,键的映射规则、重试策略、监控埋点都只有一份;新增一种缓存键,改的是消费者的映射配置,而不是散落各处的几十个写点。写路径上的业务代码退回到它本来的样子:只管写库。

第三是天然的重放能力。位点可以回退,事件可以重放。缓存集群故障恢复后怀疑有漏删?把位点拨回故障开始前,重放一遍这段时间的删除即可。这种「时光倒流」的修复能力,是同步删除加消息重试的方案给不了的。

(三)它带来的四个新问题

链路从业务代码里的一跳,变成了「日志落盘、订阅解析、队列投递、消费执行」的四跳。每一跳都是新的延迟来源、新的故障点、新的运维对象。

1、窗口变长且可变

四跳链路的正常端到端延迟在百毫秒量级——比同步删除慢了两个数量级。订阅组件重启、消息队列积压、消费者处理变慢时,延迟会窜到秒级甚至分钟级。这意味着「失效链路延迟」必须成为一条核心监控:采集从数据库提交时间戳到删除实际执行时间戳的差值,盯它的高分位,超过业务承诺的陈旧上界就告警。没有这条监控的日志驱动链路,等于开着一辆没有仪表盘的车。

2、顺序性要自己保证

同一行的两次变更,若被投进不同分区或被并发消费,到达失效服务的顺序可能与提交顺序相反。这里再次体现「删除优于更新」:如果消费动作是删除,乱序无所谓——两次删除以任何顺序执行,终态都是键不存在;如果消费动作是「按变更后的值更新缓存」,乱序就是灾难——旧事件覆盖新事件,缓存被写成旧值且无人再来纠正。所以这条链路的铁律是:按主键分区保序是基本盘,消费动作坚持只删不更,除非你准备好了在值里携带版本并做条件写。

3、批量变更的删除风暴

一次批量订正更新一百万行,日志里就是一百万条变更事件,链路末端就是一百万次删除。瞬时删除让缓存命中率断崖式下跌,紧随其后的是回源洪峰砸向数据库——这是一次自己制造的缓存雪崩。处方有三层:消费端做同键合并与限速,把删除平摊开;订正类操作错峰执行并提前通知容量;对「全量刷新」这类极端场景,改用版本号整体切换——新数据写进带新版本前缀的键空间,切换一个版本指针,旧键空间自然过期——让「失效一百万个键」变成「改一个指针」。

4、一套要养的设施

订阅组件要做高可用部署与位点管理,表结构变更时解析规则要跟着维护,消息队列要容量规划,失效服务要监控告警。团队里必须有人真正理解这条链路的每一跳——否则它出故障的那天就是一个黑盒。这是真实的、持续的人力成本,任何把日志驱动说成「一劳永逸」的描述都省略了这一段。

(四)适用判据

把收益与代价放上天平,判据可以收敛成三个条件:写入口是否多于一个服务;缓存键的种类是否多到映射规则值得集中管理;团队是否具备运维这条链路的能力。三个条件占了两个,日志驱动就是正确的投资——它用一条链路的运维成本,换掉了散落全系统的失效正确性负担。反过来,单一服务写库、缓存键屈指可数的系统,同步删除加消息重试已经足够,为「优雅」付运维税并不划算。

六、另一族方案:读写穿透与异步回写

前面的方案全部属于旁路型:业务代码同时面对缓存与数据库,自己编排读写时序。还存在另一族思路——让缓存层成为数据的唯一入口,业务代码只跟缓存层说话,数据源的读写由缓存层内部代办。责任搬了家,值得单独一章看清楚它搬走了什么、又搬来了什么。

(一)读穿透与写穿透

读穿透:读请求只查缓存层,未命中时由缓存层自己去数据源加载并回填,调用方对「回源」无感知。它的实质是把旁路读的三步(查缓存、回源、回填)收敛到一个组件内部。收益不只是代码整洁:回源逻辑集中之后,天然适合在同一处做互斥重建——同一个键的并发未命中只放一个请求去加载,其余等待结果——本地缓存框架如 Caffeine 的加载器机制就是这个模式的进程内实现。对一致性而言,读穿透本身不解决失效问题,它仍然需要外部的删除或过期来触发重新加载;它解决的是「重建风暴」这个与失效相邻的问题。

写穿透:写请求交给缓存层,由它同步写数据源、再更新自身,两步都成功才向调用方确认。单一写入口让时序完全可控,「先库后缓存」在组件内部固化,不再依赖业务代码的自觉。它的软肋是前提:所有写都必须经过缓存层。任何绕过它直写数据库的路径——订正脚本、后台工具、其他服务——都会让缓存层浑然不觉地持有旧值。这个前提在自建业务系统里很难长期守住,这也是写穿透在互联网业务里远不如旁路型普及的原因;它更常出现在缓存层由基础设施统一提供、写路径天然收口的场景里。

(二)异步回写的真实边界

异步回写在写穿透的基础上再进一步:写请求只更新缓存就立即返回,数据源的写入由后台异步批量完成。写时延降到内存操作的量级,批量合并还能大幅摊薄数据源的写压力——数据库自身的缓冲池刷脏、操作系统的页缓存回写,用的都是这个思路。但必须把它的代价说得毫不含糊:在异步刷盘完成之前,缓存是唯一持有最新数据的地方;缓存层此刻宕机,这部分数据是真正的丢失,不是陈旧。这一条把它与前面所有方案划进了不同的风险等级——其他方案最坏读到旧值,它最坏丢数据。因此它只适合两类数据:可容忍丢失的(点赞数、浏览计数、行为埋点),或缓存层本身具备持久化与副本能力、事实上已经是一个带持久化的存储系统的。把账户余额、订单状态放进异步回写,是把主存储的职责压在了一个没有主存储承诺的组件上。

七、最终一致的完整防线与工程细节

单看任何一个机制,都能找到它失手的场景:同步删除会失败,消息会积压,日志链路会停摆,窗口会被击穿。最终一致的工程含义,不是选中某一个机制然后信任它,而是把多个互相独立的机制叠成防线,让每一层的失败都有下一层接住。

(一)四层防线

第一层是同步删除:写库提交后立即删缓存,毫秒级收敛,覆盖绝大多数写。它的失败模式是进程崩溃、网络抖动、缓存不可用。第二层是异步补偿:消息重试删除,或日志订阅链路兜底失效,接住第一层的漏删,亚秒到秒级收敛。它的失败模式是链路积压与极小概率的事件丢失。第三层是过期时间:所有键必须有过期时间,它接住前两层都漏掉的极端情形,是不一致窗口的硬上界。它几乎没有失败模式——只要制度上杜绝「永不过期」的业务键。第四层是对账巡检:后台任务抽样比对缓存值与库值,发现差异即修复并计数上报。它的意义超出兜底本身:把「系统是否一致」从信仰变成一个可以画在面板上的数字。

四层各自独立、依次接力,前一层的失败恰好是后一层的输入。评审一个设计是否达到「最终一致」,就看这四层是不是都在、每层的收敛时间是不是都有数。

(二)版本号与条件写:让数据自证新旧

防线之外,还有一个把竞态窗口进一步压薄的工具:让值自己携带新旧凭证。做法是给数据行维护一个单调递增的版本(更新时间戳、序列号或事务序号),回填缓存时把版本写进值里,并用缓存的原子能力做条件写入——仅当缓存中现存值的版本更低时才允许覆盖。有了这一层,第二章那个「旧值回填覆盖新值」的竞态被直接拆除:旧读携带旧版本,条件写会拒绝它。Facebook 在 Memcache 规模化实践中公开的租约机制,本质上就是这个思路的工业化形态——未命中时缓存发一个租约令牌给回源的客户端,期间键被删除则令牌作废,携带作废令牌的回填被拒绝,一并解决了陈旧回填与重建风暴两个问题。条件写的成本是值结构与写路径的改造,通常只值得给最核心的少数键上——这也符合防线思维:普遍问题用普遍防线,尖锐问题上专用工具。

(三)与击穿防护的交叉:删除之后的重建竞争

失效方案落地时会撞上一个相邻问题:删除做得越及时,热点键上的重建竞争就越凶。一个每秒被读几万次的键被删除的瞬间,几万个未命中同时涌向数据库——失效的正确性直接制造了击穿的可用性问题。两者必须一起设计。互斥重建是基本款:同键并发未命中只放一个请求回源,其余短暂等待或拿旧值先用。逻辑过期是热点键的进阶款:值里带一个逻辑过期时间,物理键不设过期或设很长,读到逻辑过期的值时返回旧值并异步触发重建——它用「短暂读旧」换「永不击穿」,恰好是一致性与可用性折中的一个具体样本。要点是想清楚方向:失效策略在压缩「读到旧值」的窗口,击穿防护在容忍旧值换取稳定,两者的参数要在同一张表上对齐,而不是两拨人各调各的。

八、选型:把方案放回业务要求里

(一)决策路径

选型的第一个问题永远不是「哪个方案先进」,而是「这份数据能容忍多久的陈旧」。从这里出发,四个问题问完,方案自然落定。

第一问:容忍度是毫秒级还是可以到秒级?几乎不能容忍的——余额校验、库存扣减前的判断——不要用缓存承载这次读,直连主库或改造读路径,为少数强一致读单独付费。第二问:写入口是否只有一个服务?多入口的系统,业务代码级的失效注定挂一漏万,日志订阅是结构性的答案。第三问:存量代码是否已是「先删缓存」且短期改不动?是,用延迟双删过渡,同时把迁移排上日程;否,直接落到默认答案——先更新库、再删缓存、删除失败走消息补偿。最后叠加两条横切规则:无论选了哪条路,过期时间与对账巡检都必须有,它们是防线而非方案的替代品;失效动作永远选删除而非更新,除非该键重建代价极高且写后必读,且你愿意为版本条件写买单。

(二)五条路线对比

把五条路线并排:先更新库加删缓存,窗口毫秒级、实现最轻,失败模式是删除失败后旧值驻留到过期,是绝大多数读多写少业务的起点。加消息重试,把漏删的收敛从「等过期」变成「秒级补偿」,代价是一套消息设施,适合对陈旧上界有明确承诺的核心数据。先删缓存的单删,竞态即长期陈旧,新系统不应采用。延迟双删,窗口内可读旧值、窗口标定依赖观测、第二删失败即退化,定位是存量代码的过渡。日志订阅失效,窗口是链路延迟、复杂度最高,换来写入口全覆盖与集中治理,适合多入口多键种的中大型系统。读这张表的正确方式不是从上往下找「最好的」,而是从轻到重找「够用的」:窗口要求越严、写入口越多,才越有理由往重走;反方向的选择是在给自己加运维税。

(三)四类典型数据的落法

1、商品详情类:读极多、写少、可容忍秒级陈旧

默认答案即可:先更新库再删缓存,删除失败投消息重试,过期时间半小时到几小时。价格这类页面上敏感的字段若要求更严,单独拆键、单独设更短过期,而不是为整个详情提高标准。有多个运营入口改库的,上日志订阅收口。

2、账户余额与库存类:正确性优先

展示用途的余额可以走缓存加短过期,几秒的陈旧无伤大雅;但判断用途的读——扣减前校验、风控决策——必须绕开缓存直读主库,或使用数据库原子操作把判断与扣减合成一步。这类数据的正确姿势不是把缓存一致性做到极致,而是承认缓存不承载正确性,让正确性回到事务里去。

3、配置与字典类:变更极少、全局引用

进程内缓存加集中缓存两级,过期可以放宽到分钟级,变更时通过配置中心或广播通知各实例主动失效。它的风险不在时序竞态,而在「有实例没收到通知」,因此对账巡检(比对各实例版本号)比失效链路本身更重要。

4、计数与热榜类:写极频、精确性要求低

点赞数、阅读数这类数据天然适合异步回写:缓存里累加,批量刷库,接受宕机丢一小段增量。热榜的聚合结果用「定时重算加版本切换」代替「逐键失效」——每分钟算一版新榜单写进新版本键,切换指针,旧版本自然过期,完全绕开失效时序问题。

九、可观测与验收:一致性要能被测量

一个宣称「最终一致」的系统,如果说不出自己当前的不一致窗口是多少、上周检出了几次陈旧读,那么「最终一致」只是一句愿望。观测体系是把愿望变成承诺的唯一途径。

(一)四个核心指标

失效动作成功率:同步删除与补偿删除的执行结果,按键类别分开统计——全局均值会把某个业务域的局部故障摊平成看不见的小数。告警条件是成功率跌破阈值或死信队列非空。失效链路延迟:从写提交时间戳到删除实际执行时间戳的差值,取高分位。这个数字就是系统实际的软上界,超过业务承诺值即告警。陈旧读检出率:对账巡检的检出数除以抽样数。它的绝对值意义有限,趋势才是信号——检出率突增说明某条防线破了。过期兜底覆盖率:无过期时间的业务键数量,理想值恒为零。这是硬上界存在性的直接检查,一条扫描任务就能实现,性价比极高。

排障时这四个指标构成固定的三步:客诉「改了没生效」,第一眼看失效链路延迟是否在涨,第二眼看死信队列是否非空,第三眼查具体键的操作日志。三步定位不了的,几乎都是「某个写入口绕过了失效逻辑」——这正是第五章日志驱动要解决的问题,也反过来验证了它的价值。

(二)上线前的三类演练

指标之外,防线的有效性要靠演练证明。第一类:杀失效链路——停掉消息消费者或订阅组件十分钟再恢复,验证位点续放、积压消化速度,以及期间陈旧是否被过期时间兜住。第二类:拉高复制延迟——在从库上模拟大查询把延迟顶到秒级,验证依赖窗口的方案(延迟双删)是否如预期退化、告警是否触发。第三类:批量订正压测——按真实订正规模回放变更事件,验证删除风暴下的限速与合并是否生效、回源洪峰数据库是否扛得住。三类演练各针对一层防线的失败模式,全部通过,才谈得上「最终一致」四个字可以写进服务承诺。

十、常见误区清单

最后把散落全文的判断收拢成一张清单,每一条都对应一个真实世界里反复出现的错误。

  • 把「先删缓存」当成更安全的顺序。恰恰相反,删除动作召来必然未命中的读,旧值回填后长期驻留,它是四种基础时序里竞态最宽的一个。
  • 把延迟双删当成终态方案。窗口是估计值、第二删会失败、复制延迟会击穿窗口——它是存量代码的过渡补丁,不是承诺。
  • 延迟双删用同步睡眠实现。写接口时延直接加上整个窗口,吞吐塌方;第二删必须异步化并带重试。
  • 失效动作选「更新缓存」图省一次回源。并发写乱序会让旧值覆盖新值,除非配了版本条件写;删除的幂等性红利远大于省掉的那次回源。
  • 删除写在事务提交之前。隔离性让并发读回源拿到旧值并回填,等于亲手复刻「先删缓存」的竞态。
  • 写库与发失效消息不在一个事务里。进程在两者之间崩溃,消息永不出现;本地消息表或事务消息才能焊住这个缝。
  • 给核心键设「永不过期」。这等于拆掉硬上界,任何一层防线漏掉的陈旧都将永久驻留。
  • 日志订阅链路上用「按变更后的值更新缓存」。乱序消费让旧事件覆盖新事件;消费动作坚持只删不更。
  • 批量订正不通知缓存侧。百万行订正就是百万次删除的风暴,命中率断崖加回源洪峰,是自己制造的雪崩。
  • 监控只有全局均值。按键类别细分的成功率与高分位延迟才有诊断价值,全局均值是最好的遮羞布。
  • 用分布式事务追求任意时刻一致。付出时延、锁定与可用性反转的代价,买到的口径业务并不需要;强一致的少数读应当直读主库。
  • 说得出方案,说不出窗口。「保证最终一致」的正确表述是两个数字:正常时多少毫秒收敛(软上界),最坏多久收敛(硬上界)。说不出数字的一致性,等于没有一致性。

数据库与缓存的一致性问题之所以历久弥新,是因为它是分布式系统三个基本困难——副本、失败、异步——在一个最小场景里的浓缩。把这个两副本的小问题按本文的路径推演清楚,读者再去面对多副本、跨地域、多存储的一致性设计时,会发现用的仍然是同一套思维:先给一致下可验收的定义,再枚举时序找竞态,然后为每个失败模式安排下一层防线,最后用指标与演练把承诺钉进监控面板。

参考资料

模式与工程实践

  • 旁路缓存模式(微软架构中心) —— 读写流程、适用条件与生命周期管理的官方论述
  • Facebook 的 Memcache 规模化实践(NSDI 2013) —— 租约机制、失效链路与跨地域一致性的完整工程报告
  • 缓存的挑战与策略(亚马逊构建者文库) —— 过期、失效与雪崩防护的实践总结
  • 缓存失效为什么难(Martin Fowler) —— 关于缓存失效之难的经典论述
  • 缓存踩踏问题 —— 删除后重建竞争的形式化描述与常见解法
  • Caffeine 缓存框架 —— 进程内读穿透加载与自动刷新的参考实现

一致性模型

  • 一致性模型谱系(Jepsen) —— 从线性一致到最终一致的完整分类与形式定义
  • 最终一致(维基百科) —— 收敛语义与常见实现路径
  • 分布式事务的两阶段提交 —— 协调流程、阻塞性质与失败场景

变更日志与订阅链路

  • MySQL 二进制日志(官方手册) —— 变更日志的格式、写入时机与可靠性保证
  • MySQL 复制延迟的观测(官方手册) —— 主从延迟的度量口径
  • Canal 项目 —— 基于日志伪装从库的增量订阅组件
  • Debezium 文档 —— 变更数据捕获平台的连接器、位点与容错语义
  • PostgreSQL 逻辑复制(官方手册) —— 另一条变更捕获通路的官方说明

缓存实现与失效机制

  • Redis 键过期机制(官方文档) —— 过期语义、惰性删除与主动采样
  • Redis 复制(官方文档) —— 缓存侧副本的异步复制语义
  • RocketMQ 事务消息(官方文档) —— 半消息与回查机制,本地消息表的中间件化替代
  • Spring 事务同步回调(官方文档) —— 提交后执行失效动作的标准挂载点

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询