多级缓存

多级缓存是一种通过在请求处理的每个环节添加缓存,使最终到达请求并发数远小于传统方案,达到减轻服务器压力,提升服务性能的目的解决方案。
在实际的项目中,我们通常使用redis作为访问数据库热点数据的缓存,随着数据吞吐量的不断增大,单纯使用redis作为缓存已经很难满足项目需求,这时我们又通过加入本地缓存提升访问效率
多级缓存

本地缓存

本地缓存是指与应用程序同在一个进程的内存空间的缓存,本地缓存的载体是本地内存,没有网络开销,读写速度极快,但是本地缓存会占用大量内存,因而不能存放较多的数据

Caffeine

Caffeine是一个高性能、目前最佳的缓存库,缓存和ConcurrentMap有点相似,但还是有所区别。最根本的区别是ConcurrentMap将会持有所有加入到缓存当中的元素,直到它们被从缓存当中手动移除。但是Caffeine的缓存Cache 通常会被配置成自动驱逐缓存中元素,以限制其内存占用
Caffeine提供了多种特性的缓存:

  • 自动加载/异步加载元素到缓存当中
  • 当达到最大容量的时候可以使用基于W-TinyLFU算法进行基于容量的驱逐
  • 将根据缓存中的元素上一次访问或者被修改的时间进行基于过期时间的驱逐
  • 当向缓存中一个已经过时的元素进行访问的时候将会进行异步刷新
  • key将自动被弱引用所封装
  • value将自动被弱引用或者软引用所封装
  • 驱逐(或移除)缓存中的元素时将会进行通知
  • 写入传播到一个外部数据源当中
  • 持续计算缓存的访问统计指标

springboot 2用Caffeine取代了谷歌的guava cache,足以见得Caffeine性能的优越
下面是它的并发读测试结果
caffeine-read

版本

2.x 要求JDK8+, 3.x要求JDK11+

        <dependency>
            <groupId>com.github.ben-manes.caffeine</groupId>
            <artifactId>caffeine</artifactId>
            <version>2.9.3</version>
        </dependency>

常见用法

构建

Cache<Key, Value> cache = Caffeine.newBuilder()
     //写入后过期时间
    .expireAfterWrite(10, TimeUnit.MINUTES)
    //读取后过期时间
    .expireAfterAccess(3, TimeUnit.SECONDS)
    //最小容量
    .initialCapacity(50_000)
    //最大容量
    .maximumSize(10_000)
    //打开统计
    .recordStats()
    //过期元素监听器
    .evictionListener((Key key, Value value, RemovalCause cause) ->
         System.out.printf("Key %s was evicted (%s)%n", key, cause))
    //移除元素监听器
    .removalListener((Key key, Value value, RemovalCause cause) ->
         System.out.printf("Key %s was removed (%s)%n", key, cause))
    .build();

操作

// 查找一个缓存元素, 没有查找到的时候返回null
Value value = cache.getIfPresent(key);
// 查找缓存,如果缓存不存在则生成缓存元素,  如果无法生成则返回null
Value value = cache.get(key, k -> createExpensiveValue(key));
// 添加或者更新一个缓存元素
cache.put(key, value);
// 移除一个缓存元素
cache.invalidate(key);

方案

我们构建一个基本的缓存,数据访问3秒后过期,访问不存在的数据会调用update/batchUpdate方法查询

Cache<Key, Value> cache = Caffeine.newBuilder()
     .initialCapacity(10)
     .recordStats()
     .expireAfterAccess(3, TimeUnit.SECONDS)
     .build(new CacheLoader<Key, Value>() {

           @Nullable
           @Override
           public Value load(@NonNull Key key) throws Exception {
              System.out.println("update key:"+key);
              return update(key);
          }

           @Override
           public @NonNull Map<Key , Value> loadAll(@NonNull Iterable<? extends Key > keys) throws Exception {
               System.out.println("batch update keys:"+keys);
               return batchUpdate(keys);
                    }
                });

但是这段代码存在一个问题,对于过期的key我们可以读取到正确到value,但是对于被大量访问的数据,不会触发过期,因而也不会进行更新,导致数据的一致性偏差过大
这时我们可以调用api提供的定时更新方法

.refreshAfterWrite(10,TimeUnit.SECONDS)

需要注意,这个方法并不会在到时间后立即刷新,而是等到这个key再次被查询到的时候刷新
这时又存在一个问题,由于本地缓存是空启动,在程序运行的初期很可能有大量的查询请求打到数据库中,这违背了我们建立本地缓存的初衷,这时,我们可以通过预加载高频访问key(热词),将这部分数据提前存储到本地缓存中

//load step
cache.putAll(batchUpdate(hotWords));

还有一个问题,如果存在高频率访问却不存在库中的词,也会导致频繁进行update,这时我们可以将未命中的词加入到缓存中,使得未命中的高频词也能立刻返回

//get
cache.get(key, (missKey) -> {
            Value value = update(missKey);
            return value== null ? Empty : value;
        })

这时缓存已经可以使用了,在启动时缓存会批量加载热词到缓存中,如果在本地缓存中未命中,会调用update方法进行更新,更新成功返回目标值,更新失败返回空值,在缓存中的元素也会在阈值时间后更新
只是还有一点,数据每次都是单条更新,如果请求并发数极大,效率肯定不如批量更新来的快,特别是对于长期存在缓存中的热词,更新时间极可能重叠,这时我们可以创建一个计时器进行批量刷新

 //update hotwords
 ScheduledThreadPoolExecutor executorService = new ScheduledThreadPoolExecutor(
                1, new ThreadFactoryBuilder().setNameFormat("cache-updater-%d").build());
        executorService.scheduleAtFixedRate(() ->
        {
            List<Key> keys = getHotWords();
            log.debug("cache [{}] update {} row", cache.getKey(), keys.size());
            localCache.invalidateAll(keys);
            cache.getAll(Iterables.toArray(keys, Key.class));
        }, 10, 10, TimeUnit.SECONDS); 

这时一个好用的缓存就创建完成了,缓存既有相当高的命中率,又不会消耗太大的资源

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐