SpringBoot 2,用200行代码完成一个一二级分布式缓存

缓存系统的用来代替直接访问数据库,用来提升系统性能,减小数据库负载。早期缓存跟系统在一个虚拟机里,这样内存访问,速度最快。 后来应用系统水平扩展,缓存作为一个独立系统存在,如redis,但是每次从缓存获取数据,都还是要通过网络访问才能获取,效率相对于早先从内存里获取,还是不够逆天快。如果一个应用,比如传统的企业应用,一次页面显示,要访问数次redis,那效果就不是特别好,性能不够快不说,还容易使得Reids负载过高,Redis的主机出现各种物理故障。因此,现在有人提出了一二级缓存。即一级缓存跟系统在一个虚拟机内,这样速度最快。二级缓存位于redis里,当一级缓存没有数据的时候,再从redis里获取,并同步到一级缓存里。这跟CPU的一级缓存,二级缓存是一个道理。当然也面对同样的问题。

缓存概念

Cache 通常有如下组件构成

  • CacheManager,用来创建,管理,管理多个命名唯一的Cache。如可以有组织机构缓存,菜单项的缓存,菜单树的缓存等
  • Cache类似Map那样的Key—Value存储结构,Value部分 通常包含了缓存的对象,通过Key来取得缓存对象
  • 缓存项,存放在缓存里的对象,常常需要实现序列化接口,以支持分布式缓存。
  • Cache存储方式,缓存组件的可以将对象放到内存,也可以是其他缓存服务器,Spring Boot 提供了一个基于ConcurrentMap的缓存,同时也集成了Redis,EhCache 2.x,JCache缓存服务器等
  • 缓存策略,通常Cache 还可以有不同的缓存策略,如设置缓存最大的容量,缓存项的过期时间等
  • 分布式缓存,缓存通常按照缓存数据类型存放在不同缓存服务器上,或者同一类型的缓存,按照某种算法,不同key的数据放在不同的缓存服务器上。
  • Cache Hit,当从Cache中取得期望的缓存项,我们通常称之为缓存命中。如果没有命中我们称之为Cache Miss,意味着需要从数据来源处重新取出并放回Cache中
  • Cache Miss:缓存丢失,根据Key没有从缓存中找到对应的缓存项
  • Cache Evication:缓存清除操作。
  • Hot Data,热点数据,缓存系统能调整算法或者内部存储方式,使得将最有可能频繁访问的数据能尽快访问到。
  • On-Heap,Java分配对象都是在堆内存里,有最快的获取速度。由于虚拟机的垃圾回收管理,缓存放过多的对象会导致垃圾回收时间过长,从而有可能影响性能。
  • Off-Heap,堆外内存,对象存放到在虚拟机分配的堆外内存,因此不受垃圾回收管理的管理,不影响系统系统,但堆外内存的对象要被使用,还要序列化成堆内对象。很多缓存工具会把不常用的对象放到堆外,把热点数据放到堆内。

Spring Boot 缓存

Spring Boot 本身提供了一个基于ConcurrentHashMap 的缓存机制,也集成了EhCache2.x,JCache(JSR-107,EhCache3.x,Hazelcast,Infinispan),还有Couchbase,Redies等。Spring Boot应用通过注解的方式使用统一的使用缓存,只需在方法上使用缓存注解即可,其缓存的具体实现依赖于你选择的目标缓存管理器。如下使用@Cacheable

    @Service
    public class MenuServiceImpl implements MenuService {
        
        @Cacheable("menu")
        public Menu getMenu(Long id) {...}
            
    }

MenuService实例作为一个容器管理bean,Spring将会生成代理类,在实际调用MenuService.getMenu方法前,会调用缓存管理器,取得名"menu"的缓存,此时,缓存的key就是方法参数id,如果缓存命中,则返回此值,如果没有找到,则进入实际的MenuService.getMenu方法,在返回调用结果给调用者之前,还会将此查询结果缓存以备下次使用。

集成Spring cache

集成Spring Cache,只需要在pom中使用如下依赖

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

如果你使用Spring自带的内存的缓存管理器,需要在appliaction.properties里配置属性

spring.cache.type=Simple

Simple只适合单机应用或者开发环境使用或者是一个小微系统,通常你的应用是分布式应用,Spring Boot 还支持集成更多的缓存服务器。

  • simple: 基于ConcurrentHashMap实现的缓存,适合单机或者开发环境使用。

  • none:关闭缓存,比如开发阶段先确保功能正确,可以先禁止使用缓存

  • redis:使用redis作为缓存,你还需要在pom里增加redis依赖。本章缓存将重点介绍redis缓存以及扩展redis实现一二级缓存

  • Generic,用户自定义缓存实现,用户需要实现一个org.springframework.cache.CacheManager的实现

  • 其他还有JCache,EhCache 2.x,Hazelcast等,为了保持本书的简单,将不在这里一一介绍。

最后,需要使用注解 @EnableCaching 打开缓存功能。

@SpringBootApplication
@EnableCaching
public class Ch14Application {
  public static void main(String[] args) {
    SpringApplication.run(Ch14Application.class, args);
  }
}

实现Redis 俩级缓存

SpringBoot自带的Redis缓存非常容易使用,但由于通过网络访问了Redis,效率还是比传统的跟应用部署在一起的一级缓存略慢。本章中,扩展RedisCacheManager和RedisCache,在访问Redis之前,先访问一个ConcurrentHashMap实现的简单一级缓存,如果有缓存项,则返回给应用,如果没有,再从Redis里取,并将缓存对象放到一级缓存里

当缓存项发生变化的时候,注解@CachePut 和 @CacheEvict会触发RedisCache的put( Object key, Object value)和evict(Object key)操作,俩级缓存需要同时更新ConcurrentHashMap和Redis缓存,且需要通过Redis的Pub发出通知消息,其他Spring Boot应用通过Sub来接收消息,同步更新Spring Boot应用自身的一级缓存。

为了简单起见,一级缓并没有缓存过期策略,用户系统如果会有大量数据需要放到一级缓存,需要再次扩展这里的代码,比如使用LRUHashMap代替Map

实现 TowLevelCacheManager

首先,创建创建一个新的缓存管理器,命名为TowLevelCacheManager,继承了Spring Boot的RedisCacheManager,重载decorateCache方法。返回的是我们新创建的LocalAndRedisCache 缓存实现。

class TowLevelCacheManager extends RedisCacheManager {
    RedisTemplate redisTemplate;
    public TowLevelCacheManager(RedisTemplate redisTemplate,RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) {
        super(cacheWriter,defaultCacheConfiguration);
        this.redisTemplate = redisTemplate;
    }
    //使用RedisAndLocalCache代替Spring Boot自带的RedisCache
    @Override
    protected Cache decorateCache(Cache cache) {
        return new RedisAndLocalCache(this, (RedisCache) cache);
    }

  public void publishMessage(String cacheName) {
    this.redisTemplate.convertAndSend(topicName, cacheName);
  }
  // 接受一个消息清空本地缓存
  public void receiver(String name) {
    RedisAndLocalCache cache = ((RedisAndLocalCache) this.getCache(name));
    if(cache!=null){
      cache.clearLocal();
    }
  }

}

在Spring Cache中,在缓存管理器创建好每个缓存后,都会调用decorateCache方法,这样缓存管理器子类有机会实现自己的扩展,在这段代码,返回了自定义的RedisAndLocalCache实现。 publishMessage方法提供个给Cache,用于当缓存更新的时候,使用Redis的消息机制通知其他分布式节点的一级别缓存。receiver方法对应于publishMessage方法,当收到消息后,会清空一节缓存。

创建RedisAndLocalCache

RedisAndLocalCache 是我们系统的核心,他实现了Cache接口,类,会实现如下操作。

  • get操作,通过Key取对应的缓存项,在调用父类RedisCache之前,会先检测本地缓存是否存在,存在则不需要调用父类的get操作。如果不存在,调用父类的get操作后,将Redis返回的ValueWrapper放到本地缓存里待下次用。
  • put,调用父类put操作更新Redis缓存,同时广播消息,缓存改变。我们将在下一章讲如何使用Redis的Pub/Subscribe 来同步缓存
  • evict ,同put操作一样,调用父类处理,清空对应的缓存,同时广播消息
  • putIfAbsent,同put操作一样,调用父类实现,同时广播消息

RedisAndLocalCache 的构造如下

class RedisAndLocalCache implements Cache {
  // 本地缓存提供
  ConcurrentHashMap<Object, Object> local = new ConcurrentHashMap<Object, Object>();
  RedisCache redisCache;
  TowLevelCacheManager cacheManager;

  public RedisAndLocalCache(TowLevelCacheManager cacheManager, RedisCache redisCache) {
    this.redisCache = redisCache;
    this.cacheManager = cacheManager;
  }

  @Override
  public String getName() {
    return redisCache.getName();
  }

  @Override
  public Object getNativeCache() {
    return redisCache.getNativeCache();
  }

  //其他get put evict方法参考后面代码到吗片段说明
}

如上代码所示,RedisAndLocalCache 实现了Cache接口,并使用了真正的RedisCache作为其实现方法。其关键的get和put方法如下

@Override
public ValueWrapper get(Object key) {
  // 一级缓存先取
  ValueWrapper wrapper = (ValueWrapper) local.get(key);
  if (wrapper != null) {
    return wrapper;
  } else {
    // 二级缓存取
    wrapper = redisCache.get(key);
    if (wrapper != null) {
      local.put(key, wrapper);
    }
    return wrapper;
  }
}

@Override
public void put(Object key, Object value) {
  System.out.println(value.getClass().getClassLoader());
  redisCache.put(key, value);
  //通知其他节点缓存更新
  clearOtherJVM();
}
@Override
public void evict(Object key) {
  redisCache.evict(key);
  //通知其他节点缓存更新
  clearOtherJVM();
}
protected void clearOtherJVM() {
    cacheManager.publishMessage(redisCache.getName());
}
// 提供给CacheManager清空一节缓存
public void clearLocal() {
  this.local.clear();
}

变量local代表了一个简单的缓存实现, 使用了ConcurrentHashMap。其get方法有如下逻辑实现

  • 通过key从本地取出 ValueWrapper
  • 如果ValueWrapper存在,则直接返回
  • 如果ValueWrapper不存在,则调用父类RedisCache取得缓存项
  • 如果缓存项为空,则说明暂时无此项,直接返回空,等@Cacheable 调用业务方法获取缓存项

put方法实现逻辑如下

  • 先调用redisCache,更新二级缓存

  • 调用clearOtherJVM方法,通知其他节点缓存更新

  • 其他节点(包括本节点)的TowLevelCacheManager收到消息后,会调用receiver方法从而实现一级缓存

  • 为了简单起见,一级缓存的同步更新 仅仅是清空一级缓存而并非采用同步更新缓存项。一级缓存将在下一次get方法调用时会再次从Reids里加载最新数据。

  • 一节缓存仅仅简单使用了Map实现,并未实现缓存的多种策略。因此,如果你的一级缓存如果需要各种缓存策略,还需要用一些第三方库或者自行实现,但大部分情况下TowLevelCacheManager都足够使用

缓存同步说明

​ 当缓存发生改变的时候,需要通知分布式系统的TowLevelCacheManager的,清空一级缓存.这里使用Redis实现消息通知,关于Redis消息发布和订阅,参考Redis一章。

为了实现Redis的Pub/Sub 模式,我们需要在CacheConfig里添加一些代码,创建一个消息监听器

//定义一个redis 的频道,默认叫cache,用于pub/sub
@Value("${springext.cache.redis.topic:cache}")
String topicName;
@Bean
RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,
                                        MessageListenerAdapter listenerAdapter) {
  RedisMessageListenerContainer container = new RedisMessageListenerContainer();
  container.setConnectionFactory(connectionFactory);
  container.addMessageListener(listenerAdapter, new PatternTopic(topicName));
  return container;
}

如上所示,需要配置文件配置 springext.cache.redis.topic,指定一个频道的名字,如果没有配置,默认的频道名称是cache。

配置一个监听器很简单,只需要实现MessageListenerAdapter,并注册到RedisMessageListenerContainer即可。

MessageListenerAdapter 需要实现onMessage方法,我们只需要获取消息内容,这里是指要清空的缓存名字,然后交给MyRedisCacheManager 来处理即可

@Bean
MessageListenerAdapter listenerAdapter(final TowLevelCacheManager cacheManager) {
  return new MessageListenerAdapter(new MessageListener() {
    public void onMessage(Message message, byte[] pattern) {
      byte[] bs = message.getChannel();
      try {
        //Sub 一个消息,通知缓存管理器,这里的type就是Cache的名字
        String type = new String(bs, "UTF-8");
        cacheManager.receiver(type);
      } catch (UnsupportedEncodingException e) {
        e.printStackTrace();
        // 不可能出错,忽略
      }
    }
  });
}

将代码组合在一起

前三节分别实现了缓存管理器,缓存,还有缓存之间的同步,现在需要将缓存管理器配置为应用的缓存管理器,通过搭配@Configuration和@Bean实现

@Configuration
public class CacheConfig {
  @Bean
  public TowLevelCacheManager cacheManager(RedisTemplate redisTemplate) {
    //RedisCache需要一个RedisCacheWriter来实现读写Redis
    RedisCacheWriter writer = RedisCacheWriter.lockingRedisCacheWriter(redisTemplate.getConnectionFactory());
    /*SerializationPair用于Java和Redis之间的序列化和反序列化,我们这里使用自带的JdkSerializationRedisSerializer,并在反序列化过程中,使用当前的ClassLoader*/
    SerializationPair pair = SerializationPair.fromSerializer(new JdkSerializationRedisSerializer(this.getClass().getClassLoader()));
    /*构造一个RedisCache的配置,比如是否使用前缀,比如Key和Value的序列化机制(*/
    RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig().serializeValuesWith(pair);
    /*创建CacheManager,并返回给Spring 容器*/
    TowLevelCacheManager cacheManager = new TowLevelCacheManager(redisTemplate,writer,config);
    return cacheManager;
  }
}

构造一个TowLevelCacheManager较为复杂,这是因为构造RedisCacheManager复杂导致的,构造RedisCacheManager需要如下俩个参数

  • RedisCacheWriter,一个实现Redis操作的接口,SpringBoot提供了NoLock和Lock俩种实现,在缓存写操作的时候,前者有较高性能,而后者实现了Redis锁。
  • RedisCacheConfiguration 用于设置缓存特性,比如缓存项目的TTL(存活时间),缓存Key的前缀等,默认情况是TTL为0,不使用前缀。你可以为缓存管理器设置默认的配置,也可以为每一个缓存设置一个配置。
    最为重要的配置是SerializationPair,用于Java和Redis的序列化和反序列化操作,这里我们使用我们这里使用自带的JdkSerializationRedisSerializer作为序列化机制,这个类在Reids一章有详细介绍。

如上代码实现了一二级缓存,行数不到200行代码。相对于自带的RedisCache来说,缓存效率更高。相对于专业的一二级缓存服务器来说,如Ehcache+Terracotta组合,更加轻量级

最后,本博客节选了我的书 <Spring Boot 2精髓:从构建小系统到架构分布式大系统>, 此例子可以直接从gitee上下载 https://gitee.com/xiandafu/Spring-Boot-2.0-Samples
欢迎反馈
我的博客即将搬运同步至腾讯云+社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270