SpringBoot 中使用 Redis
#1. Redis 介绍
Redis 是一个
-
速度非常快的非关系数据库(Non-Relational Database)
-
它可以存储键(Key)与 5 种不同类型的值(Value)之间的映射(Mapping)
-
可以将存储在内存的键值对数据持久化到硬盘
-
可以使用复制特性来扩展读性能
-
还可以使用客户端分片来扩展写性能
为了满足高性能,Redis 采用内存(in-memory)数据集,根据使用场景,可以通过每隔一段时间转储数据集到磁盘,或者追加每条命令到日志来持久化。
持久化也可以被禁用,如果你只是需要一个功能丰富、网络化的内存缓存。
#数据模型
受支持的数据类型包括:
类型 | 说明 |
---|---|
string | 字符串 |
hash | 哈希 |
list | 列表 |
set | 集合 |
zset | sorted set,有序集合 |
BitMap | |
Geo | |
HyperLogLog |
#关键优势
Redis 的优势包括它的速度、对富数据类型的支持、操作的原子性,以及通用性:
# | 优势 |
---|---|
1 | 性能极高,它每秒可执行约 10,000 个 SET 以及约 100,000 个 GET 操作; |
2 | 丰富的数据类型,Redis 对大多数开发人员已知的大多数据类型提供了原生支持,这使得各种问题得以轻松解决; |
3 | 原子性,因为所有 Redis 操作都是原子性的,所以多个客户端会并发地访问一个 Redis 服务器,获取相同的更新值; |
4 | 丰富的特性,Redis 是一个多效用工具,有非常多的应用场景,包括缓存、消息队列(Redis 原生支持发布/订阅)、短期应用程序数据(比如 Web 会话、Web 页面命中计数)等。 |
#spring-boot-starter-data-redis
Spring Boot 提供了对 Redis 集成的组件包:spring-boot-starter-data-redis,它依赖于 spring-data-redis 和 lettuce 。
另外,这里还有两个小细节:
-
Spring Boot 1.x 时代,spring-data-redis 底层使用的是 Jedis;2.x 时代换成了 Lettuce 。
-
Lettuce 依赖于 commons-pool2 。
#Lettuce 的 timed out 问题
在 Spring Boot 2.x 默认使用 Lettuce 之后,会偶发性出现 Redis command timed out
问题,从而导致客户端(Java 代码)无法连接到 Redis Server 的问题。
而且该问题无法正面解决。网上通用的解决方案是:放弃使用 Lettuce 驱动,转而使用 Jedis 驱动。
这种情况下,你需要手动排除 spring-data-redis 对 Lettuce 的依赖,并引入 Jedis 依赖。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions> <!-- 从依赖关系中排除 -->
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<!-- 此时,也就不想再需要使用 apache 的 commons-pool 包了。-->
#2. 快速上手
#引入依赖包
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency><!-- 使用 lettuce 时要加这个包;使用 jedis 时则不需要。-->
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
引入 commons-pool2 是因为 Lettuce 需要使用 commons-pool2 创建 Redis 连接池。
#application 配置
## Redis 服务器地址
spring.redis.host=localhost
## Redis 服务器连接端口
spring.redis.port=6379
## Redis 数据库索引(默认为 0)
spring.redis.database=0
## Redis 服务器连接密码(默认为空)
spring.redis.password=
## 以下非必须,有默认值
## 连接池最大连接数(使用负值表示没有限制)默认 8
spring.redis.lettuce.pool.max-active=8
## 连接池最大阻塞等待时间(使用负值表示没有限制)默认 -1
spring.redis.lettuce.pool.max-wait=-1
## 连接池中的最大空闲连接 默认 8
spring.redis.lettuce.pool.max-idle=8
## 连接池中的最小空闲连接 默认 0
spring.redis.lettuce.pool.min-idle=0
#测试使用
在单元测试中,注入 RedisTemplate<Object, Object> 或 StringRedisTemplate 。
这两个 bean 被声明在了 ...RedisAutoConfiguration 中。在你没有自己配置 RedisTemplate 的 Bean 的情况下,spring-data-redis 使用的就是它们俩(中的一个)。
另外,StringRedisTemplate 是 RedisTemplate 的子类,它等同于 RedisTemplate<String, String> 。
StringRedisTemplate 比 RedisTemplate<Object, Object> 更简单、常见。RedisTemplate<Object, Object> 会涉及一个转换器(Serializer)的概念。优先考虑使用 StringRedisTemplate 。
@RunWith(SpringRunner.class)
@SpringBootTest
public class TestRedisTemplate {
@Autowired
private StringRedisTemplate redisTemplate;
@Test
public void testString() {
redisTemplate.opsForValue().set("hello", "world");
Assert.assertEquals("world", redisTemplate.opsForValue().get("hello"));
}
}
在这个单元测试中,我们使用 redisTemplate 存储了一个字符串 "world"
,存储之后获取进行验证,多次进行 set 相同的 key,键对应的值会被覆盖。
Spring Data Redis 针对 api 进行了重新归类与封装,将同一类型的操作封装为 Operation 接口:
专有操作 | 说明 |
---|---|
ValueOperations | string 类型的数据操作 |
ListOperations | list 类型的数据操作 |
SetOperations | set 类型数据操作 |
ZSetOperations | zset 类型数据操作 |
HashOperations | map 类型的数据操作 |
@Autowired
private StringRedisTemplate redisTemplate;
@Test
public void contextLoad() {
assertNotNull(redisTemplate);
ValueOperations<String, String> stringOperations
= redisTemplate.opsForValue();
HashOperations<String, String, String> hashOperations
= redisTemplate.opsForHash();
ListOperations<String, String> listOperations
= redisTemplate.opsForList();
SetOperations<String, String> setOperations
= redisTemplate.opsForSet();
ZSetOperations<String, String> zsetOperations
= redisTemplate.opsForZSet();
}
#3. RedisTemplate 和 Serializer
RedisTemplate<Object, Object> 看起来比 StringRedisTemplate 更『牛逼』一些,因为它不强求键和值的类型必须是 String 。
但是很显然,这和 Redis 的实际情况是相违背的:在最小的存储单元层面,Redis 本质上只能存字符串,不可能存其它的类型。
这么看来,StringRedisTemplate 更贴合 Redis 的存储本质。
那么 RedisTemplate 是如何实现以任何类型(只要是实现了 Serializable 接口)作为键值对的?通过 Serializer 。
RedisTemplate 会将你交给它的作为键或值的任意类型对象(唯一要求是实现了 Serializable 接口)使用 Serializer 进行转换,转换成字符串,然后再存入 Redis 中。这样就没有违背『Redis 的最小存储单元中只能存字符串』的准则。
RedisTemplate 默认使用的是 JdkSerializationRedisSerializer 进行 Object 到 String 的双向转换工作。它将对象转换为字节数组的字符串形式。
对象的字节数组的字符串形式如下。看起来怪怪的感觉。
127.0.0.1:6379> get "\xac\xed\x00\x05t\x00\x0cdepartment:1" "\xac\xed\x00\x05sr\x00"com.woniu.example1.bean.Department\x00\x00\x00\x00\x00\x00\x00\x01\x02\x00\x03L\x00\x02idt\x00\x13Ljava/lang/Integer;L\x00\blocationt\x00\x12Ljava/lang/String;L\x00\x04nameq\x00~\x00\x02xpsr\x00\x11java.lang.Integer\x12\xe2\xa0\xa4\xf7\x81\x878\x02\x00\x01I\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x00\x01t\x00\x05Wuhant\x00\aTesting"
考虑到『对象的字节数组的字符串形式』不便于阅读,因此,你可以考虑将默认的 JdkSerializationRedisSerializer 替换掉。这种情况下,你就需要自己去声明 RedisTemplate(@Bean)。
其实,你并不用太在意各种 Serializer 的名字是什么。spring-data-redis 通过 RedisSerializer 对各种 Serializer 提供了包装:
- 如果,你期望中的键和值是字符串,那么你可以使用 RedisSerializer.string() 所返回的 Serializer;
- 如果,你期望中的键和值是对象的字节数组(的字符串形式),那么你可以使用 RedisSerializer.java() 所返回的 Serializer;
- 如果,你期望中的键和值是 JSON(格式字符串),那么你可以使用 RedisSerializer.json() 所返回的 Serializer;
记得要加 jackson 的依赖:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
@Configuration
public class RedisConfig {
@Bean(name = "redisTemplate")
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory)
throws UnknownHostException {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
template.setKeySerializer(RedisSerializer.string());
template.setValueSerializer(RedisSerializer.json());
return template;
}
}
#4. 各类型实践
#实体
@Slf4j
@Test
public void testObj() {
ValueOperations<String, String> operations = redisTemplate.opsForValue();
operations.set("id", "9527");
operations.set("name", "tom");
operations.set("age", "21");
String name = operations.get("name");
log.info("{}", name);
}
#超时失效
Redis 在存入每一个数据的时候都可以设置一个超时间,过了这个时间就会自动删除数据。
新建一个 Student 对象,存入 Redis 的同时设置 100 毫秒后失效,设置一个线程暂停 1000 毫秒之后,判断数据是否存在并打印结果。
@Test
public void testExpire() throws InterruptedException {
final String key = "expire" ;
Student user = new Student("tom", 20);
ValueOperations<String, Student> operations = redisTemplate.opsForValue();
operations.set(key, user, 100, TimeUnit.MILLISECONDS); // 这背后就有 Serializer 在工作
Thread.sleep(1000);
boolean exists = redisTemplate.hasKey(key);
System.out.println( exists ? "exists is true" : "exists is false" );
}
输出结果:
exists is false
从结果可以看出,Reids 中已经不存在 Student 对象了,此数据已经过期,同时我们在这个测试的方法中使用了 hasKey("expire")
方法,可以判断 key 是否存在。
#删除数据
有些时候,我们需要对过期的缓存进行删除,下面来测试此场景的使用。首先 set 一个字符串 hello world
,紧接着删除此 key 的值,再进行判断。
redisTemplate.delete("key");
#Hash(哈希)
一般我们存储一个键,很自然的就会使用 get/set 去存储,实际上这并不是很好的做法。Redis 存储一个 key 会有一个最小内存,不管你存的这个键多小,都不会低于这个内存,因此合理的使用 Hash 可以帮我们节省很多内存。
Hash Set 就在哈希表 Key 中的域(Field)的值设为 value。如果 Key 不存在,一个新的哈希表被创建并进行 HSET 操作;如果域(field)已经存在于哈希表中,旧值将被覆盖。
先来看 Redis 对 Pojo 的支持,新建一个 Student 对象(需要实现 Serializable 接口),放到缓存中,再取出来。
@Test
public void testHash() {
final String key = "tom";
HashOperations<String, Object, Object> hash = redisTemplate.opsForHash();
hash.put(key, "name", "tom");
hash.put(key, "age", "20");
String value = hash.get(key, "name");
log.info("hash value : {}", value);
}
输出结果:
hash value :tom
根据上面测试用例发现,Hash set 的时候需要传入三个参数,第一个为 key,第二个为 field,第三个为存储的值。一般情况下 Key 代表一组数据,field 为 key 相关的属性,而 value 就是属性对应的值。
#其它结构的操作(了解、自学)
#List
Redis List 的应用场景非常多,也是 Redis 最重要的数据结构之一。 使用 List 可以轻松的实现一个队列, List 典型的应用场景就是消息队列,可以利用 List 的 Push 操作,将任务存在 List 中,然后工作线程再用 POP 操作将任务取出进行执行。
@Test
public void testList() {
final String key = "list";
ListOperations<String, String> list = redisTemplate.opsForList();
list.leftPush(key, "hello");
list.leftPush(key, "world");
list.leftPush(key, "goodbye");
String value = list.leftPop(key);
log.info("list value : {}", value.toString());
}
输出结果:
list value :goodbye
上面的例子我们从左侧插入一个 key 为 "list" 的队列,然后取出左侧最近的一条数据。其实 List 有很多 API 可以操作,比如从右侧进行插入队列,从右侧进行读取,或者通过方法 range 读取队列的一部分。接着上面的例子我们使用 range 来读取。
List<String> values = list.range(key, 0, 2);
for (String v : values) {
System.out.println("list range :" + v);
}
输出结果:
list range :goodbye
list range :world
list range :hello
range 后面的两个参数就是插入数据的位置,输入不同的参数就可以取出队列中对应的数据。
Redis List 的实现为一个双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销,Redis 内部的很多实现,包括发送缓冲队列等也都是用的这个数据结构。
#Set
Redis Set 对外提供的功能与 List 类似,是一个列表的功能,特殊之处在于 Set 是可以自动排重的,当你需要存储一个列表数据,又不希望出现重复数据时,Set 是一个很好的选择,并且 Set 提供了判断某个成员是否在一个 Set 集合内的重要接口,这个也是 List 所不能提供的。
@Test
public void testSet() {
final String key = "set";
SetOperations<String, String> set = redisTemplate.opsForSet();
set.add(key, "hello");
set.add(key, "world");
set.add(key, "world");
set.add(key, "goodbye");
Set<String> values = set.members(key);
for (String v : values) {
System.out.println("set value :" + v);
}
}
输出结果:
set value :hello
set value :world
set value :goodbye
通过上面的例子我们发现,输入了两个相同的值 world
,全部读取的时候只剩下了一条,说明 Set 对队列进行了自动的排重操作。
另外,Redis 为集合提供了求交集、并集、差集等操作,可以非常方便的使用,这里就不一一举例了。
#ZSet
Redis Sorted Set 的使用场景与 Set 类似,区别是 Set 不是自动有序的,而 Sorted Set 可以通过用户额外提供一个优先级(Score)的参数来为成员排序,并且是插入有序,即自动排序。
在使用 Zset 的时候需要额外的输入一个参数 Score,Zset 会自动根据 Score 的值对集合进行排序,我们可以利用这个特性来做具有权重的队列,比如普通消息的 Score 为 1,重要消息的 Score 为 2,然后工作线程可以选择按 Score 的倒序来获取工作任务。
@Test
public void testZset() {
final String key = "zset";
redisTemplate.delete(key);
ZSetOperations<String, String> zset = redisTemplate.opsForZSet();
zset.add(key, "hello", 1);
zset.add(key, "world", 6);
zset.add(key, "good", 4);
zset.add(key, "bye", 3);
Set<String> zsets = zset.range(key, 0, 3);
for (String v : zsets) {
log.info("zset-A value : {}", v);
}
Set<String> zsetB = zset.rangeByScore(key, 0, 3);
for (String v : zsetB) {
log.info("zset-B value : {}", v);
}
}
输出结果:
zset-A value : hello
zset-A value : bye
zset-A value : good
zset-A value : world
zset-B value : hello
zset-B value : bye
通过上面的例子我们发现插入到 Zset 的数据会自动根据 Score 进行排序,根据这个特性我们可以做优先队列等各种常见的场景。
另外 Redis 还提供了 rangeByScore 这样的一个方法,可以只获取 Score 范围内排序后的数据。
Redis Sorted Set 的内部使用 HashMap 和跳跃表(SkipList)来保证数据的存储和有序,HashMap 里放的是成员到 Score 的映射,而跃表里存放的是所有的成员,排序依据是 HashMap 里存的 Score,使用跳跃表的结构可以获得比较高的查找效率,并且在实现上比较简单。
#5. Redis Repositories
Spring Data Redis 从 1.7 开始提供 Redis Repositories ,可以无缝的转换并存储 domain objects,使用的数据类型为哈希(hash)。
Spring Data Redis 的 Repository 的基本实现为:CrudRepository 。
基础用法(Usage)分为以下三步:
#第一步:启用 Repository 功能
编写一个配置类(或直接利用 Spring Boot 的入口类),在其上标注 ***@EnableRedisRepositories(basePackages = "...")
***,表示启用 Repository 功能。
属性 basePackages
如果不赋值,那么默认是扫描入口类平级及之下的所有类,看它们谁的头上有 @Repository
注解。如果是同时使用 spring-data-jpa 和 spring-data-redis 时,由于它们的 Repository 的祖先中都有 CrudRepository 因此会造成冲突。虽有,最好还是加上 basePackages
属性并为它赋值,指定各自扫描的路径,以避免冲突。
#第二步:注解需要缓存的实体
添加关键的两个注解 @RedisHash
和 @Id
;
@RedisHash("user")
public class User implements Serializable {
private static final long serialVersionUID = 1L;
@Id
private Long id;
private String userName;
private String password;
private String email;
}
注解 | 说明 |
---|---|
@RedisHash | 表示将 User 类的对象都对于 Redis 中的名为 user 的 Set 中。 |
@Id | 标注于对象的唯一性标识上。 |
如果将多个 User 对象通过 Repository 存储于 Redis 中,那么,它们每个的 key 分别是:*user:<Id>
。例如:user:1
、user:2
、user:3
、...
获取它们每个对象的属性的命令为:
hget user:1 userName
#第三步:创建一个 Repository 接口
自定的 Repository 接口必须继承 CrudRepository,才能“天生”具有存取数据的能力。
@Repository
public interface UserRepository extends CrudRepository<User, Long> {
}
#6. Spring Data Redis 的默认映射规则
-
Simple Type
- String firstname = "rand"
- firstname = "rand"
-
Complex Type
- Address adress = new Address("河南", "郑州");
- address.privince = "河南"
- address.city="郑州"
-
List of Simple Type
- List<String> nicknames = ["...", "...", "..."]
- nicknames.[0] = "dragon reborn",
- nicknames.[1] = "lews therin"
-
Map of Simple Type
- Map<String, String> atts = { key1:val1, key2:val2 }
- atts.[key1] = "val1"
- atts.[key2] = "val2"
-
List of Complex Type
- List<Address> addresses = [ {...}, {...}, {...} ]
- addresses.[0].province = "emond’s field",
- addresses.[0].city = "...",
- addresses.[1].procinvce = "...",
- addresses.[1].city = "…
-
Map of Complex Type
- Map<String, Address> addresses = {key1: {}, key2: {}}
- addresses.[key1].city = "emond’s field",
- addresses.[key2].city = "…"