上个月,我被拉进一个评审会,议题是“会员积分系统缓存架构优化”。看到会议标题的瞬间,我心里大概有数了:这八成不是单纯的技术选型讨论,更像是对现有系统的一次“体检”。果然,会上核心争论点很快聚焦到一个问题上——到底要不要引入本地缓存,还是继续咬着 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,命中直接返回;
- 本地缓存未命中,查询 Redis;
- Redis 未命中,查询数据库,并回填 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 堆撑爆。
关键参数经验值如下表:
| 参数 | 配置值 | 设计理由 |
|---|---|---|
| maximumSize | 10000 | 覆盖高峰时段活跃用户,控制堆内存占用 |
| expireAfterWrite | 5分钟 | 容忍一定延迟,保证数据最终一致 |
| refreshAfterWrite | 1分钟 | 热点 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 接入 Prometheus,Grafana 上就能看到完整曲线。有一次大促后我们发现 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 这个组合,注定无法做到强一致。想清楚业务能不能接受最终一致、最终一致的时间窗是多少,比选哪个框架重要一万倍。
第三,评审要落到具体操作,包括参数值、回源策略、监控指标。我见过太多评审会讲得头头是道,散会后连个配置项都定不下来。评审结论里必须有“谁在什么时间点完成什么事”,否则这一小时算是白开了。
最后说一个真实感受:真正做过一次从本地缓存到多级缓存的架构演进,你才会发现,缓存设计里最难的从来不是怎么把数据存进去,而是在数据随时可能过期、节点随时可能扩容、消息随时可能丢失的现实里,怎么让用户始终看到“差不多正确”的积分余额。这中间的取舍,就是架构师经验的核心部分。