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

本文涉及的产品
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进行改造和替换。

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
3月前
|
消息中间件 canal 缓存
项目实战:一步步实现高效缓存与数据库的数据一致性方案
Hello,大家好!我是热爱分享技术的小米。今天探讨在个人项目中如何保证数据一致性,尤其是在缓存与数据库同步时面临的挑战。文中介绍了常见的CacheAside模式,以及结合消息队列和请求串行化的方法,确保数据一致性。通过不同方案的分析,希望能给大家带来启发。如果你对这些技术感兴趣,欢迎关注我的微信公众号“软件求生”,获取更多技术干货!
185 6
项目实战:一步步实现高效缓存与数据库的数据一致性方案
|
3月前
|
canal 缓存 NoSQL
Redis缓存与数据库如何保证一致性?同步删除+延时双删+异步监听+多重保障方案
根据对一致性的要求程度,提出多种解决方案:同步删除、同步删除+可靠消息、延时双删、异步监听+可靠消息、多重保障方案
Redis缓存与数据库如何保证一致性?同步删除+延时双删+异步监听+多重保障方案
|
4天前
|
NoSQL Java Redis
秒杀抢购场景下实战JVM级别锁与分布式锁
在电商系统中,秒杀抢购活动是一种常见的营销手段。它通过设定极低的价格和有限的商品数量,吸引大量用户在特定时间点抢购,从而迅速增加销量、提升品牌曝光度和用户活跃度。然而,这种活动也对系统的性能和稳定性提出了极高的要求。特别是在秒杀开始的瞬间,系统需要处理海量的并发请求,同时确保数据的准确性和一致性。 为了解决这些问题,系统开发者们引入了锁机制。锁机制是一种用于控制对共享资源的并发访问的技术,它能够确保在同一时间只有一个进程或线程能够操作某个资源,从而避免数据不一致或冲突。在秒杀抢购场景下,锁机制显得尤为重要,它能够保证商品库存的扣减操作是原子性的,避免出现超卖或数据不一致的情况。
32 10
|
15天前
|
缓存 NoSQL Java
Spring Boot中的分布式缓存方案
Spring Boot提供了简便的方式来集成和使用分布式缓存。通过Redis和Memcached等缓存方案,可以显著提升应用的性能和扩展性。合理配置和优化缓存策略,可以有效避免常见的缓存问题,保证系统的稳定性和高效运行。
32 3
|
1月前
|
SQL 缓存 Java
【详细实用のMyBatis教程】获取参数值和结果的各种情况、自定义映射、动态SQL、多级缓存、逆向工程、分页插件
本文详细介绍了MyBatis的各种常见用法MyBatis多级缓存、逆向工程、分页插件 包括获取参数值和结果的各种情况、自定义映射resultMap、动态SQL
【详细实用のMyBatis教程】获取参数值和结果的各种情况、自定义映射、动态SQL、多级缓存、逆向工程、分页插件
|
1月前
|
存储 缓存 监控
多级缓存有哪些级别?
【10月更文挑战第24天】多级缓存有哪些级别?
34 1
|
1月前
|
存储 缓存 监控
多级缓存
【10月更文挑战第24天】多级缓存
35 1
|
2月前
|
缓存 监控 算法
小米面试题:多级缓存一致性问题怎么解决
【10月更文挑战第23天】在现代分布式系统中,多级缓存架构因其能够显著提高系统性能和响应速度而被广泛应用。
60 3
|
2月前
|
NoSQL Java Redis
开发实战:使用Redisson实现分布式延时消息,订单30分钟关闭的另外一种实现!
本文详细介绍了 Redisson 延迟队列(DelayedQueue)的实现原理,包括基本使用、内部数据结构、基本流程、发送和获取延时消息以及初始化延时队列等内容。文章通过代码示例和流程图,逐步解析了延迟消息的发送、接收及处理机制,帮助读者深入了解 Redisson 延迟队列的工作原理。
|
3月前
|
缓存 NoSQL 应用服务中间件
SpringCloud基础8——多级缓存
JVM进程缓存、Lua语法、OpenResty、Nginx本地缓存、缓存同步、Canal
SpringCloud基础8——多级缓存