尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

积分系统缓存架构评审:Caffeine+Redis多级缓存完整实践

积分系统缓存架构评审:Caffeine+Redis多级缓存完整实践 开评审会之前我其实没想到一个积分系统的缓存改造能吵得这么热闹。争论的焦点不是Redis够不够用而是—要不要在Redis前面再加一层本地缓存。当时摆在桌上的方案有三套纯Redis、Caffeine单机缓存、CaffeineRedis多级缓存。积分系统这个业务你说它重吧流量高峰也就集中在积分查询和签到打卡你说它轻吧高峰期QPS冲到几千单库单Redis照样被打得吱哇乱叫。这篇就以我实际参与的这次会员积分系统架构评审为蓝本把我们在本地缓存和多级缓存之间做的取舍、踩的坑、压测的数据、评审的争议点全部梳理出来给同样在做缓存选型的朋友一个参考。1. 评审前夜的痛点积分系统到底卡在哪里1.1 积分系统的业务特征与流量画像先说积分系统的业务特点。这类系统跟订单系统、支付系统有个显著区别——读多写少而且读的集中度极高。用户打开会员中心首页第一眼要看的就是积分余额点进积分商城要看积分明细签到、做任务、消费返积分都会触发一次积分变动。但真正的写操作加积分、扣积分、过期清零在总请求量里的占比通常不到10%。这种特征意味着缓存系统设计的主战场在“读链路”而不是“写链路”。如果不做缓存每次请求都打到数据库那数据库的查询压力会直线上升。尤其是积分明细分页查询这种重IO操作表数据量一旦过了千万级索引再合理也扛不住频繁的随机读。一个容易被忽略的点是积分数据的热点分布。大部分积分查询都集中在少数高活跃用户身上比如每天签到打卡、做任务、逛商城的忠实用户。我统计过当时线上一个月的日志Top 5%的用户贡献了将近60%的积分查询量Top 20%的用户占了85%以上。这种“二八定律”甚至“一九定律”式的访问模型恰恰是本地缓存最擅长的场景。1.2 老架构的缓存链路与问题清单老架构其实不是没有缓存只是链路非常简单粗暴——Redis缓存。请求进来先查Redis命中就直接返回没命中回源MySQL然后把结果回填Redis。这套逻辑在业务初期没问题但随着用户量增长和活动玩法变多问题开始逐渐浮现。首先是Redis的CPU和带宽压力。高峰期读QPS上去之后Redis单实例的CPU开始飙升网络带宽也成了瓶颈。有一次做完大促活动Redis的带宽跑到了200Mbps吓得运维直接限流。本地缓存最大的优势恰恰在这里——请求根本不出应用服务器连网络开销都省了对Redis的压力自然大幅缓解。其次是热点key问题。某个爆款积分商品的详情页某个头部KOL的积分排行榜都可能成为单个key被高频访问的对象。Redis单key承载的QPS是有上限的一旦热点key的访问量冲到几十万即使Redis的性能再强面对单key的序列化、网络传输开销也会显得力不从心。第三个问题更隐蔽——数据回源抖动。Redis缓存miss之后大量请求同时穿透到MySQL很容易把数据库打崩。当时做过一次压测把Redis里一个热门key提前删掉结果瞬间有几千个请求同时打到数据库主库的CPU直接飙到了90%以上。这种缓存击穿导致的连锁反应单靠Redis本身是防不住的。正是这些问题促使我们发起了这次架构评审。评审的核心议题就一个缓存架构要不要从“Redis单级”升级为“CaffeineRedis多级”。但方案嘴上说得简单真到落地环节各种细节博弈才刚开始。2. 架构评审的第一轮交锋本地缓存凭什么被重新提起2.1 候选方案全景图纯Redis、纯Caffeine与多级组合评审会的第一轮是方案陈列。当时候选池里有三套我先把各自的特点摆出来纯Redis方案就是老架构的增强版分片集群、读写分离再把热点key做本地预热。这套思路的好处是架构简单、团队熟悉、不需要引入新的中间件但解决不了Redis本身的网络和单key瓶颈。集群扩容可以横向分摊压力但单key热点问题在分片模式下反而更棘手因为热点key只会落在某一个分片上。纯Caffeine方案把所有积分数据都放到应用本地缓存里查询完全走内存。这个方案单机性能确实炸裂但有个致命伤——每个应用实例各存一份数据一致性没法保证。用户在这个节点查到积分是100在另一个节点查到可能是90对积分这种对准确性敏感的业务来说这是不可接受的。多级缓存方案Caffeine做一级缓存L1Redis做二级缓存L2数据库在最底层兜底。查询顺序是Caffeine - Redis - MySQL回填顺序反过来MySQL加载后回填Redis和Caffeine。这套方案的出发点是结合两者的优点——用本地缓存扛热点和带宽压力用Redis保证分布式环境下各实例之间的数据最终一致性。2.2 评审维度与取舍逻辑性能、一致性、成本、复杂度三套方案列完争论就开始升级了。有人指出纯Caffeine的一致性问题是硬伤有人反驳纯Redis的带宽瓶颈早晚会爆。当时我主导评审给出了一套四维评估框架性能、一致性、成本、复杂度每个维度权重不同逐一打分。性能上多级缓存和纯Caffeine显然占优Caffeine的本地读取是纯内存操作耗时在纳秒到微秒级别而Redis一次get请求至少是0.5到2毫秒的网络往返。一致性上纯Redis最强因为全局只有一份数据多级缓存次之因为引入了本地副本存在短暂的不一致窗口纯Caffeine最差。成本和复杂度上纯Redis最优多级缓存最重因为它需要额外管理两级缓存的同步、失效、监控等一系列问题。评审最忌讳的是“既要又要”。我们当时明确了两条底线第一积分查询的性能必须从P99响应时间上持续优化目标是从当前的30ms压进10ms以内第二数据一致性不允许出现长期偏差但允许秒级的短暂延迟。这两条底线等于直接把纯Caffeine方案和纯Redis方案都否掉了多级缓存成为唯一同时满足要求的路径。2.3 单级方案的边缘场景盲区击穿、穿透与带宽评审会上有个细节让我印象很深。反对多级缓存的同事抛出一个问题“我们的QPS真的高到Redis扛不住了吗”我当时没有正面回答而是放了一组压测数据。模拟场景是积分首页的集中访问持续把同一个用户积分key打满30分钟。纯Redis架构下Redis实例的CPU先到瓶颈随后响应时间直线上升P99从2ms恶化到40ms以上多级缓存架构下Redis的QPS下降了差不多80%CPU占用平稳P99稳定在3ms以内。这个对比说明了单级缓存方案在边缘场景下的盲区。你以为系统QPS不高但热点key会人为制造“局部高并发”这个局部压力对Redis是真实存在的。另一个盲区是缓存穿透如果恶意用户频繁查询不存在的积分流水号每次请求都会穿透缓存打到数据库。纯Redis方案对这种问题没有很好的缓解手段而Caffeine本地缓存可以缓存空值把穿透拦截在应用层。评审结论在这一轮其实已经清晰了多级缓存不是锦上添花而是针对积分系统流量特征的必然选择。接下来的问题是落地时怎么做得优雅、可控而不是搞出一套复杂的中间件。3. 多级缓存架构的落地细节Spring Cache Caffeine Redis3.1 整体链路设计与数据流说明架构选定后我们开始细化落地。技术选型上用Spring Cache作为缓存抽象层底层CacheManager自定义实现一级缓存用Caffeine二级缓存用Redis。为什么选Spring Cache因为它提供了统一的缓存注解和API可以把缓存逻辑跟业务代码解耦团队成员不需要关心底层到底走了哪级缓存。链路设计是这样的。查询积分余额时先查Caffeine本地内存里有直接返回没有则查RedisRedis命中就回填Caffeine并返回还没有就查数据库然后按层级回填。这套链路的关键在于本地缓存是“加速层”Redis是“一致层”数据库是“兜底层”每一层承担的角色不同不能混为一谈。Spring Cache默认只支持单级缓存要实现多级必须自定义CacheManager和Cache。核心思路是写一个CompositeCache类实现Spring的Cache接口内部持有Caffeine和Redis两个Cache实例get方法先走本地再走远端put方法同时写入本地和远端。这里有一个细节需要注意put时必须先写Redis再写Caffeine因为如果先写了本地而Redis写入失败本地缓存就成了脏数据源头。3.2 CacheManager定制实现两级缓存怎么串联有朋友可能觉得自定义CacheManager很难其实核心代码量不多但要对Spring Cache的扩展点有清晰理解。我们定义了一个MultiLevelCacheManager继承AbstractCacheManager在loadCaches()方法里逐个创建缓存实例。每个缓存实例由名称、Caffeine配置、RedisTemplate组成。真正的工作量在MultiLevelCache的get()和put()方法里。get()的查找顺序我写成了模板方法模式先查本地本地miss则查RedisRedis命中就异步回填本地。这里为什么要异步回填因为如果同步回填每次本地miss都要等一次Caffeine写入虽然开销不大但在超高QPS场景下会放大延迟。异步回填带来的问题是一致性窗口但考虑到本地缓存的TTL只有几分钟这个窗口是可接受的。Caffeine配置上我们用了maximumSize加expireAfterWrite的组合。maximumSize控制条目数上限防止本地缓存无限膨胀吃掉JVM堆内存expireAfterWrite控制写入后的过期时间保证数据不会永久停留在本地。这两个参数需要根据实例数和数据总量来算我后面单独说。Redis层面没有做太复杂的事情就是普通的spring-data-redis的RedisTemplateKey序列化器用StringValue序列化器用GenericJackson2JsonRedisSerializer。这里有个小坑GenericJackson2JsonRedisSerializer序列化出来的JSON会带class字段虽然反序列化方便但会多占大概30%的存储空间。对积分这种数据量不大的场景无所谓如果缓存数据量巨大建议换成自定义序列化器。3.3 过期策略与淘汰策略的参数计算缓存参数设计是这次评审里最“硬核”的部分。我先算了一笔账。线上有12个应用实例每个实例分配2GB堆内存考虑到正常业务对象占用缓存最多能分到500MB左右。会员积分数据按每个条目150字节估算包括Key、Value、元数据500MB大概能存300万条数据。但实际热点用户远没那么多我们按顶部活跃用户的10%来缓存大概需要缓存30万条占用的内存不到50MB完全可控。于是Caffeine的maximumSize设成了50万留了余量。expireAfterWrite起初设的5分钟压测后发现P99响应时间稳定但Redis的QPS还有一定下降空间。后来改成expireAfterWrite10分钟加refreshAfterWrite5分钟的搭配Caffeine在5分钟时会异步刷新缓存不会阻塞请求线程有效降低了回源频率。Redis的TTL设成了30分钟比Caffeine长得多。为什么因为Redis是二级缓存作用是在本地缓存失效后提供快速恢复TTL太短会导致大量请求回源数据库太长又会占用过多Redis内存。30分钟是一个折中值既能覆盖大部分活跃会话又不会造成数据长期不一致。参数定好后我把计算公式和验证表格都留在了评审记录里方便后续容量变化时重新核算。这里强烈建议缓存参数一定不要拍脑袋定每调整一个值都要有数据和场景支撑否则上线后大概率要返工。3.4 缓存一致性方案更新顺序、延迟双删与兜底多级缓存最棘手的问题不是性能而是数据一致性。积分这种数据用户对准确性极其敏感——明明消费了积分页面还显示旧数字用户立刻就会投诉。所以我们在评审中就明确Caffeine和Redis允许短暂不一致但最终必须一致而且不一致的时间窗口要控制在秒级以内。更新链路的处理逻辑是“先更新数据库再删除Redis最后删除本地Caffeine”。为什么不更新缓存而是删除缓存因为更新缓存存在并发写冲突的风险不如删掉让下次查询回源重建更简单也更安全。删除顺序上先把Redis删了再删所有实例的本地缓存。这里有个问题——本地缓存不止一份每个实例各存各的你删了这个实例的其他实例怎么感知我们的方案是用Redis的Pub/Sub广播失效消息。任意实例更新数据后除了删除自身Caffeine还往Redis的一个专用channel发一条消息其他实例订阅该channel收到消息后删除自己本地的对应key。这套机制简单可靠不需要引入额外的消息队列。不过跨网络的广播毕竟是异步的从发消息到所有实例删完本地缓存存在一个极短的窗口这也就是为什么我们把Caffeine的TTL设得较短把它作为最终一致性的兜底防线。再补充一个“延迟双删”的变体。更新数据库后先删一次缓存等500毫秒再删一次。延迟双删是为了解决并发场景下“旧缓存被回填”的问题但也别神话它它只是在概率上降低了脏数据风险真正解决还是要靠版本号或者Binlog订阅。我们的场景里叠加了较短TTL和广播机制之后实际脏数据出现概率已经很低延迟双删作为额外保险保留了下来。4. 评审后的性能验证与稳定性演练4.1 压测数据对比单级缓存与多级缓存的差距方案落地后第一件事是压测。我们用相同的流量模型对比了纯Redis架构和多级缓存架构测试环境4个应用实例1个Redis。压测模型模拟线上积分首页的真实请求查询积分余额占70%查询积分明细占20%模拟签到写操作占10%。QPS从1000起步逐步涨到8000每个档位跑5分钟。数据显示QPS在1000时两种架构差距不明显响应时间都在5ms以下。QPS涨到4000纯Redis架构的Redis CPU占用到了60%P99涨到18ms多级缓存架构的Redis CPU不到20%P99是6ms。QPS涨到8000时纯Redis架构的Redis CPU直接到95%开始出现超时多级缓存架构的Redis CPU稳定在35%P99保持在9ms以内应用CPU略有上升但完全可控。这个结果基本验证了评审阶段的判断。本地缓存的实际意义不在于“更快”而在于把热点流量从Redis上卸载掉让Redis从“主力部队”变成“后备部队”。压测数据放到评审记录里后之前质疑多级缓存“没必要”的同事也认可了方案价值。性能数据的说服力永远比嘴上争论大得多。4.2 缓存雪崩、穿透与击穿的防御清单缓存系统上线前一定要把三类经典问题想清楚多级缓存架构最怕的不是性能不够而是被流量打穿后数据库扛不住。我整理了积分场景下的防御清单这里贴出来给大家参考。缓存穿透是查询一个不存在的key缓存里没有数据库里也没有。积分明细查询最容易触发比如用户伪造一个不存在的积分流水号。解法是缓存空值Caffeine和Redis都缓存空对象并设置较短TTL比如30秒。这样同一批恶意请求第二次就会被拦截。缓存击穿是某个热点key失效的瞬间大量请求同时回源。比如头部用户签到后积分发生变化所有缓存被删除其他用户在接下来一秒内查询这个用户就可能同时打到数据库。解法是互斥锁。在Caffeine层面我们可以用get(key, loadFunction)的原子加载方式配合Caffeine的同步加载它内部会对同一个key的并发加载做排队天然避免了击穿。缓存雪崩是大批key同时失效或Redis宕机导致流量全部打到数据库。解法有两层第一层是给Redis的TTL加随机偏移比如30分钟正负10分钟避免同一批key在同一时刻集体过期第二层是引入Redis高可用和本地缓存的缓冲即使Redis宕机Caffeine还能撑一段时间不会瞬间把数据库压垮。4.3 监控指标与告警阈值的设置上线后监控是重中之重。我们定义了几个核心指标全部接入Prometheus Grafana。第一层是缓存命中率分三级统计Caffeine命中率、Redis命中率、数据库回源率。Caffeine命中率正常应该在80%以上Redis命中率应该在95%以上如果这两个数字下滑说明缓存的TTL或淘汰策略可能有问题。第二层是耗时分布。除了应用整体P99响应时间还要看Redis操作的平均耗时常驻和分位数快速判断Redis是否成为瓶颈。第三层是Redis自身的指标比如CPU使用率、内存使用率、带宽、慢查询数。如果Redis带宽超过80%或CPU超过70%就要考虑扩容或者调大Caffeine的缓存容量。告警阈值的设置同样讲究不是所有指标都要告警也不是所有告警都值得被处理。我们对数据库回源率设置了阈值——如果单实例一次回源超过500QPS说明多级缓存可能出现了较大面积失效需要立刻排查。对Caffeine命中率设置了跌落到60%的告警对Redis CPU设置了70%的告警。告警一定要有处理动作否则打了等于没打。5. 聊聊评审中几个容易被忽视的争议点5.1 两级缓存是不是过度设计评审过程中最尖锐的质疑是“我们这套系统有没有必要搞两级缓存是不是纯属炫技”我当时没有直接反驳而是把压测数据和线上监控调出来指出一个客观事实QPS高峰段Redis的CPU持续在70%以上出现过多次由Redis延迟波动引起的接口超时。积分系统不像商品详情页那样QPS高达成千上万但在大促、活动、签到高峰期瞬间流量完全有可能打满Redis。判断是否过度设计标准不在方案本身而在业务是否有对应的极端场景。如果系统QPS常年低于500Redis完全扛得住那多级缓存确实多余但如果像我们一样活动期间会出现流量尖峰而且积分数据有典型的热点分布那多级缓存的价值就不是理论上的而是可以量化的。评审不能只看平均负载更要看尖峰负载和故障恢复能力。我的观点一直是架构选型追求的不是最好而是匹配。两级缓存不适合所有系统但它适合有热点、有峰谷、读多写少、一致性要求不极端敏感的业务。积分系统恰好符合这些条件所以不是过度设计而是提前布局。5.2 一致性要求到底怎么界定多级缓存引入后一个绕不开的问题是——怎么跟产品和业务方对齐一致性预期。刚开始我犯过一个错总觉得技术团队内部把一致性方案做好就行不需要跟业务方对齐。直到有一次积分变动后用户在活动页看到的积分还是旧值产品经理火急火燎跑过来问怎么回事我才意识到这个问题必须提前摆到台面上。我们当时的处理方式是跟产品、运营对齐了一个“一致性分级”的规则。积分余额查询允许最多10秒的延迟因为用户实际操作中从积分变动到刷新页面中间往往有操作间隔但积分扣减、过期清零这种涉及资金属性场景必须做到实时一致。根据这个分级我们对不同接口设置了不同缓存策略——普通查询走多级缓存敏感写操作则同时清理所有缓存并强制回源数据库。这种分级思路值得借鉴。不要跟业务方说“多级缓存是最终一致性”这是技术语言业务方听不懂也不关心。你要告诉他“页面上的积分最多延迟10秒刷新”这是一个可验证的业务承诺。把技术问题翻译成业务语言评审会上的很多争论就能化解。5.3 团队能 hold 住这套复杂度吗任何一个多级缓存方案最终落地的阻力往往不在技术而在团队的接受度和维护能力。当时我印象很深有一位经验丰富的后端同事问了句“出了问题你确定我们值班的人能快速定位是Caffeine的问题还是Redis的问题吗”这句话问得非常好。多级缓存的排查链路比单级缓存长因为请求可能落在任何一级也可能在两级之间来回穿透。为了解决这个可运维性问题我们没有简单地把方案丢给团队而是在架构评审记录里附加了一份排障手册。手册覆盖了典型故障场景比如Caffeine命中率骤降、Redis响应变慢、本地缓存出现脏数据等每个场景都有对应的排查路径和修复动作。同时在日志层面给每级缓存命中或未命中都打了标记一次请求经过Caffeine、Redis、MySQL时留下完整链路排障时一眼就能看出卡在哪一层。这套准备工作做完后团队才真正接受了多级缓存方案。架构评审的意义从来不在于选一个高大上的技术而在于让参与的人理解技术的代价和回报并且有能力去维护它。如果方案落地后没人能接住再先进的架构也只是给团队埋雷。多说一句后续如果要继续演进可以考虑在两级缓存之上加一层进程内消息通知或者引入分布式追踪中间件把缓存链路的耗时和命中情况做更细粒度的可视化。不过那是后话了当前这套CaffeineRedis的多级缓存方案在积分系统里跑得很稳线上验证的结果也支持了评审时的所有判断。希望这篇记录能给正在做类似选型的朋友提供一些参考。
返回列表