1. 先从“为什么变慢”说起:多级缓存到底在解决什么
我做后端性能排查这些年,听过最多的一句话就是:“接口明明查了数据库,数据量也不大,怎么就是慢?”很多人的第一反应是换一台更强的数据库,或者加一个Redis缓存挡在前面。结果往往是:加了Redis确实快了一点,但流量再大一点又扛不住了,再去查发现Redis的网络IO成了新的瓶颈。这时候才有人意识到,问题不是“缓存不够多”,而是“缓存放得太远了”。
多级缓存,从名字上看好像就是把缓存多部署几层,但它实际上解决的是一个很朴素的问题:数据离用户越近,访问就越快;数据离用户越远,访问就越慢,但越远越便宜、容量越大。 所以多级缓存的本质,是在一条完整的数据访问链路上,把高频数据复制到离用户最近的那几层,尽量让请求在早期节点就被截住,而不是每次都跑到最远端的源站去拿。
用生活里的例子类比一下。你去食堂打饭,最理想的情况是你座位旁边就有个餐车,拿起来就走,这叫进程内缓存;如果餐车没有,你走出食堂去街对面便利店买,这叫Redis;便利店也没有,你只能回家里厨房自己做,这叫数据库。多级缓存就是在这个动线上多放几个补给点,让绝大多数需求在最近的地方就满足。这样做的好处很直观:用户的响应时间从几百毫秒降到几毫秒,数据库的QPS压力从几万降到几百,服务器的负载曲线一下平坦了。
如果你是一个后端开发、架构师,或者正在做接口性能优化、系统压测的人,这篇文章应该适合你。我不会只讲概念,会把每一层缓存的分工、一致性怎么处理、穿透击穿雪崩那些坑、以及到底什么项目才值得上多级缓存,掰开揉碎说清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 链路一层层看:从浏览器到数据库,各路缓存的分工与边界
多级缓存之所以叫“多级”,是因为它在一条完整的请求链路上占了多个位置。我们以一个典型的Web应用为例,一条用户请求大致会经过:浏览器、CDN、Nginx接入层、应用进程、内存中的本地缓存、分布式缓存、数据库。每一层都能放缓存,但每一层的定位、容量、访问耗时、更新机制完全不同。
2.1 浏览器缓存与CDN:离用户最近的两道守门员
浏览器缓存是最容易被忽视的一层。它发生在用户自己的设备上,和你的服务端代码完全无关。你只需在HTTP响应头里写对几个参数,浏览器就能帮你把静态资源本地存起来,下次访问直接读本地,连网络请求都不发。
响应头里最常用的是 Cache-Control 和 ETag。Cache-Control: max-age=3600 表示这个资源在3600秒内可以直接用本地副本;ETag 是一个资源指纹,当缓存过期后,浏览器拿这个标识去服务端验证,如果资源没变,服务端返回304,响应体都不用传。这一层对静态资源极其有效,但对动态接口基本不适用,因为接口内容通常和用户、时间强相关,不能随便让浏览器缓存。
CDN则是把静态资源分发到离用户地理位置更近的边缘节点。用户在上海访问一个部署在北京的网站,如果没有CDN,图片、JS、CSS都要跑一千多公里;有了CDN,离上海最近的节点直接把文件返回,网络耗时从几十毫秒降到几毫秒。CDN缓存的控制同样靠HTTP头,只不过生效的位置从浏览器变成了CDN节点。
这两层的共同特点是:它们只管“公开的数据”,也就是静态文件、通用资源,无法处理“每个用户看到的内容都不一样”的动态数据。 所以如果你想给动态接口提速,光靠浏览器缓存和CDN是不够的,还得继续往下走。
2.2 Nginx接入层缓存:前端流量洪峰的第一道闸门
如果请求走到Nginx,说明前面两层都没拦住。Nginx处在服务端的入口,反向代理后面挂着你的应用集群。在Nginx这一层做缓存,主要的好处是:应用服务根本不会被请求打到,因为Nginx直接把响应返回了。
Nginx有一个 proxy_cache 模块,可以做反向代理的响应缓存。配置大概是这样的:
nginx复制proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=my_cache:10m max_size=10g inactive=60m use_temp_path=off;
server {
location /api/hot-list {
proxy_cache my_cache;
proxy_cache_key $scheme$request_method$host$request_uri;
proxy_cache_valid 200 60s;
proxy_pass http://backend_upstream;
}
}
这个配置的意思是:/api/hot-list 这个接口的响应会被缓存60秒,缓存Key由请求的协议、方法、域名和URI组成。在60秒内,相同请求直接由Nginx返回,后面的后端服务完全没有感知。
Nginx缓存特别适合两类场景:一是热点数据比较固定但是瞬时流量极大的接口,比如秒杀页面的商品信息;二是其他缓存的失效时间错开之后,用来挡住第一波涌向应用层的请求。但要注意,Nginx缓存是磁盘缓存,速度比内存慢,但比跨网络访问后端快很多,而且Nginx本身应对高并发的连接能力非常强,用它做缓存层的性价比很高。
2.3 Redis分布式缓存:服务端真正的主力备用区
再往下一层就是绝大多数人最熟悉的Redis了。Redis作为分布式缓存,最大优势是所有应用节点共享同一份数据,不管请求打到哪个节点,拿到的缓存内容都一样。它适合存热点数据、会话数据、计数数据等,读写速度在0.1毫秒到1毫秒之间,取决于网络和序列化开销。
Redis的用法不必多讲,但我想强调一点:在引入本地缓存之前,Redis承载了整个服务端缓存的大部分读写流量。它的容量可以横向扩展,可以持久化,可以设置过期时间,可以发布订阅失效消息。它是多级缓存体系里承上启下的一层——既承接来自Nginx层的回源请求,也向下隔离数据库的读压力。
比如一个典型的商品详情接口,数据存Redis的Key可能是 product:detail:1001,值为JSON序列化后的商品信息,过期时间设置成30分钟。请求到达应用层后,先查Redis,没有再去查数据库并把结果回填,这种模式就是最简单的“旁路缓存”。
2.4 进程内本地缓存:最后一道“快而小”的防线
Redis虽然快,但依然是一次网络请求。网络IO再快也有开销,在一个高并发应用里,单机每秒如果要对Redis发起几万次读取,网卡和线程都会被占满。这时候,本地缓存的价值就体现出来了。
本地缓存,也叫进程内缓存,常用的是Caffeine、Guava Cache,还有早期的ConcurrentHashMap。它的特点是数据存在应用自己的JVM堆内(或其他语言的应用进程内存里),读取完全不需要网络,速度是纳秒级别。但它的容量受制于应用本身的内存,而且有一个致命问题:每个应用节点的缓存内容各自独立,节点之间数据可能不一致。 比如你有10个应用实例,一个实例更新了本地缓存,其他9个实例并不知道。
Caffeine的配置大概是这样的:
java复制Cache<String, ProductDetail> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats()
.build();
本地缓存的定位是“小但是快”,适合存两类数据:一是变化频率极低的基础配置,比如商品类目、字典表;二是热点商品的详情数据,通过Redis收到失效通知时主动清理对应条目,让下一次请求重新加载。它不能承担大容量的全量缓存,否则内存会爆炸。
为了让你对“多级”有直观感受,我把这几层的典型耗时和容量列个表:
| 层级 | 典型访问耗时 | 容量特征 | 典型组件 |
|---|---|---|---|
| 浏览器缓存 | 0毫秒(本地读) | 设备磁盘/内存,有大小限制 | 浏览器 |
| CDN缓存 | 1-10毫秒(就近网络) | 边缘节点,可以很大 | Cloudflare、阿里云CDN |
| Nginx缓存 | 0.1-1毫秒(本机磁盘) | 磁盘,几GB到几十GB | Nginx proxy_cache |
| Redis缓存 | 0.1-1毫秒(网络) | 内存,几十GB到几百GB可扩展 | Redis Cluster |
| 本地缓存 | 纳秒级(进程内) | 应用内存,几十MB到几GB | Caffeine、Guava Cache |
| 数据库 | 1-10毫秒(磁盘/内存) | 几百GB到TB级别 | MySQL、PostgreSQL |
3. 链路越长,数据一致性的问题就越藏不住
很多人初学多级缓存时会有一个天真的想法:我多放几层缓存,数据反正都在那儿,用户访问快了就行。但一上线就发现问题了:用户在后台改了商品名称,前端页面还是旧名字;管订单的同事改了库存状态,缓存里却是老状态。这背后是缓存一个绕不开的核心问题——一致性。
3.1 时效性永远存在,一致性是相对的
先明确一个认知:只要数据在某个地方存了副本,副本之间就必然存在时间窗口的不一致。不管你是写数据库后立刻删缓存,还是发消息通知各层失效,这个时间窗口不可能压缩到零。区别只是窗口有多长,以及业务能不能容忍。
比如商品名称这种低频变更的数据,缓存失效延迟30秒甚至几分钟,绝大多数用户感知不到,但如果你在缓存里存的是库存数量,延迟几秒就可能导致超卖。所以在做多级缓存设计之前,先把业务数据分成两类:强一致数据(订单、库存、余额)和弱一致数据(商品详情、公告、排行榜)。强一致数据尽量不走多级缓存,或者只在Redis这一层做短TTL缓存;弱一致数据才值得在链路里多放几层。
3.2 本地缓存与Redis的同步:简单的失效广播往往不够
本地缓存和Redis之间的一致性,是多级缓存里最麻烦的一环。因为每个应用节点的本地缓存互相隔离,一旦一个节点更新了数据,其他节点不知道。
最直接的方案是:应用更新数据库后,同时删除Redis里的缓存Key,并向Redis发布一条失效消息(比如用Redis的Pub/Sub),其他应用节点收到消息后删除本地对应的缓存条目。这样做的好处是实时性高,坏处是引入了消息不可靠的问题——如果某个节点正在重启,或者网络发生抖动,失效消息丢失了,那个节点的本地缓存就会在TTL到期前一直保留旧数据。
所以我一般建议的做法是“失效广播 + 短TTL兜底”。本地缓存的过期时间不要设置太长,通常10分钟以内,就算某一两条失效消息丢了,最多也就是旧数据多存活几分钟,不会酿成大错。
3.3 经典的更新顺序问题:先写库还是先删缓存
服务端缓存更新有一个老生常谈的话题:更新数据时,先更新数据库,还是先删除缓存?
有一种常见方案是“先删缓存,再写数据库”。但这个方案有一个坑:如果两个请求同时发生,请求A删了缓存,请求B读数据发现缓存没有,于是去数据库查旧数据并写回缓存,然后请求A才把数据库更新成新数据。此时缓存里就是旧数据。这个问题的概率虽然不高,但在高并发场景下确实会发生。
更稳妥的方式是“先更新数据库,再删除缓存”,如果删除失败,用延迟双删作为补偿——等一段时间后再删一次,把中间读请求可能写入的旧缓存清掉。如果业务要求极高一致性,可以用订阅数据库binlog的组件(如Canal)来触发缓存失效,让缓存删除动作变成数据库事务的一部分,而不是靠应用代码手工调用。
3.4 版本号和时间戳:给每一份缓存数据打上标记
另一个工程上很实用的技巧是给缓存数据加版本号或时间戳。比如商品详情缓存的值结构是:
json复制{
"version": 1650001234,
"data": {
"name": "无线蓝牙耳机",
"price": 299
}
}
版本号取数据最后修改时间的Unix时间戳。更新数据时,新的写入会带上更大的版本号。读取链路里,如果某一层发现自己的版本号比上一层旧,就强制回源刷新。这种方式在网关层做校验时尤其有用,可以让Nginx或应用层判断本地缓存是否过期,而不是单纯依赖TTL。
说到底,多级缓存的复杂度随着层数增加而指数上升。每多一级,一致性需要考虑的联动就多一层。所以在设计时要给自己一个前提:别追求所有层完全同步,只要保证业务可容忍的时间窗口内一致,就达标了。
4. 穿透、击穿、雪崩:多级缓存下最常踩的三类坑
我自己处理过不少缓存事故,发现不管缓存层级多少,问题最终基本都归到三类:缓存穿透、缓存击穿、缓存雪崩。这三类问题在多级缓存里会出现新的变体,这一节仔细讲清楚。
4.1 缓存穿透:查询一个根本不存在的Key
缓存穿透是指请求的数据在缓存和数据库里都不存在,导致每次请求都穿透所有缓存层,直接打到数据库。比如用户通过接口传入一个不存在的商品ID,应用端查Redis没有,查本地缓存没有,查数据库也没有,然后返回空。对于攻击者来说,用随机ID不停地请求,就能让你的数据库被打到满负荷。
解决穿透有几个层面。第一层是参数校验,明显非法的ID直接拒绝;第二层是缓存空值,即数据库查询结果本身为空,也把“空结果”缓存起来,设置一个较短的过期时间(比如5分钟);第三层是布隆过滤器,把所有存在的ID预先加载到布隆过滤器中,不存在的ID在第一步就会被拦截。
在多级缓存场景下,布隆过滤器一般放在接入层或应用层。它可以放在本地缓存里,也可以放在Redis里,取决于数据量。如果商品ID总量在百万级别,本地缓存一个布隆过滤器就够用了;如果是十亿级别,布隆过滤器本身就会比较大,更适合放进Redis共享。
4.2 缓存击穿:热点Key在过期瞬间被打爆
缓存击穿指的是某个非常热的Key正好到了过期时间,一瞬间所有请求都发现缓存没有,于是同时回源数据库。这个问题的关键在于“热点”和“同时”。比如一个直播间有10万人在线,同时刷新商品详情,缓存刚好在这个时间点过期,数据库可能瞬间被打挂。
多级缓存对击穿问题有其独特优势:本地缓存可以作为“备份”,即使Redis里的Key过期了,本地缓存里可能还有一个副本,能拦截大部分请求。但本地缓存本身也可能过期,所以不能只靠这一个兜底。
更可靠的做法是用互斥锁。当缓存失效时,只让一个请求去数据库加载数据,其他请求等待这个请求完成后再从缓存读取。这里要注意锁的粒度,尽量用基于Key的细粒度锁,而不是全局锁,否则一个Key过期会影响所有Key的访问。
另一种思路是“逻辑过期”:缓存内容不设物理TTL,而是在数据里带一个过期时间字段。应用读取时发现逻辑时间过期,就尝试获取锁并异步刷新缓存;在刷新完成前,其他请求依然可以使用旧数据。这种方法的好处是永远不会出现缓存空洞,代价是数据可能短暂陈旧。
4.3 缓存雪崩:大量Key同时失效导致数据库整体失守
雪崩比击穿更严重,因为击穿是一个Key出事,雪崩是一大批Key同时出事,数据库的压力瞬间爆炸。常见的起因是:所有缓存的过期时间设成了同一个值,比如整点失效;或者Redis节点整体宕机,所有请求全部打到数据库。
多级缓存对雪崩的缓解主要体现在Nginx层和本地缓存层。Nginx层缓存哪怕是60秒,也能挡住第一波浪涌;本地缓存能保证即使Redis宕机,仍有部分热数据可用。但关键还是在于预防。
做法上,第一,过期时间加一个随机偏移量,避免“整点集中失效”:
java复制int randomOffset = ThreadLocalRandom.current().nextInt(300);
Duration expireTime = Duration.ofSeconds(1800 + randomOffset);
第二,Redis层做高可用,使用主从架构和哨兵,或者Redis Cluster,不能单节点裸奔;第三,数据库层要有限流和熔断机制,当数据库负载超过阈值时,直接拒绝一部分非核心请求,优先保护主流程。
说实话,这三个坑不是第一次遇到就能完美避开的。我见过不少团队在多级缓存上线后,因为忽略了穿透问题,反而被攻击者用不存在的Key打得更惨——因为以前的Redis单层还能靠查询次数扛一扛,现在多级链路的每一层都空转回源,数据库压力反而更大。所以引入多级缓存时,第一件事就是把穿透防护加上,这是基本功。
5. 不是所有项目都值得上全套:选型判断与落地配置
多级缓存听着高大上,但它同时也是高成本、高复杂度的代名词。我以前有个朋友,公司日活也就两万,非要把浏览器缓存、CDN、Nginx、Redis、本地缓存全上,结果一个月下来,光是排查各种缓存不一致的问题就占了大半开发时间,收益却几乎没感觉到。这种属于为了技术而技术。
5.1 判断标准:数据热度、访问量和一致性容忍度
要不要上多级缓存,我一般看三个条件。第一个是访问量级:单机QPS低于几百,数据库根本无压力,完全没有必要;QPS上千但数据库还能扛,考虑单层Redis即可;QPS上万且热点集中,才需要考虑多级。第二个是数据特征:如果请求的数据分布非常均匀,本地缓存的命中率会很低,上了也没用;如果90%的请求集中在10%的热点数据上,那本地缓存的价值就很大。第三个是业务一致性容忍度:如果业务要求读后立刻写且立刻读到最新值,多级缓存的延迟同步会让业务崩溃,不如别碰。
5.2 一套常用的分层配置方案(以商品详情页为例)
假设你做的是一个电商站,商品详情页QPS大概在两万左右,数据库在两千QPS时就开始告警。我会这么搭:
第一层是CDN,缓存商品图片、CSS、JS等静态资源,TTL设置一小时。页面本身的HTML如果允许,也可以放CDN,但通常商品详情页是动态的,所以CDN层主要服务静态资源。
第二层是Nginx层,用proxy_cache缓存商品详情页的接口响应,TTL设置30秒到60秒。这一层能挡掉大部分重复请求,让后端应用只处理每秒不到十分之一的流量。
第三层是Redis,存商品详情的热点数据,TTL设置10分钟到30分钟,并加随机偏移。数据格式用JSON字符串,Key按业务划分,比如 product:detail:%s。
第四层是本地缓存,用Caffeine,maximumSize设置5万个条目,expireAfterWrite设置5分钟。应用去Redis之前,先查本地缓存,如果命中就直接返回;如果本地没有,再查Redis。
核心代码如下:
java复制public ProductDetail getProductDetail(Long productId) {
// 1. 本地缓存
ProductDetail local = localCache.getIfPresent(productId);
if (local != null) {
return local;
}
// 2. 分布式缓存
String redisKey = "product:detail:" + productId;
String json = redisTemplate.opsForValue().get(redisKey);
if (json != null) {
ProductDetail detail = JSON.parseObject(json, ProductDetail.class);
localCache.put(productId, detail);
return detail;
}
// 3. 数据库兜底(加锁避免击穿)
ProductDetail detail = loadFromDB(productId);
if (detail != null) {
redisTemplate.opsForValue().set(redisKey, JSON.toJSONString(detail), randomExpire());
localCache.put(productId, detail);
} else {
// 缓存空值,防止穿透
redisTemplate.opsForValue().set(redisKey, "", Duration.ofMinutes(5));
}
return detail;
}
这套配置在压测环境里实测过,两万QPS的请求打到数据库的只有不到三百,绝大多数请求在Nginx层和本地缓存层就被拦下来了。
5.3 别忽视缓存层自身的开销
最后说一个不太受人关注的地方:缓存层本身也是有开销的。Nginx缓存占磁盘,写磁盘也是有IO的;Redis集群的维护成本高,内存也贵;本地缓存塞太多对象会造成GC压力,反而拖慢整个应用。所以在设计多级缓存容量时,要做一个简单的估算:本地缓存单机最多放多少数据?乘以节点数,和Redis里热点数据量能不能覆盖住?如果本地缓存命中率低于40%,建议先调Redis的Key设计和过期策略,而不是盲目堆新层级。
6. 多级缓存效果怎么看:命中率、耗时和数据说话
缓存上完之后,不是就说“我加了多级缓存”就完事了。你要能用数据证明它真的有效,也要能发现它哪里在空转、哪里在帮倒忙。
6.1 命中率:每一层都要有独立统计
多级缓存的每一层都需要单独统计命中率,否则你根本不知道是哪个层在干活,哪个层是摆设。Caffeine自带了 recordStats(),可以通过 Cache.stats() 拿到命中率;Redis可以通过INFO命令里面的 keyspace_hits 和 keyspace_misses 来统计;Nginx的访问日志里记录了 HIT 和 MISS 标记,可以按时间维度聚合。
我见过一个典型情况:团队上了Nginx缓存,一看命中率有85%,觉得很满意。但仔细看日志发现,命中请求全是同一个健康检查接口,真正的业务接口命中率只有十几。这个教训很重要——统计命中率时,一定要按接口维度拆,而不是看整体平均值。
6.2 链路耗时:用户侧到底快了多少
缓存最终目的是降低用户感知的响应时间。你要在网关或者应用层埋一个链路耗时的日志,对比不同层级的耗时分布。我常用的方式是在响应头加一个自定义字段,比如 X-Cache-Level,记录这次请求命中了哪一层(CDN、Nginx、Redis、Local、DB),然后在日志系统里按这个字段分组统计平均耗时。这样你不仅能知道整体响应时间,还能知道每一层级命中的请求各占多少比例,耗时分别是多少。
6.3 调优的几个经验
根据我自己踩过的坑,调优多级缓存时有几个可以优先动手的方向。
第一,调整本地缓存的容量和过期时间。如果本地缓存的命中率高,说明热点数据集中,可以适当加大容量;如果命中率低,说明数据访问太分散,本地缓存的作用有限,不如把资源省下来给Redis。
第二,梳理Key的分布。把Redis的Key按访问次数排序,找出前10%的Key占了多少访问量。如果发现所谓的“热点”其实只是几个Key在扛着,那么你在本地缓存里只需针对这几个Key做特殊处理,比如永不主动过期,靠主动失效来更新。
第三,监控数据库的慢查询。如果数据库的慢查询变多了,不要急着调SQL,先看看是不是缓存失效的节奏出了问题,比如某个定时任务会在整点批量更新数据,导致缓存整点集体失效。这类问题在压测时最容易暴露,上线前一定要模拟一下缓存失效的瞬时行为。
提示:多级缓存上线后,至少要压测一次“最差路径”,也就是所有缓存同时失效、全部请求直接打到数据库的场景。这个场景如果数据库能扛过去,线上才真的安稳。
我自己的经验是:调试多级缓存别一次性调太多参数,每次只变一个变量,观察一个指标,否则出了问题根本定位不了。先调本地缓存的TTL,再调Redis的Key拆分,最后调Nginx的回源策略,循序渐进,数据会告诉你每一步到底值不值。
