新手对缓存的理解通常是"加个 Redis"。数据慢就丢 Redis,查的时候先查 Redis 没有再查 DB。这套打法能解决一部分问题,但它只用了缓存能力的一层。真正在生产里扛住高并发的缓存设计,是从用户浏览器一路到数据库的层层缓冲,每一级拦截一部分流量,最终打到 DB 的请求只剩极小比例。
多级缓存的威力在于"层层削峰"。一个电商详情页,90% 的静态资源在 CDN 就消化了,剩下的动态请求里大部分被本地缓存和 Redis 接住,真正到 DB 的查询可能不到千分之一。这不是某一层缓存多强,而是每一层各司其职配合的结果。但要设计好多级缓存,得先想清楚每一层的职责边界和代价,尤其是跨层一致性这个最难的工程问题。
图:六级缓存层层过滤,最终打到 DB 的请求仅占极小比例
全链路的缓存层次
图:六层缓存各司其职——前两层减少网络往返,中间层追求访问速度,Redis 做共享一致主力,DB 兜底
一个完整的请求从用户发出到拿到数据,经过的缓存层次大致是六层。最贴近用户的 L0 是浏览器自己的 HTTP 缓存,连请求都不发。L1 是 CDN,把静态资源推到全球边缘节点,用户就近访问。L2 是网关层的响应缓存,命中直接返回不打服务。L3 是应用进程内的本地缓存(Caffeine),纳秒级访问。L4 是分布式缓存 Redis,所有实例共享。L5 是数据库,最后的真相来源。
每一层的设计目的不同。浏览器缓存和 CDN 服务于静态资源和少变的接口,目标是减少网络往返。网关缓存服务于不常变的动态接口。本地缓存服务于高频读且容忍短暂不一致的数据,追求极致访问速度。Redis 服务于跨实例共享、需要相对一致的数据。DB 是兜底,只有前面都 miss 才打到它。理解每层的定位,才能决定什么数据放哪一层。
浏览器缓存,连请求都不发
最贴近用户的缓存是浏览器自己。它通过 HTTP 响应头控制,核心是两种机制。强缓存,响应头里的 Cache-Control: max-age=3600 告诉浏览器这个资源一小时内有效,有效期内再访问,浏览器直接用本地副本,连请求都不发到服务器。协商缓存,用 ETag 或 Last-Modified,浏览器发请求带上标识问服务器"这个资源变了吗",没变服务器返回 304 不带 body,变了才返回新内容。
强缓存对静态资源的提效是决定性的,一张图片一年不变,max-age 设长,用户二次访问零网络开销。协商缓存适合内容可能变但变化不频繁的接口。这两套机制几乎零成本,是性价比最高的缓存层,但很多后端开发会忽略它,觉得这是前端的活。实际上 Cache-Control 这些响应头是后端设置的,用好它能挡掉海量重复请求。
CDN,静态流量的第一道防线
一个电商页面 90% 的流量是图片、JS、CSS 这些静态资源。把这些全推到 CDN,缓存在全球边缘节点,用户就近访问,源站几乎不用扛静态下载流量。不上 CDN,源站带宽和服务器会被静态资源下载吃掉大半。
CDN 的核心价值是"就近"和"卸载"。用户访问的是离自己物理位置最近的 CDN 节点,网络延迟低。CDN 节点命中就直接返回,未命中才回源拉取,源站只处理回源请求,流量被稀释了几个数量级。动态接口也能用 CDN,通过边缘计算把部分动态逻辑推到 CDN 节点,但这属于进阶用法,主流还是静态资源。
CDN 缓存的失效是个问题。资源更新后要刷新 CDN 节点的缓存,常见做法是版本号,文件名带 hash,内容变了 hash 变,URL 变了,CDN 自然回源新资源。这比主动刷新 CDN 更可靠。
本地缓存,纳秒级访问的利器
应用进程内的本地缓存(Caffeine 是 Java 生态代表)访问速度极快,纳秒级,因为不走网络。它适合高频读、容忍短暂不一致的数据,比如配置、字典、热点商品信息。我做过的人群底座服务,把热点人群数据前置到本地 Caffeine,命中率拉到 99%,服务 RT 从 45ms 降到 30ms,效果立竿见影。
本地缓存最大的陷阱是实例间不一致。每个应用实例各自维护一份本地缓存,实例 A 更新了数据,实例 B 的本地缓存还是旧的,B 的请求读到脏数据。解决思路有两个。第一,本地缓存只放变化少的数据,TTL 设短(秒级),让旧数据自动过期。第二,用发布订阅机制(Redis Pub/Sub 或 MQ),数据更新时广播失效消息,所有实例收到后清自己的本地缓存。
绝不能把强一致数据放本地缓存。库存、余额这类数据放本地缓存,更新延迟会导致超卖或资损。本地缓存的定位是"加速热点、容忍延迟",越界使用就是给自己挖坑。
分布式缓存,共享一致的主战场
跨实例共享的 Redis 是缓存的主力。所有应用实例访问同一份缓存数据,一处更新全可见,天然解决了本地缓存的实例间不一致问题。大部分缓存命中发生在这一层。
Redis 缓存的设计要点是 key 的设计和 TTL 的选择。key 要能唯一标识数据且长度可控,TTL 要根据数据的更新频率和容忍度设。命中率高(95% 以上)说明缓存设计合理,命中率低说明要么 TTL 太短,要么数据本身不适合缓存,要么 key 设计有问题。命中率是衡量缓存设计成败的核心指标,必须监控。
层层兜底的查询路径
图:请求逐层穿透,每层命中即返回,全 miss 才到 DB——多级缓存"层层削峰"的威力在于最终 DB 只扛极小比例查询
一个查询请求进来,依次经过各层缓存,每一层 miss 才进入下一层。先看浏览器强缓存,命中就直接结束。再看 CDN,静态资源命中就返回。再到本地缓存 Caffeine,命中纳秒级返回。本地 miss 查 Redis,命中返回并回填本地缓存。Redis miss 查 DB,返回并回填 Redis 和本地。
层层兜底的结果是,最终到 DB 的请求是极少数。这就是多级缓存"层层削峰"的威力,每一层过滤掉一部分,最后 DB 只扛住很小比例的查询。这套机制让 DB 能专注处理写入和真正的未命中查询,而不是被海量重复读取淹没。
跨级失效,最难的工程问题
图:数据更新时延迟双删 Redis + 发布订阅广播失效本地缓存,CDN 靠版本号——接受"几秒到几十秒不一致窗口"是使用前提
多级缓存最大的挑战是数据更新时的跨级失效。一次数据更新,要依次失效 DB 之外的各级缓存。更新 DB 后删 Redis 缓存,删本实例的本地缓存,再通过发布订阅通知其他实例删各自的本地缓存,CDN 通过刷新接口或版本号失效。
这里每一步都可能出错。Redis 删了但本地缓存没删,实例 A 失效了实例 B 没失效,更新消息丢失导致部分实例一直读旧值。多级缓存的一致性比单级复杂得多,因为它涉及多个独立的数据副本和异步的失效通知。
生产里的解法是工程妥协,不追求强一致,只追求最终一致。本地缓存用短 TTL 兜底,即使失效消息丢了,旧数据也会在几秒内过期。关键路径上的强一致数据(余额、库存、订单状态)根本不进缓存,直接读 DB。接受"几秒到几十秒的不一致窗口"是使用多级缓存的前提,硬要强一致就别用多级缓存。
数据更新时的失效顺序(延迟双删):1. 删 Redis 缓存2. 更新 DB3. 再删一次 Redis(兜底并发读旧值回填)4. 发布本地缓存失效消息(MQ/PubSub)5. 各实例订阅消息,删各自本地 Caffeine6. CDN 通过版本号或主动刷新失效按数据特征选缓存策略
不是所有数据都该走全套缓存层级,要按数据的特征决定。静态资源(图片、JS)只走 CDN,性价比最高。不常变且容忍延迟的数据(配置、字典)放本地缓存加 Redis,本地缓存扛大头。高频读但需要相对一致的数据(商品详情)以 Redis 为主,本地缓存做辅助。强一致数据(余额、库存)不缓存,直接读 DB。
这个分类是用好缓存的关键。把强一致数据塞进缓存会出一致性事故,把高频热点数据只放 Redis 不放本地缓存会浪费性能。先想清楚数据的一致性要求和访问特征,再决定它进哪一层。
本地缓存在每个实例各自维护,实例间不共享。数据更新后如果只清了当前实例的本地缓存,其他实例还会读到旧值,而且这种不一致不会报错,只表现为部分用户看到脏数据,排查极难。要么用发布订阅广播失效,要么只放变化少、TTL 短的数据。强一致数据绝对不能进本地缓存。
Redis 命中率低于 90% 就是设计问题的信号。可能 TTL 太短导致频繁回源,可能 key 设计不合理导致缓存复用率低,也可能数据本身访问模式太随机不适合缓存。把各级命中率纳入监控,命中率下滑时第一时间排查。多层缓存的健康度,最终都体现在命中率这个数字上。








