高并发多级缓存架构实战:Caffeine与Redis整合及一致性方案

发布时间:2026/9/16 2:42:29
高并发多级缓存架构实战:Caffeine与Redis整合及一致性方案
做高并发系统的朋友一定都遇到过这种尴尬Redis 集群加了一堆节点连接池调大热 key 也做了分散结果接口 P99 该涨还是涨。后来我慢慢意识到一个很现实的问题缓存链路里每个环节都有物理成本跨网络的 Redis 请求哪怕只有 1ms在高并发放大之后也是不可忽视的瓶颈。真正能扛住突发流量的方案不能只靠单一 Redis而是要把本地缓存、分布式缓存和数据库兜底串成一条完整的降级链路——这就是今天要聊的多级缓存架构。这篇内容适合正在做微服务架构、被“缓存命中率还行但接口延迟不稳定”折磨的后端同学也适合准备把本地缓存引入生产环境、但担心数据一致性搞不定的团队。我会从架构设计思路、Caffeine 和 Redis 的选型与参数配置、Spring Cache 整合落地代码、缓存穿透/击穿/雪崩防护、以及线上一致性问题排查这几个方面完整拆解一套可以上生产的方案。1. 为什么生产环境必须重新审视多级缓存1.1 别急着把所有流量都怼到 Redis很多团队一开始做缓存就是在 Service 层包一个 Redis 工具类查不到 DB 再回填。这个方案在低并发阶段没什么问题但一旦 QPS 到了数千甚至数万就会出现几个非常典型的症状Redis 单节点 CPU 飘高、连接池活跃数打满、接口平均耗时被网络往返和序列化拉高。加 Redis 节点确实能缓解容量和连接压力但每一次缓存命中都要经历一次“应用服务器 → Redis → 应用服务器”的网络往返这个延迟是物理链路决定的加再多的分片也消不掉。多级缓存的本质是把请求在离业务代码最近的地方拦截掉。JVM 内缓存Caffeine的访问延迟是纳秒级压根不涉及网络和序列化。Redis 的延迟是毫秒级虽然已经比 MySQL 的几十毫秒到几百毫秒快很多但在超高并发场景下依然是有瓶颈的。一般性能量级大概是下面这个对比层级延迟量级单机容量成本适合数据Caffeine/JVM 本地缓存纳秒级几十 ns 以内百 MB 级极低纯内存单机热点数据Redis 分布式缓存亚毫秒到毫秒级几十 GB 到几百 GB需要独立集群全量活跃数据MySQL 数据库毫秒到几十毫秒海量存储磁盘与连接成本最终一致性兜底我见过最典型的案例是一个商品详情接口DB 查询在 20ms 左右Redis 命中后大约是 2ms听起来已经不错了。但压测到 1 万 QPS 的时候Redis 的代理和连接池先扛不住了接口 P99 直接冲到 80ms。后来把 Redis 改成两级缓存本地先扛掉 6000 QPS 的热点读最终 Redis 真实压力只剩原来的三分之一接口 P99 稳定在 10ms 以内。所以多级缓存不是“性能洁癖”而是高并发场景下必须做的分层容灾。1.2 多级缓存到底解决什么问题多级缓存解决的不是单一维度问题它至少同时解决三个方向第一是降低平均延迟。本地缓存的命中请求完全不需要网络开销热 key 越多整体平均延迟越低。第二是减轻下游压力。Redis 的读写次数大幅减少DB 的流量进一步被隔离在缓存层之下特别是遇到瞬时热点事件比如秒杀开始、活动上线可以把对 DB 的冲击降一个数量级。第三是提高系统容错性。即使 Redis 集群发生抖动或者短暂不可用本地缓存还在业务接口仍然可以以较低的命中率继续服务不会直接雪崩到数据库。但是也要说清楚多级缓存不是银弹。它引入了存储副本也就引入了多副本数据一致性的问题。所以设计时要优先想清楚业务场景是否读多写少、是否允许秒级数据延迟、数据量是否能在本地缓存合理容量内装下。后文的所有方案都是基于“读多写少 允许短暂不一致”的通用互联网业务模型来展开的。2. 核心技术选型与缓存参数设计2.1 本地缓存为什么最终选 Caffeine本地缓存的选项无非就是 Guava Cache、Caffeine、ConcurrentHashMap 加手动锁以及 Ehcache 这一类的重量级选手。我用下来的结论是如果不追求持久化和复杂事务生产环境首选 Caffeine。Caffeine 的淘汰算法是基于 W-TinyLFU 改进的能很好地保存高频 key同时定期清理低频 key和传统的 LRU 相比在突发流量下不会出现热点数据被冷数据挤出缓存的问题。Guava Cache 虽然也能用但 Caffeine 在命中率、内存控制、异步加载这几个关键指标上都明显更优。对于本地缓存来说命中率就是一切因为本地缓存存不了太多数据必须是榨干每一 MB 内存。同时 Caffeine 提供了非常细的容量控制和统计能力。常见的初始化方式如下CacheString, GoodsDetail localCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofSeconds(30)) .refreshAfterWrite(Duration.ofSeconds(10)) .recordStats() .build();这里maximumSize是按条目数算的很多人会忽略健壮设计如果单个 value 特别大比如一个商品详情有几十 KB10 万条可能直接撑爆堆内存。所以生产上我会同时用maximumWeightweigher来按字节数限制比如单机本地缓存总量控制在 64MB 以内超出条目自动淘汰。这个下面会用实际公式说明。2.2 Redis 层缓存策略设计Redis 层虽然有分布式能力但设计不好照样出问题。我在生产上最关注四点Key 命名规范、TTL 策略、序列化方案、以及避免大 Key。Key 命名上一定要带业务前缀和版本号比如goods:detail:v3:{goodsId}。加版本号非常有用因为当业务数据结构发生不兼容变化时只要切换版本号就能让旧缓存自然过期失效不需要搞全量扫描删除。TTL 策略上我强烈建议使用随机过期时间不要所有 key 都用同一个固定值。比如基础 TTL 300 秒然后加一个 0 到 180 秒的随机偏移这样能避免同一批次写入的数据在同一时刻集体失效把缓存雪崩概率降下来。序列化方案上Redis 默认的 JdkSerializationRedisSerializer 千万不要直接用于生产序列化后体积大、可读性差、性能也低。我一般选 GenericJackson2JsonRedisSerializer 或者 ProtoStuff。如果追求极致的性能和体积ProtoStuff 是个好选择但是要注意类结构的兼容性加字段时可能反序列化失败。我的经验是大多数业务场景用 Jackson JSON 就够了可排查性高配合类上的JsonTypeInfo可以保证多态场景不出错。大 Key 问题也很致命。一个缓存 value 如果超过几 MB在高并发下会导致 Redis 阻塞和带宽飙升而且一旦某个 key 变成大 keyRedis Cluster 的那个分片就会成为热点瓶颈。所以缓存数据要尽量拆小比如商品详情除了基础信息还可以拆成商品基本信息、价格、库存等不同 key 按需读取而不是一把梭把整个 DB 行序列化塞进一个 key。2.3 两级缓存的 TTL 和容量如何配合两级缓存最忌讳的是两层的过期时间完全一样。比如本地缓存和 Redis 都设 5 分钟那么 5 分钟后两层在同一时间失效所有请求一起穿透到 DB等于没有多级保护。我的默认策略是本地缓存 TTL 短、Redis TTL 长。比如本地缓存设置 30 秒到 60 秒同时开refreshAfterWrite做后台刷新Redis 缓存设置 300 秒到 600 秒并且带随机偏移。这样本地缓存先失效还有 Redis 兜底Redis 先过期本地缓存还能继续扛一段时间两个层级形成错峰失效。为了保证本地缓存的数据不会太陈旧refreshAfterWrite会在 key 过了指定时间后触发一次异步加载对调用方透明不会阻塞当前请求。容量计算上本地缓存要非常克制。生产规格一般给应用堆内存 4GB 到 8GB如果本地缓存占到 1GB那么 GC 压力就会明显增大。我一般会把本地缓存总容量压到 JVM 最大堆的 10% 到 20% 以内。举例来说假设单个商品详情序列化后是 4KB分配 128MB 本地缓存大约能保存 32000 个条目。如果线上活跃商品数是 50 万那本地缓存是无法全覆盖的只能通过统计访问热度让高频的 3 万条商品留在本地其他走 Redis。这就是为什么本地缓存必须启用recordStats()后续要基于命中率数据调整容量。3. 实操Spring Cache 整合 Caffeine 与 Redis 实现两级缓存3.1 自定义两级缓存管理器Spring Cache 的抽象非常方便但默认的 CacheManager 只能对应一个缓存存储。要做多级缓存推荐用AbstractCacheManager把本地缓存和 Redis 缓存包在同一个 Cache 实现里。先配置两个独立的 CacheManager一个管 Caffeine一个管 RedisBean public CacheManager caffeineCacheManager() { CaffeineCacheManager manager new CaffeineCacheManager(); manager.setCaffeine(Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofSeconds(30)) .refreshAfterWrite(Duration.ofSeconds(10)) .recordStats()); manager.setDynamicCreation(false); return manager; } Bean public CacheManager redisCacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration config RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofSeconds(300 ThreadLocalRandom.current().nextInt(180))) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())); return RedisCacheManager.builder(factory) .cacheDefaults(config) .build(); }注意entryTtl这里如果写死随机值会导致同一个 Cache 的所有 key 都共享这个 TTL并不理想。更精确的做法是在写入缓存时通过Cache.put时手动指定或者通过RedisCacheWriter控制或者干脆在Cache实现层面对不同 key 保持统一随机基准。我的习惯是CacheManager 配一个基础 TTL业务层再通过注解参数覆盖避免所有 key 同生共死。3.2 自定义 Cache 实现两级读取与回填核心逻辑在 Cache 的get方法。读路径是本地缓存 → Redis 缓存 → 数据库。这里数据库查询不能在 Cache 内部直接执行因为 Spring Cache 抽象不知道 DB 逻辑。通常的处理方式是Cache 内部只负责从本地和 Redis 取值如果都没命中返回 null让 Service 层去查 DB然后手动调用put回填。下面是一个简化版的两级 Cache 实现为了让代码可读我把锁、指标等次要逻辑省略了public class TwoLevelCache implements Cache { private final String name; private final Cache localCache; private final Cache redisCache; Override public ValueWrapper get(Object key) { // 第 1 级本地缓存 ValueWrapper wrapper localCache.get(key); if (wrapper ! null) { return wrapper; } // 第 2 级Redis 缓存 wrapper redisCache.get(key); if (wrapper ! null) { // 回填本地缓存下次请求直接命中 localCache.put(key, wrapper.get()); return wrapper; } return null; } Override public void put(Object key, Object value) { // 先写 Redis再写本地 redisCache.put(key, value); localCache.put(key, value); } Override public void evict(Object key) { // 删除时必须两个层级都删 redisCache.evict(key); localCache.evict(key); } }注意put的顺序。有些资料说先写本地再写 Redis理由是写本地更快但我在生产上的实践是先写 Redis因为 Redis 是全局共享的事实源本地缓存只是它的下级副本。如果先写本地再写 Redis而 Redis 写入失败本地就有了本地独有数据其他节点无法感知一致性会更乱。先写 Redis 成功后本地写不写都不影响最终一致或者本地写失败也无所谓因为下次读不到会再从 Redis 回填。这里还需要注意一个隐藏问题本地缓存未命中、Redis 也未命中、业务去查 DB、然后回填这个过程如果并发量很大会出现同一时间多个线程同时查 DB 的情况这就是典型的缓存击穿。后面第 4 节会专门讲互斥锁和异步刷新怎么解决。3.3 服务启动预热与热点数据识别两级缓存上线后最容易被忽视的就是冷启动问题。服务刚启动时本地缓存是空的如果直接暴露流量全部请求都会先打到 RedisRedis 压力骤增。而且如果 Redis 里的 key 也恰好刚过期那就直接穿透到 DB造成应用启动瞬间的数据库毛刺。解决办法是启动预热。可以把最近一段时间访问量 TOP N 的 key 从 Redis 拉出来主动放进本地缓存。这一步可以在 ApplicationRunner 里完成Component public class CacheWarmer implements ApplicationRunner { Override public void run(ApplicationArguments args) { ListString hotKeys cacheTopnService.listTopN(10_000); for (String key : hotKeys) { Object value redisTemplate.opsForValue().get(key); if (value ! null) { localCache.put(key, value); } } } }生产上我会按机器分批预热避免所有实例同时启动导致 Redis 批量请求风暴。热点 key 的统计来源可以是应用层的访问日志、Redis 的INFO输出、或者业务埋点。如果用的是 Caffeine每次get之后都可以通过stats().hitRate()观察命中率根据命中率动态调整运维策略。另外引入一个轻轻的开关系统也很有必要比如用配置中心动态控制是否启用本地缓存这样当出现数据异常时可以秒级关闭某一级缓存而不需要重启服务。4. 缓存穿透、击穿、雪崩的生产级防护4.1 缓存穿透布隆过滤器与空值缓存缓存穿透指的是请求一个数据库里根本不存在的 key缓存里怎么都查不到于是每次都打到 DB。在高并发下恶意请求只要构造一批不存在的 ID就足以拖垮数据库。最常见的防御方案有两个缓存空值和布隆过滤器。缓存空值最简单就是当 DB 查不到时也把 null 或者一个特殊占位符写入缓存设置一个比较短的 TTL比如 30 秒到 60 秒。这样后续同样 key 的请求会直接命中缓存不会打 DB。但空值缓存不能解决“大量不同非法 key”的问题因为每个非法 key 都会占用缓存空间。布隆过滤器可以很好地解决这个问题。它用一个位数组和多个哈希函数判断 key 是否存在存在可能有误判不存在则一定不存在。生产上我一般是两者结合布隆过滤器在前置位挡住不存在的 key减少无效回填空值缓存处理布隆过滤器误判和极小概率的并发穿透。要注意布隆过滤器不适合频繁删除所以适合 ID 只增不减的场景。如果业务存在大量删除且需要精确判断建议改用 Redis 的 bitmap 或者直接缓存空值。4.2 缓存击穿单个热 key 瞬时过期重建击穿指的是某个非常热门的 key 在过期的一瞬间大量请求同时发现缓存 miss于是集体打到 DB。这和穿透的区别在于key 是真实存在的热度极高。防击穿常用的路子是互斥锁Mutex Key。在缓存 miss 之后先尝试获取一个分布式锁只有拿到锁的线程去加载 DB 并回填缓存其他线程阻塞等待或快速返回默认值避免同时打 DB。Caffeine 的CacheLoader天然支持单线程加载它就是通过ConcurrentHashMap的计算逻辑保证同一个 key 只有一次加载过程的。如果多级缓存中的本地层用了 CaffeinelocalCache.get 这一层已经天然做了防击穿。对 Redis 层可以手动实现一个简单的互斥重建核心逻辑类似这样public GoodsDetail getGoodsDetail(String goodsId) { Object value redis.get(goods:detail: goodsId); if (value ! null) { return (GoodsDetail) value; } String lockKey lock:goods:detail: goodsId; boolean locked redis.setIfAbsent(lockKey, 1, Duration.ofSeconds(5)); if (!locked) { // 短暂休眠后重新尝试读取缓存 Thread.sleep(50); return getGoodsDetail(goodsId); } try { GoodsDetail detail goodsMapper.selectById(goodsId); redis.set(goods:detail: goodsId, detail, Duration.ofSeconds(300)); return detail; } finally { redis.delete(lockKey); } }这个方案要特别注意锁自动过期时间。如果业务加载 DB 超过了锁过期时间那么多个线程会同时进入临界区失去互斥效果。我通常会把锁过期时间设置成预期的两倍并且在业务代码里加一个超时保护。另外一个思路是使用refreshAfterWrite做异步刷新让 key 在即将过期前自动后台加载而不是等到过期后再让请求触发重建。生产上我会让“互斥锁 异步刷新 本地热缓存”三者同时存在尽量避免任何一层被打穿。4.3 缓存雪崩大批 key 同时失效的错峰方案雪崩和击穿的区别是规模。击穿是单个 key雪崩是大批量 key 在同一时间段集体失效或者 Redis 节点本身宕机导致请求全部落到 DB。防雪崩的常规手段有这些第一TTL 加随机偏移前面已讲过。第二多级缓存天然就是一种雪崩防御因为即使 Redis 不可用本地缓存仍然能提供一部分命中本地缓存成为最后一道保障。第三Redis 高可用用主从加哨兵或 Cluster 模式以及配置合理的持久化策略。第四DB 层限流熔断当缓存 miss 率超过阈值时对 DB 查询做一个简单的过载保护比如使用 Semaphore 限制同时查询 DB 的线程数或引入 Sentinel 做熔断降级。我遇到过一次比较典型的雪崩某运营活动在凌晨 0 点准时上线所有商品详情缓存 TTL 都设置在凌晨 0 点失效活动一开始流量进来时 Redis 大面积 missDB 连接池直接被打满。后来把 TTL 改成随机化并提前半小时做了一轮缓存预热问题才彻底解决。这件事给我的教训是缓存方案必须考虑业务时间点不能只看代码逻辑还要结合运营节奏。5. 数据一致性双写一致的实战方案5.1 读多写少业务的标准缓存更新模型多级缓存引入后很多团队最怕的就是数据不一致。先明确一个边界多级缓存适合读多写少、允许秒级最终一致的场景如果业务要求强一致比如余额、库存不能有任何延迟缓存只能作为可选优化不能作为事实源。对于读多写少的通用业务标准做法是 Cache Aside读的时候先读缓存读不到再读 DB并回填写的时候更新 DB然后删除缓存。很多人会问为什么是删除缓存而不是更新缓存因为更新缓存存在并发写错顺序的风险。例如线程 A 更新 DB 的值为 v1线程 B 更新为 v2但线程 B 先写了缓存 v2线程 A 后写了 v1最终缓存是旧数据永远错下去。而删除缓存即使有并发写下一次读请求重新加载 DB 最新值即可。这个模型的问题是DB 更新成功但缓存删除失败。比如删除 Redis 时网络抖动缓存里残留旧数据客户端就会一直读到脏数据。常规解法是先更新 DB再删缓存如果删除失败通过重试机制补偿或者再往前走一步采用延迟双删更新 DB 之前先删一次缓存更新 DB 之后延迟几百毫秒再删一次。延迟双删能解决读请求在更新 DB 期间把旧数据回填到缓存的问题但是延迟时间不好确定在复杂的主从延迟场景下依然有漏洞。5.2 消息驱动与 binlog 监听下的最终一致真正要做得稳妥我建议把缓存更新从业务代码里解耦出来。第一种方式是发业务消息。Service 更新 DB 成功后发送一条消息到 MQ消费者收到消息后删除缓存。这样做至少有两点好处失败可以重试消息队列本身提供了持久化和消费重试机制削峰填谷缓存删除操作被异步化不会占用请求线程。第二种方式是监听 MySQL binlog通过 Canal 把 DB 变更事件解析出来再广播到 MQ 或直接触发缓存更新。这种方式最大的优点是业务代码无侵入不需要在每条 update 后面手动发消息。缺点是需要额外部署 Canal 组件对团队运维能力有一定要求。我在实际项目里比较偏爱先业务消息、后加 Canal 兜底的组合业务代码发实时消息保证时效性Canal 监听做最终一致性兜底补偿那些可能漏发的变更事件。在这个模型下两级缓存都需要被清理。数据库变更发生时不能只删 Redis还要把本地缓存也干掉。但本地缓存分布在各个应用节点上广播删除并不简单。我的做法是借助 Redis 的 Pub/Sub 发一条cache:evict:{key}消息所有应用实例监听并删除本地的对应缓存。Redis Pub/Sub 有丢消息的缺陷所以更保险的做法是本地缓存的 TTL 不要设置太长即使广播消息丢失本地缓存也会在几十秒内自动过期恢复。5.3 本地缓存一致性处理的边界思考本地缓存的最大问题就是副本太多每个节点各有一份跨节点无法实时感知更新。所以设计本地缓存时必须把“允许短暂不一致的时间窗口”作为一个明确的产品指标写下来。比如商品标题允许 30 秒延迟那本地 TTL 设 30 秒是合理的如果库存数据要求 1 秒内感知变化那就不应该放进本地缓存或者要做非常频繁的异步刷新。实际项目里我常用“短 TTL refreshAfterWrite 异步刷新”来代替广播失效。假设本地 TTL 为 10 秒refreshAfterWrite 为 5 秒那么一个 key 写入 5 秒后会触发异步加载更新最多 10 秒后旧数据一定消失。这个方案的优点是实现简单不需要额外的消息组件缺点是如果缓存 key 数量很大后台刷新会频繁打到 DB 或 Redis。所以只对热点 key 做刷新冷数据让 TTL 自然过期即可。6. 生产环境常见问题与排查实录6.1 命中率上不去问题出在哪本地缓存命中率低第一个要查的是 key 的分布是否集中。如果接口接收的参数是用户 ID那么每个用户访问的 key 都不同本地缓存自然很难命中。如果参数是商品 ID、配置项、城市 ID 这类有限集合本地缓存才有价值。第二个要查的是 key 是否被频繁淘汰比如本地缓存容量太小热点 key 刚写入就被其他 key 挤出表现在命中率上就是持续波动。此时用 Caffeine 的stats()输出evictionCount可以看到淘汰数量是否异常高。还有一个容易被忽略的问题是缓存 key 的粒度。比如商品详情接口如果缓存 key 是“商品 ID 用户 ID”那么命中率几乎不可能高如果缓存 key 是“商品 ID”再在 value 里做按用户维度的个性化裁剪命中率会大幅提升。这个调整在生产上效果极其明显我曾经只改了 key 的粒度就把本地命中率从 28% 提到了 74%。6.2 本地缓存导致 GC 压力变大Caffeine 虽然高效但本质上还在占用堆内存。如果 value 对象很大或者缓存条目很多会明显增加 GC 时间。排查时可以打开-XX:PrintGCDetails观察 Old GC 的频率。如果发现频繁第一步把本地缓存的权重调小第二步把大对象拆成小对象避免单个 key 里塞一个几百 KB 的巨无霸第三步考虑使用堆外缓存比如 Chronicle Map但这需要额外的维护成本我一般只在真正必要的时候才用。6.3 缓存与数据库不一致的经典 case线上最常见的不一致场景是后台更新了商品价格前端展示的仍是旧价格。排查路径一般是这样先确认 DB 里的数据是不是真的已经更新再确认 Redis 里的 key 是否还存在如果 Redis 里的 key 还是旧值再看是删除缓存失败还是删除后又被旧请求回填了。删除缓存失败的检查重试日志被旧请求回填的大概率是延迟双删没有起到效果需要把主从延迟参数、双删延迟时间拉出来重新评估。这个问题的根治方案还是回到第 5 节的“业务消息 binlog 兜底”。只要缓存更新被异步化并且有可靠的重试机制不一致窗口就能被压缩到可控范围。本地缓存方面通过短 TTL 和广播失效消息的双重机制也能在几十秒内收敛。最后再分享一个我踩过多次坑之后养成的习惯无论什么缓存上线前一定要把监控打全。命中率、回源次数、DB 查询量、缓存淘汰数、平均加载耗时、GC 时间这些指标一个都不能少。没有监控的缓存就像没有仪表盘的飞机飞得越高越危险。你可以先跑一个低流量的灰度实例观察一周的命中率和一致性指标再逐步把流量放量到全量这是多级缓存从“能用”到“生产级”最关键的一步。