SpringBoot 自研 Redis 缓存组件

大纲

前言

本文将介绍在 SpringBoot 项目中,如何基于 Redis + AOP + 反射 + 自定义注解自研缓存组件,且支持拔插式使用。由于篇幅有限,下面使用的是 Redis 单机服务,若是在生产环境,为了保证系统的可用性,建议部署 Redis 集群,这里不再累述。

特别注意

  • 本文只自定义了 @RedisCacheable 注解,用于将方法运行的结果进行缓存,在缓存时效内再次调用该方法时不会调用方法本身,而是直接从缓存获取结果并返回给调用方。特别注意,在执行数据库的 Update 或者 Delete 操作时,必须同步更新或者删除 Redis 中的缓存数据,否则数据会存在一致性问题。
  • 因此在企业项目开发中,通常还需要自定义 @RedisCachePut 或者 @RedisCacheEvict 等其他注解,以满足业务需求。具体可以参考 Spring Cache 官方的实现(JSR-107 规范),或者直接使用 Spring Cache 提供的缓存注解,比如 @Cacheable@CachePut@CacheEvict@Caching 等。

代码下载

完整的案例代码可以从 这里 下载得到。值得一提的是,本文的案例代码不仅适用于 SpringBoot 项目,理论上适用于任何 Spring 项目。

业务需求

  • 可配置
    • 自定义缓存注解标签
  • 可插拔
    • 添加,方法自带 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>

<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<hutool.version>5.8.27</hutool.version>
<druid.version>1.1.20</druid.version>
<mybatis.springboot.version>3.0.2</mybatis.springboot.version>
<mysql.version>8.0.11</mysql.version>
<mapper.version>4.2.3</mapper.version>
<persistence-api.version>1.0.2</persistence-api.version>
<spring.cloud.version>2023.0.0</spring.cloud.version>
<spring.cloud.alibaba.version>2023.0.0.0-RC1</spring.cloud.alibaba.version>
</properties>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring.cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring.cloud.alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<!--web-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<!--mysql -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
</dependency>
<!--druid-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>${druid.version}</version>
</dependency>
<!--mybatis-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.springboot.version}</version>
</dependency>
<!--persistence-->
<dependency>
<groupId>javax.persistence</groupId>
<artifactId>persistence-api</artifactId>
<version>${persistence-api.version}</version>
</dependency>
<!--通用Mapper-->
<dependency>
<groupId>tk.mybatis</groupId>
<artifactId>mapper</artifactId>
<version>${mapper.version}</version>
</dependency>
<!--aop-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--test-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>

配置信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# MySQL
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/db_cache?characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true
spring.datasource.username=root
spring.datasource.password=123456

# MyBatis
mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.type-aliases-package=com.clay.cache.entity
mybatis.configuration.map-underscore-to-camel-case=true

# Redis
spring.data.redis.database=0
spring.data.redis.host=127.0.0.1
spring.data.redis.port=6379
spring.data.redis.lettuce.pool.max-active=8
spring.data.redis.lettuce.pool.max-wait=-1ms
spring.data.redis.lettuce.pool.max-idle=8
spring.data.redis.lettuce.pool.min-idle=0

核心代码

  • 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
26
27
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

@Bean
public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(lettuceConnectionFactory);
//设置key序列化方式String
redisTemplate.setKeySerializer(new StringRedisSerializer());
//设置value的序列化方式Json
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
//设置key序列化方式String
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
//设置value的序列化方式Json
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.afterPropertiesSet();
return redisTemplate;
}

}
  • 自定义注解
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
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Redis 自定义缓存注解
*
* <p> 将方法运行的结果进行缓存,在缓存时效内再次调用该方法时不会调用方法本身,而是直接从缓存获取结果并返回给调用方
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisCacheable {

/**
* 键的前缀
*/
String keyPrefix();

/**
* SpringEL 表达式解析占位符对应的匹配 value 值
*/
String matchValue();

}
  • AOP 切面类
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
import cn.hutool.core.util.StrUtil;
import com.clay.cache.annotations.RedisCacheable;
import jakarta.annotation.Resource;
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.reflect.MethodSignature;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;

@Slf4j
@Aspect
@Component
public class RedisCacheableAspect {

@Resource
private RedisTemplate redisTemplate;

@Around("@annotation(com.clay.cache.annotations.RedisCacheable)")
public Object cacheable(ProceedingJoinPoint joinPoint) {
Object result = null;

try {
// 通过反射获取目标方法
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();

// 通过反射获取目标方法上的RedisCacheable注解,如果存在,则说明需要使用缓存
RedisCacheable RedisCacheableAnnotation = method.getAnnotation(RedisCacheable.class);

// 获得注解上面配置的参数
String keyPrefix = RedisCacheableAnnotation.keyPrefix();
String matchValueSpringEL = RedisCacheableAnnotation.matchValue();

// SpringEL 表达式的解析器
ExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression(matchValueSpringEL);
EvaluationContext context = new StandardEvaluationContext();

// 获得目标方法的形参列表
Object[] parameterValues = joinPoint.getArgs();
DefaultParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();
String[] parameterNames = discoverer.getParameterNames(method);
for (int i = 0; i < parameterNames.length; i++) {
log.debug(parameterNames[i] + "=" + parameterValues[i].toString());
context.setVariable(parameterNames[i], parameterValues[i].toString());
}

// 解析 SpringEL 表达式,拼接 Redis 的最终 key 形式
String key = keyPrefix + ":" + expression.getValue(context).toString();
if (StrUtil.isBlank(key)) {
throw new RuntimeException("it's danger, redis key cannot be empty");
}
log.info("Cache key is " + key);

// 查询 Redis 缓存
result = redisTemplate.opsForValue().get(key);
if (result != null) {
// 缓存不为空,则直接返回缓存值
log.info("Cache value found");
return result;
}

// 执行目标方法
result = joinPoint.proceed();

// 将目标方法的执行结果写入 Redis 缓存,并设置过期时间
if (result != null) {
redisTemplate.opsForValue().set(key, result, 24, TimeUnit.HOURS);
}
} catch (Throwable e) {
log.error("Occur Exception", e);
}

return result;
}

}
  • 主启动类,添加了 @EnableAspectJAutoProxy 注解
1
2
3
4
5
6
7
8
9
10
@SpringBootApplication
@EnableAspectJAutoProxy
@MapperScan("com.clay.cache.mapper")
public class MainApplication {

public static void main(String[] args) {
SpringApplication.run(MainApplication.class, args);
}

}

测试代码

数据库表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CREATE TABLE `t_user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) DEFAULT NULL COMMENT '用户名',
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
`age` tinyint DEFAULT NULL COMMENT '年龄',
`status` tinyint DEFAULT NULL COMMENT '有效状态,1有效,0无效',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;

insert t_user(`phone`, `name`, `age`, `status`) values('911', 'Jim', 18, 1);
insert t_user(`phone`, `name`, `age`, `status`) values('911', 'Amy', 20, 1);
insert t_user(`phone`, `name`, `age`, `status`) values('911', 'Tom', 25, 1);
insert t_user(`phone`, `name`, `age`, `status`) values('911', 'Peter', 28, 1);

核心代码

  • 实体类
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
48
49
50
51
52
53
import lombok.Data;

import javax.persistence.Column;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;
import java.util.Date;

@Data
@Table(name = "t_user")
public class User implements Serializable {

/**
* ID
*/
@Id
@GeneratedValue(generator = "JDBC")
private Long id;

/**
* 姓名
*/
private String name;

/**
* 手机号
*/
private String phone;

/**
* 年龄
*/
private Integer age;

/**
* 有效状态,1有效,0无效
*/
private Integer status = 1;

/**
* 更新时间
*/
@Column(name = "update_time")
private Date updateTime;

/**
* 创建时间
*/
@Column(name = "create_time")
private Date createTime;

}
  • Mapper 接口
1
2
3
4
5
6
import com.clay.cache.entity.User;
import tk.mybatis.mapper.common.BaseMapper;

public interface UserMapper extends BaseMapper<User> {

}
  • 服务接口
1
2
3
4
5
6
7
8
9
import com.clay.cache.entity.User;

public interface UserService {

void add(User user);

User get(Long id);

}
  • 服务实现类
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
import com.clay.cache.annotations.RedisCacheable;
import com.clay.cache.entity.User;
import com.clay.cache.mapper.UserMapper;
import com.clay.cache.service.UserService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;

@Service
public class UserServiceImpl implements UserService {

@Resource
private UserMapper userMapper;

@Override
public void add(User user) {
userMapper.insertSelective(user);
}

@Override
@RedisCacheable(keyPrefix = "User", matchValue = "#id")
public User get(Long id) {
return userMapper.selectByPrimaryKey(id);
}

}
  • 控制器类
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
import com.clay.cache.entity.User;
import com.clay.cache.service.UserService;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/user")
public class UserController {

@Resource
private UserService userService;

@PostMapping("/add")
public String add(@RequestBody User user) {
userService.add(user);
return "success";
}

@GetMapping("/get/{id}")
public User get(@PathVariable("id") Long id) {
return userService.get(id);
}

}

接口测试

  • 当第一次调用 /user/get/1 接口时,会从数据库查询数据,并将查询结果写入 Redis,控制台输出的日志信息如下:
1
2
[http-nio-8080-exec-1] INFO  c.c.c.aspect.RedisCacheableAspect - Cache key is User:1
[http-nio-8080-exec-1] INFO c.alibaba.druid.pool.DruidDataSource - {dataSource-1} inited
  • 当第二次调用 /user/get/1 接口时,会直接从 Redis 获取到数据,而不再查询数据库,控制台输出的日志信息如下:
1
2
[http-nio-8080-exec-3] INFO  c.c.c.aspect.RedisCacheableAspect - Cache key is User:1
[http-nio-8080-exec-3] INFO c.c.c.aspect.RedisCacheableAspect - Cache value found
  • 在 Redis 中,缓存的数据如下图所示

参考资料