
上个月我被拉进一个评审会议题是“会员积分系统缓存架构优化”。看到会议标题的瞬间我心里大概有数了这八成不是单纯的技术选型讨论更像是对现有系统的一次“体检”。果然会上核心争论点很快聚焦到一个问题上——到底要不要引入本地缓存还是继续咬着 Redis 硬扛这个积分系统最早只用了 Redis 做缓存后来业务量上来QPS 一高Redis 的压力和响应延迟都开始冒头。于是就有了“从本地缓存到多级缓存”这条路。今天这篇就把这次评审的思路、方案取舍、落地细节和踩坑记录完整梳理一遍给正在做类似架构评审或缓存优化的同学一个参考。1. 这次架构评审是怎么来的1.1 积分系统的业务特点读多写少、热点集中会员积分系统属于典型的“读多写少”业务。用户查积分余额、查积分明细、查等级进度这些接口的访问频率远高于积分变动接口。尤其是在活动期间比如签到翻倍、限时积分商城用户会反复刷新页面查看自己的积分有没有到账、能换什么商品这种密集读取请求会在短时间内集中打过来。但这里有个容易被忽略的点积分系统的读请求表面上分散实际上热点非常集中。积分榜单、热门活动积分规则、某些头部用户的积分详情这些 key 的访问量能占到总请求的 80% 以上。如果用单一 Redis 缓存扛Redis 的带宽和连接数会先成为瓶颈CPU 和内存反而还有富余。这是很多团队在初期容易踩的坑——以为加机器能解决实际是缓存架构模型本身该升级了。1.2 评审不是炫技是带着问题来的架构评审最怕什么最怕大家坐在一起聊框架、聊新名词聊完回去什么都没变。所以这次评审会之前我先整理了一份问题清单所有讨论都围绕这几个问题展开当前 Redis 缓存的瓶颈到底在哪里是连接数、带宽还是命中率引入本地缓存能解决什么问题又会引入什么新问题多级缓存的更新策略怎么设计才能保证数据最终一致缓存穿透、击穿、雪崩这些经典问题在积分场景下有哪些特殊表现评审不是为了选一个“看起来很厉害”的架构而是为了解决真实的业务痛点。带着问题清单去评审大家讨论的颗粒度才会变细不然很容易变成“我听说某某中间件不错要不要试试”的闲聊。1.3 评审目标定方案而不是选框架这次的评审目标很明确确定积分系统缓存架构的演进方案而不是纠结具体用哪个框架。Caffeine 和 Redis 的组合在 Java 生态里已经足够成熟线上案例一大堆没必要在选型上反复摇摆。真正的难点在于两级缓存之间的数据一致性、失效策略和容量规划。在实际评审时我建议大家先画一条请求链路出来把“用户请求 → 本地缓存 → Redis → 数据库”每一层的职责和耗时都标清楚然后再讨论优化点。链路画清楚了很多争论会自然消失。比如有人坚持不用本地缓存说一致性难保证但你把“积分详情”这个读多写少、可容忍秒级延迟的场景单独拎出来反对意见就会弱很多。2. 从本地缓存到多级缓存方案选型与取舍2.1 单用 Redis 到底差在哪里在很多团队眼里Redis 已经是缓存的天花板了。确实Redis 性能很强单实例 QPS 上万很正常配合集群还能横向扩展。但问题是当你的应用实例和 Redis 之间隔着一次网络 RTT延迟再低也有物理极限。在积分离散查询这种高频小请求场景下网络开销占比非常大。另外Redis 的连接数也是隐形瓶颈。应用实例扩容到几十个之后每个实例维护一批 Redis 连接连接数很容易打满。尤其是用了 Spring Boot 默认的 Lettuce 连接池连接数配置不合理时高并发下会频繁出现连接等待超时。本地缓存最大的优势就是零网络开销数据直接在应用进程内读取延迟能压到微秒级。这就是为什么我们最终决定引入本地缓存作为 Redis 前面的一层挡板。它能挡掉相当大比例的重复请求让 Redis 只处理那些真正需要跨节点共享的数据。2.2 Caffeine 凭什么成为本地缓存首选Java 生态里的本地缓存方案不少Guava Cache、Caffeine、Ehcache 都有各自的用户群。这次评审选择了 Caffeine理由很务实性能上Caffeine 的高并发读写表现稳定特别是它的 W-TinyLFU 淘汰算法比 Guava 的 LRU 更适应热点数据的动态变化。它提供了异步加载、刷新、淘汰监听等能力配合 Spring Cache 注解使用很顺手。内存控制灵活可以设置最大条数或最大权重内存吃紧时能自动淘汰不会影响应用本身的稳定性。Caffeine 的淘汰策略设计值得一提。W-TinyLFU 会记录访问频率并不只是简单地看“最近有没有被访问”还会考虑“最近被访问得有多频繁”。对积分系统这种热点集中的场景这个特性非常适用。比如某个热门活动期间积分规则被大量访问W-TinyLFU 能识别出它是高频 key尽量保留在本地缓存中。2.3 多级缓存整体模型与请求路径最终确定的架构模型分四层本地缓存、Redis、数据库、以及兜底的 MQ 异步通知。请求路径如下请求先查本地缓存 Caffeine命中直接返回本地缓存未命中查询 RedisRedis 未命中查询数据库并回填 Redis 和本地缓存积分变动时先更新数据库再删除 Redis 缓存同时发布一个缓存变更消息各个应用节点收到消息后删除本地缓存。这个模型的关键在于本地缓存不做持久化只做热点数据的短期缓存Redis 是第二道防线承担跨节点共享和失效通知的职责数据库永远是一致性的最终来源。为了保证平滑落地我们还做了一个开关配置。万一多级缓存出了兼容性问题可以随时降级成“只查 Redis”或“直接查库”的模式。架构评审时定好应急预案比出事的时候再手忙脚乱救火要靠谱得多。3. 关键配置与实现细节3.1 Spring Cache 抽象层怎么用才不别扭Spring Cache 抽象用起来确实方便几个注解一标缓存逻辑和业务逻辑就分离了。但在多级缓存场景下直接用默认配置会很难受。默认的 CacheManager 只能管一级缓存要么用 Redis要么用 Caffeine做不到“先查本地、没命中再查 Redis”的串联效果。我们的做法是自定义一个MultiLevelCacheManager内部组合 CaffeineCacheManager 和 RedisCacheManager。核心逻辑如下简化版配置Configuration public class CacheConfig { Bean public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) { // 构建 Redis 缓存管理器 RedisCacheManager redisCacheManager RedisCacheManager.builder(redisConnectionFactory) .cacheDefaults(redisCacheConfiguration()) .build(); // 构建 Caffeine 缓存管理器 CaffeineCacheManager caffeineCacheManager new CaffeineCacheManager(); caffeineCacheManager.setCaffeine(Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .recordStats()); // 组合成多级缓存管理器 return new MultiLevelCacheManager(caffeineCacheManager, redisCacheManager); } }真正执行查询时需要自己实现一个Cache包装类先读 Caffeine未命中再读 Redis。这个地方代码量不大但逻辑要塞得很严谨尤其是并发场景下要避免一个 key 同时被多个线程回源造成无谓的数据库压力。3.2 Caffeine 核心参数设置与计算思路Caffeine 参数不能照抄网上的配置要根据业务量来推。我们的积分系统高峰期 QPS 大约 3000积分查询接口占比 60%也就是 1800 QPS 左右。假设单次读取的积分信息对象大小约 1KB我们期望本地缓存能扛住 80% 的读请求那就需要支撑约 1440 QPS 的本地命中。假设每个用户每次会话期间会反复查看积分多次查询的时间间隔一般在几分钟内所以过期时间定在 5 分钟比较合理。最大条目数怎么定参考活跃会员数量和接口访问分布我们按高峰时段 15 分钟内访问过积分接口的去重用户数来估算大约 8000 到 12000 左右。再考虑到对象占用maximumSize 设成 10000 是一个相对安全的中间值。如果内存吃紧Caffeine 会按频率淘汰最不常用的条目不会把 JVM 堆撑爆。关键参数经验值如下表参数配置值设计理由maximumSize10000覆盖高峰时段活跃用户控制堆内存占用expireAfterWrite5分钟容忍一定延迟保证数据最终一致refreshAfterWrite1分钟热点 key 自动刷新减少穿透到 Redis 的请求recordStats开启采集命中率、加载耗时等监控指标refreshAfterWrite 这个参数很多人会忽略但它在多级缓存里很有用。当缓存条目超过 1 分钟没有更新时下次请求会触发异步刷新这样既能保证数据相对新鲜又不会因为同步刷新阻塞请求线程。3.3 缓存更新与失效策略的落地积分变动场景比读取场景复杂得多。用户获得积分、消费积分、积分过期这些操作都会改变积分余额。如果只更新数据库而不处理缓存用户查到的一直是旧数据。我们的更新策略是先更新数据库删除 Redis 缓存通过 Redis Pub/Sub 发送缓存失效消息各应用节点消费消息删除本地 Caffeine 缓存。为什么不更新 Redis 而是删除因为积分余额可能由多个条件拼接而成比如基础积分、活动加赠、过期扣减更新缓存前你得先算出新值算错了反而污染缓存。删除缓存是更稳妥的做法下次读取时自动回源能保证拿到的永远是最新值。本地缓存的失效依赖消息通知就带来一个问题如果消息丢失怎么办我们的兜底方案是本地缓存的 expireAfterWrite 设置为 5 分钟也就是说即使失效消息没收到本地缓存最多旧 5 分钟。对于积分系统来说用户看到几分钟前的旧积分虽然体验上不算完美但不会造成资损。一致性延迟完全在可接受范围内。3.4 一致性问题没有银弹只有取舍多级缓存最让人头疼的就是一致性问题。本地缓存分散在每个应用节点上数据更新不像单机 Redis 那样能全局同步。你永远没法做到“强一致”只能根据业务特性选择一种可以接受的“最终一致”。积分系统对一致性的要求其实没那么极端。用户查询积分余额差个几秒甚至一两分钟绝大多数场景都能接受。真正不能接受的是长时间展示脏数据。所以我们的目标定的是数据库更新成功后缓存最多在 5 分钟内收敛到一致。另一种降低一致性风险的手段是“按业务隔离缓存策略”。比如积分余额这种高频查询且变动频率不高的数据用多级缓存积分明细这类可能频繁写入、一致性要求更高的数据直接查 Redis 或数据库不做本地缓存。评审时把这个原则定下来后面实现就不会乱。4. 评审后的落地过程与踩坑实录4.1 缓存穿透空值缓存也要设计上线第一周监控面板上数据库的慢查询突然多了不少。排查后发现有人恶意批量请求不存在的会员 ID每次请求都会穿透两级缓存直接把压力打到数据库。这就是典型的缓存穿透。解决缓存穿透常见两种手段缓存空值和布隆过滤器。我们选了前者理由是实现简单、见效快。查询数据库后若结果为 null也往 Redis 写一个空值标记过期时间设置短一些比如 60 秒。这样同一 ID 的重复请求不会再次打到数据库。实现时需要注意空值缓存不能影响业务判断。我们的做法是在 Redis 中写入一个特殊占位符比如业务前缀 ID值为 JSON 格式的空对象。查询命中该占位符后直接返回 null但在代码里要和“缓存不存在”区分开避免逻辑判断出错。这个细节在评审时没人提落地时吃了亏。4.2 缓存击穿热点 key 重建要加锁积分商城秒杀活动中某个头部商品对应的积分规则会瞬间成为热点 key。这个 key 一旦在 Redis 中过期大量请求同时回源数据库数据库连接池瞬间被打满。这属于缓存击穿和穿透不同的是key 本身是真实存在的只是恰好到了过期时间。Caffeine 的 LoadingCache 自带单飞机制同一个 key 并发访问时只有一个线程真正执行 load 方法其他线程等待结果。这个特性可以很好地防止本地缓存击穿。但 Redis 层还需要额外处理。我们采用互斥锁方案当 Redis 未命中时尝试获取一个分布式锁拿到锁的线程查询数据库并回填缓存其他线程短暂自旋后重新读取缓存。锁的过期时间要设置合理我们设为 3 秒防止数据库慢查询导致锁提前失效进而引发多个线程同时重建缓存。4.3 数据不一致一次积分变动引发的“差一分”事故上线多级缓存后第二周客服反馈有用户投诉积分余额和积分明细对不上差了一分。排查后发现是本地缓存和 Redis 缓存之间出现了时间差。场景是这样的用户在 A 节点登录后积分余额被缓存到了 A 节点的 Caffeine。此时用户在 B 节点触发了积分变动B 节点删除了 Redis 缓存也发了失效消息但消息是异步的A 节点的本地缓存还没来得及删除。用户立刻回到 A 节点刷新查询A 节点本地缓存命中返回了旧积分看起来就是少了 1 分。最终解决方案是双管齐下一是缩短本地缓存过期时间从 10 分钟改到 5 分钟二是在写操作成功后主动触发一次本地缓存删除而不只依赖消息通知。虽然极端情况下仍有微小时间窗但用户感知已经从“经常差一分”降到“几乎遇不到”。4.4 监控与容量规划上线前就要想清楚多级缓存不是加上去就完事了后续的监控和容量规划才决定系统能跑多久。我们上线前就接入了三类监控指标缓存命中率分别统计 Caffeine 和 Redis 的命中率低于阈值时报警缓存加载耗时如果加载时间持续上升说明回源频率过高需要调整过期策略或扩容内存占用Caffeine 的当前容量、淘汰数量防止本地缓存占用堆内存过高。Caffeine 内置的recordStats()可以很方便地暴露命中率、命中数、加载耗时等指标再配合 Micrometer 接入 PrometheusGrafana 上就能看到完整曲线。有一次大促后我们发现 Redis 命中率跌到 70% 以下排查发现是本地缓存过期时间设置过长大量请求被本地挡住导致 Redis 中很多 key 因为长时间没被访问而自然淘汰。这个现象说明多级缓存需要持续调参不是上线就一劳永逸。容量规划方面我的经验是预留 1.5 到 2 倍的 buffer。假设预估本地缓存最大 10000 条实际部署时把堆内存余量按 20000 条来评估。JVM 堆内存紧张时Caffeine 虽然会淘汰条目但淘汰本身也有 CPU 开销真到这一步应用的整体性能已经受影响了。5. 评审结论与个人体会5.1 多级缓存不是终点是新的起点这次架构评审最终通过的方案并不是最复杂的方案但确实是最适合当前业务阶段的方案。多级缓存上线后积分查询接口的平均耗时从原来的 20ms 左右降到了 3ms 以内Redis 的 QPS 压力降低了约 65%数据库的慢查询几乎消失。数字摆在那里团队对多级缓存的质疑自然就消散了。但我也清楚这只是缓存演进路上的一站。后续如果积分业务扩展到更多端或者引入更复杂的规则引擎缓存的粒度、失效策略、一致性要求都需要重新审视。架构评审的价值不是一次性解决问题而是把当前阶段的关键问题想透让后续的修正成本可控。5.2 给做类似评审的人几点建议如果你们团队也要做缓存架构评审我有几点亲测有效的建议第一评审前先把业务场景和性能指标量化清楚。不要只说“用户多、请求大”要把 QPS、热点分布、数据规模、可容忍的延迟全部列成表格方案讨论才有据可依。第二多级缓存的一致性方案不要在评审时才讨论应该在评审前先想好。本地缓存 Redis 这个组合注定无法做到强一致。想清楚业务能不能接受最终一致、最终一致的时间窗是多少比选哪个框架重要一万倍。第三评审要落到具体操作包括参数值、回源策略、监控指标。我见过太多评审会讲得头头是道散会后连个配置项都定不下来。评审结论里必须有“谁在什么时间点完成什么事”否则这一小时算是白开了。最后说一个真实感受真正做过一次从本地缓存到多级缓存的架构演进你才会发现缓存设计里最难的从来不是怎么把数据存进去而是在数据随时可能过期、节点随时可能扩容、消息随时可能丢失的现实里怎么让用户始终看到“差不多正确”的积分余额。这中间的取舍就是架构师经验的核心部分。