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 包同层。如:

  • 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 包下拆成 mysql、redis 包。这样,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-infra 的 redis (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));
    }
}
Service 接口
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)文章。

本地缓存

重要说明:

① 由于大家普遍反馈,“本地缓存”学习成本太高,一般 Redis 缓存足够满足大多数场景的性能要求,所以基本使用 Spring Cache + Redis 所替代。

也因此,本章节更多的,是讲解如何在项目中使用本地缓存。如果你不需要本地缓存,可以忽略本章节。

② 项目中还保留了部分地方使用本地缓存,例如说:短信客户端、文件客户端、敏感词等。主要原因是,它们是“有状态”的 Java 对象,无法缓存到 Redis 中。

系统使用本地缓存,提升公用逻辑的执行性能。 例如说:

实现原理

本地缓存的实现,一共有两步,如下图所示:

  1. 项目启动时,初始化缓存:从数据库中读取数据,写入到本地缓存(例如说一个 Map 对象)
  2. 数据变化时,实时刷新缓存:(例如说通过管理后台修改数据)重新从数据库中读取数据,重新写入到本地缓存

实战案例:以 角色模块 (opens new window)为例,讲解如何实现角色信息的本地缓存。

实现原理

初始化缓存

① 在 RoleService (opens new window)接口中,定义 #initLocalCache() 方法。代码如下:

1
2
3
4
5
6
// RoleService.java

/**
 * 初始化角色的本地缓存
 */
void initLocalCache();

为什么要定义接口方法?

  • 稍后实时刷新缓存时,会调用 RoleService 接口的该方法。

② 在 RoleServiceImpl (opens new window)类中,实现 #initLocalCache() 方法,通过 @PostConstruct 注解,在项目启动时进行本地缓存的初始化。

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
// RoleServiceImpl.java

/**
 * 角色缓存
 * key:角色编号 {@link RoleDO#getId()}
 *
 * 这里声明 volatile 修饰的原因是,每次刷新时,直接修改指向
 */
@Getter
private volatile Map<Long, RoleDO> roleCache;

/**
 * 初始化 {@link #roleCache} 缓存
 */
@Override
@PostConstruct
public void initLocalCache() {
    // 注意:忽略自动多租户,因为要全局初始化缓存
    TenantUtils.executeIgnore(() -> {
        // 第一步:查询数据
        List<RoleDO> roleList = roleMapper.selectList();
        log.info("[initLocalCache][缓存角色,数量为:{}]", roleList.size());
    
        // 第二步:构建缓存
        roleCache = CollectionUtils.convertMap(roleList, RoleDO::getId);
    });
}

疑问:为什么使用 TenantUtils 的 executeIgnore 方法来执行逻辑?

  • 由于 RoleDO 是多租户隔离,如果使用 TenantUtils 方法,会导致缓存刷新时,只加载某个租户的角色数据,导致本地缓存的错误。

  • 所以,如果缓存的数据不存在多租户隔离的情况,可以不使用 TenantUtils 方法!!!!

实时刷新缓存

为什么需要使用 Spring Cloud Bus (opens new window)来实时刷新缓存?

  • 考虑到高可用,线上会部署多个 JVM 实例,需要通过 RocketMQ 广播到所有实例,实现本地缓存的刷新。

实时刷新缓存

友情提示:

对 Spring Cloud Bus 不熟悉的同学,可以后续阅读 《芋道 Spring Cloud Alibaba 事件总线 Bus RocketMQ 入门 》 (opens new window)文档。

RoleRefreshMessage

新建 RoleRefreshMessage (opens new window)类,角色数据刷新 Message。代码如下:

1
2
3
4
5
6
7
8
9
10
11
@Data
public class RoleRefreshMessage extends RemoteApplicationEvent {

    public RoleRefreshMessage() {
    }

    public RoleRefreshMessage(Object source, String originService, String destinationService) {
        super(source, originService, DEFAULT_DESTINATION_FACTORY.getDestination(destinationService));
    }

}

RoleProducer

① 新建 RoleProducer (opens new window)类,RoleRefreshMessage 的 Producer 生产者。

1
2
3
4
5
6
7
8
9
10
@Component
public class RoleProducer extends AbstractBusProducer {

    /**
     * 发送 {@link RoleRefreshMessage} 消息
     */
    public void sendRoleRefreshMessage() {
        publishEvent(new RoleRefreshMessage(this, getBusId(), selfDestinationService()));
    }
}

② 在数据的新增 / 修改 / 删除等写入操作时,需要使用 RoleProducer 发送消息。

localsession_03

RoleRefreshConsumer

新建 RoleRefreshConsumer (opens new window)类,RoleRefreshMessage 的 Consumer 消费者,刷新本地缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
@Slf4j
public class RoleRefreshConsumer {

    @Resource
    private RoleService roleService;

    @EventListener
    public void execute(RoleRefreshMessage message) {
        log.info("[execute][收到 Role 刷新消息]");
        roleService.initLocalCache();
    }
}

##

0%