news 2026/9/29 12:54:36

本地缓存与分布式缓存选型:Caffeine与Redis两级缓存实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
本地缓存与分布式缓存选型:Caffeine与Redis两级缓存实战

简介:本地缓存与分布式缓存,是后端架构设计里的常见选择难题。这份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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>com.github.benmanes.caffeine</groupId> <artifactId>caffeine</artifactId> <version>3.1.8</version> </dependency>

spring-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.Cache<Object, Object> caffeineCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats() .build(); return new TwoLevelCacheManager("twoLevelCache", caffeineCache, redisCacheManager); } }

默认 TTL 设为 30 分钟,Caffeine 最大条目数 10000,Caffeine 过期时间 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.Cache<String, Object> l1; private final RedisCache redisCache; public TwoLevelCache(String name, com.github.benmanes.caffeine.cache.Cache<String, 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((RedisCallback<Object>) 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 约 9000,Caffeine 可以设 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 "scale=2; $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 请求排队等待空闲连接”的假性延迟。

做过几轮这样验证后,我养成了一个习惯:每上一个缓存方案,都强制走一遍“命中率采样 → 故障演练 → 参数复盘的清单”,再花一个下午把演练结果整理成表格存档。缓存这东西,上线时看着快,真正出问题时往往都是沉默的,读脏数据比延迟慢更可怕。希望这篇能帮你在下一次缓存选型和排障的时候少走一段弯路。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 12:52:56

MDT部署批量安装操作系统:从PXE引导到任务序列的完整指南

简介&#xff1a;一份面向服务器运维与 IT 管理者的 MDT 批量部署实操文档。内容围绕 MDT2013 协作活动目录、WDS、DHCP、ADK 完成 Windows 批量安装&#xff0c;先说明各组件在用户认证、IP 分配、网络启动和映像分发中的分工&#xff0c;再按步骤梳理部署服务器搭建、共享目录…

作者头像 李华
网站建设 2026/9/29 12:41:39

2026年AI搜索优化行业现状与正规服务商选择指南

为什么选正规AI搜索优化服务商?你大概率会踩这4个坑在生成式AI快速普及的当下&#xff0c;通过AI搜索获取企业信息已经成为大多数用户的习惯。不管是找财税服务、口腔医疗&#xff0c;还是采购工业零部件、拓展海外业务&#xff0c;大家都会下意识打开豆包、通义千问、ChatGPT…

作者头像 李华
网站建设 2026/9/29 12:39:50

医疗类微信小程序开发实战:uniapp跨端与支付避坑指南

去年我们团队把跑了大半年的H5版远程在线诊疗系统整体重构成了微信小程序&#xff0c;整个过程从技术选型到上线维护踩了不少坑。这套系统核心解决的问题很朴素&#xff1a;患者不用到现场排队就能挂号、问诊、看报告&#xff0c;医生在排班时间可以在线接诊并开电子处方&#…

作者头像 李华
网站建设 2026/9/29 12:35:28

Vue实现混合填空组件:数据解析、双模式交互与保存回显

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华