高并发服务设计——缓存

1 缓存回收策略

1.1 基于空间

即设置缓存的存储空间,如设置为10MB,当达到存储空间时,按照一定的策略移除数据。

1.2 基于容量

基于容量指缓存设置了最大大小,当缓存的条目超过最大大小,则按照一定的策略将旧数据移除。

1.3 基于时间

TTL(Time To Live):存活期,即缓存数据从缓存中创建时间开始直到它到期的一个时间段(不管在这个时间段内有没有访问都将过期)。

TTI(Time To Idle):空闲期,即缓存数据多久没被访问过将从缓存中移除的时间。

1.4 基于Java对象引用

软引用:如果一个对象是软引用,那么当JVM堆内存不足时,垃圾回收器可以回收这些对象。软引用适合用来做缓存,从而当JVM堆内存不足时,可以回收这些对象腾出一些空间供强引用对象使用,从而避免OOM。

弱引用:当垃圾回收器回收内存时,如果发现弱引用,则将立即回收它。相对于软引用有更短的生命周期。

注意:弱引用/软引用对象只有当没有其他强引用对象引用它时,垃圾回收时才回收该引用。
即如果有一个对象(不是弱引用/软引用)引用了弱引用/软引用对象,那么垃圾回收是不会回收该引用对象。

1.5 回收算法

使用基于空间和基于容量的缓存会使用一定的策略移除旧数据,常见的如下:

  • FIFO(Fisrt In Fisrt Out):先进先出算法,即先进入缓存的先被移除。
  • LRU(Least Recently Used):最近最少使用算法,使用时间距离现在最久的数据被移除。
  • LFU(Least Frequently Used):最不常用算法,一定时间段内使用次数(频率)最少的数据被移除。

实际应用中基于LRU的缓存较多,如Guava Cache、EhCache支持LRU。

2 Java缓存类型

2.1 堆缓存

使用Java堆内存来存储对象。可以使用Guava Cache、Ehcache 3.x、MapDB实现。

  • 优点:使用堆缓存的好处是没有序列化/反序列化,是最快的缓存;
  • 缺点:很明显,当缓存的数据量很大时, GC暂停时间会变长,存储容量受限于堆空间大小;一般通过软引用/弱引用来存储缓存对象,即当堆内存不足时,可以强制回收这部分内存释放堆内存空间。一般使用堆缓存存储较热的数据。

2.2 堆外缓存

即缓存数据存储在堆外内存。可以使用Ehcache 3.x、MapDB实现。

  • 优点:可以减少GC暂停时间(堆对象转移到堆外,GC扫描和移动的对象变少了),可以支持更大的缓存空间(只受机器内存大小限制,不受堆空间的影响)。
  • 缺点:读取数据时需要序列化/反序列化,会比堆缓存慢很多。

2.3 磁盘缓存

即缓存数据的存储在磁盘上。当JVM重启时数据还是在的。而堆缓存/堆外缓存重启时数据会丢失,需要重新加载。可以使用Ehcache 3.x、MapDB实现。

2.4 分布式缓存

在多JVM实例的情况时,进程内缓存和磁盘缓存会存在两个问题:1.单机容量问题; 2.数据一致性问题(既然数据允许缓存,则表示允许一定时间内的不一致,因此可以设置缓存数据的过期时间来定期更新数据); 3.缓存不命中时,需要回源到DB/服务查询变多:每个实例在缓存不命中情况下都会回源到DB加载数据,因此,多实例后DB整体的访问量就变多了。解决办法可以使用如一致性哈希分片算法来解决。因此,这些情况可以考虑使用分布式缓存来解决。可以使用ehcache-clustered(配合Terracotta server)实现Java进程间分布式缓存。当然也可以使用如Redis实现分布式缓存。

两种模式如下:

  • 单机时:存储最热的数据到堆缓存,相对热的数据到堆外缓存,不热的数据存到磁盘缓存。
  • 集群时:存储最热的数据到堆缓存,相对热的数据到堆外缓存,全量数据存到分布式缓存。

3 Java缓存实现

3.1 堆缓存

3.1.1 Guava Cache实现

Guava Cache只提供堆缓存,小巧灵活,性能最好,如果只使用堆缓存,那么使用它就够了。

Cache myCache=
        CacheBuilder.newBuilder()
        .concurrencyLevel(4)
        .expireAfterWrite(10, TimeUnit.SECONDS)
        .maximumSize(10000)
        .build();

然后可以通过put、getIfPresent 来读写缓存。CacheBuilder有几类参数:缓存回收策略、并发设置等。

3.1.1.1 缓存回收策略/基于容量

maximumSize:设置缓存的容量,当超出maximumSize时,按照LRU进行缓存回收。

3.1.1.2 缓存回收策略/基于时间

  • expireAfterWrite:设置TTL,缓存数据在给定的时间内没有写(创建/覆盖)时,则被回收,即定期的会回收缓存数据。
  • expireAfterAccess:设置TTI,缓存数据在给定的时间内没有读/写时,则被回收。每次访问时,都会更新它的TTI,从而如果该缓存是非常热的数据,则将一直不过期,可能会导致脏数据存在很长时间(因此,建议设置expireAfterWrite)。

3.1.1.3 缓存回收策略/基于Java对象引用

weakKeys/weakValues:设置弱引用缓存。
softValues:设置软引用缓存。

3.1.1.4 缓存回收策略/主动失效

invalidate(Object key)/invalidateAll(Iterablekeys)/invalidateAll():主动失效某些缓存数据。

什么时候触发失效呢? Guava Cache不会在缓存数据失效时立即触发回收操作(如果要这么做,则需要有额外的线程来进行清理),是在PUT时会主动进行一次清理缓存,当然读者也可以根据实际业务通过自己设计线程来调用cleanUp方法进行清理。

3.1.1.5 并发级别

concurrencyLevel:Guava Cache重写了ConcurrentHashMap,concurrencyLevel用来设置Segment数量,concurrencyLevel越大并发能力越强。

3.1.1.6 统计命中率

recordStats:启动记录统计信息,比如命中率等

3.1.2 EhCache 3.x实现

CacheManager cacheManager = CacheManagerBuilder. newCacheManagerBuilder(). build(true);
CacheConfigurationBuilder cacheConfig= CacheConfigurationBuilder.newCacheConfigurationBuilder(
       String.class,
       String.class,
       ResourcePoolsBuilder.newResourcePoolsBuilder()
               .heap(100, EntryUnit.ENTRIES))
       .withDispatcherConcurrency(4)
       .withExpiry(Expirations.timeToLiveExpiration(Duration.of(10,TimeUnit.SECONDS)));

Cache myCache = cacheManager.createCache("myCache",cacheConfig);

CacheManager在JVM关闭时请调用CacheManager.close()方法。 可以通过PUT、GET来读写缓存。CacheConfigurationBuilder也有几类参数:缓存回收策略、并发设置、统计命中率等。

3.1.2.1 缓存回收策略/基于容量

heap(100, EntryUnit.ENTRIES):设置缓存的条目数量,当超出此数量时按照LRU进行缓存回收。

3.1.2.2 缓存回收策略/基于空间

heap(100, MemoryUnit.MB):设置缓存的内存空间,当超出此空间时按照LRU进行缓存回收。另外,应该设置withSizeOfMaxObjectGraph(2):统计对象大小时对象图遍历深度和withSizeOfMaxObjectSize(1, MemoryUnit.KB):可缓存的最大对象大小。

3.1.2.3 缓存回收策略/基于时间

withExpiry(Expirations.timeToLiveExpiration(Duration.of(10,TimeUnit.SECONDS))):设置TTL,没有TTI。
withExpiry(Expirations.timeToIdleExpiration(Duration.of(10,TimeUnit.SECONDS))):同时设置TTL和TTI,且TTL和TTI值一样。

3.1.2.4 缓存回收策略/主动失效

remove(K key)/ removeAll(Set keys)/clear():主动失效某些缓存数据。
什么时候触发失效呢?EhCache使用了类似于Guava Cache同样的机制。

3.1.2.5 并发级别

目前还没有提供API来设置,EhCache内部使用ConcurrentHashMap作为缓存存储,默认并发级别16。withDispatcherConcurrency是用来设置事件分发时的并发级别。

3.1.3 MapDB 3.x 实现

HTreeMap myCache =
       DBMaker.heapDB().concurrencyScale(16).make().hashMap("myCache")
       .expireMaxSize(10000)
       .expireAfterCreate(10, TimeUnit.SECONDS)
       .expireAfterUpdate(10,TimeUnit.SECONDS)
       .expireAfterGet(10, TimeUnit.SECONDS)
       .create();

然后可以通过PUT、GET来读写缓存。其有几类参数:缓存回收策略、并发设置、统计命中率等。

3.1.3.1 缓存回收策略/基于容量

expireMaxSize:设置缓存的容量,当超出expireMaxSize时,按照LRU进行缓存回收。

3.1.3.2 缓存回收策略/基于时间

  • expireAfterCreate/expireAfterUpdate:设置TTL,缓存数据在给定的时间内没有写(创建/覆盖)时,则被回收。即定期的会回收缓存数据。
  • expireAfterGet:设置TTI, 缓存数据在给定的时间内没有读/写时,则被回收。每次访问时都会更新它的TTI,从而如果该缓存是非常热的数据,则将一直不过期,可能会导致脏数据存在很长的时间(因此,建议要设置expireAfterCreate/expireAfterUpdate)。

3.1.3.3 缓存回收策略/主动失效

  • remove(Object key) /clear():主动失效某些缓存数据。
    什么时候触发失效呢?
    MapDB默认使用类似于Guava Cache的机制。不过,也支持可以通过如下配置使用线程池定期进行缓存失效。
  • expireExecutor(scheduledExecutorService)
  • expireExecutorPeriod(3000)

3.1.3.4 并发级别

concurrencyScale:类似于Guava Cache配置。

还可以使用DBMaker.memoryDB()创建堆缓存,它将数据序列化并存储到1MB大小的byte[]数组中,从而减少垃圾回收的影响。

3.2 堆外缓存

3.2.1 EhCache 3.x实现

CacheConfigurationBuilder cacheConfig= CacheConfigurationBuilder.newCacheConfigurationBuilder(
       String.class,
       String.class,
       ResourcePoolsBuilder.newResourcePoolsBuilder()
               .offheap(100, MemoryUnit.MB))
       .withDispatcherConcurrency(4)
       .withExpiry(Expirations.timeToLiveExpiration(Duration.of(10,TimeUnit.SECONDS)))
       .withSizeOfMaxObjectGraph(3)
       .withSizeOfMaxObjectSize(1, MemoryUnit.KB);

堆外缓存不支持基于容量的缓存过期策略。

3.2.2 MapDB 3.x实现

HTreeMap myCache =
       DBMaker.memoryDirectDB().concurrencyScale(16).make().hashMap("myCache")
       .expireStoreSize(64 * 1024 * 1024) //指定堆外缓存大小64MB
       .expireMaxSize(10000)
       .expireAfterCreate(10, TimeUnit.SECONDS)
       .expireAfterUpdate(10, TimeUnit.SECONDS)
       .expireAfterGet(10, TimeUnit.SECONDS)
       .create();

在使用堆外缓存时,请记得添加JVM启动参数,如-XX:MaxDirectMemorySize=10G。

3.3 磁盘缓存

3.3.1 EhCache 3.x实现

CacheManager cacheManager = CacheManagerBuilder. newCacheManagerBuilder()
        //默认线程池
        .using(PooledExecutionServiceConfigurationBuilder.newPooledExecutionServiceConfigurationBuilder().defaultPool("default",1, 10).build())
        //磁盘文件存储位置
        .with(new CacheManagerPersistenceConfiguration(newFile("D:\\bak")))
        .build(true);

CacheConfigurationBuilder cacheConfig= CacheConfigurationBuilder. newCacheConfigurationBuilder(
       String.class,
       String.class,
       ResourcePoolsBuilder.newResourcePoolsBuilder()
       .disk(100, MemoryUnit.MB,true))       //磁盘缓存
       .withDiskStoreThreadPool("default", 5) //使用"default"线程池进行dump文件到磁盘
       .withExpiry(Expirations.timeToLiveExpiration(Duration.of(50,TimeUnit.SECONDS)))
       .withSizeOfMaxObjectGraph(3)
       .withSizeOfMaxObjectSize(1, MemoryUnit.KB);

在JVM停止时,记得调用cacheManager.close(),从而保证内存数据能dump到磁盘。

3.3.2 MapDB 3.x实现

DB db = DBMaker
        .fileDB("D:\\bak\\a.data")//数据存哪里
        .fileMmapEnable() //启用mmap
        .fileMmapEnableIfSupported() //在支持的平台上启用mmap
        .fileMmapPreclearDisable() //让mmap文件更快
        .cleanerHackEnable() //一些BUG处理
        .transactionEnable() //启用事务
        .closeOnJvmShutdown()
        .concurrencyScale(16)
        .make();

HTreeMap myCache = db.hashMap("myCache")
       .expireMaxSize(10000)
       .expireAfterCreate(10, TimeUnit.SECONDS)
       .expireAfterUpdate(10, TimeUnit.SECONDS)
       .expireAfterGet(10, TimeUnit.SECONDS)
       .createOrOpen();

因为开启了事务,MapDB则开启了WAL。另外,操作完缓存后记得调用db.commit方法提交事务。

myCache.put("key" + counterWriter,"value" + counterWriter);
db.commit();

3.4 分布式缓存

3.4.1 Ehcache 3.1 + Terracotta Server

不建议使用。

3.4.2 Redis

性能非常好,有主从模式、集群模式。

3.5 多级缓存

如先查找堆缓存,如果没有查找磁盘缓存,则使用MapDB可以通过如下配置实现。

HTreeMap diskCache = db.hashMap("myCache")
       .expireStoreSize(8 * 1024 * 1024 * 1024)
       .expireMaxSize(10000)
       .expireAfterCreate(10, TimeUnit.SECONDS)
       .expireAfterUpdate(10, TimeUnit.SECONDS)
       .expireAfterGet(10, TimeUnit.SECONDS)
       .createOrOpen();

HTreeMap heapCache = db.hashMap("myCache")
       .expireMaxSize(100)
       .expireAfterCreate(10, TimeUnit.SECONDS)
       .expireAfterUpdate(10, TimeUnit.SECONDS)
       .expireAfterGet(10, TimeUnit.SECONDS)
       .expireOverflow(diskCache) //当缓存溢出时存储到disk
       .createOrOpen();

4 缓存使用模式

主要分两大类:Cache-Aside和Cache-As-SoR(Read-through、Write-through、Write-behind)

  • SoR(system-of-record):记录系统,或者可以叫做数据源,即实际存储原始数据的系统。
  • Cache:缓存,是SoR的快照数据,Cache的访问速度比SoR要快,放入Cache的目的是提升访问速度,减少回源到SoR的次数。
  • 回源:即回到数据源头获取数据,Cache没有命中时,需要从SoR读取数据,这叫做回源。

4.1 Cache-Aside

Cache-Aside 即业务代码围绕着Cache写,是由业务代码直接维护缓存,示例代码如下所示。

4.1.1 读场景

先从缓存获取数据,如果没有命中,则回源到SoR并将源数据放入缓存供下次读取使用。

//1、先从缓存中获取数据
value = myCache.getIfPresent(key);
if(value == null) {
    //2.1、如果缓存没有命中,则回源到SoR获取源数据
    value = loadFromSoR(key);
    //2.2、将数据放入缓存,下次即可从缓存中获取数据
    myCache.put(key, value);
}

4.1.2 写场景

先将数据写入SoR,写入成功后立即将数据同步写入缓存。

//1、先将数据写入SoR
writeToSoR(key,value);
//2、执行成功后立即同步写入缓存
myCache.put(key, value);

或者先将数据写入SoR,写入成功后将缓存数据过期,下次读取时再加载缓存。

//1、先将数据写入SoR
writeToSoR(key,value);
//2、失效缓存,然后下次读时再加载缓存
myCache.invalidate(key);

Cache-Aside适合使用AOP模式去实现

4.2 Cache-As-SoR

Cache-As-SoR即把Cache看作为SoR,所有操作都是对Cache进行,然后Cache再委托给SoR进行真实的读/写。即业务代码中只看到Cache的操作,看不到关于SoR相关的代码。有三种实现:read-through、write-through、write-behind。

4.2.1 Read-Through

Read-Through,业务代码首先调用Cache,如果Cache不命中由Cache回源到SoR,而不是业务代码(即由Cache读SoR)。使用Read-Through模式,需要配置一个CacheLoader组件用来回源到SoR加载源数据。Guava Cache和Ehcache 3.x都支持该模式。

4.2.1.1 Guava Cache实现

LoadingCache> getCache =
       CacheBuilder.newBuilder()
       .softValues()
       .maximumSize(5000).expireAfterWrite(2, TimeUnit.MINUTES)
        .build(new CacheLoader>() {
           @Override
           public Result load(final Integer sortId) throwsException {
                return categoryService.get(sortId);
           }
       });

在build Cache时,传入一个CacheLoader用来加载缓存,操作流程如下:

  • 应用业务代码直接调用getCache.get(sortId)。
  • 首先查询Cache,如果缓存中有,则直接返回缓存数据。
  • 如果缓存没有命中,则委托给CacheLoader,CacheLoader会回源到SoR查询源数据(返回值必须不为null,可以包装为Null对象),然后写入缓存。

使用CacheLoader后有几个好处:

  • 应用业务代码更简洁了,不需要像Cache-Aside模式那样缓存查询代码和SoR代码交织在一起。如果缓存使用逻辑散落在多处,则使用这种方式很简单的消除了重复代码。
  • 解决Dog-pile effect,即当某个缓存失效时,又有大量相同的请求没命中缓存,从而同时请求到后端,导致后端压力太大,此时限制一个请求去拿即可。

4.2.1.2 Ehcache 3.x实现

CacheManager cacheManager = CacheManagerBuilder. newCacheManagerBuilder(). build(true);
org.ehcache.Cache myCache =cacheManager. createCache ("myCache",
       CacheConfigurationBuilder.newCacheConfigurationBuilder(String.class,String.class,
               ResourcePoolsBuilder.newResourcePoolsBuilder().heap(100,MemoryUnit.MB))
               .withDispatcherConcurrency(4)
               .withExpiry(Expirations.timeToLiveExpiration(Duration.of(10,TimeUnit.SECONDS)))
                .withLoaderWriter(newDefaultCacheLoaderWriter () {
                   @Override
                   public String load(String key) throws Exception {
                        return readDB(key);
                   }
                    @Override
                   public Map loadAll(Iterable keys) throws BulkCacheLoadingException, Exception {
                        return null;
                   }
               }));

Ehcache 3.1没有自己去解决Dog-pile effect。

4.2.2 Write-Through

Write-Through,称之为穿透写模式/直写模式,业务代码首先调用Cache写(新增/修改)数据,然后由Cache负责写缓存和写SoR,而不是业务代码。

使用Write-Through模式需要配置一个CacheWriter组件用来回写SoR。Guava Cache没有提供支持。Ehcache 3.x支持该模式。

Ehcache需要配置一个CacheLoaderWriter,CacheLoaderWriter知道如何去写SoR。当Cache需要写(新增/修改)数据时,首先调用CacheLoaderWriter来同步(立即)到SoR,成功后会更新缓存。

CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder().build(true);
Cache myCache =cacheManager.createCache ("myCache",
       CacheConfigurationBuilder.newCacheConfigurationBuilder(String.class,String.class,
               ResourcePoolsBuilder.newResourcePoolsBuilder().heap(100,MemoryUnit.MB))
               .withDispatcherConcurrency(4)
               .withExpiry(Expirations.timeToLiveExpiration(Duration.of(10,TimeUnit.SECONDS)))
               .withLoaderWriter(newDefaultCacheLoaderWriter () {
                   @Override
                   public void write(String key, String value) throws Exception{
                        //write
                   }
                   @Override
                   public void writeAll(Iterable> entries) throws BulkCacheWritingException,Exception {
                        for(Object entry: entries) {
                            //batch write
                        }
                   }
                   @Override
                    public void delete(Stringkey) throws Exception {
                        //delete
                   }
                   @Override
                   public void deleteAll(Iterablekeys) throws BulkCacheWritingException, Exception {
                        for(Object key :keys) {
                            //batch delete
                        }
                   }
               }).build());

Ehcache 3.x还是使用CacheLoaderWriter来实现,通过write(String key, String value)、writeAll(Iterable> entries)和delete(String key)、deleteAll(Iterable keys)分别来支持单个写、批量写和单个删除、批量删除操作。

操作流程如下:当我们调用myCache.put(“e”,”123”)或者myCache.putAll(map)时,写缓存。首先,Cache会将写操作立即委托给CacheLoaderWriter#write和#writeAll,然后由CacheLoaderWriter负责立即去写SoR。当写SoR成功后,再写入Cache。

4.2.3 Write-Behind

Write-Behind,也叫Write-Back,称之为回写模式,不同于Write-Through是同步写SoR和Cache,Write-Behind是异步写。异步之后可以实现批量写、合并写、延时和限流。

4.2.3.1 异步写

略,可用EhCache实现

4.2.3.2 批量写

略,可用EhCache实现

4.2.4 Copy Pattern

有两种Copy Pattern, Copy-On-Read和Copy-On-Write。在Guava-Cache和EhCache中堆缓存都是基于引用的,这样如果哟人拿到缓存数据并修改了它,则可能发生不可预测的问题。Guava Cache没有提供支持,EhCache 3.x提供了支持。

public interface Copier<T> {
    T copyForRead(T obj);    //Copy-On-Read,比如myCache.get()
    T copyForWrite(T obj);   //Copy-On-Write,比如myCache.put()
}

参考来源:
[1] 亿级流量网站架构核心技术.张开涛著

你可能感兴趣的:(缓存,读书笔记,Just,Coding)