图 1:本文封面,三级缓存自上而下,越快越小越近。
上一篇缓存淘汰的真题解析里有个经典设定:MySQL 里两千万条数据,Redis 只装得下二十万条,怎么保证装的都是热点。那篇的答案是淘汰策略的选型。但面试官的下一问往往是:「就算装的都是热点,Redis 扛得住一个超级热点键吗?」——这就是本篇的主轴:在 Redis 前面再加一层应用内本地缓存,多级缓存怎么搭。
核心结论一句话
读取走三级:先查应用内本地缓存,未命中查 Redis,仍未命中才回源数据库,命中哪一级就在哪一级返回并把结果回填到上层。写入反过来:先更新数据库,再使 Redis 失效,最后通过消息广播加短 TTL 让各节点的本地缓存失效。这套结构的一致性目标要说清楚:是「秒级内收敛」,不是「任意时刻一致」。接受这个前提,多级缓存才是答案;不接受,它就是隐患。
为什么值得加本地层?
两个字:距离。本地缓存的读取不跨网络,延迟从毫秒级降到微秒级,差着三个数量级。更关键的是它替 Redis 挡住了热点流量:一个被几十万次请求盯上的热点键,如果每次都打到 Redis,单分片的 CPU 和带宽先顶不住;有了本地层,同一个键在每个应用实例里只回源一次,之后的请求全部就地消化。二十万热点数据装进 Redis 是淘汰策略的功劳,而其中最热的一小撮装进本地,是多级结构的功劳。
本地层怎么实现,边界在哪里。
Java 侧的典型选择是 Caffeine 这类进程内缓存库,它的官方用法一句话就能说清:maximumSize 限定容量上限,满了按频率与新鲜度自动淘汰;expireAfterWrite 给条目一个写入后的生存期;还可以配 refreshAfterWrite 让过期前就异步刷新。这三个参数恰好对应本地层的三条军规:必须限容量,否则本地缓存变成内存泄漏的温床;必须有 TTL 兜底,否则失效消息丢了数据就永远脏着;刷新要异步,否则回源尖峰会打穿到下层。
写入与失效传播,是多级缓存真正难的部分。
推荐的写顺序是:先更新数据库,再删除 Redis 里对应的键,最后发一条失效消息让所有应用实例清掉本地副本。本地层不设强一致,靠「短 TTL 加主动失效通知」双保险:消息到了立刻收敛,消息丢了 TTL 到期也会收敛。要警惕的是顺序不能反过来——先清缓存再写库,中间窗口里别的请求会把旧值重新读进缓存,脏数据就此住下。
还有一个读侧的细节值得补上:本地缓存未命中、回源下层的那一刻,同一个实例里可能涌进几十个并发请求。成熟的做法是合并回源——同一个键在同一实例里只允许一个请求去下层取数,其余请求等待结果,取回后一起返回。Caffeine 的加载式缓存内置了这种合并,这也是它比手工维护 Map 更稳妥的原因之一。
图 2:读取路径、写入顺序与本地层的收益代价对照。
一个具体场景:商品详情页。
大促期间头部商品的详情键每秒被读几万次。没有本地层,这些请求全部涌向 Redis 的同一个分片;加上本地层并配几十秒的 TTL,每个实例只在过期时刻回源一次,Redis 压力瞬间降几个量级。商家改了价格,写库、删 Redis、广播失效消息,几秒内全站收敛到新价格——对详情页这个延迟可以接受。但如果这是结算页的价格,秒级不一致就是资损,那种键压根不该进本地缓存。哪些键配进本地层,本身就是设计的另一半。
三个常见误区。
误区一:把本地层当强一致用。多实例各存一份,失效消息到达前必然存在不一致窗口,能容忍才放进来。误区二:不设容量上限。本地缓存吃满堆内存导致频繁 GC 甚至内存溢出的事故,比缓存miss常见得多。误区三:只靠 TTL 不要主动失效。TTL 是兜底不是主路,改价类操作等不起几十秒,消息通道必须有。
给你的行动建议。
挑你系统里读最频繁、变更最少的一个键,给它加上本地层:限容量、短 TTL、接失效消息,然后盯两个指标——本地命中率,和变更后各实例的收敛时长。这两个数站稳了,再谈推广。
你维护的系统里有多级缓存吗?本地层的 TTL 和失效消息,你们是怎么配的?欢迎聊聊。