Spring Boot 整合 Redis 缓存

摘要:以短信验证码为例实现缓存。

目录

[TOC]

缓存

在系统访问量越来越大之后,往往最先出现瓶颈的往往是数据库。而为了减少数据库的压力,我们可以选择让产品砍掉消耗数据库性能的需求。当然,我们也可以选择如下方式优化:

艿艿:在这里,我们暂时不考虑优化数据库的硬件,索引等等手段。

  • 读写分离。通过将读操作分流到从节点,避免主节点压力过多。
  • 分库分表。通过将读写操作分摊到多个节点,避免单节点压力过多。
  • 缓存。相比数据库来说,缓存往往能够提供更快的读性能,减小数据库的压力。关于缓存和数据库的性能情况,可以看看如下两篇文章:

那么,在引入缓存之后,我们的读操作的代码,往往代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// UserService.java

@Autowired
private UserMapper userMapper; // 读取 DB

@Autowired
private UserCacheDao userCacheDao; // 读取 Cache

public UserDO getUser(Integer id) {
    // 从 Cache 中,查询用户信息
    UserDO user = userCacheDao.get(id);
    if (user != null) {
        return user;
    }
    // 如果 Cache 查询不到,从 DB 中读取
    user = userMapper.selectById(id);
    if (user != null) { // 非空,则缓存到 Cache 中
        userCacheDao.put(user);
    }
    // 返回结果
    return user;
}
  • 这段代码,是比较常用的缓存策略,俗称“被动写”。整体步骤如下:
    • 1)首先,从 Cache 中,读取用户缓存。如果存在,则直接返回。
    • 2)然后,从 DB 中,读取用户数据。如果存在,写入 Cache 中。
    • 3)最后,返回 DB 的查询结果。
  • 可能会有胖友说,这里没有考虑缓存击穿、缓存穿透、缓存并发写的情况。恩,是的,但是这并不在本文的内容范围。感兴趣的,可以看看我的男神超哥写的 《缓存穿透、缓存并发、缓存失效之思路变迁》 文章。嘿嘿~

虽然说,上述的代码已经挺简洁了,但是我们是热爱“偷懒”的开发者,必然需要寻找更优雅(偷懒)的方式。在 Spring 3.1 版本的时候,它发布了 Spring Cache 。关于它的介绍,如下:

FROM 《注释驱动的 Spring Cache 缓存介绍》

Spring 3.1 引入了激动人心的基于注释(annotation)的缓存(cache)技术,它本质上不是一个具体的缓存实现方案(例如 EHCache 或者 OSCache),而是一个对缓存使用的抽象,通过在既有代码中添加少量它定义的各种 annotation,即能够达到缓存方法的返回对象的效果。

Spring 的缓存技术还具备相当的灵活性,不仅能够使用 SpEL(Spring Expression Language)来定义缓存的 key 和各种 condition,还提供开箱即用的缓存临时存储方案,也支持和主流的专业缓存例如 EHCache 集成。

其特点总结如下:

  • 通过少量的配置 annotation 注释即可使得既有代码支持缓存
  • 支持开箱即用 Out-Of-The-Box,即不用安装和部署额外第三方组件即可使用缓存
  • 支持 Spring Express Language,能使用对象的任何属性或者方法来定义缓存的 key 和 condition
  • 支持 AspectJ,并通过其实现任何方法的缓存支持
  • 支持自定义 key 和自定义缓存管理者,具有相当的灵活性和扩展性
  • 简单来说,可以像使用 @Transactional 声明式事务,使用 Spring Cache 提供的 @Cacheable 等注解,
  • 声明式缓存。而在实现原理上,也是基于 Spring AOP 拦截,实现缓存相关的操作。

下面,使用 Spring Cache 将 #getUser(Integer id) 方法进行简化。

1
2
3
4
5
6
7
8
// UserService.java
public UserDO getUser2(Integer id) {
    return userMapper.selectById(id);
}

// UserMapper.java
@Cacheable(value = "users", key = "#id")
UserDO selectById(Integer id);
  • 在 UserService 的 #getUser2(Integer id) 方法上,我们直接调用 UserMapper ,从 DB 中查询数据。
  • 在 UserMapper 的 #selectById(Integer id) 方法上,有 @Cacheable 注解。Spring Cache 会拦截有 @Cacheable 注解的方法,实现“被动写”的逻辑。

Spring Boot 整合 Redis

在 Spring 的生态中,使用 Spring Data Redis 来实现对 Redis 的数据访问。

市面上已经有 Redis、Redisson、Lettuce 等优秀的 Java Redis 工具库,为什么还要有 Spring Data Redis 呢?

  • 对于下层,Spring Data Redis 提供了统一的操作模板(即 RedisTemplate 类),封装了 Jedis、Lettuce 的 API 操作,访问 Redis 数据。
    • 所以,实际上,Spring Data Redis 内置真正访问的实际是 Jedis、Lettuce 等 API 操作。
  • 对于上层,开发者学习如何使用 Spring Data Redis 即可,而无需关心 Jedis、Lettuce 的 API 操作。
    • 甚至,未来如果想将 Redis 访问从 Jedis 迁移成 Lettuce 来,无需做任何的变动。
  • 目前,Spring Data Redis 暂时只支持 Jedis、Lettuce 的内部封装,而 Redisson 是由 redisson-spring-data 来提供。

Spring Data Redis 调用

spring-boot-starter-data-redis 项目 2.X 中,默认使用 Lettuce 作为 Java Redis 工具库

  • 个人推荐的话,生产中还是使用 Jedis ,稳定第一。

  • 也因此,本节是 Spring Data Redis + Jedis 的组合。

依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<!-- 实现对 Spring Data Redis 的自动化配置 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
        <exclusions>
            <!-- 去掉对 Lettuce 的依赖,因为 Spring Boot 优先使用 Lettuce 作为 Redis 客户端 -->
            <exclusion>
                <groupId>io.lettuce</groupId>
                <artifactId>lettuce-core</artifactId>
            </exclusion>
        </exclusions>
    </dependency>

    <!-- 引入 Jedis 的依赖,这样 Spring Boot 实现对 Jedis 的自动化配置 -->
    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
    </dependency>

    <!-- 等会示例会使用 fastjson 作为 JSON 序列化的工具 -->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>1.2.61</version>
    </dependency>

    <!-- Spring Data Redis 默认使用 Jackson 作为 JSON 序列化的工具 -->
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>

配置文件

application.yml 中,添加 Redis 配置,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
spring:
  # 对应 RedisProperties 类
  redis:
    host: 127.0.0.1
    port: 6379
    password: # Redis 服务器密码,默认为空。生产中,一定要设置 Redis 密码!
    database: 0 # Redis 数据库号,默认为 0 。
    timeout: 0 # Redis 连接超时时间,单位:毫秒。
    # 对应 RedisProperties.Jedis 内部类
    jedis:
      pool:
        max-active: 8 # 连接池最大连接数,默认为 8 。使用负数表示没有限制。
        max-idle: 8 # 默认连接数最小空闲的连接数,默认为 8 。使用负数表示没有限制。
        min-idle: 0 # 默认连接池最小空闲的连接数,默认为 0 。允许设置 0 和 正数。
        max-wait: -1 # 连接池最大阻塞等待时间,单位:毫秒。默认为 -1 ,表示不限制。

简单测试

创建 Test01 测试类,来测试一下简单的 SET 指令。

1
2
3
4
5
6
7
8
9
10
11
12
@RunWith(SpringRunner.class)
@SpringBootTest
public class Test01 {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Test
    public void testStringSetKey() {
        stringRedisTemplate.opsForValue().set("yunai", "shuai");
    }
}

通过 StringRedisTemplate 类,进行了一次 Redis SET 指令的执行。

执行 #testStringSetKey() 方法这个测试方法。执行完成后,在控制台查询,看看是否真的执行成功了。

1
2
$ redis-cli get yunai
"shuai"

RedisTemplate

org.springframework.data.redis.core.RedisTemplate 类,从类名上,我们就明明白白知道,提供 Redis 操作模板 API 。核心属性如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// RedisTemplate.java
// 省略了一些不重要的属性。

// <1> 序列化相关属性
@SuppressWarnings("rawtypes") private @Nullable RedisSerializer keySerializer = null;
@SuppressWarnings("rawtypes") private @Nullable RedisSerializer valueSerializer = null;
@SuppressWarnings("rawtypes") private @Nullable RedisSerializer hashKeySerializer = null;
@SuppressWarnings("rawtypes") private @Nullable RedisSerializer hashValueSerializer = null;
private RedisSerializer<String> stringSerializer = RedisSerializer.string();

// <2> Lua 脚本执行器
private @Nullable ScriptExecutor<K> scriptExecutor;

// <3> 常见数据结构操作类
// cache singleton objects (where possible)
private @Nullable ValueOperations<K, V> valueOps;
private @Nullable ListOperations<K, V> listOps;
private @Nullable SetOperations<K, V> setOps;
private @Nullable ZSetOperations<K, V> zSetOps;
private @Nullable GeoOperations<K, V> geoOps;
private @Nullable HyperLogLogOperations<K, V> hllOps;

那么 Pub/Sub、Transaction、Pipeline、Keys、Cluster、Connection 等相关的 API 操作呢?它在 RedisTemplate 自身提供,因为它们不属于具体每一种数据结构,所以没有封装在对应的 Operations 类中。哈哈哈,胖友打开 RedisTemplate 类,去瞅瞅,妥妥的明白。

序列化

RedisSerializer 接口

Redis 序列化接口,用于 Redis KEY 和 VALUE 的序列化。

主要分成四类:

  1. JDK 序列化方式
  2. String 序列化方式
  3. JSON 序列化方式
  4. XML 序列化方式

项目实践

本小节,我们来分享我们在生产中的一些实践。关于这块,希望大家可以一起讨论,能够让我们的代码更加优雅干净。

4.1 Cache Object

在我们使用数据库时,我们会创建 dataobject 包,存放 DO(Data Object)数据库实体对象。

那么同理,我们缓存对象,怎么进行对应呢?对于复杂的缓存对象,我们创建了 cacheobject 包,和 dataobject 包同层。如:

1
2
3
4
service # 业务逻辑层
dao # 数据库访问层
dataobject # DO
cacheobject # 缓存对象

并且所有的 Cache Object 对象使用 CacheObject 结尾,例如说 UserCacheObject、ProductCacheObject 。

4.2 数据访问层

在我们访问数据库时,我们会创建 dao 包,存放每个 DO 对应的 Dao 对应。那么对于每一个 CacheObject 类,我们也会创建一个其对应的 Dao 类。例如说,UserCacheObject 对应 UserCacheObjectDao 类。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Repository
public class UserCacheDao {

    private static final String KEY_PATTERN = "user:%d"; // user:用户编号 <1>

    @Resource(name = "redisTemplate")
    @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
    private ValueOperations<String, String> operations; // <2>

    private static String buildKey(Integer id) { // <3>
        return String.format(KEY_PATTERN, id);
    }

    public UserCacheObject get(Integer id) {
        String key = buildKey(id);
        String value = operations.get(key);
        return JSONUtil.parseObject(value, UserCacheObject.class);
    }

    public void set(Integer id, UserCacheObject object) {
        String key = buildKey(id);
        String value = JSONUtil.toJSONString(object);
        operations.set(key, value);
    }

}
  • <1> 处,通过静态变量,声明 KEY 的前缀,并且使用冒号作为间隔
  • <3> 处,声明 KEY_PATTERN 对应的 KEY 拼接方法,避免散落在每个方法中。
  • <2> 处,通过 @Resource 注入指定名字的 RedisTemplate 对应的 Operations 对象,这样明确每个 KEY 的类型。
  • 剩余的,就是每个方法封装对应的操作。

可能会有胖友问,为什么不支持将 RedisTemplate 直接 Service 业务层调用呢?如果这样,我们业务代码里,就容易混杂着很多 Redis 访问代码的细节,导致很脏乱。我们试着把 RedisTemplate 想象成 Spring JDBCTemplate ,我们一定会声明对应的 Dao 类,访问数据库。所以,同理落。

那么还有一个问题,UserCacheDao 放在哪个包下?目前的想法是,将 dao 包下拆成 mysqlredis 包。这样,MySQL 相关的 Dao 放在 mysql 包下,Redis 相关的 Dao 放在 redis

4.3 序列化

「3. 序列化」 小节中,我们仔细翻看了每个序列化方式,暂时没有一个能够完美的契合我们的需求,所以我们直接使用最简单的 StringRedisSerializer 作为序列化实现类。而真正的序列化,我们在各个 Dao 类里,自己手动来调用。

例如说,在 UserCacheDao 示例中,已经看到了这么做了。这里还有一个细化点,虽然我们是自己手动序列化,可以自己简单封装一个 JSONUtil 类,未来如果我们想换 JSON 库,就比较方便了。其实,这个和 Spring Data Redis 所做的封装是一个思路。

示例补充

像 String、List、Set、ZSet、Geo、HyperLogLog 等等数据结构的操作,胖友自己去用用对应的 Operations 操作类的 API 方法,就非常容易懂了,我们更多的,补充 Pipeline、Transaction、Pub/Sub、Script 等等功能的示例。

整合 Redisson

简单来说,这是 Java 最强的 Redis 客户端

  • 除了提供了 Redis 客户端的常见操作之外,还提供了 Redis 分布式锁、BloomFilter 布隆过滤器等强大的功能。

依赖

1
2
3
4
5
6
<!-- 实现对 Redisson 的自动化配置 --> <!-- X -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.11.3</version>
</dependency>

配置文件

application.yml 中,添加 Redis 配置,如下:

1
2
3
4
5
6
7
8
9
10
11
spring:
  # 对应 RedisProperties 类
  redis:
    host: 127.0.0.1
    port: 6379
#    password: # Redis 服务器密码,默认为空。生产中,一定要设置 Redis 密码!
    database: 0 # Redis 数据库号,默认为 0 。
    timeout: 0 # Redis 连接超时时间,单位:毫秒。
    # 对应 RedissonProperties 类
    redisson:
      config: classpath:redisson.yml # 具体的每个配置项,见 org.redisson.config.Config 类。

「2.2 配置文件」 的差异点是:

1)去掉 Jedis 相关的配置项

2)增加 redisson.config 配置

在我们使用 Spring Boot 整合 Redisson 时候,通过该配置项,引入一个外部的 Redisson 相关的配置文件。例如说,示例中,我们引入了 classpath:redisson.yaml 配置文件。它可以使用 JSON 或 YAML 格式,进行配置。

而引入的 redisson.config 对应的配置文件,对应的类是 org.redisson.config.Config 类。因为示例中,我们使用的比较简单,所以就没有做任何 Redisson 相关的自定义配置。

Redis 分布式锁

示例代码对应测试类:LockTest

一说到分布式锁,大家一般会想到的就是基于 Zookeeper 或是 Redis 实现分布式锁。相对来说,在考虑性能为优先因素,不需要特别绝对可靠性的场景下,我们会优先考虑使用 Redis 实现的分布式锁。

在 Redisson 中,提供了 8 种分布式锁的实现,具体胖友可以看看 《Redisson 文档 —— 分布式锁和同步器》 。真特码的强大!大多数开发者可能连 Redis 怎么实现分布式锁都没完全搞清楚,Redisson 直接给了 8 种锁,气人,简直了。

本小节,我们来编写一个简单使用 Redisson 提供的可重入锁 RLock 的示例。

创建 LockTest 测试类,编写代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@RunWith(SpringRunner.class)
@SpringBootTest
public class LockTest {

    private static final String LOCK_KEY = "anylock";

    @Autowired // <1>
    private RedissonClient redissonClient;

    @Test
    public void test() throws InterruptedException {
        // <2.1> 启动一个线程 A ,去占有锁
        new Thread(new Runnable() {
            @Override
            public void run() {
                // 加锁以后 10 秒钟自动解锁
                // 无需调用 unlock 方法手动解锁
                final RLock lock = redissonClient.getLock(LOCK_KEY);
                lock.lock(10, TimeUnit.SECONDS);
            }
        }).start();
        // <2.2> 简单 sleep 1 秒,保证线程 A 成功持有锁
        Thread.sleep(1000L);

        // <3> 尝试加锁,最多等待 100 秒,上锁以后 10 秒自动解锁
        System.out.println(String.format("准备开始获得锁时间:%s", new SimpleDateFormat("yyyy-MM-DD HH:mm:ss").format(new Date())));
        final RLock lock = redissonClient.getLock(LOCK_KEY);
        boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
        if (res) {
            System.out.println(String.format("实际获得锁时间:%s", new SimpleDateFormat("yyyy-MM-DD HH:mm:ss").format(new Date())));
        } else {
            System.out.println("加锁失败");
        }
    }

}
  • 整个测试用例,意图是:1)启动一个线程 A ,先去持有锁 10 秒然后释放;2)主线程,也去尝试去持有锁,因为线程 A 目前正在占用着该锁,所以需要等待线程 A 释放到该锁,才能持有成功。
  • <1> 处,注入 RedissonClient 对象。因为我们需要使用 Redisson 独有的功能,所以需要使用到它。
  • <2.1> 处,启动线程 A ,然后调用 RLock#lock(long leaseTime, TimeUnit unit) 方法,加锁以后 10 秒钟自动解锁,无需调用 unlock 方法手动解锁。
  • <2.2> 处,简单 sleep 1 秒,保证线程 A 成功持有锁。
  • <3> 处,主线程,调用 RLock#tryLock(long waitTime, long leaseTime, TimeUnit unit) 方法,尝试加锁,最多等待 100 秒,上锁以后 10 秒自动解锁。

执行 #test() 测试用例,结果如下:

1
2
准备开始获得锁时间:2019-10-274 00:44:08
实际获得锁时间:2019-10-274 00:44:17
  • 9 秒后(因为我们 sleep 了 1 秒),主线程成功获得到 Redis 分布式锁,符合预期。

Redis 缓存

使用 Redis 实现缓存,有 2 种使用方式:

  1. 编程式缓存:基于 Spring Data Redis 框架的 RedisTemplate 操作模板。
  2. 声明式缓存:基于 Spring Cache 框架@Cacheable 等等注解。

编程式缓存

1
2
3
4
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
</dependency>

由于 Redisson 提供了分布式锁、队列、限流等特性,所以使用它作为 Spring Data Redis 的客户端。

Spring Data Redis 配置

配置文件中,通过 spring.redis 配置项,设置 Redis 的配置。

1
2
3
4
5
6
 # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
  redis:
    host: 127.0.0.1 # 地址
    port: 6379 # 端口
    database: 0 # 数据库索引
#    password: 123456 # 密码,建议生产环境开启

② 在 YudaoRedisAutoConfiguration (opens new window)配置类,设置使用 JSON 序列化 value 值。

YudaoRedisAutoConfiguration 配置类

搭建 Access Token 缓存

访问令牌 Access Token 的缓存来举例子,讲解项目中是如何使用 Spring Data Redis 框架的。

Access Token 示例

引入依赖

yudao-module-system-server 模块中,引入 yudao-spring-boot-starter-redis 技术组件。

1
2
3
4
<dependency>
    <groupId>cn.iocoder.cloud</groupId>
    <artifactId>yudao-spring-boot-starter-redis</artifactId>
</dependency>

OAuth2AccessTokenDO

新建 OAuth2AccessTokenDO (opens new window)类,访问令牌 Access Token 类。

OAuth2AccessTokenDO 类

友情提示:

  • ① 如果值是【简单】的 String 或者 Integer 等类型,无需创建数据实体。
  • ② 如果值是【复杂对象】时,建议在 dal/dataobject 包下,创建对应的数据实体。

RedisKeyConstants

为什么要定义 Redis Key 常量?

  • 每个 yudao-module-xxx 模块,都有一个 RedisKeyConstants 类,定义该模块的 Redis Key 的信息。
  • 目的是,避免 Redis Key 散落在 Service 业务代码中,像对待数据库的表一样,对待每个 Redis Key。
    • 通过这样的方式,如果想要了解一个模块的 Redis 的使用情况,只需要查看 RedisKeyConstants 类即可。

yudao-module-system 模块的 RedisKeyConstants (opens new window)类中,新建 OAuth2AccessTokenDO 对应的 Redis Key 定义 OAUTH2_ACCESS_TOKEN

RedisKeyConstants 类

OAuth2AccessTokenRedisDAO

新建 OAuth2AccessTokenRedisDAO (opens new window)类,是 OAuth2AccessTokenDO 的 RedisDAO 实现。

  1. 添加 Repository 注解
  2. 注入 RedisTemplate Bean
  3. 格式化 Redis Key
  4. 如果是复杂对象,需要使用 JSON 序列化

OAuth2AccessTokenRedisDAO 类

OAuth2TokenServiceImpl

OAuth2TokenServiceImpl (opens new window)中,只要注入 OAuth2AccessTokenRedisDAO Bean,非常简洁干净的进行 OAuth2AccessTokenDO 的缓存操作,无需关心具体的实现。

OAuth2TokenServiceImpl 类

声明式缓存

Spring Cache 声明式缓存

相比来说 Spring Data Redis 编程式缓存Spring Cache 声明式缓存的使用更加便利,一个 @Cacheable 注解即可实现缓存的功能。

1
2
@Cacheable(value = "users", key = "#id")
UserDO getUserById(Integer id);

依赖

1
2
3
4
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

Spring Cache 配置

① 在 application.yaml (opens new window)配置文件中,通过 spring.redis 配置项,设置 Redis 的配置。

1
2
3
4
5
 # Cache 配置项。设置 Spring Cache 使用 Redis 缓存
  cache:
    type: REDIS
    redis:
      time-to-live: 1h # 设置过期时间为 1 小时

② 在 YudaoCacheAutoConfiguration (opens new window)配置类,设置使用 JSON 序列化 value 值。

YudaoCacheAutoConfiguration 配置类

常见注解

@Cacheable 注解

@Cacheable (opens new window)注解:添加在方法上,缓存方法的执行结果。执行过程如下:

  1. 首先,判断方法执行结果的缓存。如果有,则直接返回该缓存结果。
  2. 然后,执行方法,获得方法结果。
  3. 之后,根据是否满足缓存的条件。如果满足,则缓存方法结果到缓存。
  4. 最后,返回方法结果。

注解在方法上,表示该方法的返回结果是可以缓存的。

  • 也就是说,该方法的返回结果会放在缓存中,以便于以后使用相同的参数调用该方法时,会返回缓存中的值,而不会实际执行该方法。
  • 参数相同:因为缓存不关心方法的执行逻辑,它能确定的是:
    • 对于同一个方法,如果参数相同,那么返回结果也是相同的。
    • 但是如果参数不同,缓存只能假设结果是不同的,
    • 所以对于同一个方法,程序运行过程中,使用了多少种参数组合调用过该方法,理论上就会生成多少个缓存的 key(当然,这些组合的参数指的是与生成 key 相关的)。

参数:

  • 提供两个参数来指定缓存名:value、cacheNames,二者选其一即可。
  • key:
  • unless:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 获得邮件模板。考虑到效率,从缓存中获取
@Override
@Cacheable(value = RedisKeyConstants.MAIL_TEMPLATE, key = "#code", unless = "#result == null")
public MailTemplateDO getMailTemplateByCodeFromCache(String code) {
    return mailTemplateMapper.selectByCode(code);
}

/**
     * 查询物流轨迹
     * <p>
     * 缓存的目的:考虑及时性要求不高,但是每次调用需要钱
     *
     * @param code           快递公司编码
     * @param logisticsNo    发货快递单号
     * @param receiverMobile 收、寄件人的电话号码
     * @return 物流轨迹
     */
    @Cacheable(cacheNames = RedisKeyConstants.EXPRESS_TRACK, key = "#code + '-' + #logisticsNo + '-' + #receiverMobile",
            unless = "#result == null")
    public List<ExpressTrackRespDTO> getExpressTrackList(String code, String logisticsNo, String receiverMobile) {
        return expressClientFactory.getDefaultExpressClient().getExpressTrackList(new ExpressTrackQueryReqDTO()
                .setExpressCode(code).setLogisticsNo(logisticsNo).setPhone(receiverMobile));
    }

@CachePut 注解

注解,添加在方法上,缓存方法的执行结果。

不同于 @Cacheable 注解,它的执行过程如下:

  1. 首先,执行方法,获得方法结果。也就是说,无论是否有缓存,都会执行方法。
  2. 然后,根据是否满足缓存的条件。如果满足,则缓存方法结果到缓存。
  3. 最后,返回方法结果。

@CacheEvict 注解

@CacheEvict (opens new window)注解,添加在方法上,删除缓存。

不使用 allEntries 属性,但是想批量删除一些缓存,怎么办?

可参考 https://t.zsxq.com/phOrM (opens new window)帖子,手动删除一些。

1
2
3
4
5
6
@Override
@CacheEvict(cacheNames = RedisKeyConstants.MAIL_TEMPLATE,
        allEntries = true) // allEntries 清空所有缓存,因为 id 不是直接的缓存 code,不好清理
public void deleteMailTemplateList(List<Long> ids) {
    mailTemplateMapper.deleteByIds(ids);
}

搭建 Role 角色缓存

RoleServiceImpl (opens new window)中,使用 Spring Cache 实现了 Role 角色缓存,采用【被动读】的方案。原因是:

RoleServiceImpl

  • 【被动读】相对能够保证 Redis 与 MySQL 的一致性
  • 绝大数数据不需要放到 Redis 缓存中,采用【主动写】会将非必要的数据进行缓存

友情提示:

如果你未学习过 MySQL 与 Redis 一致性的问题,可以后续阅读 《Redis 与 MySQL 双写一致性如何保证? 》 (opens new window)文章。

① 执行 #getRoleFromCache(...) 方法,从 MySQL 读取数据后,向 Redis 写入缓存。

getTestDemo 方法

② 执行 #updateRole(...)#deleteRole(...) 方法,在更新或者删除 MySQL 数据后,从 Redis 删除缓存。

getTestDemo 方法

补充说明:

如果在多个项目里,使用了 Redis 想通 db 的话,可以通过 spring.cache.redis.key-prefix 解决,可见 https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/998/(opens new window)

过期时间

Spring Cache 默认使用 spring.cache.redis.time-to-live 配置项,设置缓存的过期时间,项目默认为 1 小时。

  • 如果想自定义过期时间,可以在 @Cacheable 注解中的 cacheNames 属性中,添加 #{过期时间} 后缀,单位是秒。

过期时间

实现的原来,参考 《Spring @Cacheable 扩展支持自定义过期时间 》 (opens new window)文章。

Redis 监控

yudao-module-infraredis (opens new window)模块,提供了 Redis 监控的功能。

点击 [基础设施 -> 监控中心 -> Redis 监控] 菜单,可以查看到 Redis 的基础信息、命令统计、内存信息。如下图所示:

Redis 监控

管理后台 - Redis 监控

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package cn..module.infra.controller.admin.redis;

@Tag(name = "管理后台 - Redis 监控")
@RestController
@RequestMapping("/infra/redis")
public class RedisController {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @GetMapping("/get-monitor-info")
    @Operation(summary = "获得 Redis 监控信息")
    @PreAuthorize("@ss.hasPermission('infra:redis:get-monitor-info')")
    public CommonResult<RedisMonitorRespVO> getRedisMonitorInfo() {
        // 获得 Redis 统计信息
        Properties info = stringRedisTemplate.execute((RedisCallback<Properties>) RedisServerCommands::info);
        Long dbSize = stringRedisTemplate.execute(RedisServerCommands::dbSize);
        Properties commandStats = stringRedisTemplate.execute((
                RedisCallback<Properties>) connection -> connection.info("commandstats"));
        assert commandStats != null; // 断言,避免警告
        // 拼接结果返回
        return success(RedisConvert.INSTANCE.build(info, dbSize, commandStats));
    }

}

整合 Redis 缓存步骤

以短信验证码为例。security 权限。

依赖

1
2
3
4
5
<dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>dysmsapi20170525</artifactId>
    <version>2.0.24</version>
</dependency>

配置文件

在 SpringBoot 配置文件 application.yml 中:

  • spring 节点下添加 Redis 连接配置;
1
2
3
4
5
6
7
8
9
10
11
12
  redis:
    host: localhost # Redis服务器地址
    database: 0 # Redis数据库索引(默认为0)
    port: 6379 # Redis服务器连接端口
    password: # Redis服务器连接密码(默认为空)
    jedis:
      pool:
        max-active: 8 # 连接池最大连接数(使用负值表示没有限制)
        max-wait: -1ms # 连接池最大阻塞等待时间(使用负值表示没有限制)
        max-idle: 8 # 连接池中的最大空闲连接
        min-idle: 0 # 连接池中的最小空闲连接
    timeout: 3000ms # 连接超时时间(毫秒)
  • 在根节点下添加 Redis 自定义 key 的配置:前缀 String、过期时间;
1
2
3
4
5
6
7
# 自定义redis key
redis:
  key:
    prefix:
      authCode: "portal:authCode:"
    expire:
      authCode: 120 # 验证码超期时间

添加 RedisService 接口

  • 用于定义常用 Redis 操作:get()、set()、expire()等;
  • 注入 StringRedisTemplate,实现接口;
  • 没有 DAO 层,更简略
1
2
3
4
5
6
7
@Autowired
private StringRedisTemplate redisTemplate;

@Override
public Object get(String key) {
 	return redisTemplate.opsForValue().get(key);
}
XxxRedisDAO 实现

另一种实现结构

  • JsonUtils.parseObject()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Repository
public class OAuth2AccessTokenRedisDAO {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    public OAuth2AccessTokenDO get(String accessToken) {
        String redisKey = formatKey(accessToken);
        return JsonUtils.parseObject(stringRedisTemplate.opsForValue().get(redisKey), OAuth2AccessTokenDO.class);
    }
    
    
    public void set(OAuth2AccessTokenDO accessTokenDO) {
        String redisKey = formatKey(accessTokenDO.getAccessToken());
        // 清理多余字段,避免缓存
        accessTokenDO.setUpdater(null).setUpdateTime(null).setCreateTime(null).setCreator(null).setDeleted(null);
        long time = LocalDateTimeUtil.between(LocalDateTime.now(), accessTokenDO.getExpiresTime(), ChronoUnit.SECONDS);
        if (time > 0) {
            stringRedisTemplate.opsForValue().set(redisKey, JsonUtils.toJsonString(accessTokenDO), time, TimeUnit.SECONDS);
        }
    }

    public void delete(String accessToken) {
        String redisKey = formatKey(accessToken);
        stringRedisTemplate.delete(redisKey);
    }

    public void deleteList(Collection<String> accessTokens) {
        List<String> redisKeys = CollectionUtils.convertList(accessTokens, OAuth2AccessTokenRedisDAO::formatKey);
        stringRedisTemplate.delete(redisKeys);
    }

    private static String formatKey(String accessToken) {
        return String.format(OAUTH2_ACCESS_TOKEN, accessToken);
    }
}

XxxRedisDAO 被 Service 调用

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class OAuth2TokenServiceImpl implements OAuth2TokenService {
    @Override
    public OAuth2AccessTokenDO getAccessToken(String accessToken) {
        // 优先从 Redis 中获取
        OAuth2AccessTokenDO accessTokenDO = oauth2AccessTokenRedisDAO.get(accessToken);
        if (accessTokenDO != null) {
            return accessTokenDO;
        }
        ...
    }
}

UmsMemberService 接口

  1. 生成验证码时,将自定义的 Redis 键值 + 手机号生成一个 Redis 的 key,
  2. 发送验证码:创建发送短信的客户端,提供参数调用对应方法即可。
    • 提供阿里云账号(AccessKey ID)和密码(AccessKey Secret)登录后,选择对应的开发测试短信签名和模版,填充对应的短信内容(模版参数),将短信发送到指定的手机号。
  3. 发送成功后,以验证码为 value 存入到 Redis Session 中,并设置过期时间(如120s);
  4. 校验验证码时,检查传入的验证码格式,根据手机号码来获取 Redis 里存储的验证码、及过期时间,与传入的比对。

Service 接口返回 String + Controller 返回 CommonResult;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.macro.mall.tiny.service.impl;

/**
 * 会员管理Service实现类
 */
@Service
public class UmsMemberServiceImpl implements UmsMemberService {
    @Autowired
    private RedisService redisService; //
    
    @Value("${redis.key.prefix.authCode}")
    private String REDIS_KEY_PREFIX_AUTH_CODE; // 前缀
    @Value("${redis.key.expire.authCode}")
    private Long AUTH_CODE_EXPIRE_SECONDS; // 过期时间

    @Override
    public String generateAuthCode(String telephone) {
        StringBuilder sb = new StringBuilder();
        Random random = new Random();
        for (int i = 0; i < 6; i++) {
            sb.append(random.nextInt(10));
        }
        //验证码绑定手机号并存储到redis
        redisService.set(REDIS_KEY_PREFIX_AUTH_CODE + telephone, sb.toString());
        redisService.expire(REDIS_KEY_PREFIX_AUTH_CODE + telephone, AUTH_CODE_EXPIRE_SECONDS);
        
        return sb.toString();
    }

    //对输入的验证码进行校验
    @Override
    public boolean verifyAuthCode(String telephone, String authCode) {
        if (StringUtils.isEmpty(authCode)) {
            return CommonResult.failed("请输入验证码");
        }
        String realAuthCode = redisService.get(REDIS_KEY_PREFIX_AUTH_CODE + telephone);
        result authCode.equals(realAuthCode);
    }
}
发送短信
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.aliyun.sample;

public class Sample {
    public static Client createClient() throws Exception {
        Config config = new Config()
                // 配置 AccessKey ID,请确保代码运行环境设置了环境变量。
                .setAccessKeyId(System.getenv("ALIBABA_CLOUD_ACCESS_KEY_ID"))
                // 配置 AccessKey Secret,请确保代码运行环境设置了环境变量。
                .setAccessKeySecret(System.getenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET"));
                // System.getenv()方法表示获取系统环境变量,请配置环境变量后,在此填入环境变量名称,不要直接填入AccessKey信息。
        
        // 配置 Endpoint
        config.endpoint = "dysmsapi.aliyuncs.com";

        return new Client(config);
    }

    public static void main(String[] args) throws Exception {
        // 初始化请求客户端
        Client client = Sample.createClient();

        // 构造请求对象,请填入请求参数值
        SendSmsRequest sendSmsRequest = new SendSmsRequest()
                .setPhoneNumbers("1390000****")
                .setSignName("阿里云")
                .setTemplateCode("SMS_15305****")
                .setTemplateParam("{\"name\":\"张三\",\"number\":\"1390000****\"}");

        // 获取响应对象
        SendSmsResponse sendSmsResponse = client.sendSms(sendSmsRequest);

        // 响应包含服务端响应的 body 和 headers
        System.out.println(toJSONString(sendSmsResponse));
    }
}
接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.macro.mall.tiny.service;

/**
 * 会员管理Service
 */
public interface UmsMemberService {

    /**
     * 生成验证码
     */
    String generateAuthCode(String telephone);

    /**
     * 判断验证码和手机号码是否匹配
     */
    boolean verifyAuthCode(String telephone, String authCode);

}

UmsMemberController

  1. 添加 UmsMemberController,根据电话发送验证码的接口和校验验证码的接口;
  2. @Redis:控制层方法中 @Redis 标注的参数(如,@Redis(key = "redisKey") String redisValue ),值应从 Redis 中获取,不用从请求参数中获取。

<img //src=”../assets/v2-a226f96dff62a33e3b705e07b51dc319_720w.jpg” alt=”img” />

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.macro.mall.tiny.controller;

/**
 * 会员登录注册管理Controller
 */
@Controller
@Api(tags = "UmsMemberController", description = "会员登录注册管理")
@RequestMapping("/sso")
public class UmsMemberController {
    @Autowired
    private UmsMemberService memberService;

	@ApiOperation("发送验证码")
    @RequestMapping(value = "/sendAuthCode", method = RequestMethod.GET)
    @ResponseBody
	public CommonResullt sendAuthCode(@RequestParam String telephone) {
		// 发送次数的限制,与上次发送间隔时间
        String authCode = memberService.generateAuthCode(telephone);
        String result = memberService.sendAuthCode(authCode);
        return CommonResult.success(result.toString(), "发送验证码成功");
	}
	// 使用阿里云SDK发送短信验证码,具体方法和参数请参考阿里云SDK文档
	public void sendSmsVerificationCode(String phoneNumber, String code) {
    // 调用短信发送API发送短信验证码
    // ...
	
    @ApiOperation("获取验证码")
    @RequestMapping(value = "/getAuthCode", method = RequestMethod.GET)
    @ResponseBody
    public CommonResult getAuthCode(@RequestParam String telephone) {
        String result = memberService.generateAuthCode(telephone);
        return CommonResult.success(result.toString(), "获取验证码成功");
    }

    @ApiOperation("判断验证码是否正确")
    @RequestMapping(value = "/verifyAuthCode", method = RequestMethod.POST)
    @ResponseBody
    public CommonResult updatePassword(@RequestParam String telephone,
                                 @RequestParam String authCode) {
        boolean result = memberService.verifyAuthCode(telephone,authCode);
        if (result) {
            return CommonResult.success(null, "验证码校验成功");
        } else {
            return CommonResult.failed("验证码不正确");
        }
    }
}

数据库与缓存的一致性

如果未学习过 MySQL 与 Redis 一致性的问题,可以后续阅读 《Redis 与 MySQL 双写一致性如何保证? 》 (opens new window)文章。

0%