先说一次很典型的线上排障。一个商品详情接口加了 Redis 缓存,结果压测数据显示 P999 从 900ms 降到了 850ms,几乎没变化。一开始大家都以为缓存代码没生效,查到最后,问题出在缓存命中率上——那个接口的 Redis 命中率只有 43%。也就是说,每 100 次请求里有 57 次会穿透到数据库,缓存形同虚设,自然“飞”不起来。
后来我们做的第一件事不是加缓存,而是先分析这个接口的数据访问特征,再把 key 设计改掉、把过期时间错峰,命中率从 43% 提到了 95% 以上,数据库压力直接下降了一个数量级,接口才真正有了质的提升。
这个案例很能说明一个问题:缓存不是“装了就能快”的东西,而是一个完整的工程问题。它涉及存储层怎么选、key 怎么设计、缓存什么时候更新、缓存和数据库怎么保持一致、缓存进来之后怎么观察和治理,甚至在当前的大模型推理、离线 Web 应用等场景里,又有完全不同的“缓存脾气”。这篇文章就顺着我这些年实际碰到的缓存问题,把从原理到实践、从高并发保护到缓存清理的各个关键点整理一遍,希望能帮你少踩几个坑。
本文内容适合后端开发、运维、前端工程师,以及任何想在系统里引入缓存、但又被缓存穿透、缓存一致性、命中率不高这些问题困扰的读者。即使你是第一次认真接触缓存,我也会把每个关键选择背后的原因讲清楚——因为在实际项目中,知道“为什么这样选”往往比“怎么配”更重要。
1. 缓存为什么快:命中率才是真正的命根子
1.1 性能瓶颈通常不在计算,而在“把数据搬过来”的过程
先想一个问题:一个请求慢,到底慢在哪里?
大部分业务场景下,慢的不是 CPU 计算,而是数据获取。从内存里读一个字段只要几纳秒到几微秒;从 SSD 上读一个随机块可能要几十微秒;从 MySQL 里执行一条经过索引优化的查询,本地也要 0.1ms 到几毫秒;如果数据在另一台机器、另一个机房,还要叠加网络往返。
夸张一点说,一次数据库查询的时间,够 CPU 执行成千上万条指令。所以单纯提高服务器配置,很多时候不能解决问题。真正的问题是:大量重复请求都在重复走同一条昂贵的数据获取路径。
比如商品详情页,同一款热销商品在流量高峰期可能一秒被访问几千次。如果没有缓存,这几千次请求都会打向数据库,数据库的连接池、事务日志、磁盘 IO 很快就吃不消。如果我们把热销商品的数据放进一个更近、更快的存储里,比如 Redis 或者进程内缓存,让大多数请求都在内存中直接拿到结果,后端数据库实际收到的请求量就会非常小。
这就是缓存最朴素的价值:用空间换时间,用更便宜的读取替代昂贵的读取。
1.2 命中率的数学账:从 50% 到 95% 意味着什么
缓存好不好,不能只看缓存本身快不快,要看命中率。命中率通常这样计算:
缓存命中率 = 缓存命中的请求次数 / 总请求次数
举个例子。假设接口 QPS 是 5000,数据库连接池只有 50。
- 没有缓存时,5000 个请求全部到数据库,连接池直接被打满,大量请求排队等待。
- 缓存命中率 90% 时,打到数据库的只有 500 QPS,连接池有压力但勉强能扛。
- 缓存命中率 95% 时,打到数据库的只有 250 QPS,后端非常从容。
- 缓存命中率只有 60% 时,打到数据库的仍然有 2000 QPS,数据库照样可能被打挂。
所以命中率是一个指数级的杠杆,它对后端产生的压力是按“未命中率”线性放大的。与其纠结缓存中间件本身能扛多少并发,不如先盯着命中率。
那命中率多高才算健康?这个数字要根据业务场景判断,不能机械地追求 100%。一般来说,读多写少的场景里,命中率长期低于 80%,基本可以认为设计有问题;一个设计良好的缓存,命中率往往能做到 95% 以上。反过来说,如果某个缓存 key 长期没有被访问,或者每次访问的数据都不同,那这个缓存本身可能在消耗内存却没有创造价值。
还有一种容易迷惑人的状态:命中率很高,但业务没变快。这是因为我见过有人把大量无用数据也塞进缓存,比如缓存里塞了永远不会被重复访问的大对象,命中的是“无价值缓存”,最终只能增加内存占用和 GC 压力。判断缓存是否需要,要看两个条件:第一,读多写少;第二,数据访问存在明显的重复性。两者不满足,缓存就是负资产。
1.3 加了缓存反而更慢的一种隐藏情况
这个话题我在很多分享里都会提,因为太容易被忽略:如果每次请求都缓存未命中,那么系统实际要做一次缓存读取、一次数据库查询、一次缓存回填,比原来直接查数据库还多了一次网络往返和序列化开销。
更要命的是,很多团队在缓存“热 key 过期”的瞬间,会让所有请求同时去重建缓存。这时候数据库可能瞬间收到数千个相同查询,比没有缓存时更早被打垮。所以缓存不是必然带来性能提升,只有在访问模式合适、key 设计合理、回填策略稳妥的前提下,它才能让系统“飞起来”。
我通常会把缓存设计的第一原则总结成一句话:不命中时怎么办,比命中时怎么办更重要。 命中时大家都快,你的缓存设计差别不大;未命中时是互斥回源、还是降级返回旧值、还是让少量请求去重建,这里才是决定系统稳定性的关键,下一章展开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓存该放哪:进程内、Redis,还是两者配合
2.1 不同层级缓存的成本和延迟完全不同
很多新人以为“加 Redis 就是加缓存”,这其实把缓存的地图缩小了。缓存可以分布在链路的不同位置,从 CPU 到磁盘、从浏览器到 CDN、从本地进程到 Redis,每一层都有不同的速度和成本。
| 缓存层级 | 典型代表 | 大致访问延迟 | 适用场景 |
|---|---|---|---|
| 进程内缓存 | Caffeine、本地 Map | 纳秒到微秒级 | 单实例热点数据、配置数据 |
| 分布式缓存 | Redis、Memcached | 亚毫秒到毫秒级 | 多实例共享缓存、跨服务共享数据 |
| 本地磁盘缓存 | 浏览器 Cache、本地文件缓存 | 毫秒级 | 客户端静态资源、离线数据 |
| CDN / 边缘缓存 | CDN、反向代理缓存 | 取决于网络节点 | 静态资源、边缘动态加速 |
从延迟上看,进程内缓存无疑是最快的,因为数据就在当前 JVM/进程的内存里,连网络请求都省了。但它的代价是:每个实例各自维护一份数据,数据分布不均匀,更新时只能通过广播或短过期时间让各实例自行失效。分布式缓存虽然多了一次网络往返,但它天然是共享的,无论前端有多少个实例,都能命中同一份数据。
2.2 Redis 层的缓存设计,先盯住 key、TTL 和对象大小
Redis 是分布式缓存里最常见的选型,但光是“往里塞 key”和“按 key 读出来”远远不够。我见过太多 Redis 集群性能问题,最后都出在 key 和 value 的设计上。
先说 key。缓存 key 建议用统一的业务分隔格式,比如 业务域:对象类型:业务ID[:附加维度],目的是避免不同模块之间的 key 冲突,也方便出问题时用 patterns 批量排查。比如商品标题可以设计成 mall:product:title:1001,而不是简单写一个 productId。如果完全不区分业务域,将来两个服务共用一套 Redis 时,很容易互相覆盖数据。
TTL 也要结合业务来设。不是所有数据都适合“永不过期”。查询类数据的过期时间可以从“数据能接受多旧”来反推,比如商品标题同步在几分钟内可以接受旧数据,TTL 就设 5~10 分钟;价格可能要求更强的时效,TTL 就要缩短或者改成主动更新。另一个很实际的问题是:TTL 不能让所有 key 同时过期,否则会发生连锁雪崩,这一点第三部分再详细展开。
value 的设计同样重要。如果不是业务需要,不要把一个几百 KB 甚至几 MB 的大对象整个塞进 Redis。缓存 value 超大不仅占内存,还会让网络传输和反序列化的时间变长,一个 1MB 的 value 读取延迟远高于一个小字符串,Redis 的阻塞风险也会增加。常见做法是把对象拆成多个独立的小缓存,或者压缩后再存入。拆不拆、怎么拆,要看业务读模式:如果读 A 时永远不需要 B 字段,就不要把它们绑死在一个 value 里。
2.3 本地缓存和 Spring 三级缓存,不是一回事
进程内缓存在“读多写少、允许各实例短时间不一致”的场景很香。比如一份系统配置、一份字典表、一份低频更新的推荐列表,本地缓存可以让接口在 Redis 抖动的瞬间依然有兜底数据。
但要分清:Spring 的“三级缓存”不是用来做业务性能优化的,它其实是 Spring 容器创建 Bean 时的一个内部处理机制。
很多新手在做缓存调研时会搜到“Spring 三级缓存原理”,又看到里面有“一级缓存、二级缓存、三级缓存”几个 Map,就误以为这和 Redis 缓存有关。其实 Spring 的三级缓存是为了解决循环依赖问题,比如 A 依赖 B,B 又依赖 A,这种对象在创建时会出现“蛋生鸡”的问题。Spring 的做法是:先让 A 的半成品对象提前暴露出去,等 B 创建时如果需要 A,可以从提前暴露的地方拿到 A 的引用,等 A 创建完成后,再填充完整。这里的“缓存”只是存储对象引用的 Map,不是热点数据的缓存。
真正用于业务缓存的 Spring 能力是 @Cacheable 这类注解。它底层除了本地 Map,还需要配合 Caffeine、Redis 这类真正的缓存实现。两个概念混在一起,后面的排查思路会完全跑偏。
还有一个容易混的是 MyBatis 的一级、二级缓存。一级缓存默认在同一个 SqlSession 内生效,存在感很弱;二级缓存默认需要 Mapper 手动开启,多表操作时很容易因为缓存没有感知其他表的更新,返回旧数据。所以在多数高并发业务里,我会建议把 MyBatis 二级缓存保持关闭,把热点查询交给统一的 Redis 或本地缓存去管理,这样至少你能控制缓存的生命周期和失效时机。
3. 读链路最怕的三件事:穿透、击穿、雪崩
缓存读流程的经典问题就三个:穿透、击穿、雪崩。很多人听过名字,但实际排查时经常搞混。简单说,穿透是“查了一个根本不存在的数据”,击穿是“热点 key 过期瞬间的并发暴击”,雪崩是“大量 key 同时失效或缓存整体不可用”。这三件事的应对方案不同,我分开说。
3.1 缓存穿透:不存在的数据也要背一口锅
什么叫穿透?查询一个数据库里根本不存在的数据,比如恶意请求不停地访问 userId=999999,缓存里不会存这个 key,所以每次请求都会打到数据库。数据库再返回空,缓存也没办法回填(因为空值常常被认为“不值得缓存”),于是每个请求都直接穿透到存储层。
很多团队遇到这个问题时第一反应是“用布隆过滤器”,这个方向没错,但布隆过滤器不是银弹,它只能做“快速判断可能不存在”,而且有一定误判率,也不能支持删除单个元素。对于“判断某个 ID 是否可能存在于集合中”的场景,布隆过滤器能让绝大多数穿透请求在缓存层就被拦截。真正落到数据库的,只剩那一小部分“存在但缓存未命中”的请求。
但在工程上,我更常用也更好理解的方式是:**对查询结果为空的情况,也写入一个占位空值,并设置
