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

🔰 全文字数 : 3K+
🕒 阅读时长 : 6min
📋 关键词汇 : 分布式 / 多级缓存 / Redis / JVM
👉 欢迎关注 : 大摩羯先生[1]

设计背景

概念

  先简单解释下什么是分布式多级缓存,所谓分布式简单理解就是异地跨机房服务应用部署;所谓多级缓存,这里狭义语义指定的是应用服务级别的缓存,通常泛指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进行改造和替换。

参考资料

[1]

https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz=MzkxMDIxMzk5Mw==#wechat_redirect: https://link.juejin.cn/?target=https%3A%2F%2Fmp.weixin.qq.com%2Fmp%2Fprofile_ext%3Faction%3Dhome%26__biz%3DMzkxMDIxMzk5Mw%3D%3D%23wechat_redirect

🔰 全文字数 : 3K+
🕒 阅读时长 : 6min
📋 关键词汇 : 分布式 / 多级缓存 / Redis / JVM
👉 欢迎关注 : 大摩羯先生[1]

设计背景

概念

  先简单解释下什么是分布式多级缓存,所谓分布式简单理解就是异地跨机房服务应用部署;所谓多级缓存,这里狭义语义指定的是应用服务级别的缓存,通常泛指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进行改造和替换。

参考资料

[1]

https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz=MzkxMDIxMzk5Mw==#wechat_redirect: https://link.juejin.cn/?target=https%3A%2F%2Fmp.weixin.qq.com%2Fmp%2Fprofile_ext%3Faction%3Dhome%26__biz%3DMzkxMDIxMzk5Mw%3D%3D%23wechat_redirect

🔰 全文字数 : 3K+
🕒 阅读时长 : 6min
📋 关键词汇 : 分布式 / 多级缓存 / Redis / JVM
👉 欢迎关注 : 大摩羯先生[1]

设计背景

概念

  先简单解释下什么是分布式多级缓存,所谓分布式简单理解就是异地跨机房服务应用部署;所谓多级缓存,这里狭义语义指定的是应用服务级别的缓存,通常泛指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进行改造和替换。

参考资料

[1]

https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz=MzkxMDIxMzk5Mw==#wechat_redirect: https://link.juejin.cn/?target=https%3A%2F%2Fmp.weixin.qq.com%2Fmp%2Fprofile_ext%3Faction%3Dhome%26__biz%3DMzkxMDIxMzk5Mw%3D%3D%23wechat_redirect


原文始发于微信公众号(大摩羯先生):实战干货 | 分布式多级缓存设计方案

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/125440.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!