资讯动态

删掉缓存不等于读到新值:数据库与缓存一致性的竞态全景、延迟双删边界与日志驱动的最终一致

发布时间:2026/10/19 1:59:06 来源:尧图企业网站定制
目录一、不一致从哪里来能被消灭吗一两个副本没有共同的事务二先给「一致」下可验收的定义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 事务同步回调官方文档 —— 提交后执行失效动作的标准挂载点

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑