Java缓存技术大全

267 阅读1小时+

Java缓存技术大全

缓存简介

什么是缓存?

缓存是一种计算机技术,主要用于存储一些经常被访问的数据或者计算结果,以便于下次访问时能够快速获取,它的目的是用空间换取时间,提高系统的性能和效率。

它的工作原理是:当第一次从一个较慢的数据源(比如硬盘或者网络)获取数据时,系统会将这些数据存储在一个更快的设备(比如内存)上,这样下次再访问同样的数据时,就可以直接从缓存中获取,而不需要再次从慢的数据源中获取

缓存可以应用在很多场景,比如网页缓存、CPU缓存、数据库缓存等。通过使用缓存,可以显著提高数据访问的速度,降低系统的负载,提高系统的性能和可用性。

一个小demo演示

import java.util.HashMap;
import java.util.Map;
 
public class CacheExample {
 
    private Map<String, Object> cache = new HashMap<>();
 
    public Object getFromCache(String key) {
        return cache.get(key);
    }
 
    public void addToCache(String key, Object value) {
        cache.put(key, value);
    }
 
    public static void main(String[] args) {
        CacheExample cache = new CacheExample();
 
        // 假设从数据库获取数据或计算结果
        Object dataFromDB = "data_from_db";
 
        // 将数据存入缓存
        cache.addToCache("dataKey", dataFromDB);
 
        // 后续程序中可以直接从缓存获取数据,而不是访问数据库
        Object cachedData = cache.getFromCache("dataKey");
 
        // 输出缓存的数据
        System.out.println(cachedData);
    }
}

在这个例子中,我们创建了一个简单的CacheExample类,它有一个HashMap作为缓存。getFromCache方法用于从缓存中获取数据,addToCache方法用于将数据添加到缓存中。在main方法中,我们演示了缓存的使用,首先将数据存入缓存,然后从缓存中获取数据并打印。这样,我们就可以看到缓存的效果。

缓存的意义

缓存在Java中有着重要的作用,主要体现在以下几个方面:

提高数据访问的速度:缓存通常存储在内存中,访问内存的速度远高于硬盘或者远程服务器。因此,将经常访问的数据缓存起来,可以显著提高程序的运行速度。

降低对原始数据源的访问频率:通过使用缓存,可以减少对数据库或其他原始数据源的访问次数,从而减轻它们的负载。

缓解网络压力:如果数据源位于远程服务器上,频繁地从服务器获取数据会导致大量的网络流量。通过使用缓存,可以将数据本地化,减少对网络的依赖,从而降低网络压力。

提高系统的可用性:当原始数据源出现问题时,缓存中的数据仍然可以被访问,从而提高系统的可用性。

总的来说,Java缓存的主要意义在于优化数据访问,提高程序的运行效率,降低系统的资源消耗。

目前缓存的做法分为两种模式

内存缓存: 缓存数据存放在服务器的内存空间中。

  • 优点: 速度快!
  • 缺点:内存资源有限!

文件缓存: 缓存数据存放在服务器的硬盘空间中。

  • 优点: 容量大。
  • 缺点:速度偏慢,尤其在缓存数量巨大时。

image-20240505161147261

缓存对于当前系统的实际意义

在增删改查中,数据库查询占据了数据库操作的80%以上, 非常频繁的磁盘I/O读取操作,会导致数据库性能极度低下。

而数据库的重要性就不言而喻了:

  • 数据库通常是企业应用系统最核心的部分
  • 数据库保存的数据量通常非常庞大
  • 数据库查询操作通常很频繁,有时还很复杂

我们知道,对于多数Web应用,整个系统的瓶颈在于数据库。

原因很简单,Web应用中的其他因素,例如网络带宽、负载均衡节点、应用服务器(包括CPU、内存、硬盘灯、连接数等)、缓存,都很容易通过水平的扩展(俗称加机器)来实现性能的提高。

而对于MySQL,由于数据一致性的要求,无法通过简单的增加机器来分散向数据库 写数据 带来的压力。虽然可以通过前置缓存(Redis等)、读写分离、分库分表来减轻压力,但是与系统其它组件的水平扩展相比,受到了太多的限制,而切会大大增加系统的复杂性。

因此数据库的连接和读写要十分珍惜。

可能你会想到那就直接用缓存呗,但大量的用、不分场景的用缓存显然是不科学的。我们不能手里有了一把锤子,看什么都是钉子。

但缓存也不是万能的,要慎用缓存,想要用好缓存并不容易。因此我花了点时间整理了一下关于缓存的实现以及常见的一些问题。

image-20240505162029071

Web请求过程

仔细了解一下Web的请求过程,我们可以通过分析完整的一次请求流程从而分析出缓存对于每个节点的作用。

image.png

缓存的工作流程

image.png

缓存策略

设计缓存的时候需要考虑的最关键的两个缓存策略。

  • TTL(Time To Live ) 存活期, 即从缓存中创建时间点开始直到它到期的一个时间段(不管在这个时间段内有没有访问都将过期)
  • TTI(Time To Idle) 空闲期, 即一个数据多久没被访问将从缓存中移除的时间

后面讲到缓存雪崩的时候,会讲到,如果缓存策略设置不当,将会造成如何的灾难性后果,以及如何避免,这里先按下不表。

接下来聊一下将数据存储于内存中的本地缓存,也是最根本,优先级最高的缓存。

本地缓存

本地缓存的数据读写都在一个进程内,相对与redis等分布式缓存,不需要网络传输的过程,访问速度很快,同时也受到JVM内存的制约,无法在数据量较多的场景下使用。

Spring Cache

Spring Cache介绍

因为我们做Java开发基本都是使用的SpringBoot或者以SpringBoot为基础构建的SpringCloud开发框架,而其底层均是基于Spring的所以这里我们优先介绍Spring Cache作为本地缓存实现方案。

Spring Cache是Spring框架提供的一种抽象缓存机制,它可以非常方便地集成各种缓存实现,如EhCache、Redis等,让你可以通过简单的配置和注解就能在你的应用中使用缓存功能。

它支持注解方式使用缓存,非常方便。

Spring Cache的实现本质上依赖了Spring AOP对切面的支持。

Spring Cache的主要对象

CacheManager:用于管理各种Cache,可以通过它获取到具体的Cache。

Cache:表示一个缓存区,提供了基本的缓存操作接口,如put、get、evict等。

Spring Cache的主要注解

@Cacheable:用于标记一个方法的返回结果应被缓存,当下次执行相同参数的方法时,直接从缓存中获取结果。

@CachePut:与@Cacheable类似,但它每次都会触发真实方法的调用,并将结果放入缓存。

@CacheEvict:用于标记一个方法,当它被调用时,会从缓存中移除相应的数据。

简单Demo实例,代码如下:

@Service
public class BookService {

    @Cacheable(value = "books", key = "#isbn")
    public Book findBookByIsbn(String isbn) {
        // 模拟查询数据库的操作
        return new Book(isbn, "Some book");
    }

    @CachePut(value = "books", key = "#book.isbn")
    public Book updateBook(Book book) {
        // 模拟更新数据库的操作
        return book;
    }

    @CacheEvict(value = "books", key = "#isbn")
    public void deleteBookByIsbn(String isbn) {
        // 模拟删除数据库中的数据
    }
}

Spring Cache实战

引入Spring Cache依赖

在你的SpringBoot工程项目下加入如下依赖:

        <!-- Spring Cache依赖 -->
		<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>
		<!-- Lombok依赖 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>
添加开启声明式的缓存的注解
package cn.org.xiaosheng;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;

@SpringBootApplication
@EnableCaching // 添加该自动开启缓存的注解
public class Main {
    public static void main(String[] args) {
        SpringApplication.run(Main.class, args);
    }
}
编写Service,Controller层代码

Service

package cn.org.xiaosheng.service.impl;

import cn.org.xiaosheng.service.CacheService;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
public class CacheServiceImpl implements CacheService {

    @Cacheable(value = "cache",key = "#p0")  // 可以保存该方法的返回值
    public String getCacheById(String id) {
        return id;
    }

    @Override
    @CacheEvict(value = "cache",key = "#id")
    public void deleteCacheById(String id) {

    }

    @Override
    @CachePut(value = "cache",key = "#id") // 可以保存(修改)该方法的返回值
    public String updateCacheById(String id) {
        return id + "###";
    }

    @Override
    @Cacheable(value = "cache_cache",key = "#id") // 可以保存(修改)该方法的返回值
    public String findCacheById(String id) {
        return "我的命名空间是cache_cache" + id;
    }

}

简单解释上述注解里面的含义:

value: 表示命名空间,不同的命名空间是隔离的,根据

key: 表示存储变量key,可以理解为Map的key,每个key可以对应一个值

简单解释上述中用到的几个注解的含义:

@Cacheable: 适用于更新(增加)数据的方法,并将返回的值放入到缓存中

@CacheEvict: 该注解的作用是删除缓存数据

@Cacheable: 使用于新增数据的方法,并将返回值放入到缓存中

Controller

package cn.org.xiaosheng.controller;


import cn.org.xiaosheng.service.CacheService;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.CacheManager;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/cache")
@RequiredArgsConstructor
public class CacheController {

    private final CacheService testCache;
    // 小伙伴们可以自行debug,看看此时是使用了哪一个manager(当没有配置manager时)
    private final CacheManager cacheManager;

    @GetMapping("/getBrownCache")
    public String getBrownCache() {
        System.out.println("进入了getBrownCache请求代码中");
        return "Brown Cache";
    }

    @GetMapping("/getSpringCache")
    public String getSpringCache() {
        System.out.println("进入了getSpringCache请求代码中");
        return "Spring Cache";
    }

    @GetMapping("/{id}")
    public String testCacheById(@PathVariable("id") String id){
        return testCache.getCacheById(id);
    }

    @GetMapping("testFind/{id}")
    public String testFindCacheById(@PathVariable("id") String id){
        return testCache.findCacheById(id);
    }

    @DeleteMapping("/{id}")
    public String testDeleteCacheById(@PathVariable("id") String id){
        testCache.deleteCacheById(id);
        return "okk";
    }

    @PutMapping("/{id}")
    public String testUpdateCacheById(@PathVariable("id") String id){
        return testCache.updateCacheById(id);
    }
}

总结: 当增删改查时,会首先根据命名空间来找到指定的空间,然后再根据key来定位到值

举个例子: 例如上述方法中getCacheById(1)findCacheById(1)当分别调用了该方法时,第一次时,由于没有此空间(缓存名字),会创建一个,当都第二次调用时,一个会在cache空间中找寻key为1的值,一个会在cache_cache中找key为1的值,两者并不干扰。

效果检测

对于缓存的实际效果检测,我们可以使用Debug的模式将应用跑起来,然后在对应地方打断点,通过观看其对象中值的变化达到检测的目的。

这里使用的测试工具是Apifox,第一次调用testCacheById方法时,此时缓存里是没有数据的,也没有我们所需要的空间cache也就是没有命中缓存,此时会走后续代码,并把返回值放入缓存中。需要走具体逻辑如图所示:

image-20240505221451201

但当我们第二次以相同的参数调用该方法时可以看到:给我们创建了命名空间,并且在该空间下可以找到我们的key,此时也就不会执行后续的逻辑,直接返回缓存中的值。

image-20240505221540586

当我们换一个参数请求时,比如id = 100,此时cache中没有key为100的,就会在缓存中添加,当我们第二次调用时就可以命中缓存

image-20240505221654177

此时小伙伴就想了,那我们要是调用了findCacheById命名空间是cache_cache跟上述cache不同会是什么样子的呢?

image-20240505221814286

当我们调用删除testDeleteCacheById方法时,会删除指定value命名空间里面对应的key,例如:当我们删除在cache命名空间中key为100的,结果会是如何呢?

image-20240505221903010

此时大家可以清晰的看见,当调用了该方法时,相对应的命名空间中的对应的key就会被删除了。

这时候如果大家调用了testUpdateCacheById(100)方法时,就会发现此时相对应的key又出现了,此时@CachePut就起到了新增的作用。如图:

image-20240505221949222

当我们没有配置默认的cacheManager时,spring给我们默认的使用了ConcurrentMapCacheManager来实现了CacheManager接口,当然我们也可以手动的(显示)的配置其缓存管理器。

@Configuration
public class CacheConfig {
    @Bean
    // 显示的配置了采用什么缓存管理器
    public CacheManager cacheManager(){
        ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager();
        return cacheManager;
    }
}

Google Guava Cache

Guava Cache简述

Guava是Google提供的一套Java工具包,而Guava Cache是一套非常完善的本地缓存机制(JVM缓存)。Guava cache的设计来源于CurrentHashMap,可以按照多种策略来清理存储在其中的缓存值且保持很高的并发读写性能。

Google Guava Cache是一个全内存的本地缓存实现,它提供了线程安全的实现机制。需要注意的是,Guava Cache是本地缓存,不支持分布式缓存的应用。

guava cache的主要应用场景为以下几种:

  • 对于访问速度有较大要求
  • 存储的数据不经常变化
  • 数据量不大,占用内存较小
  • 需要访问整个集合
  • 能够容忍数据不是实时的

Guava Cache回收机制

既然是缓存,那么总会存在没有足够的内存缓存所有数据。Cache提供三大类回收机制。

基于容量回收

如果要规定缓存项的数目不超过固定值,只需使用CacheBuilder.maximumSize(long)]。缓存将尝试回收最近没有使用或总体上很少使用的缓存项。——警告:在缓存项的数目达到限定值之前,缓存就可能进行回收操作——通常来说,这种情况发生在缓存项的数目逼近限定值时。

基于定时回收

CacheBuilder提供两种定时回收的方法:

expireAfterAccess(long, TimeUnit):缓存项在给定时间内没有被读/写访问,则回收。请注意这种缓存的回收顺序和基于大小回收一样。 expireAfterWrite(long, TimeUnit):缓存项在给定时间’'内没有被写访问(创建或覆盖),则回收。如果认为缓存数据总是在固定时候后变得陈旧不可用,这种回收方式是可取的。

基于引用类型回收

通过使用弱引用的键、或弱引用的值、或软引用的值,Guava Cache可以把缓存设置为允许垃圾回收:

CacheBuilder.weakKeys():使用弱引用存储键。当键没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式(),使用弱引用键的缓存用而不是equals比较键。 CacheBuilder.weakValue():使用弱引用存储值。当值没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式(),使用弱引用值的缓存用而不是equals比较值。 CacheBuilder.softValues():使用软引用存储值。软引用只有在响应内存需要时,才按照全局最近最少使用的顺序回收。考虑到使用软引用的性能影响,我们通常建议使用更有性能预测性的缓存大小限定(见上文,基于容量回收)。使用软引用值的缓存同样用==而不是equals比较值。

Guava Cache实战

引入GuavaCache依赖

Guava Cache的使用非常简单,首先需要引入maven包:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>18.0</version>
</dependency>
通过CacheLoader方式初始化创建
package cn.org.xiaosheng.common.config;


import cn.org.xiaosheng.service.CacheService;
import cn.org.xiaosheng.service.impl.CacheServiceImpl;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;

import javax.annotation.Resource;
import java.util.Map;
import java.util.concurrent.TimeUnit;

@Configuration
public class GuavaCacheConfig {

   public static CacheService cacheService() {
       return new CacheServiceImpl();
   }

    /**
     * 在这个例子中,我们首先使用CacheBuilder创建了一个LoadingCache,并设置了一些参数,如最大容量、写后过期时间、开启统计信息等。然后我们定义了CacheLoader来加载缓存数据。
     */
    public static final LoadingCache<String, String> loadingCache = CacheBuilder.newBuilder()
            .maximumSize(100) // 设置缓存的最大容量
            .expireAfterWrite(10, TimeUnit.MINUTES) // 设置缓存在写入10分钟后失效
            .recordStats() // 开启缓存统计
            .build(new CacheLoader<String, String>() { // 设置缓存的加载方式
                @Override
                public String load(String key) {
                    return cacheService().getDataFromDatabase(key);
                }

                @Override
                public Map<String, String> loadAll(Iterable<? extends String> keys) throws Exception {
                    return super.loadAll(keys);
                }
            });

    public static final Cache<String, String> cache = CacheBuilder.newBuilder()
            .maximumSize(100) // 设置缓存的最大容量
            .expireAfterWrite(10, TimeUnit.MINUTES) // 设置缓存在写入10分钟后失效
            .recordStats() // 开启缓存统计
            .build();

    @Bean
    public LoadingCache<String, String> guavaLoadingCache() {
        return loadingCache;
    }

    @Bean
    public Cache guavaCache() {
        return cache;
    }
}
编写Controller,Service层代码

service层

public String getDataFromDatabase(String key) {
    // 这里是模拟从数据库中加载数据
    return "Data for " + key;
}

Controller层

    @GetMapping("/GuavaCache/{key}")
    public String getGuavaCache(@PathVariable("key") String key) throws ExecutionException {
        // 方便自定义每次的获取逻辑, 原理跟CacheLoader.load()一致。 如果缓存中不存在,则调用callable()来进行获取放入缓存中。
        String s = cache.get(key, new Callable<String>() {
            @Override //回调方法用于读源并写入缓存
            public String call() throws Exception {
                // 缓存不存在则查询数据库
                String dataFromDatabase = cacheService.getDataFromDatabase(key);
                // 写回缓存
                loadingCache.put(key, dataFromDatabase);
                // 显示写回缓存, 当key存在时不进行操作,key不存在时进行插入操作
//                loadingCache.asMap().putIfAbsent(key, dataFromDatabase);
                return dataFromDatabase;
            }
        });
        return s;
    }

    @PostMapping("/GuavaCache/{key}")
    public String getGuavaLoadingCache(@PathVariable("key") String key) throws ExecutionException {
        // 直接去缓存中取用,若不存在会根据配置的数据获取方法进行获取到缓存中
        String s = loadingCache.get(key);
        return s;
    }
显示插入

使用cache.put(key, value)方法可以直接向缓存中插入值,这会直接覆盖掉给定键之前映射的值。使用Cache.asMap()视图提供的任何方法也能修改缓存。但请注意,asMap视图的任何方法都不能保证缓存项被原子地加载到缓存中。进一步说,asMap视图的原子运算在Guava Cache的原子加载范畴之外,所以相比于Cache.asMap().putIfAbsent(K, V),Cache.get(K, Callable)应该总是优先使用。 即更多的依赖自动加载而不是手动的进行添加。

asMap视图

asMap视图提供了缓存的ConcurrentMap形式。

cache.asMap()包含当前所有加载到缓存的项。因此相应地,cache.asMap().keySet()包含当前所有已加载键; asMap().get(key)实质上等同于cache.getIfPresent(key),而且不会引起缓存项的加载。这和Map的语义约定一致。

所有读写操作都会重置相关缓存项的访问时间,包括Cache.asMap().get(Object)方法和Cache.asMap().put(K, V)方法,但不包括Cache.asMap().containsKey(Object)方法,也不包括在Cache.asMap()的集合视图上的操作。比如,遍历Cache.asMap().entrySet()不会重置缓存项的读取时间。

元素移除监听器 removalListener

通过CacheBuilder.removalListener(RemovalListener)你可以声明一个监听器,以便缓存项被移除时做一些额外操作。

    /**
     * 移除监听器
     */
    static RemovalListener<String, String> MY_LISTENER_01 = removal -> {
        System.out.println(removal + "被移除了");
    };

    /**
     * 异步移除监听器  需要添加异步执行的线程池
     */
    static RemovalListener<String, String> MY_LISTENER_02=RemovalListeners.asynchronous(removal -> {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(removal + "被异步移除了");
    }, Executors.newSingleThreadExecutor());

image-20240506132900659

元素被动移除
基于数据大小的被动删除
LoadingCache<String,Object> cache= CacheBuilder.newBuilder()
        /*
            加附加的功能
        */
        //最大个数
        .maximumSize(3)
        .build(new CacheLoader<String, Object>() {
    //读取数据源
    @Override
    public Object load(String key) throws Exception {
        return Constants.hm.get(key);
    }
});

移除规则是:

  • 规则:LRU+FIFO
  • 访问次数一样少的情况下,FIFO
基于过期时间的删除

隔多长时间后没有被访问过的key被删除

.expireAfterAccess(3, TimeUnit.SECONDS)

写入多长时间后过期

//等同于expire ttl 缓存中对象的生命周期就是3秒
.maximumSize(3).expireAfterWrite(3, TimeUnit.SECONDS)
基于引用的删除

可以通过weakKeys和weakValues方法指定Cache只保存对缓存记录key和value的弱引用。这样当没有其他强引用指向key和value时,key和value对象就会被垃圾回收器回收。

LoadingCache<String,Object> cache = CacheBuilder.newBuilder()
        // 最大3个 值的弱引用
        .maximumSize(3).weakValues()
        .build(
);
 		Object v = new Object();
        cache.put("1", v);
 
        v = new Object(); //原对象不再有强引用
        //强制垃圾回收
        System.gc();

当原对象不再有强引用指向的时候,key和value对象就会被垃圾回收期回收。

元素的主动删除
单独删除
cache.invalidate(key)
// 将key=1 删除
cache.invalidate("1");
批量删除
// 将key=1和2的删除
cache.invalidateAll(Arrays.asList("1","2"));
全量删除
// 清空缓存
cache.invalidateAll();
并发设置

GuavaCache通过设置 concurrencyLevel 使得缓存支持并发的写入和读取

LoadingCache<String,Object> cache = CacheBuilder.newBuilder()
    // 最大3个 同时支持CPU核数线程写缓存
    .maximumSize(3).concurrencyLevel(Runtime.getRuntime().availableProcessors()).build();

concurrencyLevel=Segment数组的长度

同ConcurrentHashMap类似Guava cache的并发也是通过分离锁实现

V get(K key, CacheLoader<? super K, V> loader) throws ExecutionException {
    int hash = this.hash(Preconditions.checkNotNull(key));
    //通过hash值确定该key位于哪一个segment上,并获取该segment
    return this.segmentFor(hash).get(key, hash, loader);
}

​ LoadingCache采用了类似ConcurrentHashMap的方式,将映射表分为多个segment。segment之间可以并发访问,这样可以大大提高并发的效率,使得并发冲突的可能性降低了。

更新锁定

GuavaCache提供了一个refreshAfterWrite定时刷新数据的配置项。 如果经过一定时间没有更新或覆盖,则会在下一次获取该值的时候,会在后台异步去刷新缓存。 刷新时只有一个请求回源取数据,其他请求会阻塞(block)在一个固定时间段,如果在该时间段内没有获得新值则返回旧值。

LoadingCache<String,Object> cache = CacheBuilder.newBuilder()
    // 最大3个 同时支持CPU核数线程写缓存
    .maximumSize(3).concurrencyLevel(Runtime.getRuntime().availableProcessors())
    //3秒内阻塞会返回旧数据
    .refreshAfterWrite(3,TimeUnit.SECONDS).build();

Guava Cache疑难问题

GuavaCache会oom(内存溢出)吗

会,当我们设置缓存永不过期(或者很长),缓存的对象不限个数(或者很大)时,比如:

Cache<String, String> cache = CacheBuilder.newBuilder()
    .expireAfterWrite(100000, TimeUnit.SECONDS)
    .build();

不断向GuavaCache加入大字符串,最终将会oom

解决方案:缓存时间设置相对小些,使用弱引用方式存储对象

Cache<String, String> cache = CacheBuilder.newBuilder()
    .expireAfterWrite(1, TimeUnit.SECONDS)
    .weakValues().build();
Guava Cache缓存到期就会立即清楚吗

不是的,GuavaCache是在每次进行缓存操作的时候,如get()或者put()的时候,判断缓存是否过期

void evictEntries(ReferenceEntry<K, V> e) {
    drainRecencyQueue();
    while ((e = writeQueue.peek()) != null && map.isExpired(e, now)) {
        if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) {
            throw new AssertionError();
        }
    }
    while ((e = accessQueue.peek()) != null && map.isExpired(e, now)) {
        if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) {
            throw new AssertionError();
        }
    }
}

一个如果一个对象放入缓存以后,不在有任何缓存操作(包括对缓存其他key的操作),那么该缓存不会主动过期的。

Guava Cache如何找出最久未使用的数据

用accessQueue,这个队列是按照LRU的顺序存放的缓存对象(ReferenceEntry)的。会把访问过的对象放到队列的最后。 并且可以很方便的更新和删除链表中的节点,因为每次访问的时候都可能需要更新该链表,放入到链表的尾部。 这样,每次从access中拿出的头节点就是最久未使用的。 对应的writeQueue用来保存最久未更新的缓存队列,实现方式和accessQueue一样。

根据源码分析Guava Cache的存储原理

guava cache的数据结构跟ConcurrentHashMap类似,二者最基本的区别是ConcurrentMap会一直保存所有添加的元素,直至将添加的元素移除。相对地,guava cache为了限制内存占用,通常都设定为自动回收元素。

guava cache的核心类为LocalCache,LocalCache实现了ConcurrentMap接口。其中有一个Segment数组,如下所示

image-20240506153150577

获取数据的方法源码如下图所示,可以看出guava cache的存储原理为由Segment数组加上ReferenceEntry链表加上AtomicReferenceArray数组组成的数据结构。

image-20240506153250833

image-20240506153328714

image-20240506153343354

数据结构图如下所示:

image-20240506153409650

Segement数组的长度决定了cache的并发数。每一个Segment都继承了ReentrantLock,使用了单独的锁,对Segment的写操作需要先拿到锁。写操作部分源码如下所示:

img

image-20240506153542497

总体来说,Guava Cache是一款十分优异的缓存工具,功能丰富,线程安全,足以满足工程化使用,以上代码只介绍了一般的用法,实际上springboot对guava也有支持,利用配置文件或者注解可以轻松集成到代码中。

Caffeine

Caffeine简介

官网:https://github.com/ben-manes/caffeine/wiki/Home-zh-CN

Caffeine是一个由Google开发的,基于Java 8的高性能,全内存的缓存库。Caffeine提供了高效的缓存策略,包括自动加载、定时过期、引用过期、及基于大小和权重的驱逐策略。此外,Caffeine还提供了详细的缓存统计,可以方便地查看缓存的命中率、加载时间等信息。

Caffeine是基于java8实现的新一代缓存工具,缓存性能接近理论最优。可以看作是Guava Cache的增强版,功能上两者类似,不同的是Caffeine采用了一种结合LRU、LFU优点的算法:W-TinyLFU,在性能上有明显的优越性。

Caffeine是由Guava改进而来,而且在Spring5开始的默认缓存实现就将Caffeine代替原来的Google Guava,官方说明指出,其缓存命中率已经接近最优值。实际上Caffeine这样的本地缓存和ConcurrentMap很像,即支持并发,并且支持O(1)时间复杂度的数据存取。二者的主要区别在于:

  • ConcurrentMap将存储所有存入的数据,直到你显式将其移除;
  • Caffeine将通过给定的配置,自动移除“不常用”的数据,以保持内存的合理占用。

因此,一种更好的理解方式是:Cache是一种带有存储和移除策略的Map。

image-20240506155633276

Caffeine实战

引入Maven依赖
        <dependency>
            <groupId>com.github.ben-manes.caffeine</groupId>
            <artifactId>caffeine</artifactId>
            <!--https://mvnrepository.com/artifact/com.github.ben-manes.caffeine/caffeine找最新版-->
            <version>3.1.8</version>
        </dependency>
Cache手动创建

Cache是最普通的一种缓存,无需指定加载方式,需要手动调用put()进行加载。需要注意的是put()方法对于已存在的key将进行覆盖,这点和Map的表现是一致的。在获取缓存值时,如果想要在缓存值不存在时,原子地将值写入缓存,则可以调用get(key, k -> value)方法,该方法将避免写入竞争。调用invalidate()方法,将手动移除缓存。

在多线程情况下,当使用get(key, k -> value)时,如果有另一个线程同时调用本方法进行竞争,则后一线程会被阻塞,直到前一线程更新缓存完成;而若另一线程调用getIfPresent()方法,则会立即返回null,不会被阻塞。

Cache<Object, Object> cache = Caffeine.newBuilder()
          //初始数量
          .initialCapacity(10)
          //最大条数
          .maximumSize(10)
          //expireAfterWrite和expireAfterAccess同时存在时,以expireAfterWrite为准
          //最后一次写操作后经过指定时间过期
          .expireAfterWrite(1, TimeUnit.SECONDS)
          //最后一次读或写操作后经过指定时间过期
          .expireAfterAccess(1, TimeUnit.SECONDS)
          //监听缓存被移除
          .removalListener((key, val, removalCause) -> { })
          //记录命中
          .recordStats()
          .build();

  cache.put("1","张三");
  //张三
  System.out.println(cache.getIfPresent("1"));
  //存储的是默认值
  System.out.println(cache.get("2",o -> "默认值"));
  // 移除一个缓存元素
  cache.invalidate("key");
  System.out.println(cache.getIfPresent("key")); // null
Loading Cache自动创建

LoadingCache是一种自动加载的缓存。其和普通缓存不同的地方在于,当缓存不存在/缓存已过期时,若调用get()方法,则会自动调用CacheLoader.load()方法加载最新值。调用getAll()方法将遍历所有的key调用get(),除非实现了CacheLoader.loadAll()方法。使用LoadingCache时,需要指定CacheLoader,并实现其中的load()方法供缓存缺失时自动加载。 在多线程情况下,当两个线程同时调用get(),则后一线程将被阻塞,直至前一线程更新缓存完成。

    private static final Cache<String, String> LoadingCache = Caffeine.newBuilder()
            .maximumSize(100)
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .build(new CacheLoader<String, String>() {
                @Override
                public String load(String key) {
                    return cacheService().getDataFromDatabase(key);
                }
            });

  // 查找缓存,如果缓存不存在则生成缓存元素,  如果无法生成则返回null
    String value = cache.get("key");
    System.out.println(value);
    // 批量查找缓存,如果缓存不存在则生成缓存元素
    Map<String, String> values = cache.getAll(Stream.of("key1", "key2").collect(Collectors.toList()));
    System.out.println(values);

}

private static String createData(String key) {
	System.out.println("createData + " + key);
    return key + "value";
}

AsyncCache异步获取

一个AsyncCache 是 Cache 的一个变体,AsyncCache提供了在 Executor上生成缓存元素并返回 CompletableFuture的能力。这给出了在当前流行的响应式编程模型中利用缓存的能力。

synchronous()方法给 Cache提供了阻塞直到异步缓存生成完毕的能力。

当然,也可以使用 AsyncCache.asMap()所暴露出来的ConcurrentMap的方法对缓存进行操作。

默认的线程池实现是 ForkJoinPool.commonPool() ,当然你也可以通过覆盖并实现 Caffeine.executor(Executor)方法来自定义你的线程池选择。

AsyncCache<Key, Graph> cache = Caffeine.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .maximumSize(10_000)
    .buildAsync();

// 查找一个缓存元素, 没有查找到的时候返回null
CompletableFuture<Graph> graph = cache.getIfPresent(key);
// 查找缓存元素,如果不存在,则异步生成
graph = cache.get(key, k -> createExpensiveGraph(key));
// 添加或者更新一个缓存元素
cache.put(key, graph);
// 移除一个缓存元素
cache.synchronous().invalidate(key);
AsyncLoadingCache异步获取

AsyncCache是Cache的一个变体,其响应结果均为CompletableFuture,通过这种方式,AsyncCache对异步编程模式进行了适配。默认情况下,缓存计算使用ForkJoinPool.commonPool()作为线程池,如果想要指定线程池,则可以覆盖并实现Caffeine.executor(Executor)方法。synchronous()提供了阻塞直到异步缓存生成完毕的能力,它将以Cache进行返回。

在多线程情况下,当两个线程同时调用get(key, k -> value),则会返回同一个CompletableFuture对象。由于返回结果本身不进行阻塞,可以根据业务设计自行选择阻塞等待或者非阻塞。

AsyncLoadingCache<String, String> asyncLoadingCache = Caffeine.newBuilder()
        //创建缓存或者最近一次更新缓存后经过指定时间间隔刷新缓存;仅支持LoadingCache
        .refreshAfterWrite(1, TimeUnit.SECONDS)
        .expireAfterWrite(1, TimeUnit.SECONDS)
        .expireAfterAccess(1, TimeUnit.SECONDS)
        .maximumSize(10)
        //根据key查询数据库里面的值
        .buildAsync(key -> {
            Thread.sleep(1000);
            return new Date().toString();
        });

//异步缓存返回的是CompletableFuture
CompletableFuture<String> future = asyncLoadingCache.get("1");
future.thenAccept(System.out::println);

驱逐策略

驱逐策略在创建缓存的时候进行指定。常用的有基于容量的驱逐和基于时间的驱逐。

基于容量的驱逐需要指定缓存容量的最大值,当缓存容量达到最大时,Caffeine将使用LRU策略对缓存进行淘汰;基于时间的驱逐策略如字面意思,可以设置在最后访问/写入一个缓存经过指定时间后,自动进行淘汰。

驱逐策略可以组合使用,任意驱逐策略生效后,该缓存条目即被驱逐。

  • LRU 最近最少使用,淘汰最长时间没有被使用的页面。
  • LFU 最不经常使用,淘汰一段时间内使用次数最少的页面
  • FIFO 先进先出

Caffeine有4种缓存淘汰设置

  • 大小 (LFU算法进行淘汰)
  • 权重 (大小与权重 只能二选一)
  • 时间
  • 引用 (不常用,本文不介绍)
@Slf4j
public class CacheTest {
    /**
     * 缓存大小淘汰
     */
    @Test
    public void maximumSizeTest() throws InterruptedException {
        Cache<Integer, Integer> cache = Caffeine.newBuilder()
                //超过10个后会使用W-TinyLFU算法进行淘汰
                .maximumSize(10)
                .evictionListener((key, val, removalCause) -> {
                    log.info("淘汰缓存:key:{} val:{}", key, val);
                })
                .build();

        for (int i = 1; i < 20; i++) {
            cache.put(i, i);
        }
        Thread.sleep(500);//缓存淘汰是异步的

        // 打印还没被淘汰的缓存
        System.out.println(cache.asMap());
    }

    /**
     * 权重淘汰
     */
    @Test
    public void maximumWeightTest() throws InterruptedException {
        Cache<Integer, Integer> cache = Caffeine.newBuilder()
                //限制总权重,若所有缓存的权重加起来>总权重就会淘汰权重小的缓存
                .maximumWeight(100)
                .weigher((Weigher<Integer, Integer>) (key, value) -> key)
                .evictionListener((key, val, removalCause) -> {
                    log.info("淘汰缓存:key:{} val:{}", key, val);
                })
                .build();

        //总权重其实是=所有缓存的权重加起来
        int maximumWeight = 0;
        for (int i = 1; i < 20; i++) {
            cache.put(i, i);
            maximumWeight += i;
        }
        System.out.println("总权重=" + maximumWeight);
        Thread.sleep(500);//缓存淘汰是异步的

        // 打印还没被淘汰的缓存
        System.out.println(cache.asMap());
    }


    /**
     * 访问后到期(每次访问都会重置时间,也就是说如果一直被访问就不会被淘汰)
     */
    @Test
    public void expireAfterAccessTest() throws InterruptedException {
        Cache<Integer, Integer> cache = Caffeine.newBuilder()
                .expireAfterAccess(1, TimeUnit.SECONDS)
                //可以指定调度程序来及时删除过期缓存项,而不是等待Caffeine触发定期维护
                //若不设置scheduler,则缓存会在下一次调用get的时候才会被动删除
                .scheduler(Scheduler.systemScheduler())
                .evictionListener((key, val, removalCause) -> {
                    log.info("淘汰缓存:key:{} val:{}", key, val);

                })
                .build();
        cache.put(12);
        System.out.println(cache.getIfPresent(1));
        Thread.sleep(3000);
        System.out.println(cache.getIfPresent(1));//null
    }

    /**
     * 写入后到期
     */
    @Test
    public void expireAfterWriteTest() throws InterruptedException {
        Cache<Integer, Integer> cache = Caffeine.newBuilder()
                .expireAfterWrite(1, TimeUnit.SECONDS)
                //可以指定调度程序来及时删除过期缓存项,而不是等待Caffeine触发定期维护
                //若不设置scheduler,则缓存会在下一次调用get的时候才会被动删除
                .scheduler(Scheduler.systemScheduler())
                .evictionListener((key, val, removalCause) -> {
                    log.info("淘汰缓存:key:{} val:{}", key, val);
                })
                .build();
        cache.put(12);
        Thread.sleep(3000);
        System.out.println(cache.getIfPresent(1));//null
    }
}

// 基于引用
// 当key和缓存元素都不再存在其他强引用的时候驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .weakKeys()
    .weakValues()
    .build(key -> createExpensiveGraph(key));

// 当进行GC的时候进行驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .softValues()
    .build(key -> createExpensiveGraph(key));

Caffeine提供了三种方法进行基于时间的驱逐:

expireAfterAccess(long, TimeUnit): 一个元素在上一次读写操作后一段时间之后,在指定的时间后没有被再次访问将会被认定为过期项。在当被缓存的元素时被绑定在一个session上时,当session因为不活跃而使元素过期的情况下,这是理想的选择。 expireAfterWrite(long, TimeUnit): 一个元素将会在其创建或者最近一次被更新之后的一段时间后被认定为过期项。在对被缓存的元素的时效性存在要求的场景下,这是理想的选择。 expireAfter(Expiry): 一个元素将会在指定的时间后被认定为过期项。当被缓存的元素过期时间收到外部资源影响的时候,这是理想的选择。

Caffeine 允许你配置你的缓存去让GC去帮助清理缓存当中的元素,其中key支持弱引用,而value则支持弱引用和软引用。记住 AsyncCache不支持软引用和弱引用。

Caffeine.weakKeys() 在保存key的时候将会进行弱引用。这允许在GC的过程中,当key没有被任何强引用指向的时候去将缓存元素回收。由于GC只依赖于引用相等性。这导致在这个情况下,缓存将会通过引用相等(==)而不是对象相等 equals()去进行key之间的比较。

Caffeine.weakValues()在保存value的时候将会使用弱引用。这允许在GC的过程中,当value没有被任何强引用指向的时候去将缓存元素回收。由于GC只依赖于引用相等性。这导致在这个情况下,缓存将会通过引用相等(==)而不是对象相等 equals()去进行value之间的比较。

Caffeine.softValues()在保存value的时候将会使用软引用。为了相应内存的需要,在GC过程中被软引用的对象将会被通过LRU算法回收。由于使用软引用可能会影响整体性能,我们还是建议通过使用基于缓存容量的驱逐策略代替软引用的使用。同样的,使用 softValues() 将会通过引用相等(==)而不是对象相等 equals()去进行value之间的比较。

缓存移出

主动/手动移除方法

// 失效key
cache.invalidate(key)
// 批量失效key
cache.invalidateAll(keys)
// 失效所有的key
cache.invalidateAll()
移除监听器

可以为你的缓存通过Caffeine.removalListener(RemovalListener)方法定义一个移除监听器在一个元素被移除的时候进行相应的操作。这些操作是使用 Executor 异步执行的,其中默认的 Executor 实现是 ForkJoinPool.commonPool() 并且可以通过覆盖Caffeine.executor(Executor)方法自定义线程池的实现。

当移除之后的自定义操作必须要同步执行的时候,你需要使用 Caffeine.evictionListener(RemovalListener) 。这个监听器将在 RemovalCause.wasEvicted() 为 true 的时候被触发。为了移除操作能够明确生效, Cache.asMap() 提供了方法来执行原子操作。

刷新机制
被动刷新

refreshAfterWrite()表示x秒后自动刷新缓存的策略可以配合淘汰策略使用,注意的是刷新机制只支持LoadingCacheAsyncLoadingCache

private static int NUM = 0;

@Test
public void refreshAfterWriteTest() throws InterruptedException {
    LoadingCache<Integer, Integer> cache = Caffeine.newBuilder()
            .refreshAfterWrite(1, TimeUnit.SECONDS)
            //模拟获取数据,每次获取就自增1
            .build(integer -> ++NUM);

    //获取ID=1的值,由于缓存里还没有,所以会自动放入缓存
    System.out.println(cache.get(1));// 1

    // 延迟2秒后,理论上自动刷新缓存后取到的值是2
    // 但其实不是,值还是1,因为refreshAfterWrite并不是设置了n秒后重新获取就会自动刷新
    // 而是x秒后&&第二次调用getIfPresent的时候才会被动刷新
    Thread.sleep(2000);
    System.out.println(cache.getIfPresent(1));// 1

    //此时才会刷新缓存,而第一次拿到的还是旧值
    System.out.println(cache.getIfPresent(1));// 2
}

主动刷新
LoadingCache<String, String> cache = Caffeine.newBuilder()
        .maximumSize(10) 
        .expireAfterWrite(10, TimeUnit.SECONDS)
        .build(key -> createData(key));

// 刷新
cache.refresh("key");
统计
LoadingCache<String, String> cache = Caffeine.newBuilder()
        //创建缓存或者最近一次更新缓存后经过指定时间间隔,刷新缓存;refreshAfterWrite仅支持LoadingCache
        .refreshAfterWrite(1, TimeUnit.SECONDS)
        .expireAfterWrite(1, TimeUnit.SECONDS)
        .expireAfterAccess(1, TimeUnit.SECONDS)
        .maximumSize(10)
        //开启记录缓存命中率等信息
        .recordStats()
        //根据key查询数据库里面的值
        .build(key -> {
            Thread.sleep(1000);
            return new Date().toString();
        });


cache.put("1""shawn");
cache.get("1");

/*
 * hitCount :命中的次数
 * missCount:未命中次数
 * requestCount:请求次数
 * hitRate:命中率
 * missRate:丢失率
 * loadSuccessCount:成功加载新值的次数
 * loadExceptionCount:失败加载新值的次数
 * totalLoadCount:总条数
 * loadExceptionRate:失败加载新值的比率
 * totalLoadTime:全部加载时间
 * evictionCount:丢失的条数
 */
System.out.println(cache.stats());

上述一些策略在创建时都可以进行自由组合,一般情况下有两种方法

  • 设置maxSizerefreshAfterWrite,不设置expireAfterWrite/expireAfterAccess,设置expireAfterWrite当缓存过期时会同步加锁获取缓存,所以设置expireAfterWrite时性能较好,但是某些时候会取旧数据,适合允许取到旧数据的场景
  • 设置maxSizeexpireAfterWrite/expireAfterAccess,不设置 refreshAfterWrite 数据一致性好,不会获取到旧数据,但是性能没那么好(对比起来),适合获取数据时不耗时的场景

SpringBoot整合Caffeine

将Caffeine整合到SpringBoot中无非就是两个核心要点:

其实质就是通过使用[Spring Cache](# Spring Cache)的注解而存储内核使用被整合的组件

通过重写CacheManage并注入到Spring容器中从而[Spring Cache](# Spring Cache)替换默认的CacheManage存储组件

注意上述中的CacheManage类的全限定类名为org.springframework.cache.CacheManager;

引入Spring Cache依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
Spring Cache常用注解

@EnableCaching :表示开启缓存

@Cacheable:表示该方法支持缓存。当调用被注解的方法时,如果对应的键已经存在缓存,则不再执行方法体,而从缓存中直接返回。当方法返回null时,将不进行缓存操作。

@CachePut:表示执行该方法后,其值将作为最新结果更新到缓存中,每次都会执行该方法。

@CacheEvict:表示执行该方法后,将触发缓存清除操作。

@Caching:用于组合前三个注解,例如:

@Caching(cacheable = @Cacheable("CacheConstants.GET_USER"),
         evict = {@CacheEvict("CacheConstants.GET_DYNAMIC",allEntries = true)}
public User find(Integer id) {
    return null;
}

注解属性

cacheNames/value:缓存组件的名字,即cacheManager中缓存的名称。

key:缓存数据时使用的key。默认使用方法参数值,也可以使用SpEL表达式进行编写。

keyGenerator:和key二选一使用。

cacheManager:指定使用的缓存管理器。

condition:在方法执行开始前检查,在符合condition的情况下,进行缓存

unless:在方法执行完成后检查,在符合unless的情况下,不进行缓存

sync:是否使用同步模式。若使用同步模式,在多个线程同时对一个key进行load时,其他线程将被阻塞。

sync开启或关闭,在Cache和LoadingCache中的表现是不一致的:

Cache中,sync表示是否需要所有线程同步等待

LoadingCache中,sync表示在读取不存在/已驱逐的key时,是否执行被注解方法

配置缓存常量

创建缓存常量类CacheConstants,把公共的常量提取一层,复用,这里也可以通过配置文件加载这些数据,例如@ConfigurationProperties@Value

public class CacheConstants {
    /**
     * 默认过期时间(配置类中我使用的时间单位是秒,所以这里如 3*60 为3分钟)
     */
    public static final int DEFAULT_EXPIRES = 3 * 60;
    public static final int EXPIRES_5_MIN = 5 * 60;
    public static final int EXPIRES_10_MIN = 10 * 60;

    public static final String CAFFEINE_100 = "caffeine100";
    public static final String CAFFEINE_10 = "caffeine10";

}
缓存配置类CacheConfig

这里是比较核心的地方,也是Caffeine与**[Spring Cache](# Spring Cache)**联系在一起的桥梁

Enum

package cn.org.xiaosheng.common.enums;


public enum CacheEnum {

    CAFFEINE_100("caffeine100", 1000L), CAFFEINE_10("caffeine10", 10L);

    private String name;

    private Long expires;


    public String getName() {
        return name;
    }

    public Long getExpires() {
        return expires;
    }

    CacheEnum(String name, Long expires) {
        this.name = name;
        this.expires = expires;
    }


}

Config

@Configuration
@EnableCaching
public class CacheConfig {
    /**
     * Caffeine配置说明:
     * initialCapacity=[integer]: 初始的缓存空间大小
     * maximumSize=[long]: 缓存的最大条数
     * maximumWeight=[long]: 缓存的最大权重
     * expireAfterAccess=[duration]: 最后一次写入或访问后经过固定时间过期
     * expireAfterWrite=[duration]: 最后一次写入后经过固定时间过期
     * refreshAfterWrite=[duration]: 创建缓存或者最近一次更新缓存后经过固定的时间间隔,刷新缓存
     * weakKeys: 打开key的弱引用
     * weakValues:打开value的弱引用
     * softValues:打开value的软引用
     * recordStats:开发统计功能
     * 注意:
     * expireAfterWrite和expireAfterAccess同时存在时,以expireAfterWrite为准。
     * maximumSize和maximumWeight不可以同时使用
     * weakValues和softValues不可以同时使用
     */
    @Bean
    public CacheManager cacheManager() {
        SimpleCacheManager cacheManager = new SimpleCacheManager();
        List<CaffeineCache> list = new ArrayList<>();
        //循环添加枚举类中自定义的缓存,可以自定义
        for (CacheEnum cacheEnum : CacheEnum.values()) {
            list.add(new CaffeineCache(cacheEnum.getName(),
                    Caffeine.newBuilder()
                            .initialCapacity(50)
                            .maximumSize(1000)
                            .expireAfterAccess(cacheEnum.getExpires(), TimeUnit.SECONDS)
                            .build()));
        }
        cacheManager.setCaches(list);
        return cacheManager;
    }
}


这里也提供另一种集成方式,不使用SimpleCacheManager做

Service

package cn.org.xiaosheng.service.impl;

import cn.org.xiaosheng.service.CacheService;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
public class CacheServiceImpl implements CacheService {

    @Override
    @Cacheable(value = "caffeine100",key = "#id")  // 可以保存该方法的返回值
    public String getCacheById(String id) {
        System.out.println("查询了数据库");
        return id;
    }

    @Override
    @CacheEvict(value = "caffeine100",key = "#id")
    public void deleteCacheById(String id) {

    }

    @Override
    @CachePut(value = "caffeine100",key = "#id") // 可以保存(修改)该方法的返回值
    public String updateCacheById(String id) {
        return id + "###";
    }

    @Override
    @Cacheable(value = "caffeine_10",key = "#id") // 可以保存(修改)该方法的返回值
    public String findCacheById(String id) {
        return "我的命名空间是cache_10" + id;
    }

    public String getDataFromDatabase(String key) {
        // 这里是模拟从数据库中加载数据
        return "Data for " + key;
    }
}

上面的config采用的是SimpleCacheManager作为缓存管理器做的集成,如果我们不采用SimpleCacheManager也可以通过如下方式进行集成

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.interceptor.CacheResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class CaffeineCacheConfig extends CachingConfigurerSupport {

    @Bean
    public CacheManager cacheManager() {
        Caffeine<Object, Object> caffeine = Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(1000)
                .expireAfterWrite(10, TimeUnit.SECONDS) // 10 秒后过期
                .recordStats(); // 记录缓存统计信息

        return new CaffeineCacheManager("default", caffeine::build);
    }

    @Override
    public CacheResolver cacheResolver() {
        // 自定义缓存解析器(如果需要)
        // ...
        return super.cacheResolver();
    }
}

这里也简单介绍一下SimpleCacheManager:

SimpleCacheManager是Spring框架中的一个类,它是一个简单的缓存管理器。这个类的主要作用是管理和控制应用中的缓存行为。

SimpleCacheManagerAbstractCacheManager的一个实现,它工作在给定的缓存集合上。这个类非常适合于测试或者简单的缓存声明。当你需要一个简单的缓存管理器来管理你的应用中的缓存,你可以选择使用SimpleCacheManager

以下是一些SimpleCacheManager的主要功能:

  • 管理缓存:你可以使用SimpleCacheManager来管理你的应用中的所有缓存。你可以添加新的缓存,或者删除已有的缓存。
  • 获取缓存:你可以使用SimpleCacheManager来获取你的应用中的任何一个缓存。你可以使用缓存的名字来获取缓存。
  • 初始化缓存:当你设置完缓存后,你可以调用initializeCaches()方法来初始化SimpleCacheManager的内部状态。

SimpleCacheManager的意义在于,它提供了一个简单而方便的方式来管理和控制你的应用中的缓存。通过使用SimpleCacheManager,你可以轻松地添加、删除和获取缓存,而无需担心缓存管理的复杂性。

并给出一个使用Demo

import org.springframework.cache.Cache;
import org.springframework.cache.concurrent.ConcurrentMapCache;
import org.springframework.cache.support.SimpleCacheManager;

import java.util.Arrays;

public class SimpleCacheManagerExample {

    public static void main(String[] args) {
        SimpleCacheManager cacheManager = new SimpleCacheManager();
        Cache cache = new ConcurrentMapCache("cacheName");
        cacheManager.setCaches(Arrays.asList(cache));
        cacheManager.initializeCaches(); // 初始化内部状态
        // 使用缓存
        cache.put("key", "value");
        System.out.println(cache.get("key").get());
    }
}

在这段代码中,我们首先创建了一个SimpleCacheManager实例,然后创建了一个ConcurrentMapCache实例并设置为SimpleCacheManager的缓存。然后我们调用initializeCaches方法来初始化SimpleCacheManager的内部状态。最后,我们使用put方法向缓存中添加一个键值对,然后使用get方法从缓存中获取这个键对应的值。

关于初始化是否使用initializeCaches()方法,这里也给出答案:

在Spring框架中,SimpleCacheManager类的initializeCaches()方法是用来初始化内部状态的。当我们直接使用SimpleCacheManager实例(而不是通过Spring的bean注册)时,建议调用initializeCaches()方法来初始化。

但是,如果不调用initializeCaches()方法,也能正常工作。这是因为在SimpleCacheManager类中,initializeCaches()方法的实现其实是空的。这意味着,调用这个方法并不会有任何实际的操作发生。

所以,在你给出的代码示例中,即使不调用initializeCaches()方法,缓存管理器依然可以正常工作。但是,为了保持良好的编程习惯和代码清晰度,建议在设置完缓存后调用initializeCaches()方法进行初始化。

整合SpringBoot总结

其实这一章节介绍SpringBoot整合Caffeine通用于所有其他的组件与SpringBoot做集成,无非就是上面说到的两点:

:one: 其实质就是通过使用[Spring Cache](# Spring Cache)的注解而存储内核使用被整合的组件

:two: 通过重写CacheManage并注入到Spring容器中从而[Spring Cache](# Spring Cache)替换默认的CacheManage存储组件

把握好这两点了自然就能将其他存储组件(例如,redis)与SpringBoot做集成。

Ehcache

Ehcache简介

Ehcache 是一个成熟的缓存框架,你可以直接使用它来管理你的缓存,框架所具备的关键词:开源、标准化、轻量级、Java缓存、分布式。

Java缓存框架 EhCache EhCache 是一个纯Java的进程内缓存框架,具有快速、精干等特点,是Hibernate中默认的CacheProvider。

特性:可以配置内存不足时,启用磁盘缓存(maxEntriesLoverflowToDiskocalDisk配置当内存中对象数量达到maxElementsInMemory时,Ehcache将会对象写到磁盘中)。

image-20240508083729180

工作原理

缓存写入

当应用程序向 Ehcache 中写入数据时,Ehcache 会首先检查该数据是否已经存在于缓存中。如果数据已经存在于缓存中,则更新该数据;否则,将该数据写入缓存。以下是 Ehcache 缓存写入的详细流程。

当应用程序请求写入一个数据项到 Ehcache 中时,这个数据项被传递给Ehcache API。

Ehcache首先根据该数据项的键值对定位其对应的Cache对象。

Ehcache根据配置中的缓存策略,比如是否应该在缓存中创建一个新的元素,以及新元素是否会淘汰老的元素。

如果需要创建一个新缓存元素,则Ehcache创建一个新的元素并将其添加到Cache对象中。

如果缓存中已经存在该元素,Ehcache会根据缓存策略对该元素进行更新或替换。

Ehcache将更新后的Cache对象返回给应用程序。

缓存查找

当应用程序请求从 Ehcache 中读取一个数据项时,这个请求被传递给Ehcache API。

Ehcache首先根据该数据项的键值对定位其对应的Cache对象。

Ehcache检查该数据项是否已经存在于缓存中。

如果数据项存在于缓存中,Ehcache可以直接将其返回给应用程序。

如果数据项不存在于缓存中,Ehcache就需要从数据库或其他数据源(如文件、网络等)中获取数据。

获取到数据后,Ehcache会将其添加到缓存中并返回给应用程序。

缓存过期和驱逐

Ehcache 提供了多种缓存失效策略,例如基于时间的缓存失效、基于访问的缓存失效、基于大小的缓存失效等。当缓存数据过期或缓存空间不足时,Ehcache 会选择一部分缓存元素进行驱逐以腾出更多的内存空间。以下是 Ehcache 缓存过期和驱逐的详细流程。

Ehcache会周期性地扫描缓存中的元素来标记那些已经过期的元素。

Ehcache根据缓存策略(如基于时间、基于访问、基于大小等)判断哪些缓存元素应该被驱逐。

驱逐过程通常是异步执行的,Ehcache会在后台线程上执行这个操作。

缓存持久化

Ehcache 还提供了缓存持久化功能,它可以将缓存中的数据持久化到磁盘或者其他数据源。在应用程序重启或者缓存失效后,Ehcache 可以从持久化存储中恢复数据,从而保证数据的完整性和可靠性。以下是 Ehcache 缓存持久化的详细流程。

Ehcache使用磁盘存储或数据库等持久化技术来存储缓存数据。

当缓存中的数据更新时,Ehcache会自动将此数据持久化到持久化存储中。

在应用程序重启或者缓存失效后,Ehcache会从持久化存储中读取缓存数据并重新加载到内存中。

Ehcache主要API

关于Ehcache的API和使用方法,主要有以下几点:

  • CacheManager:这是Ehcache的主要类,它管理所有的缓存。你可以通过CacheManager.getInstance()获取一个默认的CacheManager实例。
  • addCache(String name):在CacheManager中添加一个名为name的缓存。
  • getCache(String name):从CacheManager中获取名为name的缓存。
  • put(Element element):将一个元素添加到缓存中。
  • get(Object key):从缓存中获取一个元素。
  • remove(Object key):从缓存中移除一个元素。
  • shutdown():关闭CacheManager实例。

使用Ehcache时需要注意以下几点:

  • Ehcache不是线程安全的:在多线程环境中使用Ehcache时,需要确保同一时间只有一个线程修改缓存。
  • 应该尽可能地减少对磁盘存储的使用:虽然Ehcache支持磁盘存储,但是磁盘存储的速度远远慢于内存存储。因此,应该尽可能地使用内存存储,只在必要时使用磁盘存储。
  • 应该定期清理缓存:为了防止缓存过大导致的内存溢出,应该定期清理缓存。
Cache主要API

Ehcache的Cache类是Ehcache库的核心类之一,代表了一个缓存实例。下面是Cache类的一些重要方法:

  • put(Element element): 添加一个元素到缓存中。如果缓存中已经存在一个与新元素具有相同键的元素,那么新元素将替换旧元素。
  • get(Object key): 从缓存中获取一个元素。如果缓存中不存在具有给定键的元素,那么此方法将返回null
  • remove(Object key): 从缓存中移除一个元素。如果成功移除了元素,那么此方法将返回true,否则返回false
  • getSize(): 获取缓存中的元素数量。
  • getKeys(): 获取缓存中所有元素的键的集合。
  • isKeyInCache(Object key): 检查缓存中是否存在具有给定键的元素。
  • isValueInCache(Object value): 检查缓存中是否存在具有给定值的元素。
  • removeAll(): 移除缓存中的所有元素。

以上是Cache类的一些重要方法,它们提供了操作缓存的基本功能。在实际使用中,你可能还需要使用Cache类的其他方法,以满足特定的需求。

数据管理机制

Ehcache数据缓存依赖Manager完成,遵循CacheManager—>Cache—>Element层级关系,以下是每个组件的特点。

CacheManager 缓存管理器,是Ehcache的入口。 Cache 每个CacheManager可以管理多个Cache,每个Cache可以管理多个Element。 Element 单条缓存的组成单位。

image-20240508155536050

Ehcache实战

引入Ehcache依赖
<dependency>
    <groupId>net.sf.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <version>2.10.6</version>
</dependency>
创建Ehcache配置文件

文件通常叫做ehcache.xml,放在项目的资源目录(src/main/resources)里。这个文件用来定义缓存的名称、内存大小、过期策略等参数。

<?xml version="1.0" encoding="UTF-8"?>
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="http://ehcache.org/ehcache.xsd"
         updateCheck="false">
    <!-- 缓存在本地磁盘存储路径 -->
    <diskStore path="user.dir/ehcache-data" />

    <!-- defaultCache:默认的缓存配置信息-->
    <defaultCache maxElementsInMemory="10000" eternal="false"
                  timeToIdleSeconds="21600" timeToLiveSeconds="21600" overflowToDisk="true" />

    <!-- 缓存会过期,内存中缓存数量超过最大数量后,会保存到磁盘 -->
    <!-- 注意每次增加一个缓存,需要配置类似如下的一条配置 -->
    <cache name="userCache" maxElementsInMemory="10000" eternal="false"
           timeToIdleSeconds="21600" timeToLiveSeconds="21600" overflowToDisk="true" />
</ehcache>

上述配置文件的核心含义解释如下:

<ehcache>:这是配置文件的根元素。

xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:noNamespaceSchemaLocation="http://ehcache.org/ehcache.xsd":这两行定义了XML文档的命名空间和对应的XML Schema定义,这是XML文档的标准写法。

updateCheck="false":这个属性设定了Ehcache是否在启动时检查新版本。设置为"false"表示不检查,这样可以避免启动时访问网络,加快启动速度。

<diskStore path="user.dir/ehcache-data" />:指定了当缓存溢出到磁盘时,数据存储的路径。

<defaultCache>:定义了默认的缓存配置,当单独的<cache>配置不存在时,会使用这个默认配置。

  • maxElementsInMemory="10000":表示内存中最多可以存储10000个对象。
  • eternal="false":表示缓存的数据不是永久的,如果是"true",超时设置将会被忽略,数据会永不过期。
  • timeToIdleSeconds="21600":表示当一个元素在21600秒(6小时)内没有被访问或修改,就会被认为是空闲的,当达到timeToLiveSeconds时该元素就会过期。
  • timeToLiveSeconds="21600":表示从创建开始,当达到21600秒(6小时)后,元素就会过期。
  • overflowToDisk="true":表示当内存中的对象数量达到maxElementsInMemory时,新的对象会溢出到磁盘中。

<cache name="student">:定义了一个名为"student"的缓存配置,它的配置项与<defaultCache>类似。这个缓存可以存储关于"student"的数据。当你在代码中需要使用这个缓存时,可以通过这个名称来获取。

以下是一些主要的标签及其含义:

  • <ehcache>:这是配置文件的根元素。它包含了所有的缓存配置。
  • <diskStore>:定义了磁盘存储的路径。例如,<diskStore path="java.io.tmpdir"/>将磁盘存储路径设置为Java的临时目录。
  • <defaultCache>:定义了默认的缓存配置。当一个特定的缓存没有明确的配置时,将使用这个默认的配置。
  • <cache>:定义了一个特定的缓存配置。你可以在这个标签中设置缓存的名称、大小、溢出到磁盘的策略等参数。

<defaultCache><cache>标签中,你可以设置以下参数:

  • name:缓存的名称。
  • maxElementsInMemory:内存中可以存储的最大元素数量。
  • maxElementsOnDisk:磁盘上可以存储的最大元素数量。
  • eternal:如果设置为true,则元素永远不会过期。如果设置为false,则需要设置timeToIdleSecondstimeToLiveSeconds
  • overflowToDisk:当内存中的元素数量达到maxElementsInMemory时,是否应该将元素溢出到磁盘。
  • timeToIdleSeconds:元素的空闲时间,即在元素最后一次被访问和被淘汰之间的最长时间。
  • timeToLiveSeconds:元素的生存时间,即在元素被创建或更新和被淘汰之间的最长时间。
  • diskPersistent:是否在JVM重启时持久化磁盘缓存。
  • diskExpiryThreadIntervalSeconds:磁盘缓存的过期线程运行间隔,用于清理过期的磁盘缓存。

编写config类,Controller类

config类

package cn.org.xiaosheng.common.config;

import net.sf.ehcache.Cache;
import net.sf.ehcache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.io.FileInputStream;
import java.io.FileNotFoundException;

@Configuration
public class EhcacheConfig {

    // 创建缓存管理器
//    static CacheManager cacheManager = CacheManager.newInstance("../src/main/resources/ehcache.xml");
    static CacheManager cacheManager1;

    static {
        try {
            cacheManager1 = CacheManager.newInstance(new FileInputStream("ehcache.xml配置文件路径"));
        } catch (FileNotFoundException e) {
            throw new RuntimeException(e);
        }
    }

    @Bean("userCache")
    public Cache cache() {
        // 从管理器获取缓存对象
        Cache cache = cacheManager1.getCache("userCache");
        /**
         * 这种方式也可以创建缓存对象,但是Ehcache官方并不推荐用该种方式进行Cache创建
         *          // 创建缓存配置
         *         Cache myCache = new Cache("myCache", 1000, false, false, 300, 200);
         *         // 将缓存添加到管理器
         *         cacheManager.addCache(myCache);
         */
        return cache;
    }

//    @Bean
//    public CacheManager cacheManager() {
//        return cacheManager;
//    }
}

Controller类

    @Resource(name = "userCache")
    private net.sf.ehcache.Cache userCache;

	@GetMapping("/Ehcache/{key}")
    public String getEhcacheCache(@PathVariable("key") String key){
        if (userCache.isKeyInCache(key)) {
            return Objects.toString(userCache.get(key).getObjectValue());
        }
        userCache.put(new Element(key, cacheService.getCacheById(key)));
        return Objects.toString(userCache.get(key).getObjectValue());
    }

通过ApiFox进行接口测试调用可以发现缓存可以正常存取。

Ehcache高级进阶

监听器和事件

在Ehcache中,CacheEventListenerCacheEventListenerFactory是用于处理缓存事件的接口和工厂类。

  1. CacheEventListener:这是一个接口,用于处理Ehcache中的缓存事件,如元素被添加、更新或删除。实现这个接口的类需要提供以下方法的实现:
    • notifyElementRemoved:当元素被移除时调用。
    • notifyElementPut:当新元素被添加时调用。
    • notifyElementUpdated:当元素被更新时调用。
    • notifyElementEvicted:当元素被逐出时调用。
    • notifyRemoveAll:当所有元素被移除时调用。
  2. CacheEventListenerFactory:这是一个抽象工厂类,用于创建CacheEventListener的实例。你需要提供自己的具体工厂类,扩展这个抽象工厂。然后可以在ehcache.xml中进行配置。

这两个类在Ehcache中的主要作用是提供了一种方式来处理缓存事件,使得开发者可以在事件发生时执行特定的操作,如记录日志、更新统计数据等。

这两个方法的主要区别在于它们被调用的时机和原因:

  • notifyElementRemoved(Element element):当元素被明确地从缓存中移除时,会调用这个方法。这通常发生在你调用 remove(key) 方法来移除一个元素时。
  • notifyElementEvicted(Element element):当元素被"逐出"缓存时,会调用这个方法。"逐出"是指元素被自动从缓存中移除,以释放空间给其他元素。这通常发生在缓存空间不足,需要为新的元素释放空间时,或者元素已经过期时。

所以,简单来说,notifyElementRemoved 是在你主动移除元素时被调用,而 notifyElementEvicted 是在系统自动移除元素时被调用。

编写Listener

package cn.org.xiaosheng.common.listener;

import net.sf.ehcache.CacheException;
import net.sf.ehcache.Ehcache;
import net.sf.ehcache.Element;
import net.sf.ehcache.event.CacheEventListener;

public class MyCacheEventListener implements CacheEventListener {

    @Override
    public void notifyElementRemoved(Ehcache cache, Element element) throws CacheException {
        System.out.println("元素被移除: " + element.getObjectKey());
    }

    @Override
    public void notifyElementPut(Ehcache cache, Element element) throws CacheException {

    }

    @Override
    public void notifyElementUpdated(Ehcache cache, Element element) throws CacheException {

    }

    @Override
    public void notifyElementExpired(Ehcache cache, Element element) {

    }

    @Override
    public void notifyElementEvicted(Ehcache cache, Element element) {
        System.out.println("元素被逐出: " + element.getObjectKey());
    }

    @Override
    public void notifyRemoveAll(Ehcache cache) {

    }

    @Override
    public Object clone() throws CloneNotSupportedException {
        return null;
    }

    @Override
    public void dispose() {

    }
}

编写ListenerFactory

package cn.org.xiaosheng.common.listener;

import net.sf.ehcache.event.CacheEventListener;
import net.sf.ehcache.event.CacheEventListenerFactory;

import java.util.Properties;

public class MyCacheEventListenerFactory extends CacheEventListenerFactory {

    public MyCacheEventListenerFactory() {
        super();
    }

    @Override
    public CacheEventListener createCacheEventListener(Properties properties) {
        return new MyCacheEventListener();
    }
}

增加配置

    <cache name="userCache" maxElementsInMemory="10000" eternal="false"
           timeToIdleSeconds="21600" timeToLiveSeconds="21600" overflowToDisk="true">
        <cacheEventListenerFactory class="cn.org.xiaosheng.common.listener.MyCacheEventListenerFactory" />
    </cache>
监控Ehcache的性能

监控是性能优化的第一步。Ehcache提供了一些用于监控的工具和方法,让咱们能够了解缓存的运行状态。

使用Ehcache的Statistics类,咱们可以获取很多有用的性能指标,比如缓存命中率、缓存元素的数量等。下面是一个获取缓存统计信息的例子:

import net.sf.ehcache.Cache;
import net.sf.ehcache.CacheManager;
import net.sf.ehcache.Statistics;

// ... 初始化CacheManager和Cache

    @PostMapping("/Ehcache/Statistics/{key}")
    public String postEhcacheCache(@PathVariable("key") String key){
        // 获取缓存的统计信息
        StatisticsGateway stats = userCache.getStatistics();
        System.out.println("缓存命中次数: " + stats.cacheHitCount());
        System.out.println("缓存错过次数: " + stats.cacheMissExpiredCount());
        return "success!";
    }

在Ehcache 3.x版本中,getStatistics()方法返回的是一个StatisticsGateway对象,而不是Statistics对象。这是Ehcache在升级过程中进行的一些API更改。

StatisticsGateway提供了许多有用的统计信息,包括缓存命中数,缓存未命中数,缓存过期数等。这些统计信息可以帮助你更好地理解和监控你的缓存的行为。

调整Ehcache的性能

内存和磁盘存储的平衡:调整缓存中内存和磁盘存储的比例,可以根据应用的具体需求来优化性能。如果缓存的对象访问频率较高,可以增加内存存储的比例;如果数据量大但访问频率较低,可以使用更多的磁盘存储。

过期策略的调整:合理配置TTL(Time-To-Live)和TTI(Time-To-Idle)参数,可以有效地管理缓存数据的生命周期。这有助于避免缓存膨胀,同时确保数据的及时更新。

并发策略的优化:Ehcache提供了不同的并发级别设置,可以根据应用的并发需求来调整。例如,如果应用对缓存的写操作不频繁,可以选择更高的读并发性能。

使用Cache Loader和Writer:通过实现Cache Loader和Writer接口,可以控制缓存数据的加载和存储过程。这样可以更好地集成数据库或其他数据源,实现数据的同步和一致性。

分布式缓存的应用:对于大规模分布式应用,可以考虑使用Ehcache的分布式缓存特性。这可以通过网络分散缓存的负载,提高缓存的可伸缩性和可靠性。

封装API工具类
import net.sf.ehcache.Cache;
import net.sf.ehcache.CacheManager;
import net.sf.ehcache.Element;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
 
/**
 * EhCache 缓存工具类
 * 使用String格式的value
 */
@Component
public class EhcacheUtils {
 
    @Autowired
    private CacheManager cacheManager;
 
    // 默认的缓存存在时间(秒)
    private static final int DEFAULT_LIVE_SECOND = 20 * 60;
 
    public static final String EHCACHE_KEY = "ehcache";
 
    /**
     * 添加缓存
     *
     * @param cacheName         xml中缓存名字
     * @param key
     * @param value
     * @param timeToLiveSeconds 缓存生存时间(秒)
     */
    public void set(String cacheName, String key, Object value, int timeToLiveSeconds) {
        Cache cache = cacheManager.getCache(cacheName);
        Element element = new Element(
                key, value,
                0,// timeToIdleSeconds=0
                timeToLiveSeconds);
        cache.put(element);
    }
 
    /**
     * 添加缓存
     * 使用默认生存时间
     *
     * @param cacheName xml中缓存名字
     * @param key
     * @param value
     */
    public void set(String cacheName, String key, Object value) {
        Cache cache = cacheManager.getCache(cacheName);
        Element element = new Element(
                key, value,
                0,// timeToIdleSeconds
                DEFAULT_LIVE_SECOND);
        cache.put(element);
    }
 
    /**
     * 添加缓存
     *
     * @param cacheName         xml中缓存名字
     * @param key
     * @param value
     * @param timeToIdleSeconds 对象空闲时间,指对象在多长时间没有被访问就会失效。
     *                          只对eternal为false的有效。传入0,表示一直可以访问。以秒为单位。
     * @param timeToLiveSeconds 缓存生存时间(秒)
     *                          只对eternal为false的有效
     */
    public void set(String cacheName, String key, Object value, int timeToIdleSeconds, int timeToLiveSeconds) {
        Cache cache = cacheManager.getCache(cacheName);
        Element element = new Element(
                key, value,
                timeToIdleSeconds,
                timeToLiveSeconds);
        cache.put(element);
    }
 
    /**
     * 添加缓存
     *
     * @param cacheName xml中缓存名字
     * @param key
     * @return
     */
    public Object get(String cacheName, String key) {
        Cache cache = cacheManager.getCache(cacheName);
        Element element = cache.get(key);
        if (element == null) {
            return null;
        }
        return element.getObjectValue();
    }
 
    /**
     * 删除缓存数据
     *
     * @param cacheName
     * @param key
     */
    public void delete(String cacheName, String key) {
        try {
            Cache cache = cacheManager.getCache(cacheName);
            cache.remove(key);
        } catch (Exception e) {
 
        }
    }
}
 

对Ehcache的介绍基本就到这里啦,后续我们将学以致用,学习了不少的本地缓存框架了,我们可以手写一个缓存框架试一下,提升一下自己的技术实力。

自定义实现缓存

继承LinkedHashMap实现

我们可以利用LinkedHashMapremoveEldestEntry()实现LRU淘汰机制的缓存,但是我们也需要考虑到LinkedHashMap 本身并不是线程安全的。如果多个线程并发修改 LinkedHashMap,那么它的行为是不可预测的。所以在重写的时候需要考虑到加入读写锁,以保证并发环境下的线程安全。

在多线程环境中使用 LinkedHashMap,有以下几种解决方案:

使用 Collections.synchronizedMap() 方法:这个方法会返回一个线程安全的 Map,它是通过在所有方法上添加 synchronized 关键字实现的。例如:

Map<String, String> map = Collections.synchronizedMap(new LinkedHashMap<>());

使用 ConcurrentHashMapConcurrentHashMap 是一个线程安全的 Map 实现,它通过分段锁技术实现了比 Collections.synchronizedMap() 更好的并发性能。但是, ConcurrentHashMap 不保证元素的插入顺序。

使用 java.util.concurrent.ConcurrentSkipListMapConcurrentSkipListMap 是一个线程安全的 Map,它保证了元素的插入顺序。但是,它的性能可能比 ConcurrentHashMap 差一些。

我们还可以通过重写,然后在重写类中主动加入可重入读写锁来有效控制线程安全。

具体实现代码如下:

package cn.org.xiaosheng.custom;

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 * 该种方式就没有过期时间字段设置了,仅仅使用LRU算法进行实现
 */
public class LRUCache extends LinkedHashMap {

    /**
     * 可重入读写锁,保证并发读写安全性
     */
    private ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private Lock readLock = readWriteLock.readLock();
    private Lock writeLock = readWriteLock.writeLock();

    /**
     * 缓存大小限制
     */
    private int maxSize;

    public LRUCache(int maxSize) {
        super(maxSize + 1, 0.75f, true);
        this.maxSize = maxSize;
    }

    @Override
    public Object get(Object key) {
        readLock.lock();
        try {
            return super.get(key);
        } finally {
            readLock.unlock();
        }
    }

    @Override

    public Object put(Object key, Object value) {
        writeLock.lock();
        try {
            return super.put(key, value);
        } finally {
            writeLock.unlock();
        }
    }

    /**
     * 这个方法默认返回 false,不会移除最老的元素。但是,你可以覆盖这个方法,来控制何时移除最老的元素。这个功能可以用来实现 LRU(Least Recently Used,最近最少使用)缓存策略。
     * @param eldest The least recently inserted entry in the map, or if
     *           this is an access-ordered map, the least recently accessed
     *           entry.  This is the entry that will be removed it this
     *           method returns {@code true}.  If the map was empty prior
     *           to the {@code put} or {@code putAll} invocation resulting
     *           in this invocation, this will be the entry that was just
     *           inserted; in other words, if the map contains a single
     *           entry, the eldest entry is also the newest.
     * @return
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry eldest) {
        return this.size() > maxSize;
    }
}

这样我们就实现了一个LRULinkedHashMap,进行测试:

package cn.org.xiaosheng.test;

import cn.org.xiaosheng.custom.CacheUtils;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.concurrent.TimeUnit;

@SpringBootTest
public class LRUCacheTest {

    @Test
    void contextLoads() throws InterruptedException {
        // 写入缓存数据 2秒后过期
        CacheUtils.put("name", "qx", 2);

        Object value1 = CacheUtils.get("name");
        System.out.println("第一次查询结果:" + value1);

        // 停顿3秒
        TimeUnit.SECONDS.sleep(3);

        Object value2 = CacheUtils.get("name");
        System.out.println("第二次查询结果:" + value2);
    }
}
第一次查询结果:qx
第二次查询结果:null

实现超时过期的缓存容器

要实现超时过期的缓存容器,我们需要注意两个问题:

老生常谈的线程安全,这里我们可以利用ConcurrentHashMap进行存储容器

超时过期,这里可以利用一个定时线程任务去实时进行时间判断,清除过期数据

为了提高降低定时任务线程带来的性能消耗,这里我使用了线程池进行构建,关于线程池以及异步执行可以看异步编排执行

具体代码如下:

package cn.org.xiaosheng.custom;

import lombok.Data;

@Data
public class MyCache {

    /**
     * 键
     */
    private String key;

    /**
     * 值
     */
    private Object value;

    /**
     * 过期时间
     */
    private Long expireTime;


}
package cn.org.xiaosheng.custom;

import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

/**
 * 采用定时清楚缓存的方式
 * 新增容器MyCache,在容器中装载key(键), value(值), expiretime(过期时间)
 */
public class CacheUtils {

    /**
     * 缓存数据Map
     */
    private static final Map<String, MyCache> CACHE_MAP = new ConcurrentHashMap<String, MyCache>();

    /**
     * 定时器线程池,用于清除过期缓存
     */
    private static final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();


    static {
        // 注册一个定时线程任务,服务启动1秒之后,每隔500毫秒执行一次
        // 定时清理过期缓存
        executorService.scheduleAtFixedRate(CacheUtils::clearCache, 1000, 500, TimeUnit.MILLISECONDS);
    }

    /**
     * 添加缓存
     *
     * @param key    缓存键
     * @param value  缓存值
     * @param expire 过期时间,单位秒
     */
    public static void put(String key, Object value, long expire) {
        MyCache myCache = new MyCache();
        myCache.setKey(key);
        myCache.setValue(value);
        if (expire > 0) {
            /**
             * 当前时间戳+过期时长(毫秒数)
             * Duration.ofSeconds(expire).toMillis() 是Java 8中 java.time.Duration 类的一个使用方式。
             * 这段代码的含义是将一段时长(用秒数表示)转化为毫秒数。具体来说,Duration.ofSeconds(expire) 会创建一个表示 expire 秒的时长,然后 toMillis() 方法会将这个时长转换为毫秒数。
             * 例如,如果 expire 的值为60(即60秒或1分钟),那么 Duration.ofSeconds(expire).toMillis() 将返回60000,表示60000毫秒或1分钟。
             */
            long expireTime = System.currentTimeMillis() + Duration.ofSeconds(expire).toMillis();
            myCache.setExpireTime(expireTime);
        }
        CACHE_MAP.put(key, myCache);
    }

    /**
     * 获取缓存
     *
     * @param key 缓存键
     * @return 缓存数据
     */
    public static Object get(String key) {
        if (CACHE_MAP.containsKey(key)) {
            return CACHE_MAP.get(key).getValue();
        }
        return null;
    }

    /**
     * 移除缓存
     *
     * @param key 缓存键
     */
    public static void remove(String key) {
        CACHE_MAP.remove(key);
    }

    /**
     * 清理过期的缓存数据
     */
    private static void clearCache() {
        if (CACHE_MAP.size() <= 0) {
            return;
        }
        // 判断是否过期 过期就从缓存Map删除这个元素
        CACHE_MAP.entrySet().removeIf(entry -> entry.getValue().getExpireTime() != null && entry.getValue().getExpireTime() > System.currentTimeMillis());
    }

}

暂时我们就实现这两种自定义缓存,后续可能会写一套详细缓存框架,如果完成了会及时开源给大家。

引入中间件实现缓存

通过Redis实现缓存

Redis概述

Redis的作者笔名叫antirez,2008年的时候他做了一个记录网站访问情况的系统,比如每天有多少个用户,多少个页面被浏览,访客的IP、操作系统、浏览器、使用的搜索关键词等等(跟百度统计、CNZZ功能一样)。最开始存储方案用MySQL,效率太低,09年的时候antirez就自己写了一个内存的List,这个就是Redis。

在这里插入图片描述

 最开始Redis只支持List。现在数据类型丰富了、功能也丰富了,在全世界都非常流行。Redis的全称是Remote Dictionary Service,直接翻译过来是远程字典服务。

  从Redis的诞生历史可以看出,在某些场景中,关系型数据库并不适合用来存储我们的Web应用的数据。那么,关系型数据库和非关系型数据库,或者说SQL和NoSQL有什么区别呢

SQL和NoSQL

在绝大部分时候,我们都会首先考虑用关系型数据库来存储业务数据,比如 SQLServer, Oracle, MySQL等等。关系型数据库的特点:

:one: 它以表格的形式,基于行存储数据,是一个二维的模式。 :two: 它存储的是结构化的数据,数据存储有固定的模式(schema),数据需要适应表结构。 :three: 表与表之间存在关联(Relationship)。 :four: 大部分关系型数据库都支持SQL (结构化查询语言)的操作,支持复杂的关联查询。 :five: 通过支持事务(ACID酸)来提供严格或者实时的数据一致性。

但是使用关系型数据库也存在一些限制,比如:

要实现扩容的话,只能向上(垂直)扩展,比如磁盘限制了数据的存储,就要扩大磁盘容量,通过堆硬件的方式,不支持动态的扩缩容。水平扩容需要复杂的技术来实现,比如分库分表。 表结构修改困难,因此存储的数据格式也受到限制。 关系型数据库通常会把数据持久化到磁盘,在高并发和高数据量的情况下, 基于磁盘的读写压力比较大。

为了规避关系型数据库的一系列问题,我们就有了非关系型的数据库,我们一般把它叫做"non-relational"或者 “Not Only SQL”NoSQL 最开始是不提供 SQL(Structured Query Language结构化查询语言)的数据库的意思,但是后来意思慢慢地发生了变化。非关系型数据库的特点:

存储非结构化的数据,比如文本、图片、音频、视频。 表与表之间没有关联,可扩展性强。 保证数据的最终一致性,遵循BASE (碱)理论。Basically Available (基本可用);Soft-state (软状态);Eventually Consistent (最终一致性)。 支持海量数据的存储和高并发的高效读写。

支持分布式,能够对数据进行分片存储,扩缩容简单。对于不同的存储类型,我们又有各种各样的非关系型数据库,比如有几种常见的类型:

  1. KV存储:RedisMemcached
  2. 文档存储:MongoDB
  3. 列存储:HBase
  4. 图存储:Neo4j
  5. 对象存储
  6. XML存储等

 随着行业的发展,还出现了所谓的NewSQL数据库。NewSQL 结合了 SQL和 NoSQL 的特性。例如 TiDB (PingCAP)、VoltDB、ScaleDB等。

特性SQLNoSQLNewSQL
关系模型×
SQL语法×
ACID×
水平扩展×
海量数据×
无结构化××
Redis数据类型与结构

Redis 有许多重要的数据结构,其存储结构从外层往内层依次是 redisDb、dict、dictht、dictEntry。redisDb 默认情况下有16个,每个 redisDb 内部包含一个 dict 的数据结构,dict 内部包含 dictht 数组,数组个数为2,主要用于 hash 扩容使用。dictht 内部包含 dictEntry 的数组,dictEntry 其实就是 hash 表的一个 key-value 节点。   我们谈到的redis数据类型指的就是dictEntry里面value对象的数据类型。 在这里插入图片描述

它支持多种数据结构,包括以下几种:

  1. 字符串(String):字符串是Redis最基本的类型,可以包含任何数据,例如jpeg图像或者序列化的对象。它在最大长度为512MB的限制内可以包含任何长度的字符串。
  2. 列表(List):Redis列表是简单的字符串列表,按照插入顺序排序。可以添加一个元素到列表的头部(左边)或者尾部(右边)。
  3. 集合(Set):Redis的Set是字符串的无序集合。和列表一样,你可以添加、删除、查找元素。但是,它保证每个元素只会出现一次,即每个元素都是唯一的。
  4. 有序集合(Sorted Set):Redis有序集合和集合一样也是字符串的集合,不同的是每个元素都会关联一个double类型的分数。Redis通过分数来为集合中的成员进行从小到大的排序。
  5. 哈希(Hash):Redis哈希是一个字符串值的集合。每个哈希可以存储430亿个键值对。

以上就是Redis支持的数据结构,每种数据结构都有其适用的场景,可以根据项目的实际需求来选择合适的数据结构。

Redis实战
编写配置类,工具类

配置类

package cn.org.xiaosheng.common.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;

/**
 * @Description  redis配置类
 * @Author xiaosheng
 */
@Configuration
public class RedisConfig {
    /**
     *  定制Redis API模板RedisTemplate
     * @param redisConnectionFactory
     * @return
     */
    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<Object, Object> template = new RedisTemplate();
        template.setConnectionFactory(redisConnectionFactory);
        // 使用JSON格式序列化对象,对缓存数据key和value进行转换
        Jackson2JsonRedisSerializer jacksonSeial = new Jackson2JsonRedisSerializer(Object.class);
        // 解决查询缓存转换异常的问题
        ObjectMapper om = new ObjectMapper();
        System.out.println("这里是RedisTemplate");
        // 指定要序列化的域,field,get和set,以及修饰符范围,ANY是都有包括private和public
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        // 指定序列化输入的类型,类必须是非final修饰的,final修饰的类,比如String,Integer等会跑出异常
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jacksonSeial.setObjectMapper(om);
        // 设置RedisTemplate模板API的序列化方式为JSON
        template.setDefaultSerializer(jacksonSeial);
        return template;
    }

    /**
     *
     * 定制Redis缓存管理器RedisCacheManager,实现自定义序列化并设置缓存时效
     * @param redisConnectionFactory
     * @return
     */
//    @Bean
//    public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
//        // 分别创建String和JSON格式序列化对象,对缓存数据key和value进行转换
//        RedisSerializer<String> strSerializer = new StringRedisSerializer();
//        Jackson2JsonRedisSerializer jacksonSeial = new Jackson2JsonRedisSerializer(Object.class);
//        // 解决查询缓存转换异常的问题
//        ObjectMapper om = new ObjectMapper();
//        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
//        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
//        jacksonSeial.setObjectMapper(om);
//        // 定制缓存数据序列化方式及时效
//        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
//                .entryTtl(Duration.ofDays(1))   // 设置缓存有效期为1天
//                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(strSerializer))
//                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jacksonSeial))
//                .disableCachingNullValues();   // 对空数据不进行缓存
//        RedisCacheManager cacheManager = RedisCacheManager.builder(redisConnectionFactory).cacheDefaults(config).build();
//        return cacheManager;
//    }
}


工具类

package cn.org.xiaosheng.common.utils;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;

import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;

/**
 * Redis工具类
 */
@Component
@Slf4j
public class RedisUtils {

    @Resource
    private RedisTemplate redisTemplate;


    /**
     * 指定缓存失效时间
     *
     * @param key  键
     * @param time 时间(秒)
     * @return
     */
    public boolean expire(String key, long time) {
        try {
            if (time > 0) {
                redisTemplate.expire(key, time, TimeUnit.SECONDS);
            }
            return true;
        } catch (Exception e) {
            log.error("指定缓存失效时间出现异常 error {} e {}", e.getMessage(), e);
            return false;
        }
    }

    /**
     * 根据key 获取过期时间
     *
     * @param key 键 不能为null
     * @return 时间(秒) 返回0代表为永久有效
     */
    public long getExpire(String key) {
        return redisTemplate.getExpire(key, TimeUnit.SECONDS);
    }

    /**
     * 判断key是否存在
     *
     * @param key 键
     * @return true 存在 false不存在
     */
    public boolean hasKey(String key) {
        try {
            return redisTemplate.hasKey(key);
        } catch (Exception e) {
            log.error("判断key是否存在 error {} e {}", e.getMessage(), e);
            return false;
        }
    }

    /**
     * 删除缓存
     *
     * @param key 可以传一个值 或多个
     */
    @SuppressWarnings("unchecked")
    public void del(String... key) {
        if (key != null && key.length > 0) {
            if (key.length == 1) {
                redisTemplate.delete(key[0]);
            } else {
                redisTemplate.delete(CollectionUtils.arrayToList(key));
            }
        }
    }

    /*                         String                           */

    /**
     * 普通缓存获取
     *
     * @param key 键
     * @return 值
     */
    public Object get(String key) {
        return key == null ? null : redisTemplate.opsForValue().get(key);
    }

    /**
     * 普通缓存放入
     *
     * @param key   键
     * @param value 值
     * @return true成功 false失败
     */
    public boolean set(String key, Object value) {
        try {
            redisTemplate.opsForValue().set(key, value);
            return true;
        } catch (Exception e) {
            log.error("普通缓存放入 error {} e {}", e.getMessage(), e);
            return false;
        }
    }

    /**
     * 普通缓存放入并设置时间
     *
     * @param key   键
     * @param value 值
     * @param time  时间(秒) time要大于0 如果time小于等于0 将设置无限期
     * @return true成功 false 失败
     */
    public boolean set(String key, Object value, long time) {
        try {
            if (time > 0) {
                redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
            } else {
                set(key, value);
            }
            return true;
        } catch (Exception e) {
            log.error("普通缓存放入并设置时间 出现异常 error {} e {}", e.getMessage(), e);
            return false;
        }
    }

    /**
     * 递增
     *
     * @param key   键
     * @param delta 要增加几(大于0)
     * @return
     */
    public long incr(String key, long delta) {
        if (delta < 0) {
            throw new RuntimeException("递增因子必须大于0");
        }
        return redisTemplate.opsForValue().increment(key, delta);
    }

    /**
     * 递减
     *
     * @param key   键
     * @param delta 要减少几(小于0)
     * @return
     */
    public long decr(String key, long delta) {
        if (delta < 0) {
            throw new RuntimeException("递减因子必须大于0");
        }
        return redisTemplate.opsForValue().increment(key, -delta);
    }

    /*                         Map                           */

    /**
     * HashGet
     *
     * @param key  键 不能为null
     * @param item 项 不能为null
     * @return 值
     */
    public Object hget(String key, String item) {
        return redisTemplate.opsForHash().get(key, item);
    }

    /**
     * 获取hashKey对应的所有键值
     *
     * @param key 键
     * @return 对应的多个键值
     */
    public Map<Object, Object> hmget(String key) {
        return redisTemplate.opsForHash().entries(key);
    }

    /**
     * HashSet
     *
     * @param key 键
     * @param map 对应多个键值
     * @return true 成功 false 失败
     */
    public boolean hmset(String key, Map<String, Object> map) {
        try {
            redisTemplate.opsForHash().putAll(key, map);
            return true;
        } catch (Exception e) {
            log.error("error {} e {}", e.getMessage(), e);
            return false;
        }
    }

    /**
     * HashSet 并设置时间
     *
     * @param key  键
     * @param map  对应多个键值
     * @param time 时间(秒)
     * @return true成功 false失败
     */
    public boolean hmset(String key, Map<String, Object> map, long time) {
        try {
            redisTemplate.opsForHash().putAll(key, map);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            log.error("HashSet设置时间 出现异常 error {} e {}", e.getMessage(), e);
            return false;
        }
    }

    /**
     * 向一张hash表中放入数据,如果不存在将创建
     *
     * @param key   键
     * @param item  项
     * @param value 值
     * @return true 成功 false失败
     */
    public boolean hset(String key, String item, Object value) {
        try {
            redisTemplate.opsForHash().put(key, item, value);
            return true;
        } catch (Exception e) {
            log.error("向一张hash表中放入数据,如果不存在将创建 出现异常 error {} e {}", e.getMessage(), e);
            return false;
        }
    }

    /**
     * 向一张hash表中放入数据,如果不存在将创建
     *
     * @param key   键
     * @param item  项
     * @param value 值
     * @param time  时间(秒) 注意:如果已存在的hash表有时间,这里将会替换原有的时间
     * @return true 成功 false失败
     */
    public boolean hset(String key, String item, Object value, long time) {
        try {
            redisTemplate.opsForHash().put(key, item, value);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            log.error("向一张hash表中放入数据,如果不存在将创建 出现异常 error {} e {}", e.getMessage(), e);
            return false;
        }
    }

    /**
     * 删除hash表中的值
     *
     * @param key  键 不能为null
     * @param item 项 可以使多个 不能为null
     */
    public void hdel(String key, Object... item) {
        redisTemplate.opsForHash().delete(key, item);
    }

    /**
     * 判断hash表中是否有该项的值
     *
     * @param key  键 不能为null
     * @param item 项 不能为null
     * @return true 存在 false不存在
     */
    public boolean hHasKey(String key, String item) {
        return redisTemplate.opsForHash().hasKey(key, item);
    }

    /**
     * hash递增 如果不存在,就会创建一个 并把新增后的值返回
     *
     * @param key  键
     * @param item 项
     * @param by   要增加几(大于0)
     * @return
     */
    public double hincr(String key, String item, double by) {
        return redisTemplate.opsForHash().increment(key, item, by);
    }

    /**
     * hash递减
     *
     * @param key  键
     * @param item 项
     * @param by   要减少记(小于0)
     * @return
     */
    public double hdecr(String key, String item, double by) {
        return redisTemplate.opsForHash().increment(key, item, -by);
    }

    /*                         Set                           */

    /**
     * 根据key获取Set中的所有值
     *
     * @param key 键
     * @return
     */
    public Set<Object> sGet(String key) {
        try {
            return redisTemplate.opsForSet().members(key);
        } catch (Exception e) {
            log.error("根据key获取Set中的所有值 出现异常 error {} e {}", e.getMessage(), e);
            return null;
        }
    }

    /**
     * 根据value从一个set中查询,是否存在
     *
     * @param key   键
     * @param value 值
     * @return true 存在 false不存在
     */
    public boolean sHasKey(String key, Object value) {
        try {
            return redisTemplate.opsForSet().isMember(key, value);
        } catch (Exception e) {
            log.error("根据value从一个set中查询,是否存在 出现异常 error {} e {}", e.getMessage(), e);
            return false;
        }
    }

    /**
     * 将数据放入set缓存
     *
     * @param key    键
     * @param values 值 可以是多个
     * @return 成功个数
     */
    public long sSet(String key, Object... values) {
        try {
            return redisTemplate.opsForSet().add(key, values);
        } catch (Exception e) {
            log.error("将数据放入set缓存 出现异常 error {} e {}", e.getMessage(), e);
            return 0;
        }
    }

    /**
     * 将set数据放入缓存
     *
     * @param key    键
     * @param time   时间(秒)
     * @param values 值 可以是多个
     * @return 成功个数
     */
    public long sSetAndTime(String key, long time, Object... values) {
        try {
            Long count = redisTemplate.opsForSet().add(key, values);
            if (time > 0) {
                expire(key, time);
            }
            return count;
        } catch (Exception e) {
            log.error("将set数据放入缓存 出现异常 error {} e {}", e.getMessage(), e);
            return 0;
        }
    }

    /**
     * 获取set缓存的长度
     *
     * @param key 键
     * @return
     */
    public long sGetSetSize(String key) {
        try {
            return redisTemplate.opsForSet().size(key);
        } catch (Exception e) {
            log.error("获取set缓存的长度 出现异常 error {} e {}", e.getMessage(), e);
            return 0;
        }
    }

    /**
     * 移除值为value的
     *
     * @param key    键
     * @param values 值 可以是多个
     * @return 移除的个数
     */
    public long setRemove(String key, Object... values) {
        try {
            Long count = redisTemplate.opsForSet().remove(key, values);
            return count;
        } catch (Exception e) {
            log.error("移除值为value的 出现异常 error {} e {}", e.getMessage(), e);
            return 0;
        }
    }

    /*                         List                           */

    /**
     * 获取list缓存的内容
     *
     * @param key   键
     * @param start 开始
     * @param end   结束 0 到 -1代表所有值
     * @return
     */
    public List<Object> lGet(String key, long start, long end) {
        try {
            return redisTemplate.opsForList().range(key, start, end);
        } catch (Exception e) {
            log.error("获取list缓存的内容 出现异常 error {} e {}", e.getMessage(), e);
            return null;
        }
    }

    /**
     * 获取list缓存的长度
     *
     * @param key 键
     * @return
     */
    public long lGetListSize(String key) {
        try {
            return redisTemplate.opsForList().size(key);
        } catch (Exception e) {
            log.error("获取list缓存的长度 出现异常 error {} e {}", e.getMessage(), e);
            return 0;
        }
    }

    /**
     * 通过索引 获取list中的值
     *
     * @param key   键
     * @param index 索引 index>=0时, 0 表头,1 第二个元素,依次类推;index<0时,-1,表尾,-2倒数第二个元素,依次类推
     * @return
     */
    public Object lGetIndex(String key, long index) {
        try {
            return redisTemplate.opsForList().index(key, index);
        } catch (Exception e) {
            log.error("通过索引 获取list中的值 出现异常 error {} e {}", e.getMessage(), e);
            return null;
        }
    }

    /**
     * 将list放入缓存
     *
     * @param key   键
     * @param value 值
     * @return
     */
    public boolean lSet(String key, Object value) {
        try {
            redisTemplate.opsForList().rightPush(key, value);
            return true;
        } catch (Exception e) {
            log.error("将list放入缓存 出现异常 error {} e {}", e.getMessage(), e);
            return false;
        }
    }

    /**
     * 将list放入缓存
     *
     * @param key   键
     * @param value 值
     * @param time  时间(秒)
     * @return
     */
    public boolean lSet(String key, Object value, long time) {
        try {
            redisTemplate.opsForList().rightPush(key, value);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            log.error("将list放入缓存 出现异常 error {} e {}", e.getMessage(), e);
            return false;
        }
    }

    /**
     * 将list放入缓存
     *
     * @param key   键
     * @param value 值
     * @return
     */
    public boolean lSet(String key, List<Object> value) {
        try {
            redisTemplate.opsForList().rightPushAll(key, value);
            return true;
        } catch (Exception e) {
            log.error("将list放入缓存 出现异常 error {} e {}", e.getMessage(), e);
            return false;
        }
    }

    /**
     * 将list放入缓存
     *
     * @param key   键
     * @param value 值
     * @param time  时间(秒)
     * @return
     */
    public boolean lSet(String key, List<Object> value, long time) {
        try {
            redisTemplate.opsForList().rightPushAll(key, value);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            log.error("将list放入缓存 出现异常 error {} e {}", e.getMessage(), e);
            return false;
        }
    }

    /**
     * 根据索引修改list中的某条数据
     *
     * @param key   键
     * @param index 索引
     * @param value 值
     * @return
     */
    public boolean lUpdateIndex(String key, long index, Object value) {
        try {
            redisTemplate.opsForList().set(key, index, value);
            return true;
        } catch (Exception e) {
            log.error("根据索引修改list中的某条数据 出现异常 error {} e {}", e.getMessage(), e);
            return false;
        }
    }

    /**
     * 移除N个值为value
     *
     * @param key   键
     * @param count 移除多少个
     * @param value 值
     * @return 移除的个数
     */
    public long lRemove(String key, long count, Object value) {
        try {
            Long remove = redisTemplate.opsForList().remove(key, count, value);
            return remove;
        } catch (Exception e) {
            log.error("移除N个值为value 出现异常 error {} e {}", e.getMessage(), e);
            return 0;
        }
    }

}
创建切面(可选)

切面可以让你在操作redis存储时进行环绕处理,还可以写注解,通过注解的方式进行处理

package cn.org.xiaosheng.common.aspect;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

/**
 * @Description Redis切面类
 * @Author xiaosheng
 */
@Aspect
@Slf4j
public class RedisAspect {
    @Pointcut("execution(* cn.org.xiaosheng.common.utils.*(..))")
    public void pointcut(){
    }
    @Around("pointcut()")
    public Object handleException(ProceedingJoinPoint joinPoint){
        Object result = null;
        try {
            result= joinPoint.proceed();
        } catch (Throwable throwable) {
            log.error("Redis似乎出现了某些不可违因素");
        }
        return result;
    }
}
测试
package cn.org.xiaosheng.test;

import cn.org.xiaosheng.common.utils.RedisUtils;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;

import javax.annotation.Resource;

@SpringBootTest
public class RedisCacheTest {

    @Autowired
    private RedisTemplate<String, String> template;

    @Resource
    private RedisUtils redisUtils;

    @Test
    public void test() {
        // 初始化需要设置一些值和比如序列化方式等等,否则会报错
//        RedisTemplate<String, String> template = new RedisTemplate<>();
        template.opsForValue().set("name","Alice");
        String name = template.opsForValue().get("name");
        System.out.println("获取到的值:" + name); // 输出:获取到的值:Alice
        template.delete("name");
    }

    @Test
    public void saveValue() {
        //存入Redis
        redisUtils.set("username", "Hello XiaoSheng!");
        System.out.println("保存成功!!!");
        //根据key取出
        String username = (String) redisUtils.get("username");
        System.out.println("username="+username);
        redisUtils.del("username");
    }
}

通过运行测试,我们发现效果是达到预期的。

image-20240509152526053

设置浏览器缓存

在 Java Web应用中,实现浏览器缓存可以使用HttpServletResponse 对象来设置与缓存相关的响应头,以开启浏览器的缓存功能,它的具体实现分为以下几步。

配置Cache-Control

Cache-Control 是 HTTP/1.1 中用于控制缓存策略的主要方式。它可以设置多个指令,如 max-age(定义资源的最大存活时间,单位秒)、no-cache(要求重新验证)、public(指示可以被任何缓存区缓存)、private(只能被单个用户私有缓存存储)等,设置如下:

response.setHeader("Cache-Control", "max-age=3600, public"); // 缓存一小时

配置Expires

设置一个绝对的过期时间,超过这个时间点后浏览器将不再使用缓存的内容而向服务器请求新的资源,设置如下:

response.setDateHeader("Expires", System.currentTimeMillis() + 3600 * 1000); // 缓存一小时

配置ETag

ETag(实体标签)一种验证机制,它为每个版本的资源生成一个唯一标识符。当客户端发起请求时,会携带上先前接收到的 ETag,服务器根据 ETag 判断资源是否已更新,若未更新则返回 304 Not Modified 状态码,通知浏览器继续使用本地缓存,设置如下:

String etag = generateETagForContent(); // 根据内容生成ETag
response.setHeader("ETag", etag);

配置Last-Modified

指定资源最后修改的时间戳,浏览器下次请求时会带上 If-Modified-Since 头,服务器对比时间戳决定是否返回新内容或发送 304 状态码,设置如下:

long lastModifiedDate = getLastModifiedDate();
response.setDateHeader("Last-Modified", lastModifiedDate);

具体代码

在 Spring Web 框架中,可以通过 HttpServletResponse 对象来设置这些头信息。例如,在过滤器中设置响应头以启用缓存:

package cn.org.xiaosheng.common.filter;

import javax.servlet.*;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class CacheFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        // 设置缓存策略,缓存一小时
        httpResponse.setHeader("Cache-Control", "max-age=3600");
        // 设置一个绝对的过期时间,超过这个时间点后浏览器将不再使用缓存的内容而向服务器请求新的资源, 这里设置过期时间一小时
        httpResponse.setDateHeader("Expires", System.currentTimeMillis() + 3600 * 1000);
        // 使缓存通行
        chain.doFilter(request, response);
    }
}

以上就是在 Java Web 应用程序中利用 HTTP 协议特性控制浏览器缓存的基本方法。

接下来聊一下Nginx缓存。

Nginx缓存

Nginx 中开启缓存的配置总共有以下 5 步。

定义缓存配置

在 Nginx 配置中定义一个缓存路径和配置,通过 proxy_cache_path 指令完成,例如,以下配置:

proxy_cache_path /path/to/cache levels=1:2 keys_zone=my_cache:10m max_size=10g inactive=60m use_temp_path=off;

其中:

  • /path/to/cache:这是缓存文件的存放路径。
  • levels=1:2:定义缓存目录的层级结构。
  • keys_zone=my_cache:10m:定义一个名为 my_cache 的共享内存区域,大小为 10MB。
  • max_size=10g:设置缓存的最大大小为 10GB。
  • inactive=60m:如果在 60 分钟内没有被访问,缓存将被清理。
  • use_temp_path=off:避免在文件系统中进行不必要的数据拷贝。

启用缓存

在 server 或 location 块中,使用 proxy_cache 指令来启用缓存,并指定要使用的 keys zone,例如,以下配置:

server {  
    ...  
    location / {  
        proxy_cache my_cache;  
        ...  
    }  
}

设置缓存有效期

使用 proxy_cache_valid 指令来设置哪些响应码的缓存时间,例如,以下配置:

location / {  
    proxy_cache my_cache;  
    proxy_cache_valid 200 304 12h;  
    proxy_cache_valid any 1m;  
    ...  
}

配置反向代理

确保你已经配置了反向代理,以便 Nginx 可以将请求转发到后端服务器。例如,以下配置:

location / {  
    proxy_pass http://backend_server;  
    ...  
}

重新加载配置

保存并关闭 Nginx 配置文件后,重新加载配置,使更改生效。

// 测试nginx配置文件语法配置是否无误
nginx -t  
// 重新加载配置文件
nginx -s reload

下面我们聊一下围绕缓存技术比较核心的几点问题。

缓存核心问题

缓存由于其高并发和高性能的特性,已经在项目中被广泛使用。尤其是在高并发、分布式和微服务的业务场景和架构下。

无论是高并发、分布式还是微服务都依赖于高性能的服务器。而谈到高性能服务器,就必谈缓存。

所谓高性能主要体现在高可用情况下,业务处理时间短,数据正确。

数据处理及时就是个“空间换时间”的问题,利用分布式内存或者闪存等可以快速存取的设备,来替代部署在一般服务器上的数据库,机械硬盘上存储的文件,这是缓存提升服务器性能的本质。

高并发(High Concurrency): 是互联网分布式系统架构设计中必须考虑的因素之一,它通常是指,通过设计保证系统能够同时并行处理很多请求。

分布式: 是以缩短单个任务的执行时间来提升效率的。 比如一个任务由10个子任务组成,每个子任务单独执行需1小时,则在一台服务器上执行改任务需10小时。 采用分布式方案,提供10台服务器,每台服务器只负责处理一个子任务,不考虑子任务间的依赖关系,执行完这个任务只需一个小时。

微服务: 架构强调的第一个重点就是业务系统需要彻底的组件化和服务化,原有的单个业务系统会拆分为多个可以独立开发,设计,运行和运维的小应用。这些小应用之间通过服务完成交互和集成。

缓存一致性问题

当数据源中的数据发生修改时,如何保证缓存中的数据与数据源保持一致是一个重要的问题。在分布式系统中,使用本地缓存最大的问题就是一致性问题,所谓的一致性问题指的是当数据库发生数据变更时,缓存也要跟着一起变更。而分布式系统中每台机器都有自己的本地缓存,所以想要保证(本地缓存的)一致性是一个比较难的问题,但通过以下手段可以最大程度的保证本地缓存的一致性问题。

① 设置本地缓存短时间内失效

设置本地缓存短时间内失效,短的存活周期,保证了数据的时效性比较高,当数据失效之后,再次访问数据就会拉取新的数据了,这样能尽可能的保证数据的一致性。

它的特点是:代码实现简单,不需要写多余的代码;缺点是,效果不是很明显,不适合高并发的系统。

② 通过配置中心协调和同步

通过微服务中的配置中心(例如 Nacos)来协调,因为所有服务器都会连接到配置中心,所以当数据修改之后,可以修改配置中心的配置,然后配置中心再把配置变更的事件推送给各个服务,各个服务感知到配置中心的配置发生更改之后,再更新自己的本地缓存,这样就实现了本地缓存的数据一致性。

③ 本地缓存自动更新功能

使用本地缓存框架的自动更新功能,例如 Caffeine 中的 refresh 功能来自动刷新缓存,这样就可以设置很短的时间来更新最新的数据,从而也能尽可能的保证数据的一致性,如下代码所示:

// 创建 Caffeine 缓存实例
Cache<String, String> caffeineCache = Caffeine.newBuilder()
// 设置缓存项在 5s 后开始自动更新
.refreshAfterWrite(5, TimeUnit.SECONDS)
// 自定义缓存更新逻辑(即获取新值逻辑)
.build(new CacheLoader<String, String>() {
    @Override
    public void reload(String key, String oldValue) throws Exception {
        // 模拟更新缓存的操作
        updateCache(key, oldValue);
    }
});

不同的业务系统,会采用不同的解决方案,例如以下这些场景和对应的解决方案:

  • 如果对数据一致性要求不是很高,并且程序的并发压力不大的情况下,可能使用方案 1,也就是设置本地缓存短时间内失效的解决方案,因为它的实现最简单。
  • 如果对数据的一致性要求极高,且有配置中心的情况下,可使用配置中心协调和同步本地缓存。
  • 相反,如果对一致性要求没有那么高,且为高并发的系统,那么可以采用本地缓存的自动更新功能来实现。

缓存的淘汰策略

当缓存满了以后,我们需要淘汰一部分缓存数据,以腾出空间存储新的数据。常见的缓存淘汰算法有FIFO(先进先出)、LRU(最近最少使用)、LFU(最不经常使用)等。

缓存雪崩

产生原因:

a. 由于Cache层承载着大量请求,有效的保护了Storage层(通常认为此层抗压能力稍弱),所以Storage的调用量实际很低,所以它很爽。

b. 但是,如果Cache层由于某些原因(宕机、cache服务挂了或者不响应了)整体crash掉了,也就意味着所有的请求都会达到Storage层,所有Storage的调用量会暴增,所以它有点扛不住了,甚至也会挂掉

产生原因2. 我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。

雪崩问题在国外叫做:stampeding herd(奔逃的野牛),指的的cache crash后,流量会像奔逃的野牛一样,打向后端。

image-20240509154400826

解决方案如下:
加锁/队列 保证缓存单线程的写

失效时的雪崩效应对底层系统的冲击非常可怕。

大多数系统设计者考虑用加锁或者队列的方式保证缓存的单线 程(进程)写,从而避免失效时大量的并发请求落到底层存储系统上。

加锁排队只是为了减轻数据库的压力,并没有提高系统吞吐量。

假设在高并发下,缓存重建期间key是锁着的,这是过来1000个请求999个都在阻塞的。同样会导致用户等待超时,这是个治标不治本的方法!

加锁排队的解决方式分布式环境的并发问题,有可能还要解决分布式锁的问题;线程还会被阻塞,用户体验很差!因此,在真正的高并发场景下很少使用!

避免缓存同时失效

将缓存失效时间分散开,比如我们可以在原有的失效时间基础上,末尾增加一个随机值。

缓存降级

当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。

系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。

降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。

在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅;从而梳理出哪些必须誓死保护,哪些可降级。

比如可以参考日志级别设置预案:

(1)一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;

(2)警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;

(3)错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;

(4)严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。

image-20240509154730067

缓存击穿/缓存穿透

产生原因:

缓存穿透是指查询一个一定不存在的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。

image-20240509154848650

解决方案
缓存空值

一个简单粗暴的方法,如果一个查询返回的数据为空(不管是数 据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。

image-20240509155019090

采用布隆过滤器

最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被 这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。

例如,商城有100万用户数据,将所有用户id刷入一个Map。

当请求过来以后,先判断Map中是否包含该用户id,不包含直接返回,包含的话先去缓存中查是否有这条数据,有的话返回,没有的话再去查数据库。

这样不仅减轻了数据库的压力,缓存系统的压力也将大大降低。

缓存并发控制

在高并发场景下,如何保证缓存系统的线程安全和高效性,需要采取一些并发控制策略,例如使用锁、信号量等并发控制机制。

缓存预热问题

统在启动时,如果能将热点数据加载进缓存,可以避免缓存冷启动问题,提高系统的响应速度。

最后

以上就是针对缓存从实现层面到核心问题的理论层面的详细描述,实现层面包含本地缓存,自定义缓存,引入中间件的缓存,以及浏览器缓存,Nginx缓存。内容详实且丰富,可能在一些内容和技术点上存在一些纰漏或者不正确的地方,希望读者朋友不吝赐教,共同成长,一起为中国的IT事业贡献自己的能量。

文章中的所有代码均在:https://gitee.com/Sheng-ShengBuXi/code-train.git,开源仓库,大家可以拉取下来学习调试。