简介本地缓存与分布式缓存是后端架构设计里的常见选择难题。这份PPT用一整套幻灯片讲清两种缓存机制先交代缓存以空间换时间、应对高并发读压力的本质再逐一对比本地缓存的命中速度快、适合小数据量存储但存在集群一致性、应用重启丢失等短板分布式缓存可支撑大数据量、横向扩容并保证多应用共享却因跨网络传输而读写延迟偏高。内容还结合场景给出判断方法例如数据量不大、QPS极高可选Guava Cache/Caffeine热点数据、ORM二级缓存场景则可选Redis/Memcache。全包共1个PPT课件压缩包大小2.14MB作者qq_31644891整理章节从概念、优缺点到应用场景逐层展开配套典型框架说明适合后端开发者、运维人员直接用于团队培训、技术分享或面试复习。目前已有1305人学习下载。1. 缓存选型不是选技术而是选数据流从本地缓存到分布式缓存的真实分叉点如果你搜过“小米浏览器本地缓存视频怎么导出”应该知道那是在文件目录里翻出 MP4 再复制出来而不是把视频当普通缓存文件重新打包一层。服务端缓存选型也是同一个逻辑决定用本地缓存还是分布式缓存不取决于哪个技术“更高级”而取决于这份数据在系统里到底能被谁访问、多久变一次、丢了是否可接受。很多团队的翻车现场不是 Caffeine 和 Redis 谁的性能问题而是把“进程内数据”当成了“全局共享视图”。这篇就把本地缓存与分布式缓存的优缺点拆开落到两级缓存怎么搭、参数怎么设、线上哪些坑最容易被反复踩。2. 本地缓存与分布式缓存的边界Caffeine 和 Redis 各自守哪条线2.1 本地缓存把热点数据关进单机 JVM 内存里本地缓存指的是和应用进程共生的缓存常见实现有 Guava Cache、Caffeine、EhCache。它的核心特征是数据存放在当前 JVM 的堆内或堆外内存空间读写路径只有一次函数调用不涉及网络 IO、不涉及序列化。Caffeine 是目前这个位置上的主流选择它基于 Window-TinyLFU 淘汰算法。这个算法的价值在于它不是简单地按最后访问时间淘汰而是通过一个频率布谷鸟过滤器统计每个 key 的历史访问频率兼顾“最近访问”和“高频访问”在高命中率场景下表现比 LRU 干净得多。实际压测中本地缓存读延迟通常在微秒到几十微秒级别而 Redis 即使在同一机房内网也要 0.2ms 到 1ms 级别的往返。如果你的接口请求量在每秒千级以上且存在“同一份热点数据被反复读取”的特征本地缓存的性价比非常明显。但它有两个天然短板。第一是数据无法跨多实例共享每个服务实例各自持有一份拷贝数据更新时只能逐个节点通知失效做不到事务级的全局一致。第二是容量受堆内存约束一个 4G 堆的 Java 服务不可能把 200G 的业务数据全塞进本地缓存所以它只适合缓存“有限数量的热点数据”而不是全量数据。2.2 分布式缓存Redis 提供的不是存储而是全局视图分布式缓存把缓存数据独立到应用进程之外的存储节点上。以 Redis 为例它自己是一个单线程事件循环服务通过 IO 多路复用处理大量客户端连接内存中直接存储数据结构因此即使跨网络也比大多数磁盘型数据库快一到两个数量级。它解决了本地缓存最头疼的两个问题第一是全局共享所有服务实例访问的都是同一份数据不存在“节点间数据分叉”第二是容量可扩展Redis 集群模式下可以水平扩分片把数据分散到多台机器突破单机内存上限。但分布式缓存把钱花在了新的地方一次读取要经过“应用进程 → TCP 连接 → Redis 服务 → 反序列化 → 返回”涉及到网络往返和序列化开销。另外一个很多人忽略的事实是Redis 依然只是缓存不是数据库。即使开启了 AOF 和 RDB 持久化它仍然不适合作为唯一存储进程崩溃和主机故障期间缓存命不中时系统依然要回源到数据库。也就是说分布式缓存把你从“一台机器的内存问题”转移成了“一台独立服务的运维问题”这部分复杂度是很多人选型时没算进去的。2.3 一张对比表看清优缺点再谈选型标准下表按实际工程项目里关心的维度把两张技术方案摆在一起对比维度本地缓存Caffeine分布式缓存Redis读延迟微秒级无网络开销毫秒级内网约 0.2-1ms数据一致性多实例各自持有天然分叉全局唯一多实例共享容量上限受单机堆内存限制可集群扩容几乎无上限持久化无重启即全部丢失RDB / AOF 可选数据更新方式仅本进程内生效所有应用实例统一可见运维复杂度零随应用启动需要独立部署、监控、高可用典型适用热点参数、低频变量、字典表用户状态、会话、共享业务数据2.4 选型不是二选一多数场景答案是“都要但有主次”我的选型原则分三层。第一层先问数据能不能容忍一定时间窗口内的不一致。比如配置开关、白名单这类业务允许“延迟几秒生效”的数据放本地缓存即可不需要拉 Redis。第二层问所有实例是否必须共享同一份数据快照。比如用户登录态、订单状态任何实例都不能看到不同的版本这一类数据必须走 Redis。第三层问数据量级和缓存命中率。数据量大且每个请求都分散访问不同 key那缓存本身没有意义不如直接读写数据库。真实项目里最常见的形态其实是两级缓存Caffeine 做第一级挡住绝大多数读流量Redis 做第二级兜底进程内未命中的部分并提供跨节点共享能力。下一章直接把这个结构落地到 Spring Boot 代码里。3. 在 Spring Boot 里搭两级缓存Caffeine 挡流量Redis 保共享3.1 场景设定与缓存键结构设计假设要做一个商品详情接口读多写少QPS 峰值 3000 左右商品数据存储在 MySQL 中并发读时需要支撑多实例部署。这里需要缓存两个层面一是单个商品的基础信息数据量大但热点集中在前 20% 商品二是全局配置类数据比如类目标签、价格策略开关。缓存键结构要统一规划我建议用业务域:实体类型:ID的格式例如mall:product:8848。这样设计的好处是后续按业务域清理缓存时可以直接用 Redis 的SCAN按前缀匹配也方便在监控系统里按照 key 前缀分组统计命中率。不要把 key 设计成不可读的一长串哈希值排障时会非常痛苦。3.2 依赖引入与配置项参数说明在 Spring Boot 项目中先引入 Caffeine、Redis 以及缓存抽象层依赖。下面这段pom.xml是核心部分dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-cache/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.github.benmanes.caffeine/groupId artifactIdcaffeine/artifactId version3.1.8/version /dependencyspring-boot-starter-cache提供Cacheable、CacheEvict注解和CacheManager抽象spring-boot-starter-data-redis负责 Redis 连接Caffeine 是本地缓存具体实现。然后用一段自动配置定义两级缓存管理器Configuration EnableCaching public class CacheConfig { Bean public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) { // 先构建 Redis 缓存作为第二级 RedisCacheManager redisCacheManager RedisCacheManager.builder(redisConnectionFactory) .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .disableCachingNullValues()) .build(); // Caffeine 本地缓存定义为第一级 com.github.benmanes.caffeine.cache.CacheObject, Object caffeineCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats() .build(); return new TwoLevelCacheManager(twoLevelCache, caffeineCache, redisCacheManager); } }默认 TTL 设为 30 分钟Caffeine 最大条目数 10000Caffeine 过期时间 10 分钟。这里有个关键参数逻辑Caffeine 的expireAfterWrite通常要远小于 Redis TTL目的是让本地缓存尽早过期从而拉长“缓存不新鲜”的恢复周期。否则本地缓存 30 分钟不刷新Redis 里的数据即使被重新写入本地也无法感知。3.3 两级缓存的读写实现先查本地再查 Redis最后回源数据库接下来实现一个自定义CacheManager和Cache让注解服务能直接使用两级结构。先看读写核心类public class TwoLevelCache implements Cache { private final String name; private final com.github.benmanes.caffeine.cache.CacheString, Object l1; private final RedisCache redisCache; public TwoLevelCache(String name, com.github.benmanes.caffeine.cache.CacheString, Object l1, RedisCache redisCache) { this.name name; this.l1 l1; this.redisCache redisCache; } Override public String getName() { return this.name; } Override public ValueWrapper get(Object key) { String cacheKey String.valueOf(key); // 第一次查询本地 Caffeine Object localValue l1.getIfPresent(cacheKey); if (localValue ! null) { return () - localValue; } // 第二次查询Redis 缓存 ValueWrapper redisValue redisCache.get(cacheKey); if (redisValue ! null) { Object value redisValue.get(); l1.put(cacheKey, value); // 回填到本地下次直接命中 return () - value; } return null; // 两级均未命中由业务层回源数据库 } Override public void put(Object key, Object value) { String cacheKey String.valueOf(key); // 写入 Redis保证多实例共享 redisCache.put(cacheKey, value); // 本地缓存直接失效下次读取时再从 Redis 重新加载 l1.invalidate(cacheKey); } Override public void evict(Object key) { String cacheKey String.valueOf(key); redisCache.evict(cacheKey); l1.invalidate(cacheKey); // 发布一个消息通知其他实例也清理本地缓存 redisCache.put(evict: cacheKey, System.currentTimeMillis()); } }这个实现有几个细节要说明。get方法的回填逻辑里如果 Redis 命中后直接把数据写进 Caffeine并没有重新设置过期时间Caffeine 的过期策略仍然按照首次写入 Caffeine 的时间计算所以过期时间业务上是可控的。put方法这里选择“更新 Redis 并失效本地”而不是“同时写两份缓存”原因很简单——写本地缓存会掩盖其他实例的更新导致本地旧值覆盖不到、新值又未同步的尴尬状态。业务侧的使用方式就非常自然了Service public class ProductService { Cacheable(cacheNames product, key #productId) public Product getProduct(Long productId) { return productMapper.selectById(productId); } CacheEvict(cacheNames product, key #productId) public void updateProduct(Product product) { productMapper.updateById(product); } }Cacheable会先走TwoLevelCache.get按本地 → Redis → 数据库的顺序取值CacheEvict对应两级清理。强调一点updateProduct方法里必须先写数据库再触发缓存失效。如果先删缓存再写数据库期间进来的请求会把旧数据重新写回缓存造成缓存与数据库长期不一致。3.4 用 Redis Pub/Sub 让多实例的本地缓存同步失效上面代码里的evict方法留了一个消息发布动作。因为 Caffeine 是每个进程各自的只清理当前实例的本地缓存还不够其他实例的 Caffeine 里可能还存着旧数据。需要借助 Redis 的发布订阅能力广播“该 key 已失效”的信号Component public class CacheEvictSubscriber { Autowired private StringRedisTemplate redisTemplate; PostConstruct public void registerListener() { // 监听 evict channel收到消息后清理本地缓存 redisTemplate.execute((RedisCallbackObject) connection - { connection.subscribe((message, pattern) - { String cacheKey new String(message.getBody()); // 从 Caffeine 移除不做任何回填 CaffeineCacheManager.getInstance().getCache(product).evict(cacheKey); }, cache:evict.getBytes()); return null; }); } }这里要注意subscribe回调里不能处理耗时操作它是阻塞在同一个 Redis 连接上的。如果本地缓存清理很慢会影响当前实例后续所有 Redis 操作。清理逻辑本身就是一次 map remove微秒级可以接受。另一种方案是用 Redis Stream 或 MQ 来做广播逻辑类似但多引入一个中间件依赖大多数场景下 Pub/Sub 足够。3.5 TTL 和容量参数怎么定而不是抄默认值在参数上我一般按数据特征区分三档。全局配置类数据TTL 给 30 分钟Caffeine 条目数 2000 以内因为变化频率极低本地缓存可以大方放。商品详情类热点数据TTL 给 10 到 15 分钟Caffeine 条目数按平时接口访问的 distinct key 数量估算通常是 QPS × 单 key 平均访问时长例如 3000 QPS、单 key 平均被访问 5 分钟那么 distinct key 约 9000Caffeine 可以设 15000留出余量。用户态数据强制走 Redis不放本地缓存因为多实例间必须看到一致的状态而 Caffeine 的本地副本只会造成状态漂移。此外recordStats()要保留Caffeine 内置的命中率统计在后期排查时很有用。下一章就进入最容易翻车的几个线上场景。4. 缓存线上三大坑与排查手册穿透、雪崩、多节点本地分叉4.1 坑一Redis 正常但接口大量超时——本地缓存成了“分叉数据源”现象部署两个服务实例之后配置中心修改了某个开关发现 A 实例五分钟内生效B 实例一直不生效部分用户看到新版功能部分用户还是旧版排查半天发现两台机器的本地缓存里存的是不同值。原因本地缓存是进程私有的。配置中心或业务接口只能更新 Redis无法感知每个 JVM 内Caffeine已缓存的内容。即使 Redis 里的数据已经更新没有收到失效通知的实例会继续返回本地旧值。解决能接受短时不一致的业务把本地 TTL 压到 1 分钟以内。不能接受不一致的业务把数据从本地缓存剔除只走 Redis。或者用上面写的 Pub/Sub 订阅方案在更新事务成功后向cache:evictchannel 广播 key。注意 Pub/Sub 也可能丢消息实时性要求高的场景需要配合 TTL 作最终兜底。4.2 坑二某个不存在的 ID 反复把 MySQL 打挂——缓存穿透现象线上监控里 MySQL 慢查询飙升发现大量select * from product where id 123456这个 ID 是一个伪造的或已删除的商品缓存里从未存在过所以每次都穿透到数据库。原因缓存的 key 里根本不存在这个值。TwoLevelCache.get返回 null业务层随后查询数据库数据库也没有业务层又没有把空结果写回缓存下次同样的请求再次穿透。攻击者只需要构造一批不存在的 ID就能让后端数据库持续承受无效查询。解决对于数据集合可枚举的场景加一个布隆过滤器在最外层判断 key 是否存在不存在直接返回空结果。对于数据集合不固定的场景缓存空值并设置一个较短的 TTL比如 5 分钟redisCache.put(product: id, null, 5min)。这样同一个不存在的 ID 在 5 分钟内只会穿透一次到数据库。同时给数据库接口增加合理的限流保护防止极端情况下被打满。4.3 坑三Redis 启动瞬间数据库被击穿——缓存雪崩与预热缺失现象某次 Redis 机房断电重启后服务恢复了但紧接着数据库连接数打满大量业务超时。表面看缓存系统恢复了实际上所有请求都回源到了数据库。原因Redis 在启动后是空的此时并发请求看到缓存全部未命中同时回源打数据库。如果 Redis 通过 AOF 或 RDB 恢复数据需要一个过程这个窗口期可能持续几十秒到几分钟数据库承受不住。更糟的是如果大量 key 的 TTL 恰好设置在同一时刻比如所有缓存 key 统一在 0 点过期也会有同样的雪崩效果。解决Redis 必须做高可用哨兵或集群模式避免单点重启后出现空窗。业务侧在缓存层前面加一层简单的限流保护当 Redis 不可用时应用直接降级返回而不是让全部流量穿透到 DB。TTL 不要设置成统一时长加上随机偏移量比如TTL 30分钟 random(-300, 300)秒让每个 key 的过期时间散开。还有一个习惯性操作上线前或 Redis 恢复后主动做一次热点数据预热把 top 1000 的 key 提前加载到缓存里避免冷启动。4.4 排查步骤顺一遍先看哪一级未命中再定量分析遇到缓存类故障时先不要改代码按这个顺序排查。第一步确认 Redis 命中率执行redis-cli info stats观察keyspace_hits和keyspace_misses命中率低于 80% 说明缓存价值不大。第二步确认本地缓存是否仍持有旧值用一个测试 key 模拟写入 Redis观察应用实例返回值是否变化。第三步查看日志中缓存方法的耗时曲线如果 Redis 平均耗时稳定但接口变慢问题大概率出在序列化或连接池。第四步看是否有大量 get 结果为 null 的请求有就查看这些 key 的规律是否指向同一类不存在的数据。这条路线能覆盖 80% 的线上问题。4.5 缓存与数据库不一致的终极解延迟双删和版本兜底一致性问题上最流行的方案叫“延迟双删”。流程是先删除 Redis 缓存再更新数据库然后 sleep 一小段时间比如 200ms再次删除 Redis 缓存。第一次删除是为了避免更新数据库期间老线程把旧值写回去第二次删除是为了清掉数据库更新完成之前可能重新被写入的旧缓存。但 sleep 是个玄学网络抖动稍微长一点就没用了。更可靠的做法是在对象里带一个版本号或时间戳写入缓存前比对版本版本小于等于当前值则拒绝写入。这个方案把“精确的一致性”变成“过期数据不改写新数据”实际工程里更实用。5. 验证缓存方案是否健康命中率、故障演练和参数复盘5.1 用一小时采样脚本确认命中率是及格还是优秀上线缓存后不能只看接口速度爽了就算完要验证缓存是否真的在起作用。这里给一段采样脚本每小时从 RedisINFO里抓一次命中率把结果打印出来#!/bin/bash # 采样 Redis 缓存命中率输出到 logs/cache_hit.log while true; do INFO$(redis-cli -h 127.0.0.1 -p 6379 info stats) HITS$(echo $INFO | grep keyspace_hits | awk -F: {print $2} | tr -d \r) MISSES$(echo $INFO | grep keyspace_misses | awk -F: {print $2} | tr -d \r) TOTAL$((HITS MISSES)) if [ $TOTAL -gt 0 ]; then RATE$(echo scale2; $HITS * 100 / $TOTAL | bc) echo $(date %F %T) Redis Hit Rate: ${RATE}% (hits$HITS misses$MISSES) logs/cache_hit.log fi sleep 3600 done脚本用grep提取字段bc计算百分比每小时记录一次。命中率长期在 90% 以上说明缓存设计合理低于 70% 就要重新分析数据访问特征要么是 key 设计太分散要么是 TTL 太短导致缓存经常过期。对于本地缓存 Caffeine则通过cache.stats()拿到hitCount和missCount在监控系统里打点即可。5.2 故障演练用三个场景验证缓存边界我通常会在预发布环境跑一组故障演练就三件事手动重启 Redis观察应用是否触发降级手动向 Redis 写入一个过期时间很短的缓存 key观察本地缓存是否会因为在有效期内而正常返回模拟大量不存在 key 的查询观察数据库连接数是否异常上升。演练过程中盯两个指标接口 99 分位延迟是否爆表、数据库慢查询数量。如果 Redis 重启后接口 P99 从 30ms 涨到 3000ms说明降级策略没有生效如果数据库连接数直接翻了三倍说明穿透防护没接入。把演练结果和参数调整形成一个 checklist每次改完缓存策略强制跑一遍。5.3 调参的真实思路TLL、maximumSize 和并发度上线一段时间后会得到一个缓存快照本地缓存实际条目数、Redis 内存使用量、各 key 的访问频率。调参时先看本地缓存条目数是否常年打满如果打满且命中率不低说明maximumSize偏小应该至少扩到位平时的两倍如果条目数常年只在三成以下说明设得太大没有意义反而让淘汰算法管理更多条目增加开销。Redis TTL 则看业务侧可容忍的“脏数据窗口”商品价格这类强敏感数据给 1 到 5 分钟文章内容这类弱敏感数据给 30 分钟以上。最后是 Redis 连接池默认 8 个连接很多时候不够按 Tomcat 线程池数的 1/3 起步配置比如 200 个业务线程给 64 个连接较合理低于这个值容易出现“Redis 请求排队等待空闲连接”的假性延迟。做过几轮这样验证后我养成了一个习惯每上一个缓存方案都强制走一遍“命中率采样 → 故障演练 → 参数复盘的清单”再花一个下午把演练结果整理成表格存档。缓存这东西上线时看着快真正出问题时往往都是沉默的读脏数据比延迟慢更可怕。希望这篇能帮你在下一次缓存选型和排障的时候少走一段弯路。本文还有配套的精品资源点击获取