搞后端的人,早晚都会碰上缓存。刚开始可能就是一个Redis走天下,等到并发一上来、响应时间一要求,你就会发现单靠Redis还是有点吃力。这时候,“Caffeine + Redis”这套组合就成了很自然的解法。本地缓存解决热点访问的延迟问题,分布式缓存解决多实例间的数据共享,两者配合,既能扛住流量,又能保证数据相对可控。这篇文章我不讲虚的,直接把我实际搭建这套多级缓存架构的完整过程、配置参数、踩坑记录都整理出来,给正准备做缓存优化的同学一个可落地的参考。
1. 内容整体设计与思路拆解
1.1 为什么单独用Redis不够,还要加一层Caffeine
先看一个很典型的场景。某个商品详情接口,QPS冲到了5000,Redis的压力其实已经很大了。你去查监控,Redis的CPU使用率可能已经飙到70%甚至更高,网络带宽也顶着,平均响应时间从原来的2毫秒涨到了8毫秒。这时候如果你继续加Redis节点,成本上去了,但本质上问题还是没解决——每一次请求都要走一次网络。
很多人会问,Redis本身已经够快了,为什么还要本地缓存?我换个说法你就明白了。Redis再快,也是一次局域网内的网络往返,大概0.5到2毫秒。而Caffeine这种本地缓存,直接存在JVM内存里,访问耗时是纳秒级别,两者差了两个数量级。更关键的是,当5000个请求同时打到同一个热点key时,Redis要处理5000次网络请求,而Caffeine只让第一个请求穿透到Redis,剩下4999个直接内存命中。
打个比方,Redis是小区门口的快递柜,Caffeine是你家里冰箱。快递柜取东西总要下楼走几步,冰箱就在手边。你把最常吃的东西放冰箱里,不用每次都下楼。
那为什么不能只用Caffeine?因为单机JVM内存有限,而且每个实例的缓存是独立的。你有10个实例,同一个key在每个实例里各存一份,数据不一致的问题会非常明显。而且当实例重启或者缓存过期,所有请求会同时穿透到数据库,也就是经典的“缓存雪崩”。所以本地缓存只适合放热点数据,Redis作为兜底的全量缓存层,两边各司其职。
1.2 这套架构解决的核心问题
我把这套架构要解决的问题梳理了一下,主要就是下面这几个:
- 热点数据访问延迟:把最热的数据放到JVM内存里,访问耗时从毫秒级降到微秒级
- Redis压力过大:相当一部分读请求被本地缓存拦截,Redis的QPS可以下降50%到70%
- 网络带宽瓶颈:特别是大value的场景,不走网络对带宽的节省非常明显
- 数据库冲击:即便Caffeine和Redis都miss了,也可以通过互斥锁等手段,保证只有一个请求打到数据库
你应该也看出来了,这套架构不是用来替代Redis的,而是给Redis做减负。最终目标是在保证数据一致性的前提下,让大部分读请求尽量停在离应用最近的地方。
2. 核心细节解析与实操要点
2.1 Caffeine的核心参数到底怎么调
Caffeine是Google Guava Cache的继任者,底层用了Java 8的ConcurrencyHashMap思想加Window TinyLFU淘汰算法。相比Guava Cache,它的命中率更高,淘汰更精准,读写性能也更稳定。
我贴一段实际项目里常用的配置,参数含义我在代码后面逐个说:
Cache<String, Object> localCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .refreshAfterWrite(Duration.ofMinutes(5)) .recordStats() .build();maximumSize:本地缓存最大条目数。这个数字怎么定?我习惯用以下公式估算:JVM堆内存 × 5% ÷ 单条缓存平均大小。比如堆内存4GB,一个缓存对象平均2KB,那大约能放10万条。我一般取计算结果的50%作为安全值,给其他业务留足空间。expireAfterWrite:写入后固定过期时间。这是兜底策略,保证数据最终能被更新。refreshAfterWrite:写入后固定刷新时间。这里有个细节,它不是主动去刷新,而是当一个key在刷新时间之后被访问时才触发异步加载。也就是说,它只能把过期时间无限往后延,但不能完全替代expireAfterWrite。recordStats:开启统计,方便你通过cache.stats()观察命中率,后面压测会用到。
需要注意,expireAfterWrite和refreshAfterWrite配合使用时有个特性:如果一个key被持续访问,refresh不断触发,数据理论上可以被“永久”续期。所以refresh时间要合理设置,我一般设为expire时间的一半,比如expire是10分钟,refresh就是5分钟。
2.2 Redis侧的关键设置
Redis这边,我要强调几个容易被忽略的点:
spring: redis: host: x.x.x.x port: 6379 timeout: 2000ms lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5 max-wait: 3000mstimeout:建议不要超过2秒。Redis挂掉的时候,我们可以快速失败,让请求直接走Caffeine或数据库,而不是卡在连接上。max-active:连接池最大连接数。过大会造成Redis资源浪费,过小会在高并发下出现连接等待。我用一个经验公式:预估QPS × 单次操作耗时(秒)。比如QPS 3000,单次耗时1毫秒,那并发连接约3个,但考虑到突发流量,我会放大10到20倍,取50。max-wait:获取连接的最大等待时间。超过后直接抛异常,不要无限等下去。这里我用的是Lettuce,它本身是线程安全的,还支持异步,比Jedis在连接管理上更省心。
2.3 过期时间的设计逻辑
过期时间不是一个拍脑袋的数字,我一般遵循两个原则:
第一,数据变更频率决定Redis和Caffeine的过期时间。比如商品信息,一天可能变几次,Caffeine设10分钟,Redis设30分钟;比如用户登录态,变更极少,Caffeine可以设1小时,Redis可以设1天。
第二,散列过期时间,避免雪崩。如果所有key都是整点过期,那整点就会出现一波穿透。我通常会在基础过期时间上加一个随机偏移量。比如Redis的过期时间设为30分钟 + RandomUtils.nextInt(0, 300)秒,这样过期时间点被均匀打散。这条经验后面在排查故障时救过我一次,后面细说。
3. 实操过程与核心环节实现
3.1 多级缓存请求链路搭建
先看核心逻辑。整个缓存处理流程就是教科书上的“先本地、再分布式、最后数据库”,但实现起来有几个关键判断要做,代码我逐段解释。
@Service public class ProductService { @Resource private Cache<String, Object> localCache; @Resource private StringRedisTemplate redisTemplate; @Resource private ProductMapper productMapper; private static final String PRODUCT_CACHE_KEY_PREFIX = "product:detail:"; public Product getProductById(Long productId) { String cacheKey = PRODUCT_CACHE_KEY_PREFIX + productId; // 1. 查Caffeine Object localValue = localCache.getIfPresent(cacheKey); if (localValue != null) { return (Product) localValue; } // 2. 查Redis String redisValue = redisTemplate.opsForValue().get(cacheKey); if (redisValue != null) { Product product = JSON.parseObject(redisValue, Product.class); // 回填Caffeine,并设置较短的本地过期时间 localCache.put(cacheKey, product); return product; } // 3. 查数据库 Product product = queryFromDBWithLock(cacheKey, productId); // 4. 回填Redis和Caffeine if (product != null) { redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), Duration.ofMinutes(30 + ThreadLocalRandom.current().nextInt(300))); localCache.put(cacheKey, product); } return product; } }这段代码看起来简单,但我在实际项目中踩过坑,也做过不少演进。
3.2 三级缓存穿透的防护:互斥锁的实现
如果只是按上面代码的逻辑,在高并发下会遇到一个很严重的问题:当某个key在Caffeine和Redis中都失效时,1000个并发请求会同时打到数据库。这直接就是缓存击穿。我在框架里加了分布式锁来保证只有一个请求能查库。
private Product queryFromDBWithLock(String cacheKey, Long productId) { String lockKey = "lock:" + cacheKey; String lockValue = UUID.randomUUID().toString(); // 尝试加锁,等待时间3秒,自动释放时间5秒 boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(5)); if (!locked) { // 没拿到锁,说明其他线程正在查库,这里短暂休眠后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } Object retryVal = localCache.getIfPresent(cacheKey); if (retryVal != null) { return (Product) retryVal; } return getProductById(productId); } try { // 拿到锁,二次检查Redis,防止上一个线程已经回填 String redisValue = redisTemplate.opsForValue().get(cacheKey); if (redisValue != null) { return JSON.parseObject(redisValue, Product.class); } // 查数据库 Product product = productMapper.selectById(productId); return product; } finally { // 释放锁,需要校验value防止误删其他线程的锁 String currentLockValue = redisTemplate.opsForValue().get(lockKey); if (lockValue.equals(currentLockValue)) { redisTemplate.delete(lockKey); } } }这里有几个关键点:
- 锁的value用UUID:这样才能在释放锁时确认是自己加的锁。你可能会问,为什么不用简单的
setnx然后del?因为如果不校验value,在锁自动过期后,线程A的finally块可能删掉线程B刚加的锁,引发的连锁问题很难排查。 - 锁的过期时间是5秒:但数据库查询正常情况下都在100毫秒内完成。这个时间不能太短,不然业务逻辑还没执行完锁就过期了;也不能太长,万一持有锁的线程挂了,也得让锁尽快释放。我按最慢的查询场景估算,5秒是比较稳妥的。
- 拿到锁后二次检查Redis:为什么要检查?因为可能刚才的线程A已经查完库并回填了Redis,线程B现在拿到锁,如果直接查库,就白拿锁了。
3.3 缓存更新策略:先更新数据库还是先删缓存
这是老生常谈,但很多人的方案是有问题的。我直接说结论:先更新数据库,再失效缓存。
组合一:先更新数据库,再删除缓存。如果删除失败,缓存里是旧数据,解决办法是引入重试机制或者订阅数据库binlog来异步删缓存。
组合二:先删除缓存,再更新数据库。这个有坑,在更新过程中会有一瞬间缓存为空,请求直接打到数据库,同时另一个线程可能已经读走了旧数据并回填了缓存,之后你更新了数据库,缓存里依然是旧数据。需要配合较短的缓存过期时间来收敛,整体上不如组合一可控。
我实际使用的是“先更新数据库,再删除缓存,删除失败走重试队列”的方案。重试队列我用内存队列加定时任务实现,结构大概是这样:
@Component public class CacheInvalidationTask { @Resource private StringRedisTemplate redisTemplate; private final Queue<String> retryQueue = new ConcurrentLinkedQueue<>(); public void addToRetryQueue(String cacheKey) { retryQueue.offer(cacheKey); } @Scheduled(fixedDelay = 10_000) public void processRetryQueue() { String cacheKey; while ((cacheKey = retryQueue.poll()) != null) { try { redisTemplate.delete(cacheKey); localInvalidate(cacheKey); } catch (Exception e) { // 删除仍然失败,放回队列等下一次 retryQueue.offer(cacheKey); break; } } } }关于本地缓存的失效,有一个必须注意的点。Redis可以只删一份,因为它本身就是集中式的;但Caffeine是每个JVM实例各存一份,删除Redis缓存后,还需要让每个实例的本地缓存都失效。工业界常用的方案有两种:
第一种,Redis的key过期后,通过Redis的KeySpace Notification(键空间通知)广播事件,所有实例订阅后删除本地缓存。这个方案要注意,Redis的通知默认是关闭的,需要配置notify-keyspace-events Ex。而且在高频key过期场景下,通知数量会很大,容易造成消息堆积。
第二种,各实例本地缓存设置相对比较短的过期时间,比如5到10分钟,牺牲一点点命中率,换取最终一致。这里要强调的是,如果你保留了短时间的本地缓存,那在一致性要求极高的场景(比如库存)是不能用本地缓存的;但是要求最终一致的场景(比如商品名称、详情介绍),这是个非常简洁的方案。我在项目里通常两种方案结合,核心数据用方案一,次要数据用方案二。
3.4 本地缓存的主动失效
我在CacheInvalidationTask里写了一个localInvalidate(cacheKey)方法,它的作用是主动清理本机JVM里的Caffeine缓存项。这里有一个关键点:我用的不是Caffeine的Cache直接删,而是通过Spring的事件机制。因为删除本地缓存是每个实例的事情,如果我在一个实例里调用了localCache.invalidate(key),只会删掉当前这个实例的数据,其他实例还得靠自己的机制触发。
实际实现时,我给每个实例都加了Redis消息订阅,当某个实例收到“缓存失效”的消息后,才执行localCache.invalidate(key)。这才是真正的多实例广播。只删Redis不删本地,是最容易被忽略的坑之一。
3.5 多级缓存的数据一致性落地
说了这么多,我用一个表格把“不同场景下缓存策略怎么组合”总结一下。
| 场景 | 一致性要求 | Caffeine过期时间 | Redis过期时间 | 更新策略 |
|---|---|---|---|---|
| 商品详情 | 最终一致即可 | 5-10分钟 | 30分钟+随机 | 先更新DB,再删Redis,广播清理Caffeine |
| 用户信息 | 要求较高 | 1-2分钟 | 10分钟 | DB更新后立即删Redis,广播清理Caffeine |
| 库存数量 | 要求极高 | 不用本地缓存 | 尽量短或不缓存 | 直接读DB或用分布式锁控制写操作 |
| 配置信息 | 极低频率变更 | 1小时 | 1天 | 手工或定时刷新 |
看过这个表你应该能理解,多级缓存并不是所有数据都适合套用,必须根据业务需求来取舍。我在第一个版本里把库存也放进了Caffeine,结果因为本地缓存没能及时失效,导致超卖差点发生。从那以后,凡是涉及强一致的数据,我一律不走本地缓存。
4. 常见问题与排查技巧实录
4.1 缓存穿透、击穿、雪崩,一次讲清楚
这三个概念本来应该很清晰,但很多人混淆。我结合多级缓存的场景,把区别讲透,顺便给出对应的解法。
- 缓存穿透:查询一个根本不存在的数据。比如用不存在的商品ID查详情,Redis没有,数据库也没有,每次都要打到数据库。解法是布隆过滤器,或者在查不到数据的key上缓存一个空值。我在实际项目里用了空值缓存,注意给空值也设置过期时间,比如1分钟。
- 缓存击穿:某个热点key在过期的一瞬间,大量请求同时绕过缓存打到数据库。解法就是前面写的分布式锁,或者把热点key的过期时间设为永不过期,然后通过异步任务去更新数据。
- 缓存雪崩:大量key在同一时间过期,或者Redis宕机,导致请求全部打到数据库。解法前面已经提到,过期时间加随机偏移,本地加Caffeine作为最后一道防线,Redis挂掉时有降级方案。
第三这里特别强调,“本地缓存”在雪崩场景里其实是很大的保底。当Redis不可用时,我们还可以把数据库查询结果直接放入Caffeine短暂缓存,虽然命中率下降,但至少服务还活着。
为方便你把排查问题的方法记住,我列了一个速查表。
| 现象 | 可能原因 | 排查手段 | 解决方案 |
|---|---|---|---|
| 缓存命中率突降 | 缓存过期时间太短 | cache.stats()看命中率 | 调整过期时间,热点key加refresh |
| 数据库QPS暴涨 | 缓存雪崩 | Redis监控看OPS和过期数量 | 过期时间加随机偏移 |
| 某个key反复查库 | 缓存击穿 | 查看Redis是否存在该key | 加分布式锁 |
| 缓存和数据库不一致 | 缓存删除失败 | 检查Redis删除结果和异常日志 | 加重试队列 |
| 本地缓存一直不更新 | 只删了Redis没有广播本地失效 | 查看实例日志 | 加入广播机制 |
| 内存占用过高 | Caffeine条目数太多 | 查看堆内存和Cache大小 | 调小maximumSize |
4.2 踩过的坑:Redis连接断开后服务假死
这个问题我记得很清楚。某次Redis实例因为内存达到上限触发了淘汰策略,大量key被逐出,紧接着服务端发生了短暂的阻塞。我们的应用里Redis连接池的max-wait设置的是2000毫秒,理论上超过2秒应该抛异常。但实际每个线程卡在了获取连接上,其他线程也在排队,最后Tomcat线程池被打满,接口大面积超时。
当时我这里还犯了两个错:一是没有给获取连接设置超时兜底,二是没有给RedisTemplate配置CacheKey序列化方式,压测时key出现乱码,排查了几个小时。后来我用Jedis替换了Lettuce,倒不是说Lettuce不好,只是当时团队对它的连接模型不够熟悉。现在再让我选,我会用Lettuce加超时熔断。但不管用哪个客户端,有一条经验是通用的:
把Redis当成一个“可能随时挂掉”的外部依赖来设计,而不是默认它永远可用。所有对Redis的操作,都要有超时、有降级、有熔断。
4.3 降级方案设计
降级分为两层。第一层,Redis不可用时,直接把Caffeine过期时间拉长,让本地缓存承担全部缓存职责。具体做法是把Caffeine的过期时间在Redis故障时调整为1小时。第二层,如果Caffeine也没有命中,DB查询结果直接回填Caffeine,不写Redis,等Redis恢复后再自动续上。这样做的好处是,至少请求不会因为Redis故障而大面积失败。当然,这只适用于读多写少、一致性要求不高的数据。
4.4 压测数据参考
最后给出一组我们当时的压测数据,方便你有个直观的感受。测试场景是商品详情接口,模拟1000并发请求持续10分钟,热点key比例为20%。
| 指标 | 只用Redis | Caffeine + Redis |
|---|---|---|
| 平均响应时间 | 3ms | 0.6ms |
| P99响应时间 | 12ms | 3ms |
| Redis QPS | 18000 | 5200 |
| Redis CPU | 67% | 22% |
| 命中率 | - | 本地缓存命中率78% |
结论很明显,加了Caffeine之后,Redis的QPS和CPU占用都降了一大截,响应时间提升了5倍左右。这套架构的效果不是“锦上添花”,而是实打实能帮你省下Redis节点扩容的成本。
我不写总结,只分享一个实际心得:这套缓存架构写起来不难,真正难的是你需要把每个参数的设置逻辑想明白,不然上线第二天就会因为过期时间设置不合理、本地缓存没及时失效等问题被人从床上叫起来。你先从小范围的业务试起,跑一两周把参数调稳了,再往核心业务推。缓存这个东西,只有线上流量才是最好的测试环境。