实战干货 | 分布式多级缓存设计方案

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
云原生网关 MSE Higress,422元/月
简介: 分布式多级缓存设计方案,解决海量数据读取的性能问题,包含多级缓存的存储设计,流程设计;利用多数据副本保证数据的可用性,同时通过不同数据源特点提供更高性能、更多场景数据差异化的支持

设计背景


概念


  先简单解释下什么是分布式多级缓存,所谓分布式简单理解就是异地跨机房服务应用部署;所谓多级缓存,这里狭义语义指定的是应用服务级别的缓存,通常泛指RedisMemcached等;所谓多级缓存,这里是将JVM级的驻留缓存和外部依赖的缓存服务相比而言的。RedisMemcached等都提供了性能优越的缓存服务,在高并发场景下作为提高吞吐量、优化服务性能的利器立下了汗马功劳。


  进行分布式多级缓存设计的初衷是:利用多数据副本保证数据的可用性,同时通过不同数据源特点提供更高性能、更多场景数据差异化的支持。


场景


  一般情况下,缓存我们只使用Redis作为唯一缓存就可以满足大多数业务场景。这里我们不考虑一般的业务场景,现在试图将服务场景复杂化去进行设计,进一步提高对服务性能的追求。


  举例一个业务场景,假设应用服务每天需要提供亿级别调用量的查询业务,在最原始阶段,外部业务提供有效入参请求服务接口返回业务数据即可,然而在之后需求迭代中,增加了对调用方权限校验(渠道校验、授权码校验、入参许可校验)和对返回业务数据的保护(涉及脱敏和非授权字段的过滤排除),业务逻辑瞬间丰富和复杂起来。


一般场景


网络异常,图片无法展示
|


复杂场景


网络异常,图片无法展示
|


以上复杂场景下,需要解决如下几个问题:


  • 数据对比
  • 大量的数据需要进行核验有效性,增加了服务响应的线性时间,可以试图通过哈希表存储避免线性遍历带来的性能问题,通过空间换时间达到O(1)时间复杂度
  • 数据读取
  • 校验数据是相对稳定且数据量较小的,可以将其预加载配置数据到缓存中,减少高频次对数据库层的读取以提高性能
  • 一般而言,Redis作为二级缓存即可满足,由于Redis数据读取也是一层网络传递消耗,为了追求性能极致和服务SLA的更高要求,增加了应用缓存作为一级缓存直接做数据返回
  • 数据存储
  • 配置数据量小,变更低频,读取高频,适合驻留使用一级缓存
  • 业务数据量相对较大,变更不可控,读取高频,适合存储使用二级缓存

技术调研


这里是基于Java语言实现的,其他语言也可以参考匹配对应技术栈来完成技术调研和设计。


  对于二级缓存,选择了功能强大的Redis


  对于一级缓存,也就是本地缓存有很多选择性。通常,在Java语言中我们会选择HashMap或线程安全的ConcurrentHashMap作为JVM缓存容器来存储数据。这里推荐可以尝试Caffeine,它是一套封装良好天生为本地缓存服务的框架,提供了诸多缓存特性,号称 ”本地缓存天花板“


存储设计


一级缓存 · 服务能力设计


定义本地缓存服务的能力定义,如下


/**

* @author: guanjian

* @date: 2020/07/06 16:11

* @description: 本地缓存接口定义

*/

public interface LocalCache {

   // 获取缓存对象

   Object get(Object key);

   // 设置缓存对象

   void put(Object key, Object value);

   // 设置缓存对象,如果不存在某个Key

   void putIfAbsent(Object key, Object value);

   // 设置缓存Map

   void put(Map map);

   // 移除某个缓存

   void remove(Object key);

   // 获取Key集合

   Collection<?> getKeys();

   // 清空

   void clear();

   // 判断是否存在Key

   boolean hasKey(Object key);

   // 销毁缓存

   void destroy();

   // 返回Key数量

   long size();

   // 判断是否为空

   boolean isEmpty();

   // 获取本地缓存区域

   String getRegion();

   // Map结构化

   Map asMap();

}


一级缓存 · 存储区域化扩展


  由于缓存都是Key-Value形式存储,只能支持Key单维度数据存储,为了提供更为便捷和可扩展的数据存储与读取场景,引入了Region分区使得缓存支持多维度业务。其实这里每个缓存实现内部都持有一个可见性的Map<Region,LocalCache<Object,Object>>,每个Region都是单例的只会被初始化一次,可以简单理解为两个嵌套Map的数据结构,数据的存取都是基于Region分区来进行读取的,一般拆分两个维度可以满足大部分场景,如果复杂的数据结构可以考虑继续对Value进行序列化。


网络异常,图片无法展示
|


ConcurrentHashMap本地缓存的实现


/**

* @author: guanjian

* @date: 2020/07/06 16:15

* @description: 通过ConcurrentHashMap构建本地缓存

*/

public class ConcurrentHashMapCache implements LocalCache {


   /**

    * 多分区单例

    * {@String region 缓存分区标识}

    */

   private static volatile Map<String, ConcurrentHashMapCache> INSTANCES = Maps.newConcurrentMap();


   /**

    * 缓存分区标识

    */

   private String region;


   /**

    * 缓存容器

    * {@code Map<Object,Object> 缓存信息}

    */

   private Map<Object, Object> cache = Maps.newConcurrentMap();


   @Override

   public Object get(Object key) {

       return cache.get(key);

   }


   @Override

   public void put(Object key, Object value) {

       cache.put(key, value);

   }


   @Override

   public void putIfAbsent(Object key, Object value) {

       cache.putIfAbsent(key, value);

   }


   @Override

   public void put(Map map) {

       cache.putAll(map);

   }


   @Override

   public void remove(Object key) {

       cache.remove(key);

   }


   @Override

   public Collection<?> getKeys() {

       return cache.keySet();

   }


   @Override

   public void clear() {

       cache.clear();

   }


   @Override

   public boolean hasKey(Object key) {

       return cache.containsKey(key);

   }


   @Override

   public void destroy() {

       INSTANCES.remove(region);

   }


   @Override

   public long size() {

       return cache.size();

   }


   @Override

   public boolean isEmpty() {

       return cache.isEmpty();

   }


   @Override

   public String getRegion() {

       return this.region;

   }


   @Override

   public Map asMap() {

       return cache;

   }


   public static ConcurrentHashMapCache getInstance(String region) {

       if (INSTANCES.containsKey(region)) {

           return INSTANCES.get(region);

       }


       ConcurrentHashMapCache instance = null;

       if (!INSTANCES.containsKey(region)) {

           synchronized (INSTANCES) {

               if (!INSTANCES.containsKey(region)) {

                   instance = new ConcurrentHashMapCache(region);

                   INSTANCES.putIfAbsent(region, instance);

               }

           }

       }

       return instance;

   }


   private ConcurrentHashMapCache(String region) {

       this.region = region;

   }

}


Caffeine本地缓存的实现


/**

* @author: guanjian

* @date: 2020/07/08 9:17

* @description: 通过Caffeine构建本地缓存

*/

public class CaffeineCache implements LocalCache {


   private final static Logger LOGGER = LoggerFactory.getLogger(CaffeineCache.class);

   /**

    * 多分区单例

    * {@String region 缓存分区标识}

    */

   private static volatile Map<String, CaffeineCache> INSTANCES = Maps.newConcurrentMap();


   /**

    * 缓存分区标识

    */

   private String region;


   /**

    * 缓存容器

    * {@code Cache<Object,Object> 缓存信息}

    */

   private Cache<Object, Object> cache = Caffeine.newBuilder()

           .recordStats()

           .initialCapacity(2 << 2)

           .build();


   private Object synLock = new Object();


   @Override

   public Object get(Object key) {

       Object value = cache.getIfPresent(key);

       LOGGER.debug("[CaffeineCache] region={}, key={},value={} getted.", region, key, JSON.toJSONString(value));

       return value;

   }


   @Override

   public void put(Object key, Object value) {

       cache.put(key, value);

       LOGGER.debug("[CaffeineCache] region={}, key={},value={} putted.", region, key, JSON.toJSONString(value));

   }


   @Override

   public void putIfAbsent(Object key, Object value) {

       synchronized (synLock) {

           if (null != cache.getIfPresent(key)) return;

           cache.put(key, value);

           LOGGER.debug("[CaffeineCache] region={}, key={},value={} putted.", region, key, JSON.toJSONString(value));

       }

   }


   @Override

   public void put(Map map) {

       cache.putAll(map);

       LOGGER.debug("[CaffeineCache] region={}, map={} putted.", region, JSON.toJSONString(map));

   }


   @Override

   public void remove(Object key) {

       cache.cleanUp();

   }


   @Override

   public Collection<?> getKeys() {

       return cache.asMap().keySet();

   }


   @Override

   public void clear() {

       cache.invalidateAll();

   }


   @Override

   public boolean hasKey(Object key) {

       LOGGER.debug("[CaffeineCache] region={}, key={}, map={}.", region, key, JSON.toJSONString(cache.asMap()));

       return null != cache.getIfPresent(key);

   }


   @Override

   public void destroy() {

       INSTANCES.remove(region);

   }


   @Override

   public long size() {

       return cache.asMap().keySet().size();

   }


   @Override

   public boolean isEmpty() {

       return 0 == cache.asMap().keySet().size();

   }


   @Override

   public String getRegion() {

       return region;

   }


   @Override

   public Map asMap() {

       return cache.asMap();

   }


   public static CaffeineCache getInstance(String region) {

       if (INSTANCES.containsKey(region)) {

           return INSTANCES.get(region);

       }


       CaffeineCache instance = null;

       if (!INSTANCES.containsKey(region)) {

           synchronized (INSTANCES) {

               if (!INSTANCES.containsKey(region)) {

                   instance = new CaffeineCache(region);

                   INSTANCES.putIfAbsent(region, instance);

                   LOGGER.debug("[CaffeineCache] region={} is established.", region);

               }

           }

       } else {

           instance = INSTANCES.get(region);

       }

       return instance;

   }


   private CaffeineCache(String region) {

       this.region = region;

   }

}


二级缓存 · 数据存储设计


  由于Redis提供了非常高效、便捷的数据结构,数据存储及选取的数据结构如下:


数据名称

数据类型

存储数据结构

业务字段-1

配置数据

Hash

业务字段-2

配置数据

Hash

业务字段-3

配置数据

Hash

业务富信息-1

业务数据

String(JSON序列化)

业务富信息-2

业务数据

String(JSON序列化)

业务富信息-3

业务数据

String(JSON序列化)


流程设计


缓存架构设计


网络异常,图片无法展示
|


  我们将全视图从上到下拆分为调用方→缓存层→持久层→数据库的核心数据交互主线,此外还有涉及业务数据变更的用户操作、涉及配置或运营数据变更的管理员操作,以及对缓存服务监控的定时服务等。


  「缓存层」 是整个缓存架构方案的核心。主要依赖JVM做配置数据的一级缓存存储,依赖Redis做业务数据存储及配置数据的兜底。


  由于应用部署是分布式的,JVM的数据一致性依赖Zookeeper进行实现,通过对Path进行监听,数据变更都会触发Path变化从而产生event驱动JVM重新拉去数据以保证JVM缓存数据一致。虽然Zookeeper是一个CP的实现,但是JVM分布式缓存这里采用一种AP实现,由于ZookeeperJVM缓存与DB存储数据唯一通信的信道,一旦出现网络或中间件异常,会出现无法通信无法变更数据的情况,对于这种极端情况,目前采用两种策略进行控制,一是应用启动后会有一个定时轮询的守护线程监控数据情况保证即使在脏数据下服务也部分可用,二是JVM由于监听了ZookeeperPath变更及Session事件,对于失联情况可以选择异常报警或超时失联做服务下线保护,这里分布式通信是一个非常复杂的业务场景,仅提供一个较为可行的实现思路,具体实现可以根据业务场景做更为精细化、高可用保障的实现逻辑。


  「数据层」 主要做业务数据变更的缓存移除,确保缓存数据保持一致。这里通过切面环绕业务方法实现缓存移除或更新。


缓存拦截流程


网络异常,图片无法展示
|


  • 「Step - 1」 业务请求先请求缓存是否存在业务数据,若存在直接返回
  • 「Step - 2」 若缓存中为empty则说明业务数据为空,这里是为了防止缓存穿透做的空值缓存
  • 「Step - 3」 若缓存值为空,避免缓存击穿会首先设置缓存为empty,而后请求DB,为了避免多个请求同一时刻穿透到DB,需要竞态获取分布式锁,获取锁成功的请求可以顺利抵达数据库进行数据获取,如果查询到数据则立刻更新缓存,无数据则不修改缓存继续保持empty并返回空数据,释放分布式锁
  • 「Step - 4」 当业务方法涉及业务数据的变更时,进行切面环绕,保持第一时间清除缓存,保证缓存与DB数据一致性


缓存加载流程


网络异常,图片无法展示
|


  • 「Step - 1」 数据加载首先判断Redis缓存中是否存在数据,若存在直接将Redis作为数据源进行数据获取加载JVM
  • 「Step - 2」Redis数据为空则请求DB进行数据拉取,为了避免同一时刻集群JVM频繁请求和拉取DB数据,这里做了分布式锁控制,同一时刻只发起一次数据拉取操作之后更新Redis,未获取分布式锁JVM进行异步轮询Redis完成最终数据加载


缓存更新流程


网络异常,图片无法展示
|


  • Redis缓存更新直接通过业务方法触发进行存储、移除设置即可。
  • JVM缓存的更新主要通过Zookeeper来做分布式协调,当数据库配置数据产生变化,随机触发Zookeeper迭代数据版本,JVM集群订阅Zookeeper数据变更事件触发版本对比后进行数据拉取,进入缓存加载流程保持数据更新


小结


  「多数据源分层」 数据以瀑布流形式分层级存在,一级缓存追求强劲内存级读性能支持,二级缓存虽然性能略逊于一级缓存但是借助Redis的强大特性支持能对业务数据进行较好的治理和存储扩展,数据库是持久化的最终归宿充当源数据作用,整体上是一个分而治之的实现思想。


  「多数据源管理」 分布式系统最大的特点就是多数据副本,要基于CAP进行技术方案选型做取舍,案例中业务接受短时间数据不一致场景下的AP实现方案。对于多数据源的治理中,协调者的角色非常重要,案例中选用了Zookeeper,未来还可以根据业务情况进行扩展,对比其他竞品ETCDConsul进行改造和替换。

相关文章
|
9天前
|
存储 缓存 NoSQL
Redis专题-实战篇二-商户查询缓存
本文介绍了缓存的基本概念、应用场景及实现方式,涵盖Redis缓存设计、缓存更新策略、缓存穿透问题及其解决方案。重点讲解了缓存空对象与布隆过滤器的使用,并通过代码示例演示了商铺查询的缓存优化实践。
69 1
Redis专题-实战篇二-商户查询缓存
|
2月前
|
存储 缓存 安全
Go语言实战案例-LRU缓存机制模拟
本文介绍了使用Go语言实现LRU缓存机制的方法。LRU(最近最少使用)是一种常见缓存淘汰策略,当缓存满时,优先删除最近最少使用的数据。实现中使用哈希表和双向链表结合的方式,确保Get和Put操作均在O(1)时间内完成。适用于Web缓存、数据库查询优化等场景。
|
4月前
|
人工智能 负载均衡 Java
Spring AI Alibaba 发布企业级 MCP 分布式部署方案
本文介绍了Spring AI Alibaba MCP的开发与应用,旨在解决企业级AI Agent在分布式环境下的部署和动态更新问题。通过集成Nacos,Spring AI Alibaba实现了流量负载均衡及节点变更动态感知等功能。开发者可方便地将企业内部业务系统发布为MCP服务或开发自己的AI Agent。文章详细描述了如何通过代理应用接入存量业务系统,以及全新MCP服务的开发流程,并提供了完整的配置示例和源码链接。未来,Spring AI Alibaba计划结合Nacos3的mcp-registry与mcp-router能力,进一步优化Agent开发体验。
1951 15
|
4月前
|
NoSQL 算法 安全
redis分布式锁在高并发场景下的方案设计与性能提升
本文探讨了Redis分布式锁在主从架构下失效的问题及其解决方案。首先通过CAP理论分析,Redis遵循AP原则,导致锁可能失效。针对此问题,提出两种解决方案:Zookeeper分布式锁(追求CP一致性)和Redlock算法(基于多个Redis实例提升可靠性)。文章还讨论了可能遇到的“坑”,如加从节点引发超卖问题、建议Redis节点数为奇数以及持久化策略对锁的影响。最后,从性能优化角度出发,介绍了减少锁粒度和分段锁的策略,并结合实际场景(如下单重复提交、支付与取消订单冲突)展示了分布式锁的应用方法。
351 3
|
6月前
|
消息中间件 缓存 NoSQL
缓存与数据库的一致性方案,Redis与Mysql一致性方案,大厂P8的终极方案(图解+秒懂+史上最全)
缓存与数据库的一致性方案,Redis与Mysql一致性方案,大厂P8的终极方案(图解+秒懂+史上最全)
|
8月前
|
存储 缓存 Java
Java中的分布式缓存与Memcached集成实战
通过在Java项目中集成Memcached,可以显著提升系统的性能和响应速度。合理的缓存策略、分布式架构设计和异常处理机制是实现高效缓存的关键。希望本文提供的实战示例和优化建议能够帮助开发者更好地应用Memcached,实现高性能的分布式缓存解决方案。
156 9
|
8月前
|
存储 缓存 NoSQL
云端问道21期方案教学-应对高并发,利用云数据库 Tair(兼容 Redis®*)缓存实现极速响应
云端问道21期方案教学-应对高并发,利用云数据库 Tair(兼容 Redis®*)缓存实现极速响应
231 1
|
9月前
|
消息中间件 架构师 数据库
本地消息表事务:10Wqps 高并发分布式事务的 终极方案,大厂架构师的 必备方案
45岁资深架构师尼恩分享了一篇关于分布式事务的文章,详细解析了如何在10Wqps高并发场景下实现分布式事务。文章从传统单体架构到微服务架构下分布式事务的需求背景出发,介绍了Seata这一开源分布式事务解决方案及其AT和TCC两种模式。随后,文章深入探讨了经典ebay本地消息表方案,以及如何使用RocketMQ消息队列替代数据库表来提高性能和可靠性。尼恩还分享了如何结合延迟消息进行事务数据的定时对账,确保最终一致性。最后,尼恩强调了高端面试中需要准备“高大上”的答案,并提供了多个技术领域的深度学习资料,帮助读者提升技术水平,顺利通过面试。
本地消息表事务:10Wqps 高并发分布式事务的 终极方案,大厂架构师的 必备方案
|
9月前
|
缓存 NoSQL Java
Spring Boot中的分布式缓存方案
Spring Boot提供了简便的方式来集成和使用分布式缓存。通过Redis和Memcached等缓存方案,可以显著提升应用的性能和扩展性。合理配置和优化缓存策略,可以有效避免常见的缓存问题,保证系统的稳定性和高效运行。
263 3

热门文章

最新文章