缓存的一致性幻觉:为什么缓存越多数据越不可信

每个做过高并发系统的人,大概都经历过这样的时刻:线上出了个数据不一致的bug,排查一圈发现是缓存没更新。修完之后加个主动失效,觉得踏实了。过几天又出现,这次是另一个缓存层级。再修。再过段时间,用户反馈看到的金额对不上——你一查,三个缓存层级,两个过期时间不一样,一个还挂着CDN缓存头。

这不是段子,这是每天都在生产环境里上演的日常。

团队解决性能问题的第一反应永远是加缓存。页面慢?加个Redis。接口慢?加个本地缓存。前端渲染慢?加个HTTP Cache-Control。数据库扛不住?加个查询缓存。每一层缓存都在解决一个真实的问题,但每一层缓存也在制造一个你暂时看不见的新问题——等到它浮现的时候,往往已经是你最不想看到的形态。

 

缓存是性能的银弹,也是一致性的地雷

 

这里有个反直觉的事实:缓存从不制造bug,它只是把bug从"现在就暴露"延迟到"不知道什么时候暴露"。

一个没有缓存的系统,数据读出来就是最新的,哪怕慢一点,至少不会错。但当你开始在链路上堆缓存,你事实上引入了一个隐含假设:旧数据在一段时间内是可以接受的。这个假设在大部分场景下成立,但"在大部分场景下成立"和"在你的场景下成立"是两回事。

金融系统里,用户看到的余额是10分钟前的,可能没什么影响——如果他只是浏览。但如果他在这个过期余额的基础上做了一笔赎回操作呢?他的实际余额可能已经不够了,但缓存告诉他还够。这个窗口期可以很短,短到毫秒级,但只要它存在,它就不是概率问题,是必然问题。

我见到过一种说法,叫"最终一致性是可以接受的"。这句话在很多场景下确实成立——社交动态晚几秒更新、推荐列表略有延迟、计数器不是实时的,这些都能接受。但"最终一致性"这个词本身就在制造幻觉,因为它暗示了一个确定性的"最终"。现实是,在多层缓存的系统里,"最终"从来不是一个保证到达的时间点,而是一个你无法观测的渐近线。

 

多层缓存:每次优化都在放大不一致窗口

 

单体架构时代,缓存层通常就一层——要么是服务端内存缓存,要么是Redis。不一致窗口很简单,失效逻辑也容易推演。但现代全栈架构下,一个数据的缓存可能同时存在于:

浏览器HTTP缓存 → CDN边缘节点 → 网关层缓存 → BFF层本地缓存 → Redis集群 → 数据库查询缓存

六层。任何一个数据更新,要穿透这六层缓存才能让用户看到最新值。每一层都有自己的过期策略、自己的淘汰机制、自己的失效延迟。你在数据库里改了一条记录,多久之后用户能看到?你答不上来。没人答得上来。

更麻烦的是,不同层级的缓存用的是不同的失效机制。HTTP缓存靠的是Cache-Control和ETag,CDN靠的是 purging 或者过期时间,本地缓存靠的是TTL和主动删除,Redis靠的是业务代码里的del命令。这些机制之间没有协调,没有共识,甚至不存在一套统一的方式告诉你"所有缓存都已经更新了"。

所以你只能做一个妥协:接受某个时间窗口内的数据可能不是最新的。这个窗口有多长?取决于最慢的那一层缓存。而最慢的那一层,往往是你最不容易控制的——可能是某个CDN节点的配置,可能是某个网关的默认策略,也可能是你完全不知道的某个中间层代理的行为。

每次性能优化加一层缓存,不一致窗口就会被拉长一截。但你很容易忽略这一点,因为性能优化的效果是立刻可见的——响应时间下去了,吞吐量上去了,监控面板变绿了。而一致性的退化是隐性的,它不会出现在你的监控指标里,直到某个用户拿着截图找上门来。

 

主动失效:看似解决了问题,实则创造了更复杂的问题

 

很多团队意识到TTL不够可靠之后,会转向主动失效策略——数据更新时主动删缓存或者更新缓存。这确实缩小了不一致窗口,但引入了另一类问题。

最经典的是缓存与数据库的并发竞态。你更新了数据库,准备删缓存,但就在这个间隙,另一个读请求过来了,它把数据库的旧值又写回了缓存。你的删除白做了。为了解决这个问题,有人用延迟双删——删完缓存,等一会儿再删一次。等多久?看经验。什么经验?没人说得清,总之等个几百毫秒到一秒吧。这个问题本质上是个分布式并发问题,延迟双删只是个极其粗糙的heuristics,它能把概率压低,但永远无法清零。

还有一种更隐蔽的问题:缓存更新顺序依赖。假设你的业务逻辑是"更新完A之后,根据A的新值更新B"。如果某个缓存层级里A的值已经刷新了,但B的值还是旧的——因为B的失效消息还在消息队列里排队,或者B所在的缓存节点恰好再做GC——你的业务逻辑就可能读到A新B旧的组合状态。这种组合状态在业务逻辑里往往是完全没有被考虑过的,因为你的代码假设的是"读到的要么全旧要么全新"。

主动失效的另一个代价,是失效逻辑散落在各个服务里。一个数据被多少个地方缓存,就有多少个地方需要写失效逻辑。漏掉一个,就多一个不一致源。在微服务架构下,这个问题尤其严重——你的订单服务更新了一条数据,谁知道有几个下游服务在缓存它?你本地维护的缓存清理清单,根本跟不上服务拆分的速度。

 

金融场景:缓存的可接受误差是零

 

大部分技术讨论缓存一致性的时候,默认前提是"非关键数据"。推荐列表、搜索结果、点赞数——这些数据晚一点更新确实无所谓。但金融场景不一样。

用户在理财应用上看到的持仓金额、可用余额、收益数据,直接影响他的投资决策。一个缓存导致的展示错误,往小了说是客诉,往大了说是合规风险。在严格的风控要求下,金融系统里的某些数据是不能容忍任何不一致窗口的——不是"尽量快",是"不能错"。

这就导致了一个两难:性能要求你加缓存,准确性要求你不加缓存。

我看到过一种做法,把数据分级。T+0级别的数据(余额、持仓这些跟钱直接相关的)不做缓存,或者只做本地进程内缓存并配合事件驱动失效;T+1级别的数据(历史收益、排名、统计指标)可以上Redis,设置较短的TTL;T+7级别的数据(用户画像、推荐权重)可以放心用CDN。这个分级思路是对的,但执行起来有个现实问题——分级的边界很难划清楚,而且一旦某个T+0的数据被下游服务缓存了(而下游开发者并不知道这是T+0数据),分级就形同虚设。

在金融系统里,"这个数据能不能缓存"不应该是一个技术决策,而应该是一个业务决策。技术团队知道缓存能换来多少毫秒的优化,但只有业务侧能评估"这份数据错了"的代价有多大。然而在大部分团队里,缓存加不加、加在哪一层、过期时间设多少,几乎完全由开发者拍脑袋决定。这才是最深层的问题。

 

缓存治理:不是不加,而是要知道你加了什么

 

说了这么多,我并不是反对缓存。没有缓存,大部分系统根本撑不住。但问题在于,大部分团队对缓存的态度是"加上就忘了"——加缓存的时候很积极,因为效果立竿见影;但缓存失效策略、缓存层级梳理、一致性窗口监控,这些事情几乎没有人愿意做,因为它们是负功——做好了也不加分,做不好要背锅。

如果把缓存当作一种架构决策而不是优化手段,事情会清楚很多。每加一层缓存,你至少应该回答三个问题:

这份数据允许多久不一致?这个答案不应该是拍脑袋的,应该跟业务方确认。如果业务方说"不能错",那就别加,性能问题用其他方式解决。

缓存失效的触发点有哪些?不是只列正常流程,还要考虑异常流程——失败了要不要回滚缓存?部分成功怎么处理?消息丢了怎么办?

谁负责缓存的一致性?这不是个抽象问题。具体的,当数据不一致的bug出现时,该找谁?如果答案是"谁加了这层缓存谁负责",那在人员流动频繁的团队里,这基本等于没人负责。

还有一件经常被忽略的事:缓存的可观测性。你知道你的系统现在有多少层缓存吗?每一层的命中率是多少?平均过期时间是多少?当不一致发生时,你能定位到是哪一层的缓存出问题吗?大部分团队答不上来。缓存加了多少层心里没数,出了问题只能一层一层排查,运气好半小时找到,运气不好查两天。

 

结束之前说个反直觉的事

 

有时候,删掉一层缓存反而更快。

这不是抖机灵。当缓存命中率低到一定程度,缓存带来的开销——序列化、网络传输、过期检查、失效逻辑——可能已经超过了它省下来的数据库查询时间。特别是本地缓存,如果过期时间设得很短,大量请求实际上都miss了,但缓存框架的拦截、检查、回写逻辑每个请求都要走一遍,反而增加了延迟。与其维护一个命中率不到20%的本地缓存,不如直接走Redis。与其维护一个命中率不到50%的Redis缓存,不如想想能不能优化数据库查询本身。

缓存是药,不是饭。药要对症,要控制剂量,要定期检查副作用。而大部分团队对待缓存的方式,是把药当保健品吃——感觉不太舒服就加点,从来不看成分表,也不做体检。

最终,你的系统里堆满了各种层级的缓存,每个都解决了一个具体问题,但它们叠加在一起形成的一致性黑洞,比所有单独的问题都更难对付。而且最讽刺的是,当不一致真的发生了,排查难度和缓存层数成正比——六层缓存意味着六个可能出问题的环节,每个环节的失效策略还都不一样。

所以下次有人跟你说"加个缓存就好了"的时候,不妨问一句:加完之后,你知道你的数据什么时候不可信吗?

You voted 1. Total votes: 7

添加新评论