[缓存] 缓存技术初探

1 背景

使用场景:计算或检索一个值的代价很高,并且对同样的输入需要不止一次获取值的时候,就应当考虑使用缓存。

高并发下,为提高 频繁 查询 大量 可能常用的 数据库数据的 查询效率。

大部分情况下,单机用Google Guava(Cache/LoadCache) / ehcache,分布式用redismemcache,各有各的好处,现在企业都是应用很多种中间件供后端程序员选择。

2 缓存技术

什么是缓存?

1 - Cache是高速缓冲存储器 一种特殊的存储器子系统,其中复制了频繁使用的数据以利于快速访问
2 - 凡是位于速度相差较大的两种硬件/软件之间的,用于协调两者数据传输速度差异的结构,均可称之为 Cache
3 - 缓存技术设计思想: 典型的空间换时间

2-1 分类

  • 操作系统磁盘缓存(加速/减少磁盘机械操作) / 数据库缓存(加速/减少访问文件系统I/O) / 【应用程序缓存】(加快/减少对数据库的查询) / Web服务器缓存(加速/减少应用服务器请求) / 浏览器缓存(加速/减少对网站的访问)

  • 分布式缓存 / 本地缓存

  • 介质: 基于内存缓存 / 基于磁盘缓存 / 基于中间件[数据库]缓存(Redis/Memcache/...,本质:内存+磁盘) / 基于JVM缓存(本质:基于内存)

2-2 缓存开源组件

OSCache / Java Caching System(JCS) / / JCache / ShiftOne / SwarmCache / TreeCache / JBossCache / WhirlyCache

EHCache
Google Guava(核心类: Cache/LoadingCache;内存/JVM/本地缓存; Spring5之后,官方放弃Guava改用Caffeine)
Caffeine

2-3 缓存的指标

  • 命中率
  • 最大容量
  • 清空策略(过期策略)

先进先出算法(FIFO)
first in first out ,最先进入缓存得数据在缓存空间不够情况下(超出最大元素限制时)会被首先清理出去

最不经常使用算法(LFU)
Less Frequently Used ,一直以来最少被使用的元素会被被清理掉。这就要求缓存的元素有一个hit 属性,在缓存空间不够得情况下,hit 值最小的将会被清出缓存

最近最少使用算法(LRU)
Least Recently Used ,最近最少使用的,缓存的元素有一个时间戳,当缓存容量满了,而又需要腾出地方来缓存新的元素的时候,那么现有缓存元素中时间戳离当前时间最远的元素将被清出缓存

最近最常使用算法(MRU)
这个缓存算法最先移除最近最常使用的条目。一个MRU算法擅长处理一个条目越久,越容易被访问的情况。

自适应缓存替换算法(ARC)
在IBM Almaden研究中心开发,这个缓存算法同时跟踪记录LFU和LRU,以及驱逐缓存条目,来获得可用缓存的最佳使用。

2-4 基于JVM缓存的实现方案

  • 方案1: HashMap / CocurrentHashMap
  • 方案2: 开源组件(Google Guava: Cache / LoadingCache)
Cache/LoadingCache 均继承自 CocurrentHashMap

2-5 缓存产生的问题

  • Q1: 缓存数据与源数据一致性问题(数据同步)
解决方法
1) write back(写回策略): 更新数据源数据时,只更新缓存的数据。当缓存需要被替换(挤出)时,才将缓存中更新的值写回磁盘。
在写回策略中,为了减少写操作,缓存数据单元通常还设有1个脏位(dirty bit),用于标识该块在被载入后,是否发生过更新。
若1个缓存数据单元在被置换回内存之前,从未被写入过,则:可以免去回写操作;
写回的优点是:节省了大量的写操作

2) write through(写通策略): 更新数据源数据时,同时更新缓存的数据。
  • Q2: 缓存数据存放时间问题
  • Q3: 缓存的多线程并发控制问题

3 基于Google Guava开源组件的JVM缓存实现

需求背景: 一项目中多个接口、频繁地批量查询数据库一类数据————发布的数据服务信息,又要求3s内立即做出响应。 (存在高并发问题)

关于 Google Guava 开源缓存组件: google guava中有cache包,此包提供内存缓存功能。内存缓存需要考虑很多问题,包括并发问题,缓存失效机制,内存不够用时缓存释放,缓存的命中率,缓存的移除等等。 当然这些东西guava都考虑到了。
guava中使用缓存需要先声明一个CacheBuilder对象,并设置缓存的相关参数,然后调用其build方法获得一个Cache接口的实例。请看下面的代码和注释,注意在注释中指定了Cache的各个参数。

3-0 Maven依赖

<dependency>
        <groupId>com.google.guava</groupId>
 	<artifactId>guava</artifactId>
 	<version>28.0-jre</version>
</dependency>

3-1 IDataServiceInfoCacheService

package xxx.service;

import com.google.common.cache.LoadingCache;
import com.yyy.DataServiceInfo;

import java.util.Map;

/**
 * @date: 2020/11/12  16:58:05
 * @description: 缓存数据服务信息
 */

public interface IDataServiceInfoCacheService {
    /**
     * 从缓存中 获取 数据服务信息
     *  若缓存中不存在该信息,将自动从数据库中加载,再返回
     * @param serviceId
     * @return
     * @throws Exception
     */
    public DataServiceInfo get(String serviceId) throws Exception;

    //public void put(String serviceId, DataServiceInfo dataServiceInfo);

    //public void putAll(Map<? extends String, ? extends DataServiceInfo> dataServiceInfoMap);

    public long size();

    public void remove(String serviceId);

    public void removeAll(Iterable<Long> serviceIds);

    public void removeAll();
}

3-2 DataServiceCacheServiceImpl

package xxx.service.impl;

import com.xxx..Dept;
import com.xxx.ServerSystem;
import com.xxx.BusinessException;
import com.google.common.cache.*;
import com.xxx.BmsCacheService;
import com.xxx.LoggerUtil;
import com.xxx.Tools;
import com.xxx.DataServiceInfo;
import com.xxx.DataServiceInfoCacheService;
import com.xxx.ServiceInfoMapper;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.PostConstruct;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * @date: 2020/11/12  17:28:03
 * @description: ...
 */
@Service
public class DataServiceCacheServiceImpl implements IDataServiceInfoCacheService {
    private static LoadingCache<String, DataServiceInfo> DATA_SERVICE_INFO_CACHE;

    @Autowired
    private ServiceInfoMapper serviceInfoMapper;
    @Autowired
    private BmsCacheService bmsCacheService;

    @PostConstruct // 解决 【静态变量】初始化时调用【实例方法】问题
    public void init() {
        DATA_SERVICE_INFO_CACHE = CacheBuilder
                .newBuilder() ////CacheBuilder的构造函数是私有的,只能通过其静态方法newBuilder()来获得CacheBuilder的实例
                .concurrencyLevel(8) // //8个segment,分段锁8;设置并发级别为8,并发级别是指可以同时写缓存的线程数
                .expireAfterWrite(120, TimeUnit.SECONDS)// 设置写缓存后120秒钟过期 (缓存在写缓存后的指定时间内没有被新的值覆盖时,将失效) 【expire是指定时间过后,expire是remove该key,下次访问是发起同步请求以返回获取到新值】
                //.expireAfterAccess(120, TimeUnit.SECONDS)// 设置读缓存后120秒钟过期 (缓存在读缓存后的指定时间内没有被读写时,将失效)
                .refreshAfterWrite(120, TimeUnit.SECONDS) // 设置写缓存后120秒钟刷新 【refresh是指定时间后,不会remove该key,下次访问会触发刷新,新值没有回来时返回旧值】
                //.refreshAfterAccess(120, TimeUnit.SECONDS) // 设置读缓存后120秒钟刷新
                //使用弱引用存储键。当没有(强或软)引用到该键时,相应的缓存项将可以被垃圾回收。由于垃圾回收是依赖==进行判断,因此这样会导致整个缓存也会使用==来比较键的相等性,而不是使用equals()
                .weakKeys()
                //使用弱引用存储缓存值。当没有(强或软)引用到该缓存项时,将可以被垃圾回收。由于垃圾回收是依赖==进行判断,因此这样会导致整个缓存也会使用==来比较缓存值的相等性,而不是使用equals()
                .weakValues()
                .initialCapacity(1000)// 设置缓存容器的初始容量为1000
                .maximumSize(10000)// 设置缓存最大容量为10000,超过10000之后就会按照LRU最近虽少使用算法来移除缓存项
                .recordStats()// 设置要统计缓存的命中率
                .removalListener(getRemovalListener())// 设置缓存的移除通知(移除时的触发操作)
                .build(getCacheLoader());// build方法中可以指定CacheLoader,在缓存不存在时通过CacheLoader的实现自动加载缓存
    }

    @Transactional(readOnly = true)
    protected Map<String, Object> getSourceSystemInfo(String serviceId) throws Exception {

        Map<String, Object> data = new HashMap<String, Object>();

        Map<String, Object> provides = serviceInfoMapper.getServiceProvideSystems(serviceId);
        if (provides != null) {
            if (provides.get("provideSystemId") != null) {
                String provideSystemIds = provides.get("provideSystemId").toString();
                String[] provideIds = provideSystemIds.split(",");
                StringBuilder systemNames = new StringBuilder();
                for (String id : provideIds) {
                    ServerSystem sys = bmsCacheService.getSysById(id);
                    if (sys != null) {
                        systemNames.append(sys.getSystemName()).append(",");
                    }
                }
                data.put("provideSystemIds", provideSystemIds);
                if (provideIds.length > 0) {
                    data.put("provideSystemNames", systemNames.deleteCharAt(systemNames.length() - 1));
                }
            }

            if (provides.get("deptId") != null) {
                String deptIds = provides.get("deptId").toString();
                String[] provideIds = deptIds.split(",");
                StringBuilder departNames = new StringBuilder();
                for (String id : provideIds) {
                    Dept dept = bmsCacheService.getDeptById(id);
                    if (dept != null) {
                        departNames.append(dept.getDeptName()).append(",");
                    }
                }
                data.put("provideDepartIds", deptIds);
                if (provideIds.length > 0) {
                    data.put("provideDepartNames", departNames.deleteCharAt(departNames.length() - 1));
                }
            }
        }
        return data;
    }

    @Transactional(readOnly = true)
    protected DataServiceInfo loadDataServiceInfo(String serviceId) throws Exception {
        DataServiceInfo dataServiceInfo = new DataServiceInfo();
        Map<String, Object> sourceSystemInfo = null;
        sourceSystemInfo = this.getSourceSystemInfo(serviceId);
        Map<String, String> serviceAndCatalogInfoMap = null;
        serviceAndCatalogInfoMap = serviceInfoMapper.getServiceInfoAndCatalogInfoById(serviceId);
        if (Tools.isNull(sourceSystemInfo) && Tools.isNull(serviceAndCatalogInfoMap)) {//通过 serviceId,均未查找到 数据服务信息
            String errorMsg = "根据所提供的数据服务编号,未能查找到数据服务信息!";
            LoggerUtil.error(LoggerUtil.DATASERVICE_MNG_CORE_LOGGER_INSTANCE, String.format(errorMsg + " [serviceId: %s]", serviceId));
            throw new BusinessException(errorMsg);
        }
        dataServiceInfo.setServiceId(serviceId);
        if (Tools.isNotNull(sourceSystemInfo)) {
            dataServiceInfo.setProvideDepartIds(Tools.isNotNull(sourceSystemInfo.get("provideDepartIds")) ? sourceSystemInfo.get("provideDepartIds").toString() : "");
            dataServiceInfo.setProvideDepartNames(Tools.isNotNull(sourceSystemInfo.get("provideDepartNames")) ? sourceSystemInfo.get("provideDepartNames").toString() : "");
            dataServiceInfo.setProvideSystemIds(Tools.isNotNull(sourceSystemInfo.get("provideSystemIds")) ? sourceSystemInfo.get("provideSystemIds").toString() : "");
            dataServiceInfo.setProvideSystemNames(Tools.isNotNull(sourceSystemInfo.get("provideSystemNames")) ? sourceSystemInfo.get("provideSystemNames").toString() : "");
        }
        if (Tools.isNotNull(serviceAndCatalogInfoMap)) {
            dataServiceInfo.setCatalogId(Tools.isNotNull(serviceAndCatalogInfoMap.get("catalogId")) ? serviceAndCatalogInfoMap.get("catalogId").toString() : "");
            dataServiceInfo.setCatalogName(Tools.isNotNull(serviceAndCatalogInfoMap.get("catalogName")) ? serviceAndCatalogInfoMap.get("catalogName").toString() : "");
            dataServiceInfo.setServiceName(Tools.isNotNull(serviceAndCatalogInfoMap.get("serviceName")) ? serviceAndCatalogInfoMap.get("serviceName").toString() : "");
            dataServiceInfo.setTableUnicode(Tools.isNotNull(serviceAndCatalogInfoMap.get("tableUnicode")) ? serviceAndCatalogInfoMap.get("tableUnicode").toString() : "");
        }
        return dataServiceInfo;
    }

    private RemovalListener<Object, Object> getRemovalListener() {
        return new RemovalListener<Object, Object>() {
            public void onRemoval(RemovalNotification<Object, Object> removalNotification) {
                String removeLog = removalNotification.getKey() + " was removed, cause is " + removalNotification.getCause();
                LoggerUtil.info(LoggerUtil.DATASERVICE_MNG_CORE_LOGGER_INSTANCE, removeLog);
            }
        };
    }

    private CacheLoader getCacheLoader() {
        return new CacheLoader<String, DataServiceInfo>() {
            @Override
            public DataServiceInfo load(String serviceId) throws Exception {// 处理缓存键不存在缓存值时的重新获取最新缓存值的处理逻辑
                LoggerUtil.info(LoggerUtil.DATASERVICE_MNG_CORE_LOGGER_INSTANCE, "[dataServiceInfoCache] loading dataService is: " + serviceId);
                return loadDataServiceInfo(serviceId);
            }
        };
    }

    @Override
    public DataServiceInfo get(String serviceId) throws Exception {
        DataServiceInfo dataServiceInfo = null;
        dataServiceInfo = DATA_SERVICE_INFO_CACHE.get(serviceId);
        if (Tools.isNull(dataServiceInfo)) {
            dataServiceInfo = loadDataServiceInfo(serviceId);
            if (Tools.isNotNull(dataServiceInfo)) {
                DATA_SERVICE_INFO_CACHE.put(serviceId, dataServiceInfo);
            }
            return dataServiceInfo;
        }
        return dataServiceInfo;
    }

    /**
     * @Override public void put(String serviceId, DataServiceInfo dataServiceInfo) {
     * DATA_SERVICE_INFO_CACHE.put(serviceId, dataServiceInfo);
     * }
     * @Override public void putAll(Map<? extends String, ? extends DataServiceInfo> dataServiceInfoMap) {
     * DATA_SERVICE_INFO_CACHE.putAll(dataServiceInfoMap);
     * }
     */
    @Override
    public long size() {
        return DATA_SERVICE_INFO_CACHE.size();
    }

    @Override
    public void remove(String serviceId) {
        DATA_SERVICE_INFO_CACHE.invalidate(serviceId);
    }

    @Override
    public void removeAll(Iterable<Long> serviceIds) {
        DATA_SERVICE_INFO_CACHE.invalidateAll(serviceIds);
    }

    @Override
    public void removeAll() {
        DATA_SERVICE_INFO_CACHE.invalidateAll();
    }
}

3-3 补充

Guava的其它API

另外Guava还提供了下面一些方法,来方便各种需要:

/** 
 * 该接口的实现被认为是线程安全的,即可在多线程中调用 
 * 通过被定义单例使用 
 */  
public interface Cache<K, V> {

  /** 
   * 通过key获取缓存中的value,若不存在直接返回null 
   */  
  V getIfPresent(Object key);  

  /** 
   * 一次获得多个键的缓存值
   */    
  ImmutableMap<K, V> getAllPresent(Iterable<?> var1);
 
  /** 
   * 获得缓存数据的ConcurrentMap<K, V>快照
   */  
  ConcurrentMap<K, V> asMap()

  /** 
   * 通过key获取缓存中的value,若不存在就通过valueLoader来加载该value 
   * 整个过程为 "if cached, return; otherwise create, cache and return" 
   * 注意valueLoader要么返回非null值,要么抛出异常,绝对不能返回null 
   */  
  V get(K key, Callable<? extends V> valueLoader) throws ExecutionException;  

  /** 
   * 添加缓存,若key存在,就覆盖旧值 
   */  
  void put(K key, V value);  

  /** 
   * 刷新缓存,即重新取缓存数据,更新缓存
   */  
  void refresh(K key) 

  /** 
   * 从缓存中移除缓存项;删除该key关联的缓存 
   */  
  void invalidate(Object key);  

  /** 
   * 从缓存中移除缓存项;删除所有缓存 
   */  
  void invalidateAll();  

  /** 
   * 清理缓存 。执行一些维护操作,包括清理缓存 
   */  
  void cleanUp();  
}

性能测试

  • 配置信息
jdk: 1.8
本机电脑测试:
database: mysql 5.7 / 物理表 32条数据

Guava-Config:
int concurrencyLevel = 8;
long expireAfterWriteTime = 300;
long refreshAfterWriteTime = 600;
int initialCapacity = 100;
int maximumSize = 100;
  • 查询性能测试
    0.125000s --> 0.015000s (0.125/0.015≈8.3)
【首次查询(无缓存)】
Johnny@LAPTOP-RFPOFJM7 MINGW64 /xx/share-portal (dev)
$ curl -so /tmp/sdc-tmp-data/tmpfile.json -w ' namelookup: %{time_namelookup}
>  connect: %{time_connect}
>  appconnect: %{time_appconnect}
>  pretransfer: %{time_pretransfer}
>  redirect: %{time_redirect}
>  starttransfer: %{time_starttransfer}
>  -------
>  total: %{time_total}
> ' http://localhost:18181/backend/access-log/v1/accessStatisticOverview
 namelookup: 0.016000
 connect: 0.016000
 appconnect: 0.000000
 pretransfer: 0.016000
 redirect: 0.000000
 starttransfer: 0.125000
 -------
 total: 0.125000

【第二/三/四/五/六/...次查询(有缓存)】
Johnny@LAPTOP-RFPOFJM7 MINGW64 /xx/share-portal (dev)
$ curl -so /tmp/sdc-tmp-data/tmpfile.json -w ' namelookup: %{time_namelookup}
>  connect: %{time_connect}
>  appconnect: %{time_appconnect}
>  pretransfer: %{time_pretransfer}
>  redirect: %{time_redirect}
>  starttransfer: %{time_starttransfer}
>  -------
>  total: %{time_total}
> ' http://localhost:18181/backend/access-log/v1/accessStatisticOverview
 namelookup: 0.015000
 connect: 0.015000
 appconnect: 0.000000
 pretransfer: 0.015000
 redirect: 0.000000
 starttransfer: 0.015000
 -------
 total: 0.015000 (第二/三/四/五/六/...次查询均为此结果)

X 参考与推荐文献

posted @ 2020-11-13 01:38  千千寰宇  阅读(175)  评论(0编辑  收藏  举报