深入探讨 Spring Boot 缓存

 2022-08-17
原文地址:https://cloud.tencent.com/developer/article/1188310

Spring Boot缓存

《Spring Boot 实战开发》—— 基于 Gradle + Kotlin的企业级应用开发最佳实践

我们知道一个系统的瓶颈通常在与数据库交互的过程中。内存的速度远远快于硬盘速度。所以,当我们需要重复地获取相同的数据的时候,我们一次又一次的请求数据库或者远程服务,这无疑是性能上的浪费——会导致大量的时间耗费在数据库查询或者远程方法调用上(这些资源简直太奢侈了),导致程序性能的恶化——于是有了“缓存”。缓存(Cache)就是数据交换的缓冲区。 本章介绍在 Spring Boot 项目开发中怎样来使用Spring Cache 实现数据的缓存。

1.1 Spring Cache 简介

Spring 3.1 中,Costin Leau引入了对Cache的支持。在spring-context 包中定义了org.springframework.cache.CacheManager和org.springframework.cache.Cache接口用来统一不同的缓存的技术。其中,CacheManager是Spring提供的各种缓存技术抽象接口,Cache接口包含缓存的常用操作: 增加、删除、读取等。

针对不同的缓存技术,需要实现不同的CacheManager,Spring定义了如表所示的CacheManager实现。Spring支持的常用CacheManager如下表所示

SimpleCacheManager 使用简单的Collection来存储缓存

ConcurrentMapCacheManager 使用java.util.concurrent.ConcurrentHashMap实现的Cache

NoOpCacheManager 仅测试用,不会实际存储缓存

EhCacheCacheManager 集成使用EhCache缓存技术。EhCache 是一个纯Java的进程内缓存框架,具有快速、精干等特点,是Hibernate中默认的CacheProvider,也是JAVA领域应用最为广泛的缓存。

JCacheCacheManager 支持JCache(JSR-107)标准的实现作为缓存技术,如Apache Commons JCS

CaffeineCacheManager 使用Caffeine来作为缓存技术。Caffeine是使用Java8对Guava缓存的重写版本,在Spring Boot 2.0中将取代Guava。如果出现Caffeine,CaffeineCacheManager将会自动配置。使用spring.cache.cache-names属性可以在启动时创建缓存

CompositeCacheManager CompositeCacheManager用于组合CacheManager,即可以从多个CacheManager中轮询得到相应的Cache

Spring Cache 的使用方法和原理都类似于Spring对事务管理的支持,都是AOP的方式。其核心思想是:当我们在调用一个缓存方法时会把该方法参数和返回结果作为一个键值对存放在缓存中,等到下次利用同样的参数来调用该方法时将不再执行该方法,而是直接从缓存中获取结果进行返回。 Spring Cache 提供了@Cacheable、@CachePut、@CacheEvict等注解,在方法上使用。通过注解Cache可以实现类似于事务一样,缓存逻辑透明的应用到我们的业务代码上,且只需要更少的代码就可以完成。

1.2 Cache 注解详解

Spring 中提供了4个注解来声明缓存规则。如下表

注解 描述 @Cacheable 主要针对方法配置,能够根据方法的请求参数对其结果进行缓存

@CachePut 主要针对方法配置,能够根据方法的请求参数对其结果进行缓存,和 @Cacheable 不同的是,它每次都会触发真实方法的调用 @CacheEvict 主要针对方法配置,能够根据一定的条件对缓存进行清空 @Caching 用来组合使用其他注解,可以同时应用多个Cache注解

下面我们分别来简单介绍。

  1. @Cacheable

其中,注解中的属性值说明如下:  value: 缓存名,必填。  key:可选属性,可以使用SPEL标签自定义缓存的key。  condition:属性指定发生的条件。

代码示例:

    @Cacheable("userList") // 标识读缓存操作
    override fun findAll(): List<User> {
        return userDao.findAll()
    }

@Cacheable(cacheNames = ["user"], key = "#id") // 如果缓存存在,直接读取缓存值; 如果不存在调用目标方法,并将方法返回结果放入缓存

    override fun findOne(id: Long): User {
        return userDao.getOne(id)
    }
  1. @CachePut

使用该注解标识的方法,每次都会执行目标逻辑代码,并将结果存入指定的缓存中。之后另一个方法就可以直接从相应的缓存中取出缓存数据,而不需要再去查询数据库。@CachePut注解的属性说明如下:

 value:缓存名,必填。  key:可选属性,可以使用SPEL标签自定义缓存的key。

代码示例:

    @Transactional
    @CachePut(cacheNames = ["user"], key = "#user.id")// 写入缓存,key 为 user.id ; 一般可以标注在save方法上面
    override fun saveUser(user: User): User {
        return userDao.save(user)
    }
  1. @CacheEvict

标记要清空缓存的方法,当这个方法被调用后,即会清空缓存。@CacheEvict注解属性说明如下:

 value:必填  key:可选(默认是所有参数的组合)  condition:缓存的条件  allEntries:是否清空所有缓存内容,默认为 false,如果指定为 true,则方法调用后将立即清空所有缓存。  beforeInvocation:是否在方法执行前就清空,缺省为 false,如果指定为 true,则在方法还没有执行的时候就清空缓存,缺省情况下,如果方法执行抛出异常,则不会清空缓存。

代码示例

    @Transactional
    @CacheEvict(cacheNames = ["user"], key = "#id")// 根据 key (值为id) 来清除缓存 ; 一般标注在delete,update方法上面
    override fun updatePassword(id: Long, password: String): Int {
        return userDao.updatePassword(id, password)
    }
  1. @Caching

@Caching注解的源码如下, 从中可以看到我们可以同时使用(cacheable/put/evict方法)

    public @interface Caching {
        Cacheable[] cacheable() default {};
    
        CachePut[] put() default {};
    
        CacheEvict[] evict() default {};
    }

使用@Caching注解可以实现在同一个方法上可以同时使用多种注解,例如

@Caching(evict={@CacheEvict(“u1”),@CacheEvict(“u2”,allEntries=true)})

1.3 项目实战讲解 本节我们通过完整的项目案例来讲解 Spring Cache 的具体使用方法。 1.3.1 准备工作 1.创建项目 首先使用 Spring Initializr 创建基于 Gradle、Kotlin的 Spring Boot 项目。使用的 Kotlin 版本和 Spring Boot版本如下

    kotlinVersion = '1.2.20'
    springBootVersion = '2.0.1.RELEASE'

2.添加依赖 添加spring-boot-starter-cache项目依赖如下

    dependencies {
      compile('org.springframework.boot:spring-boot-starter-cache')
    }

3.数据库配置 本项目需要连接真实的数据库,我们使用 MySQL,同时 ORM 框架选用 JPA。所以我们在项目依赖中添加如下依赖

      runtime('mysql:mysql-connector-java')
      compile('org.springframework.boot:spring-boot-starter-data-jpa')
      compile('org.springframework.boot:spring-boot-starter-web')

本地测试数据库中创建 schema如下:

    CREATE SCHEMA `demo_cache` DEFAULT CHARACTER SET utf8 ;
    在application.properties中配置数据库连接信息如下
    spring.datasource.url=jdbc:mysql://localhost:3306/demo_cache?useUnicode=true&characterEncoding=UTF8&useSSL=false
    spring.datasource.username=root
    spring.datasource.password=root
    spring.datasource.driverClassName=com.mysql.jdbc.Driver
    spring.jpa.database=MYSQL
    spring.jpa.show-sql=true
    spring.jpa.hibernate.ddl-auto=create-drop
    spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5Dialect
    spring.jpa.hibernate.naming.physical-strategy=org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy

4.实体类 为了简单起见,我们设计一个用户实体,包含3个字段:id,username,password。具体的代码如下

    package com.easy.springboot.demo_cache
    
    import javax.persistence.*
    
    @Entity
    class User {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        var id: Long = 0
    
        @Column(unique = true, length = 100)
        var username: String = ""
        @Column(length = 100)
        var password: String = ""
    
    }

5.数据访问层 使用 JPA 写 Dao 层代码是一件相当快乐的事情——不需要我们去写那么多样板化的CRUD方法。代码如下

    package com.easy.springboot.demo_cache
    
    import org.springframework.data.jpa.repository.JpaRepository
    import org.springframework.data.jpa.repository.Modifying
    import org.springframework.data.jpa.repository.Query
    import org.springframework.data.repository.query.Param
    
    interface UserDao : JpaRepository<User, Long> {
        @Query("update #{#entityName} a set a.password = :password where a.id=:id")
        @Modifying
        fun updatePassword(@Param("id") id: Long, @Param("password") password: String): Int
    }

其中,需要注意的是这里的updatePassword()函数,需要添加@Modifying注解。否则会报如下错误:

    org.hibernate.hql.internal.QueryExecutionRequestException: Not supported for DML operations [update com.easy.springboot.demo_cache.User a set a.password = :password where id=:id]
    at org.hibernate.hql.internal.ast.QueryTranslatorImpl.errorIfDML(QueryTranslatorImpl.java:311)

6.业务层代码

缓存服务我们通常是在业务逻辑层来使用。我们接口定义如下

    interface UserService {
        fun findAll(): List<User>
        fun saveUser(u: User): User
        fun updatePassword(id:Long, password: String): Int
        fun findOne(id: Long): User
    }

对应的实现类代码是

    package com.easy.springboot.demo_cache
    
    import org.springframework.beans.factory.annotation.Autowired
    import org.springframework.cache.annotation.CacheEvict
    import org.springframework.cache.annotation.CachePut
    import org.springframework.cache.annotation.Cacheable
    import org.springframework.stereotype.Service
    import org.springframework.transaction.annotation.Transactional
    
    @Service
    open class UserServiceImpl : UserService {
    
    
        @Autowired lateinit var userDao: UserDao
    
        @Cacheable("userList") // 标识读缓存操作
        override fun findAll(): List<User> {
            return userDao.findAll()
        }
    
        @Transactional
        @CachePut(cacheNames = ["user"], key = "#user.id")// 写入缓存,key 为 user.id ; 一般可以标注在save方法上面
        override fun saveUser(user: User): User {
            return userDao.save(user)
        }
    
        @Transactional
        @CacheEvict(cacheNames = ["user"], key = "#id")// 根据 key (值为id) 来清除缓存 ; 一般标注在delete,update方法上面
        override fun updatePassword(id: Long, password: String): Int {
            return userDao.updatePassword(id, password)
        }
    
        @Cacheable(cacheNames = ["user"], key = "#id") // 如果缓存存在,直接读取缓存值; 如果不存在调用目标方法,并将方法返回结果放入缓存
        override fun findOne(id: Long): User {
            return userDao.getOne(id)
        }
    
    }

7.测试 Controller 为了看到缓存的效果,我们编写UserController代码来进行测试缓存的效果。代码如下

    package com.easy.springboot.demo_cache
    
    import org.springframework.beans.factory.annotation.Autowired
    import org.springframework.web.bind.annotation.GetMapping
    import org.springframework.web.bind.annotation.PathVariable
    import org.springframework.web.bind.annotation.RestController
    
    @RestController
    class UserController {
        @Autowired lateinit var userService: UserService
    
        @GetMapping("/user/list")
        fun findAll(): List<User> {
            return userService.findAll()
        }
    
        @GetMapping("/user/save")
        fun save(user: User): User {
            return userService.saveUser(user)
        }
    
        @GetMapping("/user/updatePassword")
        fun updatePassword(id: Long, password: String): Int {
            return userService.updatePassword(id, password)
        }
    
        @GetMapping("/user/{id}")
        fun findOne(@PathVariable("id") id: Long): User {
            return userService.findOne(id)
        }
    
    }

8.启用Cache功能 在 Spring Boot 项目中启用 Spring Cache 注解的功能非常简单。只需要在启动类上添加@EnableCaching注解即可。实例代码如下

    @SpringBootApplication
    @EnableCaching
    open class DemoCacheApplication
    
    fun main(args: Array<String>) {
        ...
    }
  1. 数据库初始化测试数据 为了方便测试,我们在数据库中初始化3条用户数据进行测试。初始化代码如下
    fun main(args: Array<String>) {
        SpringApplicationBuilder().initializers(
                beans {
                    bean {
                        ApplicationRunner {
                            initUser()
                        }
                    }
                }
        ).sources(DemoCacheApplication::class.java).run(*args)
    }
    
    private fun BeanDefinitionDsl.BeanDefinitionContext.initUser() {
        val userDao = ref<UserDao>()
        try {
            val user = User()
            user.username = "user"
            user.password = "user"
            userDao.save(user)
    
            val jack = User()
            jack.username = "jack"
            jack.password = "123456"
            userDao.save(jack)
    
            val admin = User()
            admin.username = "admin"
            admin.password = "admin"
            userDao.save(admin)
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

其中,BeanDefinitionDsl 是 Spring 5中提供的基于 Kotlin 的函数式风格的 Bean 注册 DSL(Functional bean definition Kotlin DSL)。

10.运行测试

启动项目,访问http://localhost:8080/user/list ,返回

    [
      {
        "id": 1,
        "username": "user",
        "password": "user"
      },
      {
        "id": 2,
        "username": "jack",
        "password": "123456"
      },
      {
        "id": 3,
        "username": "admin",
        "password": "admin"
      }
    ]

当我们通过调用接口http://localhost:8080/user/save?username=who&password=xxx ,向数据库中新增一条记录。我们去数据库中查看,可以发现数据新增成功。但是在此访问http://localhost:8080/user/list ,依然返回上面的3条数据。这表明下面的

    @Cacheable("userList") // 标识读缓存操作
    override fun findAll(): List<User>

这里findAll()函数的执行确实是走了缓存,而没有去查询数据库。 我们再来测试一下@CacheEvict与@Cacheable注解的功能。对应的是下面的这段代码

    @Transactional
    @CacheEvict(cacheNames = ["user"], key = "#id")// 根据 key (值为id) 来清除缓存 ; 一般标注在delete,update方法上面
    override fun updatePassword(id: Long, password: String): Int {
        return userDao.updatePassword(id, password)
    }

@Cacheable(cacheNames = ["user"], key = "#id") // 如果缓存存在,直接读取缓存值; 如果不存在调用目标方法,并将方法返回结果放入缓存

    override fun findOne(id: Long): User {
        return userDao.getOne(id)
    }

首先,访问http://localhost:8080/user/1 得到的结果是

    {
      "id": 1,
      "username": "user",
      "password": "user"
    }

此时,我们调用被@CacheEvict标注的updatePassword()函数,该注解会清空 id=1的缓存。访问接口http://localhost:8080/user/updatePassword?id=1&password=ppp ,返回值为1,表明成功更新1条数据。此时,我们再次访问http://localhost:8080/user/1 得到的结果是

    {
      "id": 1,
      "username": "user",
      "password": "ppp"
    }

这表明缓存被成功更新了。最后,我们手工去数据库修改 id=1的用户数据 UPDATE demo_cache.user SET password='mmm' WHERE id='1'; 更改完成后,我们再次访问http://localhost:8080/user/1 得到的结果依然是

    {
      "id": 1,
      "username": "user",
      "password": "ppp"
    }

这表明,此时id=1的 User数据依然是从缓存中读取的并没有去查询数据库。

1.4 本章小结

通常情况下,使用内置的Spring Cache 只适用于单体应用。因为这些缓存的对象是存储在内存中的。在大型分布式的系统中,缓存对象往往会非常大,这个时候我们就会有专门的缓存服务器(集群)来存储这些数据了,例如 Redis。 我们可以把一些经常查询的数据放到 Redis 中缓存起来,不用每次都查询数据库。这样也不用直接占用大量内存了。关于 Redis 的使用我们将在下一章 Spring Boot 的Session统一管理中介绍。 Spring Cache对这些缓存实现都做了非常好的集成适配,所以我们使用起来可以说是“相当平滑”。另外,我们通常会使用一级缓存、二级缓存,本书限于篇幅就不详细介绍了。

提示:本章示例工程源代码https://github.com/EasySpringBoot/demo_cache