尧图精选

多级缓存实战:Caffeine+Redis架构、一致性与高并发防护全解析

🕒 发布时间:2026/9/17 2:17:22 📁 来源:尧图网络
之前整理Redis学习笔记的时候多级缓存这一章我偷了个懒标题直接写了句“这里就没记了用到再学”。结果后来真到了要用的时候才发现这块水特别深——不是简单在Redis前面加一层本地缓存就完事一致性、穿透、击穿、雪崩、序列化、过期策略每一个都能把人折腾到半夜。这次把欠的债补上把多级缓存的原理、选型、代码落地和线上踩坑完整梳理一遍给同样标记过“用到再学”的朋友一份能直接照着做的完整笔记。1. Redis不是银弹先搞懂多级缓存到底在解决什么问题1.1 一个真实性能瓶颈案例先说说我自己的实际经历。去年做了一个电商详情页的系统商品信息放Redis缓存单机QPS压到五千左右就开始不对劲了平均RT从基线时的5毫秒一路爬到40毫秒线程池里大量请求排队Tomcat线程被打满CPU没跑满但吞吐就是上不去。用Arthas看线程栈发现大量线程阻塞在Jedis连接池获取连接的地方。Redis平均响应才2-3毫秒但问题在于每一次读Redis都需要一次完整的网络往返再加上连接池竞争、序列化反序列化、GC停顿这些开销在高并发下会被无限放大。数据库快扛不住了吗没有数据库负载很低瓶颈反而出在Redis这一层本身。这就是很典型的场景Redis虽然是内存数据库但它终究是一个独立的进程每一次操作都伴随着网络IO和协议解析。当请求量达到一定级别Redis可能不是瓶颈但Redis的访问链路会成为瓶颈。说白了本地缓存JVM进程内缓存和阿斯Redis的差别就相当于你从自己家里的冰箱拿饮料和下楼去便利店买饮料的差别。前者是毫秒级的进程内访问后者是毫秒级的网络IO在极高并发下成千上万次“下楼买水”足以把整个系统拖垮。1.2 多级缓存的基本架构多级缓存的核心思路就是让数据在离调用方更近的地方驻留一份。一个典型的三级结构如下层级存储位置典型载体访问耗时容量成本L1 本地缓存JVM进程内Caffeine / Guava Cache / Ehcache0.1-0.5msMB-GB级极低L2 分布式缓存独立缓存服务Redis / Memcached1-5msGB-TB级中L3 持久化存储数据库MySQL / PostgreSQL5-50msTB级高在实际链路中请求先查L1未命中查L2再未命中才查L3。L3查询结果逐级回填到L1和L2。这样做还有一个额外的好处当Redis出现波动、网络分区甚至宕机时L1本地缓存还能扛住大部分热点请求给故障恢复争取时间。1.3 为什么本地缓存比Redis快一个数量级这得从底层说起。Redis处理一次GET请求经历了客户端编码请求 - 网络传输 - Redis服务端读取并解析 - 查询哈希表 - 编码响应 - 网络传回 - 客户端解析响应。在这个过程里网络往返是大头跨机房的RTT可能高达几毫秒即使本机访问也有百微秒级的开销。而Caffeine这类本地缓存的get操作本质上就是一次ConcurrentHashMap的read操作配合各类淘汰算法全程都在进程内完成不需要上下文切换不需要网络栈。实测下来Caffeine的读延迟可以稳定在0.05ms以下比Redis快1-2个数量级。当然“快”也是有代价的本地缓存的数据无法跨进程共享每个节点存的是自己的一份副本一致性控制成了最头疼的问题。但如果能接受最终一致性并做好延迟双删或消息广播失效这个代价完全值得。2. 选本地缓存不是选RedisCaffeine为什么是最优解2.1 Guava Cache、Ehcache、Caffeine横向对比把本地缓存选型放在Redis前面确认很多人会犯嘀咕“随便用一个不就行了吗”。真不是这里面的性能差异很大。组件淘汰算法异步加载命中率统计性能表现Guava CacheLRU近似实现支持支持基准稍慢EhcacheLRU/LFU/先进先出支持支持中规中矩CaffeineWindow-TinyLFU支持支持性能领先接近最优Caffeine在读写吞吐上有明显优势尤其是高并发写入场景因为有高性能的有界队列和无锁算法加持线程竞争远小于Guava Cache。另外Caffeine基于Java 8设计API更现代化配合Spring Boot的CacheManager集成也足够顺滑。所以如果新项目选本地缓存我建议首选Caffeine除非你已经在用Ehcache且有大量历史配置不想迁移。2.2 Window-TinyLFU淘汰策略的妙处Caffeine默认使用Window-TinyLFU策略这是它区别于其他本地缓存的最大亮点。可能有人会问LFU不是早就有了吗传统LFU的问题在于频率计数器永不衰减历史上被高频访问但现在已经过时的数据会一直赖在缓存里不走。而LRU的问题则相反对偶发性的热点访问不够敏感。Window-TinyLFU的策略是这样的整个缓存空间分成两部分一小部分作为窗口区Window按LRU管理专门捕捉突发流量大部分作为主区Main内部又分成受保护段和试用段按访问频率来判断谁该留下。它用了一个Count-Min Sketch频次过滤器来记录每个key的访问频率而不是存完整计数器极大节省了内存。类比一下这就像一个社交群既有“刚进来但说话很猛的新人”窗口区的突发热点也有“长期活跃的老面孔”主区的稳定热点两者都要照顾而不是只看某一种。这个结构让Caffeine在流量忽高忽低、热点随时切换的业务里比Guava Cache的命中率高出不少尤其是热点key变化快、存在短时集中访问的场景下效果肉眼可见。2.3 最容易被人忽略的容量设置问题Caffeine的容量设置有个坑maximumSize并不是越大越好。设置太大缓存内容丰富、命中率上升但JVM堆内存被蚕食直接挤占业务对象空间Full GC频繁系统反而变慢设置太小命中率低下缓存形同虚设。我的经验是分两步走。第一步先按业务数据总量估算一个初始值比如热点商品总数在50万可以先给10万到20万的整体容量第二步上线后开启Caffeine的统计数据观察命中率如果持续低于70%说明容量太小逐步上调如果命中率已经95%以上但延迟没有明显改善那就不要再调大了因为边际效应已经到顶。CacheString, Product cache Caffeine.newBuilder() .maximumSize(100_000) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats() .build(); // 定期输出指标 CacheStats stats cache.stats(); // hitRate0.95 无需再扩容 System.out.println(stats.hitRate());3. 缓存一致性是绕不过去的坎写操作怎么办3.1 三种经典缓存读写模式比较多级缓存设计里读链路基本都类似真正区分方案水平的是写链路。先看三种经典模式模式写策略适用场景一致性窗口Cache Aside先更新数据库再删除缓存绝大多数业务系统有短暂窗口期Read Through缓存组件负责查DB回填对缓存组件封装要求高依赖组件实现Write Through写DB前先写缓存写少读多且对一致性要求高缓存写失败会阻塞Write Behind先写缓存异步批量写DB写多读少、允许数据延迟宕机会丢数据在业务系统里我最常用的是Cache Aside。它的读链路是“先查缓存未命中查DB把结果写回缓存”写链路是“先更新DB删除缓存”。为什么要删缓存而不是更新缓存因为更新缓存存在并发写覆盖问题两个线程同时写DB后写DB的不一定后写缓存容易把旧数据覆盖到缓存里。删除则没有这个问题下一次读天然会把新数据拉回来。3.2 延迟双删到底怎么用删缓存这个动作看似简单但有一个经典的并发漏洞线程A读缓存未命中线程B写DB删除缓存线程A查DB拿到旧值线程A把旧值写回缓存。这样的话缓存里还是旧数据而删除已经被B执行过了。怎么解决延迟双删——B在删除缓存后等待一段时间再次删除一次。这个“延迟时间”需要大于A完成“查DB写缓存”的总耗时一般取500毫秒到1000毫秒。public void updateProduct(Product product) { // 第一次删除缓存 redisTemplate.delete(PRODUCT_KEY product.getId()); // 更新数据库 productMapper.updateById(product); // 延迟一段时间再删除一次 scheduledExecutor.schedule(() - { redisTemplate.delete(PRODUCT_KEY product.getId()); // 同时清掉所有实例的本地缓存 localCache.invalidate(PRODUCT_KEY product.getId()); }, 800, TimeUnit.MILLISECONDS); }注意延迟双删能覆盖大部分并发场景但并不是严格的强一致方案。如果业务对一致性要求极高比如库存类操作那就不该依赖缓存而是直接查DB或者用带版本号的更新策略确保只有新版本才能覆盖缓存。3.3 MQ异步广播失效的工程化实践本地缓存的最大问题就是多实例部署后一致性没法靠Redis单方面解决。比如上面的删除操作只清了Redis其他机器上的Caffeine里还留着旧数据。这时候就需要让所有节点都收到“缓存失效”的通知。比较通用的方案是引入消息队列做广播。某个节点更新DB后发一条缓存失效消息到Topic所有实例消费这条消息各自把自己本地缓存里的对应key删掉。由于MQ有一定的异步延迟配合延迟双删的第二次删除基本上能把一致性窗口压缩到几百毫秒内。// 发送失效消息 mqTemplate.convertAndSend(CACHE_CHANNEL, PRODUCT_KEY product.getId()); // 每个实例监听消费 RabbitListener(queues CACHE_CHANNEL) public void onMessage(String cacheKey) { localCache.invalidate(cacheKey); // 同时删除Redis redisTemplate.delete(cacheKey); }另外还有一个补充方案在业务数据上维护一个全局自增版本号缓存value里带上版本号。读取时如果发现本地缓存的版本号低于Redis里的版本号就丢弃本地缓存并重新加载。这个方式适合无法接受MQ多一跳延迟的场景但对数据库和Redis的结构设计都有要求需要权衡后再上。4. 手把手搭一个 Spring Boot 两级缓存4.1 依赖准备与基础配置下面给出的是经过实际项目验证过的比较顺滑的组合Caffeine做L1Redis做L2Spring Cache负责统一抽象。如果你习惯了直接用代码控制也可以用自定义工具类但Spring Cache注解方式开发效率更高。dependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId version3.1.8/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-cache/artifactId /dependency然后在配置类里定义两级缓存的管理器。这里的关键点是不能只定义一个CacheManager否则Caffeine和Redis会混淆。我用了一个组合CacheManager先查Caffeine未命中再查Redis。Configuration public class CacheConfig { Bean public CacheManager cacheManager(RedisConnectionFactory factory) { CaffeineCacheManager local new CaffeineCacheManager(); local.setCaffeine(Caffeine.newBuilder() .maximumSize(20_000) .expireAfterWrite(Duration.ofMinutes(15)) .recordStats()); RedisCacheManager redis RedisCacheManager.builder(factory) .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofHours(1)) .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))) .build(); return new MultiLevelCacheManager(local, redis); } }4.2 自定义组合CacheManager核心代码Spring Cache默认的CacheManager只能管一个缓存两级缓存需要自己实现一个CompositeCache。核心逻辑就一句话先查本地未命中查Redis再未命中查数据库并逐级回填。public class MultiLevelCache implements Cache { private final String name; private final Cache localCache; private final Cache redisCache; Override public ValueWrapper get(Object key) { ValueWrapper value localCache.get(key); if (value ! null) { return value; } value redisCache.get(key); if (value ! null) { // 把Redis数据回填到本地缓存 localCache.put(key, value.get()); return value; } return null; } Override public void put(Object key, Object value) { localCache.put(key, value); redisCache.put(key, value); } Override public void evict(Object key) { localCache.evict(key); redisCache.evict(key); } }这里有个实际经验回填到本地缓存时一定要设置一个比Redis更短的过期时间。因为本地缓存无法感知其他节点的写更新设置太长的TTL会导致脏数据驻留很久。我通常的做法是本地缓存TTL设为5到10分钟Redis TTL设为30到60分钟数据库兜底不变。4.3 缓存序列化的额外处理Redis缓存有一个隐藏很深的问题就是默认的JdkSerializationRedisSerializer。用JDK序列化有几个坑一是空间大而且膨胀严重我见过一个十几KB的JSON经过JDK序列化后膨胀到几十上百KB二是模型类加了字段或改了包名老数据反序列化直接报错。所以我改用GenericJackson2JsonRedisSerializer。但这里需要特别注意LocalDateTime的序列化问题Jackson默认不支持Java 8时间类型需要配置JavaTimeModule并且时间格式最好统一成字符串否则Redis里存的时间字段会是一串数字排查问题极其痛苦。ObjectMapper om new ObjectMapper(); om.registerModule(new JavaTimeModule()); om.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); GenericJackson2JsonRedisSerializer serializer new GenericJackson2JsonRedisSerializer(om);4.4 配合Spring Cache注解使用定好CacheManager后业务代码直接用注解即可。注意在这套结构下CachePut和CacheEvict分别对应回填和删除语义别搞混。Cacheable(cacheNames product, key #id, unless #result null) public Product getProductById(Long id) { return productMapper.selectById(id); } CacheEvict(cacheNames product, key #product.id) public void updateProduct(Product product) { productMapper.updateById(product); }5. 穿透、击穿、雪崩三个高频故障方案5.1 缓存穿透布隆过滤器与空值缓存穿透就是恶意请求用一个不存在的ID反复打缓存每次都在Redis没命中最后全部落到数据库。如果攻击者遍历随机IDDB瞬间就可能被打爆。两个常用招数一是查DB结果为空时也往缓存里写一个空值TTL设短一些比如2分钟防止大量相同请求穿透到DB二是用布隆过滤器把所有真实存在的ID先过滤一遍不存在就直接返回。public Product getById(Long id) { if (!bloomFilter.mightContain(id)) { return null; // 布隆过滤器说没有就是没有 } Product product getFromCache(id); if (product null) { product db.getById(id); if (product ! null) { putToCache(id, product); } else { putToCache(id, EMPTY_PLACEHOLDER, Duration.ofMinutes(2)); } } return product; }布隆过滤器用到的是Guava的BloomFilter初始容量最好设成预估数据量的10倍以上误判率设万分之一。默认容量设得太小插入一多会导致误判率飙升把真实存在的ID都拦截掉那才是灾难。5.2 缓存击穿互斥锁与逻辑过期击穿和穿透是两码事。击穿是指一个热点key过期瞬间大量并发请求同时冲到数据库数据库被打挂。最常见也是最稳妥的解决方案是互斥锁当缓存未命中时先尝试加锁拿到锁的线程去DB查数据并回填其他线程阻塞等待拿到锁时再次查缓存这次基本就能命中。public Product getByIdWithLock(Long id) { Product product getFromCache(id); if (product ! null) { return product; } // 用Redis SETNX实现互斥锁注意设置过期时间防止死锁 boolean locked redisTemplate.opsForValue() .setIfAbsent(LOCK_KEY, 1, Duration.ofSeconds(3)); if (locked) { try { product getFromCache(id); if (product null) { product db.getById(id); putToCache(id, product); } return product; } finally { redisTemplate.delete(LOCK_KEY); } } else { // 等锁期间休眠重试 Thread.sleep(50); return getByIdWithLock(id); } }这种方式的缺点是每次过期后都会有一次阻塞等待峰值时请求RT会升高。另一种“逻辑过期”的思路是缓存里存数据时顺手存一个过期时间字段当读到逻辑过期时主动返回旧值同时异步去DB加载新值回填。好处是读请求不会阻塞坏处是高峰期会短暂读到旧数据。5.3 缓存雪崩过期时间打散与多级限流雪崩是大面积key同时过期或者Redis直接宕机导致所有请求落到DB。最有效的预防手段是给每个key的TTL增加一个随机偏移把过期时间均匀散开。比如基础TTL是1小时实际TTL就设为1小时加一个0到600秒的随机数。Duration ttl Duration.ofHours(1).plusSeconds(ThreadLocalRandom.current().nextLong(600));同时在应用层加一个简单的降级开关如果DB查询超过某个阈值就返回缓存中的旧数据或者返回默认的兜底内容让系统韧性更强。这里就能体现出L1本地缓存的额外价值了Redis挂掉的情况下只要本机的Caffeine没过期还能扛住相当比例的热点请求。多级限流的思路是给不同层级的服务设置不同的容量阈值上游Nginx/LVS限流、应用层限流、DB连接池限流层层递减保证DB始终处于可控负载之下。真到了极端情况宁可牺牲掉部分非核心功能也要保住核心链路的可用性这个取舍很重要。6. 线上踩坑、实测数据与经验教训6.1 所有实例的本地缓存同时过期引发的抖动第一次上线时我把所有商品的Caffeine过期时间都设成了15分钟Redis的过期时间设成了1小时。结果每15分钟就会看到一次明显的DB主从延迟抖动监控图上DB读QPS像梳子一样一齿一齿的。原因很简单所有实例的缓存加载时机接近过期窗口也接近导致DB流量周期性骤增。后来把本地缓存的过期时间改成了“15分钟基础值0到300秒随机偏移”并且增加了定时预热任务在缓存过期前1分钟主动刷新热点数据监控曲线立刻变得平滑。这个教训说明多级缓存的每一个时间参数都不能想当然设置必须考虑集群部署的齐步走效应和DB流量的波峰预期。6.2 序列化升级导致的兼容性问题有一次线上发布给商品模型增加了一个priceLevel字段当时用的是JDK序列化结果一上线就收到大面积反序列化异常核心链路的缓存全部失效流量直扑DB。吓得我赶紧回滚版本。后来彻底改用JSON序列化并约定模型类新增字段时必须考虑默认值兼容老缓存JSON反序列化时缺失字段就用默认值填充避免线上事故重演。这里我也顺便推荐一个Redis可视化工具的注意事项用RedisDesktopManager这类客户端查看数据时如果key是Java序列化格式看起来会是一堆乱码改成JSON序列化后排查数据问题会轻松很多。很多运维同事都在用一个叫Another Redis Desktop Manager的开源客户端对JSON的支持也很不错。6.3 缓存命中率监控与调优闭环本地缓存加了一会儿之后我们上线了一个定时任务每隔5分钟采集一次各业务缓存的hitRate。数据能说明很多问题有的缓存命中率长期低于10%压根不应该放在第一级有的命中率在80%以上但容量一直稳定在低位说明热点非常集中就可以缩小本地缓存容量来节省内存。这是非常重要的一个原则多级缓存不是每张表、每个业务都适合加的。如果一个数据的读写比接近1:1加缓存只会白白增加一致性维护成本如果一个数据每次更新都会立刻被读取那缓存反而可能加剧数据不一致问题。上线前先确认业务形态上线后持续用指标说话这才是长期可维护的做法。6.4 压测数据对比没白折腾最后放一组压测数据作为参考。同样的详情页查询接口分别测了三种结构方案平均RT单机QPS95线RTDB负载只查DB35ms90060ms高Redis单级缓存5ms520012ms低Caffeine Redis两级缓存0.8ms132002ms极低从数据看两级缓存把单机QPS从五千多拉到了1.3万平均RT降到了1毫秒以内效果还是很直观的。需要注意的是压测时一定要带上随机的过期时间策略否则你用固定TTL测出来的结果上线后会因为雪崩效应打七折。多级缓存这套东西说起来就三个核心快一点再快一点同时还要保证不出错。如果让我给一个落地顺序我的建议是先把Redis这一层做到极致序列化、过期策略、防击穿三件套再加Caffeine本地缓存最后再花精力处理一致性问题。上来就一把梭全加上出了线上问题排查起来极其痛苦别问我怎么知道的。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →