一次价格错误引发的反思
你一定见过这样的场景。
运营同学在后台把某个商品的价格从 99 元改成了 79 元,秒杀活动马上要开始了。改完之后刷了一下页面,价格变了,没问题。活动一开始,客服电话被打爆了:“你们页面上写的 79,怎么下单变成 99 了?”
查了一圈,原因不复杂。商品详情页读的是分布式缓存,价格已经更新了。但订单服务读的是本地缓存,TTL 还没到期,拿到的还是旧价格。两层缓存,两个不同的“真相”。
这不是什么罕见事故。在百万 QPS 的系统里,这类问题偶尔出现,通常靠人工刷缓存就能兜住。但当流量攀升到千万 QPS,几百台应用服务器各自持有本地缓存,任何一个节点的数据不一致都可能被放大成批量的业务异常。
缓存失效的难度,不在于“怎么删”,而在于“怎么让所有人都知道该删了”。
这就是今天要聊的话题:缓存失效策略的演进,从最朴素的 TTL,到事件驱动的主动失效。
缓存失效到底在解决什么问题?
先把问题定义清楚。
缓存的本质是用空间换时间,用一份“副本”来减少对数据源的访问压力。但副本天然就有一个问题:数据源变了,副本不会自动跟着变。
所以缓存失效要回答的核心问题只有一个:当数据源发生变更时,如何让缓存中的旧数据尽快被淘汰或更新?
围绕这个问题,有三个关键维度:
在十万 QPS 的系统里,这三个维度的要求都不算苛刻,一个合理的 TTL 就能覆盖绝大多数场景。但随着规模增长,每个维度上的挑战都会被放大。
为什么规模越大,失效越难?
先看一个直觉上的数字对比。
假设系统的缓存命中率是 99%,数据源是 MySQL。
数字本身不吓人,但它背后藏着几个质变点。
第一,失效的“广播面”变了。 十万 QPS 时,一条数据可能只被缓存在 1~2 个节点上。千万 QPS 时,热点数据可能同时存在于分布式缓存的多个分片,外加几百台应用实例的本地缓存。一次数据变更,需要通知的“接收者”从个位数变成了几百个。
第二,失效的“时间窗口”代价变了。 假设一个商品详情接口的 QPS 是 5000,TTL 是 30 秒。那么每次数据变更后,最多有 15 万次请求拿到的是旧数据(5000 × 30)。在百万级系统里这个数字或许可以接受,在千万级系统里,热点数据的单 Key QPS 可能是 5 万甚至更高,同样的 TTL 意味着 150 万次脏读。
第三,失效操作本身成了流量。 当系统有几百个缓存节点和几百台应用实例时,一次批量的缓存失效操作(比如一次促销活动修改了 1000 个 SKU 的价格)可能瞬间产生几十万次删除请求。失效风暴本身可能成为系统的新瓶颈。
所以,不是 TTL 不好用了,而是系统的规模让“被动等过期”这个策略的代价越来越高。
TTL:最简单的失效策略,也是最容易被误用的
TTL(Time To Live)的逻辑极其简单:给每条缓存数据设一个过期时间,到期自动删除。下次访问时重新从数据源加载。
这个机制有两个天然的优点:实现零成本,不需要任何额外的基础设施;容错性好,即使所有主动失效机制都挂了,TTL 是最后的兜底。
TTL 被动过期的流程非常直观:
但 TTL 有一个绕不开的问题:一致性窗口完全由时间决定,和业务变更无关。
TTL 设长了,一致性差;设短了,缓存命中率下降,数据源压力增大。这是一个经典的 trade-off,没有完美解。
在实践中,TTL 的设置往往不是一个统一的数字,而是需要根据数据的变更频率和业务容忍度来分层:
即使后面引入了更先进的失效机制,TTL 仍然是不可或缺的“兜底防线”。任何主动失效机制都可能有 bug,都可能有消息丢失,而 TTL 保证了数据不会“永远”是错的。
TTL 不是要被替代的,而是要被补充的。
主动失效:写入时删除缓存
既然 TTL 是被动等待,那能不能主动一点?数据变了,马上告诉缓存“这条数据作废了”。
这就是最常见的主动失效模式:Cache-Aside 模式下的“写时删除”。写入数据库后,立即删除对应的缓存 Key。下次读请求发现缓存不存在,自然会回源加载最新数据。
Cache-Aside 写时删除的交互时序如下:
这个模式在百万 QPS 以下的系统里非常实用。逻辑清晰,实现简单,一致性窗口从 TTL 的“秒/分钟级”缩短到了“毫秒级”。
但它有几个在大规模下会暴露的问题。
问题一:删除操作和写入操作耦合。 每个写数据库的地方都需要“记得”去删缓存。当系统有几十个微服务、几百个写入点时,漏删几乎是必然事件。“你确定所有改了这张表的服务都记得删缓存了吗?”这个问题,在大型团队里没有人敢拍胸脯。
问题二:只能管到分布式缓存这一层。 如果系统用了本地缓存(比如 Caffeine、Guava Cache),写服务删除了 Redis 里的 Key,但其他几百台机器的本地缓存不知道这件事。
问题三:并发场景下的时序问题。 经典的“先删缓存还是先写数据库”的讨论背后,本质是在并发读写时,缓存和数据库之间可能出现短暂的不一致。虽然有“延迟双删”等补偿方案,但增加了系统复杂度。
主动失效解决了“什么时候删”,但没解决“谁来通知谁”。
事件驱动:让数据变更“广播”出去
前面两种方式的共同问题是:缓存失效的触发者是业务代码。业务代码既要负责写数据,又要负责管缓存,职责混在一起。
有没有一种方式,让缓存失效和业务代码彻底解耦?
答案是:监听数据源的变更事件,由独立的服务来驱动缓存失效。
在 MySQL 生态里,这个方案已经非常成熟:通过订阅 Binlog,捕获每一条数据变更,转换成缓存失效指令,再分发给各个缓存节点。
事件驱动缓存失效的整体架构如下:
这个架构相比前两种方案,有三个本质变化:
第一,失效触发点唯一化。 不管有多少个服务、多少种写入路径,只要数据最终落到了数据库,Binlog 里就一定有记录。不存在“漏通知”的问题。
第二,失效范围可以覆盖所有缓存层级。 通过消息队列的广播能力(比如 Kafka 的 Consumer Group 机制),可以同时通知分布式缓存和所有应用实例的本地缓存。
第三,业务代码和缓存管理彻底解耦。 写服务只需要关心写数据库,缓存的失效由独立的基础设施保证。这对于大型团队协作来说,意义重大。
但事件驱动也不是银弹。它引入了新的复杂度:
值得注意的是,事件驱动失效的延迟通常在百毫秒到秒级,对于大多数业务场景已经足够。对于极少数要求“写后立即可读最新数据”的场景(比如用户修改了自己的昵称后立刻刷新页面),可以在写入端做一个“写后读穿透”的短路优化,绕过缓存直接读数据库,而不需要为这类边缘场景把整个失效机制做成同步的。
多级缓存的失效:千万 QPS 的真正挑战
前面讨论的失效策略,主要针对的是单层缓存(通常是 Redis 这样的分布式缓存)。但在千万 QPS 的系统里,架构几乎一定是多级缓存。
典型的三级缓存结构:
多级缓存之所以在千万 QPS 场景下几乎是必选项,原因很简单:Redis 再快,一次网络往返也要 0.5~1ms。当单机 QPS 达到几万时,这个延迟和连接数都会成为瓶颈。本地缓存的访问延迟在纳秒级,能扛住最热的那一批数据。
但多级缓存也让失效问题变得更加复杂。
核心矛盾:本地缓存是“孤岛”。 每台应用实例的本地缓存是独立的,它不知道数据源发生了什么变化,也不知道其他实例的缓存状态。在千万 QPS 的系统里,可能有 300~800 台应用实例,每台都有自己的本地缓存,每一个都是一座信息孤岛。
如何让这几百座孤岛同步失效?常见的有两种模式:
模式一:广播失效。 通过消息队列(如 Kafka、RocketMQ)或专门的广播通道(如 Redis Pub/Sub),向所有实例推送失效消息。每个实例收到消息后,删除自己本地缓存中对应的 Key。

模式二:短 TTL 兜底。 本地缓存设置很短的 TTL(比如 3~10 秒),不做主动失效。靠快速过期来保证不一致窗口足够小。
两种本地缓存失效模式的对比:
在实际的大规模系统中,这两种模式通常是组合使用的:
广播负责“尽力而为”的快速通知,短 TTL 负责“兜底”的最终一致。
这样做的好处是:即使广播消息偶尔丢失或延迟,最坏情况也只是多等几秒 TTL 过期。而大多数时候,广播能在百毫秒内完成失效。
但广播模式在千万 QPS 下还有一个需要特别关注的问题:失效风暴。
想象一下,一次大促前运营批量更新了 10000 个商品的价格。这意味着:
- Binlog 产生 10000 条变更事件
- 每条事件需要通知 500 台实例
- 总计 500 万条失效消息,在很短的时间窗口内涌入
如果消息队列的消费能力不够,或者实例的本地缓存删除操作阻塞了业务线程,整个系统可能出现抖动。
应对这个问题的常见策略:
三种策略的选择:不是替代,是叠加
回顾一下从 TTL 到事件驱动的演进脉络,核心逻辑是清晰的:
但很重要的一点是,这三种策略不是互相替代的关系,而是层层叠加的。
一个成熟的千万 QPS 缓存体系,通常是三层策略同时存在:
这种分层设计背后的原则,其实和软件工程中的“纵深防御”思想一脉相承:不把可靠性押注在任何单一机制上。
从缓存失效看架构演进的一个规律
写到这里,不妨退后一步,看看缓存失效策略的演进背后,是否有更一般性的规律。
十万 QPS 时,系统足够小,耦合不是问题,TTL 加人工运维就够了。百万 QPS 时,开始需要主动失效,但还能靠团队规范和 Code Review 来保证每个写入点都“记得”删缓存。千万 QPS 时,靠人不行了,必须靠机制,靠独立的基础设施来保证一致性。
从“靠约定”到“靠机制”,是系统规模化过程中反复出现的模式。
缓存失效如此,服务治理如此,数据同步也如此。当团队规模和系统规模超过某个临界点后,任何依赖“人人都遵守约定”的方案,都会在概率上失败。
这或许是架构演进中最朴素,也最深刻的一个道理。
最后,一个开放性的问题
在你的实际项目中,缓存失效这件事是怎么做的?是纯靠 TTL,还是已经上了事件驱动?如果是后者,用的是什么方案,踩过哪些坑?
另外,还有一个值得思考的方向:随着 CRDT 和各种分布式一致性协议在应用层的落地,缓存一致性问题有没有可能从“事后补偿”变成“天然一致”?这个话题,下次有机会再聊。
欢迎在评论区分享你的经验和思考。