
1. 缓存金字塔的架构设计别一上来就堆 Redis1.1 接口慢的根源都怪数据库扛下了所有先交代一下背景我最近接手了一个用户画像查询接口QPS 不算夸张峰值也就是两百左右可接口耗时长期在 800ms 上下徘徊。打开监控面板一看90% 的时间都花在了数据库查询上一张宽表关联六张子表还有一段热点用户标签的逻辑没法走索引每次请求都要扫描近万行数据分组汇总。最烦人的是这个接口的调用频率极高首页一加载首屏四个组件各打一次前端还在 for 循环里串行调用用户体感直接飙到两秒以上。这种场景下绝大多数人的第一反应是上 Redis。我也是这么过来的给查询结果加了一层 Redis 缓存之后接口耗时确实从 800ms 降到了 120ms 左右。但压测一跑就发现问题了热点用户的数据在 Redis 里反复被命中单台 Redis 的 get 操作平均 0.5ms 到 1ms网络往返加上序列化反序列化整体来说吞吐量是上去了可延迟并没有想象中那么好看。更要命的是每次请求都要经过网络栈在高并发下网卡和 Redis 单线程模型会成为新的瓶颈。为什么会出现这种情况因为 Redis 本质上仍然是跨进程的网络访问就算它在千兆内网里跑得飞快也逃不过两次上下文切换、一次 TCP 往返、一次内存复制。一次请求从应用线程发起 get 命令到拿到结果大概要走 10 微秒到 1 毫秒。而进程内的本地内存访问呢纳秒级。这中间的差距大概有三个数量级。所以如果你真的想追求接口延迟的极致不能只依赖 Redis必须把最热的数据放到离 CPU 最近的地方也就是应用进程内部。还有个容易被忽略的问题缓存 Key 的重复计算。同样的用户 ID在 Redis 里虽然能命中值但 Key 的拼接、序列化器的选择、连接池的获取每一步都有开销。一个接口如果同时查五六个 Redis Key这些小开销攒在一起就足够让 p99 延迟很难看了。所以我才开始认真考虑本地缓存也就是标题里说的缓存金字塔。1.2 金字塔模型L1、L2、L3 到底怎么分工先说明一下我对缓存金字塔的理解。它不是什么高深理论就是把数据按照访问频率和一致性要求分层最热的数据放最快的地方越往上层越快、容量越小、成本越高越往底层越慢、容量越大、成本越低。我落地的时候分了四层不是简单的三层层级存储介质典型访问延迟容量一致性要求L1 本地进程内缓存CaffeineJVM 堆内纳秒级可忽略小控制在几百 MB 内宽松允许秒级延迟L2 分布式缓存Redis0.5ms ~ 1ms大可控主保持一致L3 数据库MySQL / PostgreSQL5ms ~ 50ms无限最终一致L4 外部依赖第三方接口 / 搜索引擎100ms无最终一致这个金字塔里每层都有自己不可替代的位置。我把查询最频繁、据统计占了 70% 以上访问量的前 1 万用户的数据放进了 L1其余用户的数据放 L2Cold 数据走 L3。实际跑出来的结果就是接口平均延迟从 800ms 降到 80ms整整 10 倍。其中 L1 命中率在 85% 左右L2 命中率在 90% 以上最终落到数据库的请求只有原来的 5% 左右。这里我想强调一点缓存金字塔不是让你放弃 Redis而是让 Redis 从一线退到二线。Redis 依然不可或缺因为它在多实例部署时承担了全局数据一致性和跨实例共享的职责只是不能再让它承担所有热点流量。L1 层的加入相当于给 Redis 加了一层盾牌把 80% 的重复请求挡在了进程内部。2. SpringBoot 3 项目中的本地缓存落地Caffeine 的正确打开方式2.1 引入依赖与基础配置直接说结论Spring Boot 3 项目里做本地缓存我选的是 Caffeine。原因很简单它是目前 JVM 系本地缓存里单测性能和功能均衡性最好的支持基于 size 和时间的失效策略还自带统计命中率的能力不用额外接一套监控。Guava Cache 我也用过但 Caffeine 在并发读写下表现更好淘汰算法用的是 W-TinyLFU比 Guava 的 LRU 更适合热点数据。Maven 依赖也很简单加两个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-cache/artifactId /dependency dependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId version3.1.8/version /dependencySpring Boot 3 用的是 JSR-107 缓存抽象默认支持 Caffeine。依赖加完之后先配置一个 Caffeine CacheManager 的 Bean。之前踩过一个坑就是直接用CaffeineCacheManager默认配置结果没指定过期时间缓存永远不失效脏数据整整跑了一周才发现。所以配置必须自己写清楚。Configuration public class CacheConfig { Bean public CacheManager cacheManager() { CaffeineCacheManager manager new CaffeineCacheManager(); manager.setCaffeine(Caffeine.newBuilder() .maximumSize(100_000) .expireAfterWrite(Duration.ofSeconds(60)) .recordStats()); manager.setCacheNames(List.of(userProfile, orderList)); return manager; } }这里几个参数说明一下。maximumSize(100_000)表示缓存最多放 10 万个条目超出后 Caffeine 会根据 W-TinyLFU 算法自动淘汰低频使用的条目。expireAfterWrite(Duration.ofSeconds(60))是写入 60 秒后过期这个时间窗口是我根据业务容忍度定的后面会细说。recordStats()打开统计功能线上可以通过cache.stats()拿到命中率。2.2 核心参数为什么这么定过期时间、容量、刷新策略先说过期时间。有人可能觉得缓存过期时间越短越安全这没错但越短命中率越低。我这边用户画像数据的特征是不变则已一变就是批量变而且用户不会感知到 60 秒内的标签变化。所以expireAfterWrite设成 60 秒是对时效性和命中率的平衡。如果你做的是库存类、余额类数据那不能这么玩哪怕一秒的过期时间都太长了那种场景应该走分布式锁 主动失效而不是过期兜底。再说refreshAfterWrite。Caffeine 支持在过期之前后台刷新这个功能非常适合读多写少的场景。我给它也设了 30 秒意思是一条数据写入 30 秒之后下一次访问会触发一次后台异步加载加载完成后替换旧值同时对外继续返回旧值。这就能避免缓存过期瞬间大量请求同时穿透到 DB也就是俗称的缓存击穿。我当时用的是expireAfterWrite(60s)refreshAfterWrite(30s)的组合。有读者可能觉得奇怪30 秒就刷新了60 秒的过期时间还有意义吗有因为refreshAfterWrite只对访问到的 key 生效如果某个 key 在 30 秒内没有被访问就不会触发刷新直到 60 秒写过期兜底。这两个机制一个负责主动更新一个负责兜底清理正好互补。关于容量我是这么算的用户画像的单个 JSON 大约 2KB10 万条就是约 200MB堆内存 4GB 的实例完全扛得住。如果你搞的是大数据量的列表缓存一条记录几十 KB那 10 万条就是好几个 GB明显不合适就该把maximumSize调低或者干脆只缓存 key 对应的摘要。CacheString, UserProfile userCache Caffeine.newBuilder() .maximumSize(100_000) .expireAfterWrite(Duration.ofSeconds(60)) .refreshAfterWrite(Duration.ofSeconds(30)) .recordStats() .build(key - loadUserFromDB(key));build方法里我传了一个loadUserFromDB加载函数这样缓存缺失时会自动调用这个函数回源 DB 并填入缓存。注意这个加载函数里一定要做好空值保护如果 DB 里也不存在这个用户建议缓存一个空对象并设置较短的过期时间避免缓存穿透——也就是频繁查询一个不存在的 Key导致每次请求都打到数据库。这方面我吃过亏后面在避坑部分展开。2.3 如果只用 Spring Cache 注解你会错过什么Spring Cache 抽象用起来很爽在接口方法上加Cacheable(cacheNames userProfile, key #userId)就能自动套缓存。但我必须说实际做高并发接口时我几乎不会只用注解而是手写 Cache API 和注解混着用。为什么因为Cacheable有几个痛点第一它只能做调用级别的拦截没法实现原子性的“先查本地再查 Redis 再回源 DB”这个三层逻辑除非你嵌套多个 cache 注解但那样代码极难维护第二Cacheable的缓存操作发生在方法调用之前如果方法抛异常它默认不回填缓存某些配置下行为不同行为不符合预期第三注解没法灵活的指定缓存层级——同一个 key 你想同时写 L1 和 L2注解不好控制。所以我实际落地时用了一个自研的CachePro组件核心逻辑就是先查 L1miss 再查 L2再 miss 才回源然后逐层回填。这段代码我放在第 3 节展开这里先卖个关子。Spring Cache 我用在了没有本地缓存加持、只依赖 Redis 兜底的次要接口上也算物尽其用。3. 接口改造实操从 800ms 到 80ms 的完整链路3.1 原接口是怎么写的这是改造前的接口也是很多项目的典型写法进来先查 Redis没有就查 MySQL。逻辑没毛病但就是没有本地缓存性能天花板被锁死。RestController RequestMapping(/api/user) RequiredArgsConstructor public class UserProfileController { private final StringRedisTemplate stringRedisTemplate; private final UserProfileDao userProfileDao; GetMapping(/profile/{userId}) public ResultUserProfile getProfile(PathVariable Long userId) { String key user:profile: userId; // 先查 Redis String json stringRedisTemplate.opsForValue().get(key); if (json ! null) { return Result.ok(JSON.parseObject(json, UserProfile.class)); } // 再查 MySQL UserProfile profile userProfileDao.selectByUserId(userId); if (profile ! null) { stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(profile), 60, TimeUnit.SECONDS); } return Result.ok(profile); } }这段代码最大的问题就在并发高了之后每个请求都先从 Redis 查询哪怕 90% 的 Key 都在 Redis 里仍然要消耗一次网络 IO。看一眼监控数据改造前这个接口平均耗时 780msp99 要到 3.6s。而且 Redis 的 QPS 被打到 8000 多连接数一直顶在 200 不降。数据库虽然被 Redis 挡住不少请求但热点用户一旦出现缓存集中失效数据库 CPU 立刻飙升。这种架构加到 2 台实例会好一点但治标不治本。3.2 金字塔改造三层缓存一次查询一次回源改造后的代码我把流程定义为本地缓存L1→ RedisL2→ 数据库L3统一封装成一个组件。Component RequiredArgsConstructor public class UserProfileService { private static final String CACHE_PREFIX user:profile:; private final RedisTemplateString, String stringRedisTemplate; private final UserProfileDao userProfileDao; private final CacheString, UserProfile localCache Caffeine.newBuilder() .maximumSize(100_000) .expireAfterWrite(Duration.ofSeconds(60)) .refreshAfterWrite(Duration.ofSeconds(30)) .recordStats() .build(key - loadUserFromDB(key)); public UserProfile getProfile(Long userId) { String key buildKey(userId); // L1 本地缓存纳秒级 UserProfile cached localCache.getIfPresent(key); if (cached ! null) { return cached; } // L2 Redis毫秒级 String json stringRedisTemplate.opsForValue().get(key); if (json ! null) { // 回填 L1注意这里设置了空值保护 UserProfile profile JSON.parseObject(json, UserProfile.class); if (profile ! null) { localCache.put(key, profile); } return profile; } // L3 数据库回源 UserProfile profile loadUserFromDB(key); if (profile ! null) { // 逐层回填 stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(profile), 60, TimeUnit.SECONDS); localCache.put(key, profile); } return profile; } private UserProfile loadUserFromDB(String key) { Long userId Long.valueOf(key.substring(CACHE_PREFIX.length())); UserProfile profile userProfileDao.selectByUserId(userId); // 空值保护数据库没有数据也缓存一个空标记避免穿透 if (profile null) { profile UserProfile.empty(); } return profile; } private String buildKey(Long userId) { return CACHE_PREFIX userId; } }这段代码我把最重要的几点拆出来讲一下第一L1 的getIfPresent操作是线程安全的 Caffeine 方法并发场景下内部会做分段锁性能远比ConcurrentHashMap手动读写要好得多。第二回填顺序是先 Redis 后本地这是刻意的——本地缓存只是进程内的副本Redis 才是跨实例的真相源先写全局的再写局部的其他实例才能同步看到。第三loadUserFromDB里的空值保护是我反复踩坑后的救命操作没有它一个不存在的 userId 就能把数据库打挂。3.3 失效和更新金字塔最致命的一环缓存架构里读路径只是半边天写路径一旦处理不好之前的性能优势全白搭。我主要做了三件事主动失效、双删策略、Redis Pub/Sub 广播。先看主动失效。当用户画像发生修改时必须先更新 DB然后主动删除缓存而不是等它自己过期。注意这里是“删除”而不是“更新”原因很简单更新缓存需要保证写入顺序和 DB 一致多线程并发场景下容易发生脏写删除则没有这个问题下一次读取会自然回填新值。public void updateProfile(Long userId, UserProfile newProfile) { // 1. 先更新数据库 userProfileDao.updateByUserId(userId, newProfile); // 2. 删除 Redis 缓存 stringRedisTemplate.delete(buildKey(userId)); // 3. 删除本地缓存 localCache.invalidate(buildKey(userId)); }然后是双删策略。更新数据库和删除缓存这两个操作不是原子的极端情况下可能 DB 更新前另一个线程已经读到了旧数据并回填了缓存导致缓存里永久是旧数据。解决思路是延迟双删先删一次缓存再更新 DB再延迟几百毫秒删一次。public void updateProfile(Long userId, UserProfile newProfile) { // 第一次删除缓存 stringRedisTemplate.delete(buildKey(userId)); localCache.invalidate(buildKey(userId)); // 更新数据库 userProfileDao.updateByUserId(userId, newProfile); // 延迟删除兜底并发下的脏数据 CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS).execute(() - { stringRedisTemplate.delete(buildKey(userId)); localCache.invalidate(buildKey(userId)); }); }最后是 Redis Pub/Sub 广播。这个主要解决多实例场景下的本地缓存一致性问题A 实例更新了用户数据它自己的本地缓存已经失效了但 B 实例的本地缓存里还是旧数据如果不做广播B 实例最长还要等 30 秒refreshAfterWrite才能感知。我这里做了一个极简的 Redis 消息订阅更新操作发一条CACHE_EVICT消息所有实例收到后主动失效对应 key。Component RequiredArgsConstructor public class CacheEvictListener { private final UserProfileService userProfileService; EventListener public void onMessage(CacheEvictEvent event) { // 收到广播后清除本地缓存中的指定 key userProfileService.evictLocal(event.getKey()); } }为了不把文章写成分布式系统论文这里我只展示思路。真要完整落地建议用 Redis Stream 或者独立的 MQ 做广播直接在生产环境引入 Spring 事件机制也可以但要注意消息丢失没有补偿最好还是让本地缓存依赖过期兜底。3.4 实测压测从 800ms 到 80ms 的数据对比改造完成后我立刻用 wrk 做了一轮压测。压测环境是 2 台 4C8G 的容器Redis 单独一台 2C4G数据库原来那台不动。接口请求全部走真实用户 ID 池模拟线上热点分布前 20% 的 ID 覆盖了 80% 的请求。改造前一版的数据平均延迟 782msp99 3.2s数据库 QPS 1500 左右Redis 的 get QPS 4100。这个吞吐其实已经让数据库有点吃不消了CPU 差不多 70%。改造后的数据指标改造前改造后提升比例平均延迟782ms78ms约 10 倍p99 延迟3.2s180ms约 17.8 倍DB QPS150065降低 96%Redis QPS4100420降低 90%L1 命中率0%86%-压测过程中我还特地把一台实例停掉再重新拉起来观察冷启动后的表现。因为没有预热机制实例刚起来时 L1 命中率为零前两分钟延迟会回落到 350ms 左右等缓存逐渐填满后才稳定到 80ms。关于预热后面讲注意事项时再展开。4. 避坑排雷这些细节才是决定工程质量的胜负手4.1 缓存穿透、击穿、雪崩三大经典事故我用了缓存这么多年这三个词听着是老生常谈但每次事故复盘几乎都逃不开其中一两个。先说穿透就是大量请求查询不存在的 Key缓存里没有数据库里也没有每次请求都直穿到底层。解决方式我在上文已经实现了空值保护这里再补充一点单纯缓存空对象还不保险我还在业务上加了布隆过滤器拦截不存在的 userId这属于双层保险。缓存击穿指的是一个热点 Key 过期瞬间大量并发请求同时发现缓存 miss一起冲击数据库。Caffeine 的refreshAfterWrite能解决大半问题但要注意它只在访问到时才异步刷新如果热点 Key 在 30 秒内没有任何访问到了 60 秒expireAfterWrite触发时还是会击穿。这时候需要加互斥锁让同一个 Key 只有一个线程回源。我用了 Caffeine 的get(key, mappingFunction)方法它内部对同一个 Key 的并发加载做了合并去重默认只有第一个线程真正执行加载函数后面线程直接等结果。UserProfile profile localCache.get(key, k - loadUserFromDB(k));这个写法比getIfPresentput更安全推荐优先使用。缓存雪崩是多个 Key 在同一时间集中失效导致请求洪峰全部压到 DB。我的处理是过期时间加随机扰动所有 key 的过期时间在 50 秒到 70 秒之间随机避免集体到期。另外Redis 层的过期时间也加随机值两层同时炸的概率就被拉低了。4.2 内存监控与本地缓存容量规划本地缓存最大的隐患是内存占用不受控制尤其在堆内存比较紧张的容器里。我在生产环境还遇到过实例内存溢出导致频繁 Full GC 的情况排查下来是某个核心列表的缓存把maximumSize设成了 500 万每个对象又是包含内嵌列表的大 JSON结果几百 GB 堆内存都不够用。所以容量规划一定要做估算。我的公式很简单单条数据大小 × 预估条数 × 1.5膨胀系数≤ 堆内存的 1/10。比如单条 2KB预估 10 万条那就是 200MB × 1.5 300MB如果堆内存 4G没问题如果是 2G就不合适了只能减少条数。我还在应用里加了一个定时任务每 30 秒打一次日志输出各缓存的名字、当前条数、命中率、内存占用方便快速定位冷热数据失衡的问题。Caffeine 的recordStats()打开后可以直接拿到这些数据。Scheduled(fixedRate 30_000) public void logCacheStats() { CacheStats stats localCache.stats(); log.info(本地缓存统计大小{}, 命中率{}, 内存约{}MB, localCache.estimatedSize(), String.format(%.2f%%, stats.hitRate() * 100), (int) (localCache.estimatedSize() * 2.0 / 1024)); }我强烈建议团队内部做一个统一的缓存监控面板别等到线上出事了再来猜。我见过很多生产事故都源于“缓存没问题”的直觉其实命中率跌到 50% 以下不告警用户体感早就开始劣化了。4.3 缓存预热和优雅启动新实例上线时本地缓存是空的如果不预热前几分钟的请求会全部穿透到 Redis 和 DB可能导致刚扩容的实例反而把数据库打爆。我的处理方式是启动时用热点用户列表预热 L1简单粗暴在ApplicationRunner里刷一批 Top 热点 Key 的缓存。Component RequiredArgsConstructor public class CacheWarmer implements ApplicationRunner { private final UserProfileService userProfileService; Override public void run(ApplicationArguments args) { ListLong hotUserIds userProfileService.getHotUserIds(10_000); hotUserIds.parallelStream().forEach(userId - { userProfileService.getProfile(userId); }); log.info(完成本地缓存预热数量{}, hotUserIds.size()); } }你们在实际做的时候要注意预热时别用parallelStream无限并发给 Redis 留点余量。不然预热瞬间 Redis QPS 冲上好几万没准又把缓存服务打挂了。我是设置了 100 个线程的线程池跑满为止。4.4 监控指标和告警最后说说告警缓存监控不能只看平均延迟我目前的告警项有这几条L1 命中率低于 70% 时告警说明本地缓存的设计或容量有问题Redis 的 get QPS 超过 5000 时告警说明 L1 可能没有命中请求穿透量过大平均延迟超过 300ms 时告警说明缓存体系可能失效了本地缓存条数长期达到 maximumSize 上限时告警需要评估是否扩容或优化存储结构。这些告警阈值不是拍脑袋定的我是先跑了一周压测观察正常波动范围再取上限的 1.5 倍作为阈值。告警不要设太敏感否则运维疲劳出现真问题时反而没人看了。5. 还能榨出多少性能本地缓存之外的加速手段接口优化到 80ms 之后我并不满足因为还有几个位置能继续提速度。第一件是压缩。用户画像 JSON 里很多字段是重复的长文本我用 gzip 压缩了 L2 层存储的值Redis 里存的不是 JSON 而是压缩后的字节数组单条数据从 2KB 降到 600 字节左右。读取时再解压虽然增加了 CPU 开销但网络开销和内存占用大幅下降整体实测延迟又降了 8ms。第二件是序列化方式。Spring 默认的StringRedisTemplate用的是 Jackson JSON虽然方便但占用空间大、解析慢。如果你们的数据结构稳定强烈建议换成 Protobuf 或者 Kryo 又快又小。我在线上用 Protobuf 测试过解析时间只有 JSON 的三分之一。这个改动不算难但对高频接口的收益非常直观。第三件是 CDN 和浏览器缓存。像首页热点数据这种公开的、不依赖用户身份的数据根本不需要后端参与直接让 CDN 挡住 80% 的流量后端接口只需要服务真正不稳定的部分。这一步其实是在系统层面做更大的“缓存金字塔”比应用内的 Caffeine 来得更划算。6. 写在最后的经验总结对我个人来说这个项目最大的收获不是把接口提速了 10 倍而是深刻意识到“缓存”从来不是简单地在数据库前面加一层中间件而是要根据数据的温度搭建一套能自动匹配访问频率的存储体系。本地缓存金字塔在今天高并发、高时效性的业务场景里是绕不开的基础设施但也不是说加了就一定好至少要满足两个前提你的热点数据比例足够集中并且你能接受最终一致性在业务上的表现。如果你刚接触这块我建议不要一上来就照抄我的代码先把压测基线搭好把监控仪表盘建好再根据你自己的业务特征调整参数。代码是骨架参数和监控才是灵魂。把 Caffeine 的recordStats打开认真看一周命中率数据你会比看任何教程都有收获。最后再提一句本地缓存虽然是个老话题但是 Spring Boot 3 出现之后整个配置过程变得更优雅了我强烈建议还在用 Spring Boot 2 的团队找个时间升级收益比想象中要大得多。