缓存失效怎么选:TTL、版本与事件
卷中目录
缓存难的部分很少是把值放进去,而是决定它什么时候必须消失。读路径上省下的那一次查询,会在写路径上变成一个问题:哪些副本是旧的,谁负责把它们作废,作废之前读者看到旧值能不能接受。失效策略选得不合适,轻则页面晚几秒刷新,重则把别人的权限、余额或草稿状态卡住,直到时钟碰巧走到。

可选的办法大体是三条。按时间过期,按版本把旧键扔掉,按写入事件去通知持有者删除。三条可以叠在一起,但不能互相替代。下面用“笔记详情”和“账号下的笔记列表”这两种读模型,把选择的依据写清楚。先写容忍度,再谈键,最后才谈用哪一种失效。顺序反了,人们会先选一个熟悉的中间件,再让业务去迁就它的过期语义。
先写明能容忍的旧
做缓存之前先写一句容忍度,而不是先选中间件。笔记的阅读数晚三十秒变化,读者无感,业务也不用它做结算。笔记是否已发布,晚三十秒就会让编辑以为发布失败再点一次,造成重复提交。成员是否还在账号里,晚三十秒会让刚被移出的人继续看到草稿。三种数据不能共用一个生存时间。
还要分清缓存的是源数据还是派生物。源数据的权威在数据库。缓存挂了,应该还能回源读到正确结果,只是更慢。派生物例如“这个账号最近二十条已发布标题”,重建要扫表,成本更高,失效也更要成套。把源数据放进缓存时,键和失效都要简单。把派生物放进缓存时,想清楚它依赖哪些写入。说不清依赖,就不要缓存这个派生物。
容忍度要写成可观察的句子。例如“撤稿后,详情接口在两秒内不再返回正文;搜索摘要允许晚一分钟;阅读数允许晚半分钟”。有了这三句,生存时间、版本和事件各自该覆盖哪一句就清楚了。没有这三句,争论会变成“要不要上消息队列”,那是工具选择,不是正确性选择。
生存时间适合容忍窗口明确的读
生存时间好理解:写入缓存时给定一个秒数,到期删除,下次读取再回源。它不需要写路径知道缓存的存在。读多写少、旧值可接受时,这很合适。阅读数、不参与权限的展示文案、外部平台的昵称,都可以这样放。
边界也很硬。到期前的每一次读取都可能是旧的,没有“写完立刻可见”的保证。把生存时间改短,旧窗口变小,回源变多,缓存的意义变弱。把生存时间改长,故障时的错误也会被记住很久。负缓存同样如此。查询一个不存在的笔记编号,若把“没有”缓存十分钟,作者刚创建完,接口仍会说没有。负缓存的生存时间要比正缓存短,并且创建成功的写入要能删掉这个“没有”。
同时过期会造成踩踏。一批键在同一秒到期,请求一起打到数据库。可以把到期时刻打散,在基础秒数上加一个小的随机量。也可以让单个请求回源,其余请求短暂等待这个结果,避免同一键上许多请求同时查库。等待要有上限,回源失败时不要把失败永久记成空值。空值若来自一次超时,它表示“刚才没问到”,不是“确定没有”。把两者写成同一个缓存值,下游故障会变成大面积的虚假缺失。
时钟是另一处边界。应用机器的时间若漂移,可能提前过期,也可能过期更晚。生存时间用缓存服务自己的时钟,不要用应用算好的绝对时间戳再让另一台机器解释,除非所有机器的对时是你明确信任的。多级缓存各有各的生存时间时,用户看到的旧窗口是各级窗口叠起来的,不是你在某一级上设置的那个数字。浏览器一分钟、边缘节点五分钟、应用缓存三十秒,读者可能在六分钟里一直看见旧标题。文档里要写叠加后的窗口,不要只写你改过的那一个配置。
生存时间也救不了“写路径根本没失效,只是碰巧过期了”。若正确性依赖过期,你等于用时钟做业务协议。时钟不会在撤稿的那一刻响起,它只在秒数走完时响起。能接受这个空隙,再用它。不能接受,就不要把生存时间当成撤稿机制。
版本适合让读路径自己发现旧
版本不依赖通知送达。资源每次实质变更,版本号递增。缓存键带上版本,例如笔记详情带 note:{id}:v:{ver}。读者先拿当前版本,再按版本取正文。版本变了,旧键不再被读到,可以等它自己过期,也可以异步删除。删除失败只浪费空间,不会把旧正文重新送出去,这是版本键相对“原地覆盖”的好处。
版本从哪里来要固定。数据库里的整数列比“用更新时间当版本”稳。更新时间会碰撞,也会因为时钟回拨而倒退。整数只增不减,冲突时用条件更新:where id = ? and version = ?,成功才把版本加一。这样缓存失效和并发写入用的是同一条事实。两个编辑同时保存,只有一个条件更新成功,失败的一方必须重读再提交,缓存里也不会出现“后写的请求把版本写小”的倒退。
列表比单条难。列表的内容依赖很多行,“列表版本”可以放在账号上。该账号下任何笔记的发布、撤回、删除,都让账号的列表版本加一。列表缓存键带上这个版本。这样不需要知道列表缓存具体存了哪些键,旧列表会因为版本对不上而不再被命中。代价是这个账号的全部列表缓存一起失效,包括只改了一条笔记、其实另一页并没有变的情况。写得频繁的账号会几乎总是回源。这时版本策略仍然正确,只是不再省事,需要回到前面的问题:这个列表是否值得缓存。
版本也会过期。若读者缓存了版本号本身,它仍可能拿着旧版本去取旧正文。所以版本号的缓存要么生存时间很短,要么放在和写入同一处、读的时候总能看到新值的存储里。常见的放法是版本走数据库或一个强一致的小键,正文走容量更大的缓存。少一次数据库读取的诱惑,常常会让人把版本和正文捆在同一个长寿命缓存里,失效语义就退回到生存时间。
条件请求是版本的一种对外形式。响应里给出验证标记,客户端下次带来。标记不符才回传正文。这能省带宽,但不能代替服务端的授权。标记里不要只放笔记版本,还要考虑主体是否仍有权查看。无权时返回拒绝,不要返回旧正文,也不要靠“标记没变”继续使用本地副本。本地副本是客户端的事,服务端至少不能在人已经被移出后主动再送一次。
原地覆盖同一个键看起来更省空间。覆盖要原子,而且读者可能在覆盖过程中读到半截。更麻烦的是并发回源:一个旧请求慢悠悠地在新版本写入之后才把旧正文写回同一个键,新值就被旧值盖掉了。版本键避免这件事,因为旧请求写的是旧版本的键,新读者不会再去读它。若必须原地覆盖,写入时要带上期望版本,版本不匹配就放弃这次回填。
事件适合写完就要被别人看见的变更
有些变更不能等生存时间,也很难用一个版本键覆盖所有副本。例如撤稿之后,内容分发网络上的边缘节点、搜索索引、推荐用的摘要,都还握着旧标题。这些系统不会来问你的版本号。这时写入要发出失效事件:哪类资源、哪个编号、新版本、发生时间。持有者收到后删除或重建。
事件不可靠是常态,不是例外。进程在提交数据库之后、发出事件之前崩溃,缓存会一直旧下去。把事件和业务写入放进同一事务的发件箱里,由另一个投递器发送,投递成功再标记。接收方按至少送达一次来写:同一条事件来两次,删除两次没有关系,重建两次结果应相同。不要假设事件只来一次,也不要假设先发出的事件先到达。
顺序错了会把新值删掉。先处理了版本 9 的失效,又处理迟到的版本 8,若无脑删除再回源,还好,回源能读到 9。若事件里携带正文直接写入缓存,迟到的 8 会覆盖 9。失效事件只携带编号和版本,让接收方回源,并且忽略版本小于当前值的事件。不要让事件成为第二份权威正文。正文的权威仍是数据库。事件是“请你再看一眼”的信号。
事件风暴也要有界。一次批量撤稿几千条,就发出几千条失效。接收方若每条都同步重建复杂列表,写入会被读放大拖垮。可以按账号合并:短时间内同一账号只重建一次列表。合并会再引入一个很小的延迟,这个延迟要写进容忍度。不能接受这个延迟,就不要走合并。合并之后仍要保留这批编号里最大的版本,迟到的旧事件才能被比较掉。
谁来订阅要显式登记。新加一个缓存层却忘了订阅,这个层会静静地旧着。启动时做一次对账比事后查事故便宜:按资源类型列出持有者,对账任务抽查版本是否一致。对账不是主路径,主路径仍然是事件;对账用来抓住漏订和丢失。对账发现不一致时,修的是失效路径,不是在库里把数据改回旧缓存的样子。
三层叠在一起时怎么分工
一个具体的读路径可以这样分工。笔记正文使用版本键,写路径递增版本,读路径按版本取,生存时间只作为垃圾回收,不作为正确性来源。阅读数使用短生存时间,允许旧。账号列表使用账号级列表版本。撤稿除了递增版本,再发事件给搜索和边缘缓存,因为那些副本不读你的版本键。
不要三层都当成正确性来源。正确性只靠一处:回源时的数据库,加上写路径上的版本条件。缓存是加速。加速层全部失效时,系统应当变慢,而不是变错。若缓存挂了就拒绝服务,说明你把缓存放进了关键路径的正确性里,失效策略会无法单独演进。阅读数缓存挂了可以显示为空或延后加载;草稿正文缓存挂了必须还能从数据库读出来。两种失败不要共用一个“缓存不可用就报错”的分支。
键的设计要把权限算进去。同一篇笔记,主人看到的草稿字段和外人看到的公开字段不是同一份值。键里没有主体或可见性,公共缓存会把草稿送给外人。要么按可见性拆成不同的值,要么私有响应不进共享缓存。失效时两份都要失效,只删公开那份,草稿那份仍会在编辑之间串。成员关系变化时,除了删正文,还要让“这个主体能看见哪些账号”的缓存一起失效,否则列表版本是新的,可见账号集合仍是旧的。
边界
多地区部署时,事件跨地区会慢,生存时间是你唯一能承诺的上限,版本键若读的是本地区的滞后副本,版本本身可能是旧的。对撤稿这类决定,版本读取走主区域,或者接受一个写明的跨区延迟。不要在文档里写“写入后立刻全球可见”,除非主路径真的等复制完成。等复制会拉长写入时间,这个代价要显式承担,不要在口号里承担。
缓存穿透、击穿、雪崩是三个不同的故障。穿透是查不存在的键一直打到库上,用短负缓存和合法性校验挡住。击穿是热点键到期,用单飞和打散到期挡住。雪崩是大面积同时失效,用打散、限流和能降级的回源挡住。三套办法不要混成一句“加个锁”。锁只能缓解击穿,不能解决穿透。合法性校验要挡在缓存之前:明显不合法的编号不要进入缓存,也不要进入数据库。
人工刷新按钮不是策略。它可以作为应急:运维按账号丢掉列表版本。日常正确性若依赖有人去点按钮,事件或版本就还没接上。按钮背后应该调用和写路径相同的失效函数,而不是另写一条“删除某前缀”的脚本。脚本的前缀格式一改,按钮就删错地方。应急操作也要留下是谁在何时丢掉了哪个版本,否则一次误删会被当成神秘的缓存抖动。
本地内存缓存是最容易被忘掉的一层。进程里一个映射表,生存时间写在代码常量里,多实例之间互不知道。发布了一台新进程,它的内存是空的,旧进程的内存仍是旧的。用户打到不同实例,会看到不同的标题。要么不用进程内缓存保存会变的业务值,要么进程内只保留极短的生存时间,并且写入时尽量通知到本机。通知不到其他机器时,就承认这层只能靠短窗口,不要把它算进“事件已经可靠送达”的叙事里。
收束
先写下旧值能留多久、依赖哪些写入、副本分布在哪。能接受固定窗口,用生存时间,并把到期打散。需要读路径自己避开旧值,用只增版本,键里带版本,生存时间只负责回收。写完就必须通知到不回源的副本,用事件,经过发件箱,按至少送达一次处理,忽略旧版本。三套可以共存,但要说清哪一套负责正确,哪一套负责省流量。说不清的那一层,会在某次撤稿之后继续把旧标题送给读者。缓存可以旧,但不可以在你已经承诺看不见的时刻还在说话。
见字如晤