高并发服务缓存的设计思路

作者 | 薛章鹰
来自 | CSDN

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<String, String> 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<String, String> cacheConfig= CacheConfigurationBuilder.newCacheConfigurationBuilder(
       String.class,
       String.class,
       ResourcePoolsBuilder.newResourcePoolsBuilder()
               .heap(100, EntryUnit.ENTRIES))
       .withDispatcherConcurrency(4)
       .withExpiry(Expirations.timeToLiveExpiration(Duration.of(10,TimeUnit.SECONDS)));

Cache<String, String> 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<String, String> 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<String, String> 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<Integer,Result<Category>> getCache =
       CacheBuilder.newBuilder()
       .softValues()
       .maximumSize(5000).expireAfterWrite(2, TimeUnit.MINUTES)
        .build(new CacheLoader<Integer,Result<Category>>() {
           @Override
           public Result<Category> 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<String, String> 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<String, String> () {
                   @Override
                   public String load(String key) throws Exception {
                        return readDB(key);
                   }
                    @Override
                   public Map<String, String> loadAll(Iterable<? extendsString> 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<String, String> 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<String, String> () {
                   @Override
                   public void write(String key, String value) throws Exception{
                        //write
                   }
                   @Override
                   public void writeAll(Iterable<? extends Map.Entry<? extendsString, ? extends String>> entries) throws BulkCacheWritingException,Exception {
                        for(Object entry: entries) {
                            //batch write
                        }
                   }
                   @Override
                    public void delete(Stringkey) throws Exception {
                        //delete
                   }
                   @Override
                   public void deleteAll(Iterable<? extends String>keys) 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] 亿级流量网站架构核心技术. 张开涛著