Spring RE 11 Spring Cache

Spring RE 11 Spring Cache

自从在CSAPP中看完了高速缓存的原理, 对于缓存这个东西的原理搞清楚了, 不过实际的缓存开发中也是另外一个世界, 有很多术语. 由于缓存的本质也是找点地方在哪里存着, 是一种存储机制, 所以Spring的缓存机制很类似使用数据库, 用一套统一的东西封装底层的东西, 不管后台使用哪种缓存框架, 都可

自从在CSAPP中看完了高速缓存的原理, 对于缓存这个东西的原理搞清楚了, 不过实际的缓存开发中也是另外一个世界, 有很多术语.

由于缓存的本质也是找点地方在哪里存着, 是一种存储机制, 所以Spring的缓存机制很类似使用数据库, 用一套统一的东西封装底层的东西, 不管后台使用哪种缓存框架, 都可以一致的解决问题.

  1. 缓存常见术语
  2. 简单的缓存例子
  3. 缓存注解 - @Cacheable
  4. 缓存注解 - @Cacheput
  5. 缓存注解 - @CacheEvict
  6. 缓存注解 - @Caching
  7. 缓存注解 - @CacheConfig
  8. 缓存管理器
  9. 缓存注解中的SPEL表达式
  10. 基于XML的声明
  11. 配置第三方缓存

缓存常见术语

缓存核心的一个概念就是命中率, 即从缓存中读取的次数/总读取次数. 如果这个非常低, 那其他的概念就无需关心了, 必须先把这个提升上来, 命中率低下意味着缓存根本就没有发挥作用.

次要的一个概念是过期策略, 有很多种, 常见的是:

  1. FIFO, 先存入缓存的内容先过期
  2. LRU, 最久未使用的内容被移除
  3. LFU, 最近最少使用的内容被移除
  4. TTL, 一旦进入缓存, 就设置一个有效期, 有效期超过就移除缓存内容
  5. TTI, 一个内容超过一定时间没有被访问, 就被移除.

缓存工作的逻辑是, 当需要读取数据的时候, 先从缓存内读取, 如果缓存内没有, 再从数据源(或者设置的其他路径)中读取.

缓存的原理其实就是在查询之前, 先去缓存中查找对应的数据, 如果找到就直接返回, 如果没找到, 就继续到其他数据源中寻找. 当然这其中还有一些相关的问题, 比如一个数据对象更新了, 缓存中依然存有原来的数据对象, 要如何处理.

先用一个简单的例子来看.

简单的缓存例子

没有什么比自己写一个缓存例子来看一下缓存原理更好的方法了.编写一个简单的缓存管理器, 如下:

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class Cache<T> {
    private Map<String, T> cache = new ConcurrentHashMap<>();

    public T getValue(String key) {
        return cache.get(key);
    }

    public void addOrUpdateCache(String key, T value) {
        cache.put(key, value);
    }

    public void evictCache(String key) {
        cache.remove(key);
    }

    public void evictCache() {
        cache.clear();
    }
}

这里使用了并发版本的HashMap来当做缓存, 通过缓存管理器对象按照一个字符串类型的键来查找对象.

然后我们写一个简单的User类, 注意要实现Serializable接口:

import java.io.Serializable;

public class User implements Serializable {

    private String userId;
    private String userName;
    private int age;

    public User() {
    }

    public User(String userId, String userName, int age) {
        this.userId = userId;
        this.userName = userName;
        this.age = age;
    }

    public String getUserId() {
        return userId;
    }

    public void setUserId(String userId) {
        this.userId = userId;
    }

    public String getUserName() {
        return userName;
    }

    public void setUserName(String userName) {
        this.userName = userName;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    @Override
    public String toString() {
        return "User{" +
                "userId='" + userId + '\'' +
                ", userName='" + userName + '\'' +
                ", age=" + age +
                '}';
    }
}

然后来写一个类, 模拟一个Service类, 手工创建另外一个类来当成数据库:

//当成数据库的类:
import java.util.ArrayList;
import java.util.List;

public class MockDB {

    private List<User> users = new ArrayList<>();

    {
        User user1 = new User("N1", "owl", 2);
        User user2 = new User("N2", "kiki", 1);
        User user3 = new User("N3", "niuniu", 1);
        User user4 = new User("N4", "Saner", 5);
        User user5 = new User("N5", "Sitong", 5);
        users.add(user1);
        users.add(user2);
        users.add(user3);
        users.add(user4);
        users.add(user5);
    }

    public User getUserById(String id) {
        try {
            System.out.println("正在从数据库中查找...");
            Thread.sleep(1000);
            return users.get(Integer.parseInt(String.valueOf(id.charAt(1))) - 1);

        } catch (Exception e) {
            System.out.println("未找到该记录");
            return null;
        }
    }

    public static void main(String[] args) {
        MockDB mockDB = new MockDB();

        System.out.println(mockDB.getUserById("N0"));
    }
}

//UserService类
public class UserService {

    private Cache<User> cacheManager;

    private MockDB mockDB = new MockDB();

    public UserService() {
        this.cacheManager = new Cache<>();
    }

    public User getUserById(String id) {
        User result = cacheManager.getValue(id);
        if (result == null) {
            result = mockDB.getUserById(id);
            cacheManager.addOrUpdateCache(id, result);
        }
        return result;
    }

    public void refresh() {
        cacheManager.evictCache();
    }
}

整个程序的逻辑就是UserService会根据id查找User对象, 查找的时候先去缓存中查询, 查询不到的话, 就到数据库中查询, Cache类是缓存管理器, MockDB是伪装的数据库类.

之后编写测试代码:

public static void main(String[] args) {
    UserService service = new UserService();

    service.getUserById("N2");
    service.getUserById("N2");
    service.getUserById("N2");
    service.getUserById("N2");
    service.refresh();
    System.out.println("-----------------");
    service.getUserById("N2");
    service.getUserById("N2");
    service.getUserById("N2");
}

可以发现, 第一次查询都要到数据库中查询, 之后会将查询到的结果写入到缓存中, 再进行查询, 就都是从缓存中拿数据了.

后来自己尝试缓存的时候, 发现没有生效, 可能是没有配置好, 或者直接使用的缘故. 后来我使用了之前学习时候的简单增删改查项目, 配置了缓存, 发现就可以了, 具体方式如下:

  1. 在src/main/webapp/WEB-INF/spring-mvc-crm.xml中添加如下配置:
        <cache:annotation-driven/>
        <bean id="cacheManager" class="org.springframework.cache.support.SimpleCacheManager">
            <property name="caches">
                <set>
                    <bean class="org.springframework.cache.concurrent.ConcurrentMapCacheFactoryBean" p:name="default"/>
                    <bean class="org.springframework.cache.concurrent.ConcurrentMapCacheFactoryBean" p:name="users"/>
                </set>
            </property>
        </bean>
        
    相关的命名空间和xis:location可以让IDE自动添加.
  2. 在cc/conyli/dao/CustomerDAOImpl.java中给查找方法加上两个打印语句表明执行了这个函数:
        @Override
        public List<Customer> getCustomers() {
            System.out.println("从数据库中查找多个");
            Session session = sessionFactory.getCurrentSession();
            return session.createQuery("From Customer customer ORDER BY customer.id", Customer.class).getResultList();
        }
    
        @Override
        public Customer getCustomer(int customerId) {
            System.out.println("从数据库中查找单个");
            Session session = sessionFactory.getCurrentSession();
            return session.get(Customer.class, customerId);
        }
            
  3. 给Service类加上缓存注解, 名称使用XML配置中的名称:
        @Override
        @Transactional
        @Cacheable(cacheNames = "users")
        public List<Customer> getCustomers() {
            return customerDAO.getCustomers();
        }
    
        @Override
        @Transactional
        @Cacheable(cacheNames = "users")
        public Customer getCustomer(int customerId) {
            return customerDAO.getCustomer(customerId);
        }
        

这么配置之后, 再启动项目, 可以发现在第一次查找的时候, 显示 "从数据库中查找多个", 之后再刷新页面就不再显示.

如果将注解去掉, 反复刷新列表页, 可以发现每次都显示"从数据库中查找多个", 前后对比, 说明缓存正常发生了作用.

回过头来看, 其实我们就是用XML中的Bean来取代了自己编写的Cache类, Spring提供了一些不依赖外部缓存框架的缓存管理类可供使用, 配置中的org.springframework.cache.support.SimpleCacheManager就是一个实现类.

缓存管理类内部需要配置具体的缓存实现, 需要命名, 命名是一定需要的, 比如配置中有一个默认的default, 然后还配置了一个users名称的缓存, 这些缓存都使用了JDK的并发版本HashMap作为缓存.

再次强调缓存命名是必须的, 在使用@Cacheable注解的时候, 可以看到提示一定要指定一个缓存的名称.

缓存注解 - @Cacheable

通过刚才的例子, 可以发现使用缓存的方法还是比较简单的:

  1. XML中配置注解驱动和缓存管理器类, 以及具体的缓存实现
  2. 使用注解添加在需要缓存的Service上, 以实现缓存功能

其实不难想到, 肯定也是用AOP去生成的缓存类. 下边就来看一下缓存注解, Spring Cache一共有5种可以在类或者方法级别上使用的缓存注解, 其核心就是决定一个方法的返回值进入缓存还是从缓存中删除.

@Cacheable, 这是最主要的注解, 表示被注解方法的返回值需要放入到缓存中.

这个注解的主要属性如下:

  1. value/cacheNames, 要提供至少一个缓存名称, cacheNames属性接受一个字符串数组或者单个字符串表示的缓存名称.
  2. key, key属性则可以用来指定存入缓存的键是什么, 如果不指定key属性, 会使用默认的键生成器, 默认的键生成器在有参数的时候会使用参数作为key, 无参数的时候使用SimpleKey.EMPTY来当成key, 多个参数的时候则使用包含所有参数的SimpleKey. 指定key属性的话, 可以通过SPEL表达式 key="#varname.xxx"来绑定被注解方法的参数, 也可以指定自行编写的Key生成器(需要实现KeyGenerator接口)的名称, 比如 key="myGenerator".
  3. condition/unless, 属性condition, 是一个字符串, 这个字符串被当成SPEL表达式来解析, 如果结果为true, 则会进行缓存. unless和condition相反, SPEL表达式为false的时候会进行缓存.

一个condition的例子如下:

@Override
@Transactional
@Cacheable(cacheNames = "users", condition = "#customerId < 5")
public Customer getCustomer(int customerId) {
    return customerDAO.getCustomer(customerId);
}

这个表示id小于5的查询才会被缓存. 这么配置之后重启服务器, 访问http://localhost:8080/api/customers/8, 确实不缓存了, 而http://localhost:8080/api/customers/1就依然缓存.

缓存注解 - @Cacheput

如果按照例子来做, 现在尝试去更新id=1的用户信息, 会发现在查询页面依然查到的是原来的信息, 这是因为缓存中的信息没有更新. @Cacheput会强制执行被注解的方法, 然后将方法的返回值更新进缓存. 这与@Cacheable是不同的, @Cacheable会在查询到缓存的时候直接跳过方法执行.

所以@Cacheput一般用于增和改之后, 强制将内容更新到缓存中. 这个注解的属性和上一个完全相同.

在我们的例子里, 如果想让更新后的客户在单独访问的时候可以获取到新的内容, 就需要在Service类中修改.

首先, 原来Service中的saveCustomer方法返回值是void, 这样是不能缓存的. 必须将其修改成返回保存的Customer对象. 这很容易, 只需要从DAO类开始修改接口和对应实现类即可:

@Override
public Customer saveCustomer(Customer customer) {
    Session session = sessionFactory.getCurrentSession();
    session.saveOrUpdate(customer);
    return customer;
}

其他的修改都省略, 然后给Service类的saveCustomer加上注解:

@Override
@Transactional
@CachePut(cacheNames = "users", key = "#customer.id")
public Customer saveCustomer(Customer customer) {
    return customerDAO.saveCustomer(customer);
}

这里有若干点要注意, 首先@Cacheput保证了每次这个方法都会执行, 然后把返回值, 也就是新保存的customer对象放入缓存.

其次, 保存的键要注意, 因为这个方法的入参是customer对象, 不像上边的方法入参是int类型的id, 所以要手工指定id.

在hibernate保存了之后, 就会更新customer对象的id数值, 所以此时将key设置成customer的id, 就可以覆盖原来在缓存中旧的数据了.

再次启动服务器, 先访问http://localhost:8080/api/customers/2, 反复刷几次 ,可以看到缓存生效, 只有最初的访问需要查询数据库, 之后到列表页中去修改, 修改之后, 再访问http://localhost:8080/api/customers/2, 可以看到数据是更新后的数据, 但是没有再访问数据库, 而是从缓存中访问.

缓存注解 - @CacheEvict

这个注解看名称就知道, 是负责从缓存中移除一个指定的值. 如果按照例子来做, 到现在会发现, 不管怎么修改, 列表页中的数据始终没变化. 这是因为这个方法:

@Override
@Transactional
@Cacheable(cacheNames = "users")
public List<Customer> getCustomers() {
    return customerDAO.getCustomers();
}

在第一次访问之后, 没有其他任何方法去更新缓存, 所以每次都查找缓存中的旧内容. 为了解决这个问题, 可以发现, 每次只要有任何客户的内容增加, 减少, 或者修改, 都需要在缓存里删除这个列表才行.

所以我们可以尝试一下, 由于这个方法无参, 可以指定一个key:

    @Override
    @Transactional
    @Cacheable(cacheNames = "users", key="'list'")
    public List<Customer> getCustomers() {
        return customerDAO.getCustomers();
    }

然后在刚才的更新用户的方法上添加@CacheEvict:

@Override
@Transactional
@CachePut(cacheNames = "users", key = "#customer.id")
@CacheEvict(cacheNames = "users", key="'list'")
public Customer saveCustomer(Customer customer) {
    return customerDAO.saveCustomer(customer);
}

现在每次更新用户信息的时候, 都会强制删除缓存中key=list的内容, 下次访问列表的时候, 就会强制重数据库中载入. 运行程序, 就会发现, 每次在列表页进行更新, 再返回到列表页的时候, 日志都会打印出从数据库中查找数据的信息, 数据也确实更新了.

这个注解也有key和condition属性, 可以控制每次删除哪些缓存内容.此外还有两个布尔值的属性:

  1. allEntries, 设置为true, 则直接清空指定缓存的全部内容, 默认是false.
  2. beforeInvocation, 定义在调用被注解的方法之前还是之后删除指定缓存内容, 默认是false=方法运行之后删除.

缓存注解看到这里, 基本上也懂了Spring缓存这个套路了, 通过对于方法返回值在缓存中的添加, 删除来达成缓存的目的.

缓存注解 - @Caching

这个注解, 就是可以把前边三个注解都写到这个注解里, 一次性配置, 不用像saveCustomer方法一样加上两个缓存注解. 其内部用逗号分割单独的注解. 比如可以将上边的saveCustomer方法修改成:

@Override
@Transactional
@Caching(put=@CachePut(cacheNames = "users", key = "#customer.id"),evict = @CacheEvict(cacheNames = "users", key="'list'"))
public Customer saveCustomer(Customer customer) {
    return customerDAO.saveCustomer(customer);
}

这个注解有三个属性, 从上边已经可以看到有put和evict, 还有cacheable, 每一个都是一个数组, 可以代表多个对应的注解. 这个注解实际上就是个壳子, 可以让代码不那么凌乱.

缓存注解 - @CacheConfig

这个注解是添加在类上的, 在Spring4.0之前, 没有基于类的缓存配置注解, 就像前边我们的类一样, 要把缓存名称在每个注解里都写一遍.

有了这个注解之后, 缓存名称就可以统一抽到类配置上去, key之类其他配置也可以. 只要方法级别的注解不覆盖配置, 就可以生效.

完整的使用了所有上述注解的Service如下:

@Service
@CacheConfig(cacheNames = "users")
public class CustomerServiceImpl implements CustomerService {

    private CustomerDAO customerDAO;

    @Autowired
    public CustomerServiceImpl(CustomerDAO customerDAO) {
        this.customerDAO = customerDAO;
    }

    @Override
    @Transactional
    @Cacheable(key = "'list'")
    public List<Customer> getCustomers() {
        return customerDAO.getCustomers();
    }

    @Override
    @Transactional
    @Caching(put = @CachePut(key = "#customer.id"), evict = @CacheEvict(key = "'list'"))
    public Customer saveCustomer(Customer customer) {
        return customerDAO.saveCustomer(customer);
    }

    @Override
    @Transactional
    @Cacheable
    public Customer getCustomer(int customerId) {
        return customerDAO.getCustomer(customerId);
    }

    @Override
    @Transactional
    public void deleteCustomer(int customerId) {
        customerDAO.deleteCustomer(customerId);
    }
}

这段代码也更新到这个项目的git上去了, 缓存的基本使用了解了.

如果要使用外部的缓存框架, 很明显只要替换缓存管理类和具体的缓存实现就可以了.

缓存管理器

从前边的配置+注解一下就能看出来, 核心就是缓存管理器这个Bean, 需要缓存功能就会到容器里找这个Bean.

缓存管理器都实现了CacheManager接口, 一般内部都需要再定义具体的缓存实现.

Spring提供了如下几个实现类:

  1. SimpleCacheManager, 可以配置缓存列表的缓存管理器, 内部可配置多个名称不同的具体缓存实现.
  2. NoOpCacheManager, 用于测试目的, 内部无法配置任何缓存实现, 也不会进行缓存.
  3. CompositeCacheManager, 这个其实是缓存管理器的组合, 如果仅仅使用注解驱动, 只能定义一个缓存管理器, 有了这个, 就可以在CompositeCacheManager的Bean内部继续定义不同的缓存管理器.
  4. org.springframework.cache.concurrent.ConcurrentMapCacheManager, 这个直接就使用了JDK并发版的HashMap, 无需配置具体缓存实现, 只配置bean就可以.

缓存注解中的SPEL表达式

除了使用#varName绑定参数之外, 缓存注解中的SPEL表达式还有额外的一个对象#root,可以用起来获取一些特殊的内容:

  1. #root.methodname, 获取被注解的方法名
  2. #root.method, 获取被注解的方法
  3. #root.target, 被注解的方法所在的对象
  4. #root.targetClass, 被注解的方法所在的对象的类
  5. #root.args[0], 被注解的方法的第0个参数
  6. #root.caches[0], 被注解的方法的第0个缓存对象

这些如果用到, 还是比较方便的.

基于XML的声明

注解简单方便, 但是有一大问题就是只能使用在源代码上. 如果只有包和方法, 想要配置缓存, 就要改用AOP的方式配置, 为想要配置缓存的方法设置增强.

如果是为上一篇文章的CustomerServiceImpl配置XML, 大概是这样:

<bean id="customerService" class="cc.conyli.service.CustomerServiceImpl"/>

<cache:advice id="cacheAdvice" cache-manager="cacheManager">
    <cache:caching cache="users">
        <cache:cacheable method="getCustomers" key="123"/>
        <cache:cache-evict method="saveCustomer" key="123"/>
        <cache:cache-put method="saveCustomer" key="'#customer.id'"/>
        <cache:cacheable method="getCustomer" key="'#root.args[0]'"/>
    </cache:caching>

</cache:advice>
<aop:config>
    <aop:advisor advice-ref="cacheAdvice" pointcut="execution(* cc.conyli.service.CustomerServiceImpl.*(..))" />
</aop:config>

不过这里是总的织入, 还有些问题, 查询单个无法更新. 不过基本的套路已经知道了. 还是使用注解方便啊.

配置第三方缓存

现在返回到还是原来注解配置的状态, 要配置第三方缓存, 其实前边也提了很多次了, 要么更改缓存管理器, 要么其中的缓存实现.来看一下常用的几种缓存框架.

EhCache

使用EhCache, 需要将其依赖加入到maven中:

<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <version>3.8.1</version>
</dependency>

然后修改XML, 将缓存管理器换成EhCache提供的管理器, 但我发现我竟然cache包中没有EhCache相关的类, 查了一下才知道, 原来在spring-context-support里, 把这个添加到Maven中:

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context-support</artifactId>
    <version>${springframework.version}</version>
</dependency>

之后可以来配置XML文件了, 由于核心是缓存管理器, 所以要把我们目前的缓存管理器来替换掉:

<bean id="cacheManager" class="org.springframework.cache.ehcache.EhCacheCacheManager" p:cacheManager-ref="ehcache"/>

<bean id="ehcache" class="org.springframework.cache.ehcache.EhCacheManagerFactoryBean"
      p:configLocation="WEB-INF/ehcache.xml" />

Ehcache的缓存管理类需要另外一个Bean, 也就是EhCacheManagerFactoryBean来加载xml配置文件从而创建一个缓存实现.

这里有篇文章可供参考, 在/WEB-INF/下创建ehcache.xml, 在其中配置一个简单的叫做ehentai的缓存:

<?xml version="1.0" encoding="UTF-8"?>
    <ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:noNamespaceSchemaLocation="http://ehcache.org/ehcache.xsd">

    <defaultCache
            maxEntriesLocalHeap="10000"
            eternal="false"
            timeToIdleSeconds="120"
            timeToLiveSeconds="120"
            maxEntriesLocalDisk="10000000"
            diskExpiryThreadIntervalSeconds="120"
            memoryStoreEvictionPolicy="LRU"/>

        <!-- helloworld缓存 -->
    <cache name="ehentai"
           maxElementsInMemory="1000"
           eternal="false"
           timeToIdleSeconds="5"
           timeToLiveSeconds="5"
           overflowToDisk="false"
           memoryStoreEvictionPolicy="LRU"/>
</ehcache>

可以看到也配置了叫做default和ehentai的两个缓存. 然后需要把CustomerServiceImpl的注解改成@CacheConfig(cacheNames = "ehentai").

这里我成功的启动了缓存, 不过不知道为什么没有(表面上)生效, 每次还是要去查询数据库. 不过这个缓存配置的套路算是清楚了.

剩下的几个缓存库, 如果有需要回来再看了, 还有就是如何使用Redis进行缓存, 核心也是编写缓存管理器, 用到再进行补充.

下一个是异步任务了.

LICENSED UNDER CC BY-NC-SA 4.0
Comment