背景
在需要面对高并发的服务的架构设计中,缓存是一个非常重要的组件,它能够使用户请求仅需命中缓存中的数据即可返回,不仅能降低如MySQL等持久性数据库的压力,也能提高系统整体的响应速度。
然而,许多系统中的缓存仅通过增加一个内存型数据库redis实现了系统的缓存组件,未做其他处理。然而,缓存的使用存在许多细节值得深究,这样实现固然很简单,却有可能遇见诸多问题,如下述的缓存穿透、击穿、雪崩、热key问题等等。
作者这篇文章为读者介绍了一个比较完善的多级缓存架构,通过本地缓存、redis、Canal、MQ、异步线程实现,能够有效解决缓存中所遇见的诸多问题,同时能够实现缓存与数据库的实时数据同步,并能够保证高质量的数据一致性。
缓存共性问题
首先我们需要知道多级缓存的架构设计是为了解决什么问题,在了解了缓存设计需要注意的问题之后,才能知晓多级缓存架构设计中的每一个细节有何目的。
缓存穿透
定义:数据源和缓存都没有的数据,每次都要经过缓存去访问数据库,在设计缓存架构时,可以通过下图中的在缓存中设置空值缓存以及设置布隆过滤器来解决该问题。
缓存击穿
定义:指在高并发访问的情况下,某个特定的缓存键失效或不存在,导致大量请求直接落到后端数据库或服务,从而对后端系统造成巨大压力。缓存击穿在实际业务中,大概率来自热key的突然过期或失效,因此设计缓存架构时需要注意此点。
缓存雪崩
定义:指在某一时刻大量缓存数据同时失效,导致大量请求直接涌向后端数据库或服务,从而对后端系统造成巨大压力,甚至可能导致系统崩溃。
热key问题
定义:在缓存系统中,某些特定的键(key)被频繁访问,导致这些键的访问量远远超过其他键,从而对缓存系统和后端数据库造成压力。热key问题可能会导致缓存失效、缓存击穿、甚至缓存雪崩等一系列问题。热key在redis中的表现可能为:某个group或者某个分片中的key的访问次数异常高于其他group或分片,从而导致该group或分片出现问题。
缓存刷新策略
缓存中的数据具备时效性,需要通过刷新策略进行刷新,否则会出现与MySQL等持久性数据库的数据不一致。在缓存架构设计时,需要决策使用何种刷新策略才能合理高效地保证缓存的数据一致性。
超时剔除
为缓存数据通过expire设置超时时间,缓存存在时间到期则剔除缓存。
这种方式适用于对于缓存一致性要求不高的场景,通常作为缓存的兜底刷新策略。
定时刷新
设置一个定时器,周期性地刷新缓存:
这种方式适用于缓存key的数量有限,且对于数据一致性要求不高的场景。
可以通过系统中注册定时任务实现
基于日志主动刷新
在分布式场景中,数据库如MySQL等为了实现主从库间的数据同步,会通过日志的形式记录数据的变更情况。在Mysql中,是通过binlog的方式实现主从复制的。MySQL主节点负责写,从节点负责读,主节点中的数据变更会记录到一个名为binlog的日志文件中,然后该日志会被传输给MySQL从节点,从节点接受后会根据日志中记录的数据变更更新自身,从而做到主从库数据同步。
而MySQL和redis的同步也可以借用该日志实现,redis可以将自身伪装成MySQL的一个从节点,从而接受MySQL主节点的数据变更binlog,可以增量式的实现数据同步,具有良好的实时性。
基于客户端主动异步刷新
该方式是在客户端请求访问redis的某个key时,通过新开异步线程为该key设置一个刷新时间,异步地刷新缓存。
这种方式通常会为刷新时间设置随机量,从而避免了缓存短时间内大量过期,能够有效防止缓存击穿,同时实现了redis更新事务的削峰。
多级缓存设计思路
在设计缓存时,需要从不同问题的角度寻找解决方案,最后综合在一起,形成最后的完整架构。
首先,为了解决缓存穿透,可以通过在缓存中为持久性数据库中不存在的数据设置空值实现。为了避免redis中直接存储key-value,而value为空值或者空字符串可能会带来的误解或处理错误,可以将value设置为一个预先定义好的值,若value等于该值,则说明key为空值key,为请求返回空值。
其次,为了解决缓存击穿,即可使用上述基于客户端的主动异步刷新,可以在每次key被访问时,开一个异步线程设置一定范围内随机的刷新时间,从而有效防止大量key在短时间内失效。
而缓存雪崩在实际业务场景中大都可能是因为redis服务节点出现了故障,导致redis无法访问,从而出现雪崩。这种问题需要通过启用redis集群处理,最好是设置哨兵模式,使redis内部能够在master宕机后自动推选出新的master,从而保证可用性。若是整个redis集群全部宕机,则应该关闭redis服务,最佳方案是在应用服务的配置文件中写上是否启用redis的开关,在redis故障时,将配置文件启用redis改为false,直连MySQL。在分布式应用架构如微服务架构中,这种配置信息可以直接记录在微服务配置管理中心,可以实现配置热更新,不用再手动通过冗杂步骤关闭redis服务。
需要注意的是,在启用直连MySQL后,需要通过日志或其他监听手段密切关注MySQL的运行情况,如CPU、连接数、qps等,若有异常情况,可以考虑容错三板斧:熔断、降级、限流。
然后是热key问题,也就是使用多级缓存的原因。在单redis架构中,热key可能会导致某个redis节点(group、分片)由于少数key的频繁访问出现问题。为了解决该问题,可以启用本地缓存,将异常频繁访问的数据放在应用服务自身的本地中,命中后直接返回,甚至不需要访问redis,大幅提高该次请求响应速度。在本地缓存中,可以通过LRU队列对key进行存储,从而保证经常访问的key一直存储于本地,提高命中率。
热key的检测可以通过事先预测热key、客户端收集、在访问redis前设置proxy统计key数量实现:
此外,还可以通过本地缓存Guava Cache组件实现热key检测:
其中可以看出,GuavaCache为Map,key为key,value为key访问次数,通过AtomicLong.getAndIncrement实现并发环境下的原子性递增。访问缓存的每个key都会通过isHotKey方法在GuavaCache中通过LRU队列判定以及更新热key
本地缓存一般通过Guava Cache实现,这是 Google 的 Guava 库中的一个缓存组件,用于在本地 JVM 内存中存储数据。它提供了灵活且高效的缓存机制,可以帮助开发者在不依赖外部缓存服务的情况下实现本地缓存。Guava Cache 具有多种特性,使其在性能和灵活性方面表现出色。Guava Cache自身也是通过Java ConcurrentHashMap实现,保证了并发环境下key的正确读写,并能够使用LRU队列存放key。
除去以上问题带来的因素以外,考虑到目前大多业务场景下需要保证数据的实时性,因此还需要通过订阅binlog实现redis和MySQL间的数据实时同步。具体思路为:一个服务专门通过Canal监听目标数据库的binlog变更情况,在binlog发生变更后,通过消息队列将变更数据发送至指定主题;另一服务设置监听指定主题的Consumer,一旦接受消息,读取消息中的数据变更,更新redis。binlog+canal+MQ更新缓存的方式实现了缓存实时更新,同时使得缓存更新逻辑从业务代码中解耦,对业务代码无侵入。
此外,还需要为缓存设置过期时间,通过超时剔除方案进行兜底。
多级缓存架构图
综合上面所述,该多级缓存架构大致如下图所示:
多级缓存效果展示
下图为热key处理,当热key出现后,实时监控热key并更新至本地缓存,显著减少对redis的请求量
下图为异步刷新的削峰效果