目录
前言
如果你想在网上再找一个这么详细的入门 Spirng Boot + redis的项目,那你可得费点力气了……因为我就尝试过……
NoSQL
我们知道数据库连接和调用是耗时的(包括连接,查询等操作)。而且在高并发的情况下会出现明显的瓶颈。所以如何减少数据库访问就逐渐成为互联网系统加速的重要优化点。为此NoSQL诞生了,其中使用广泛的就是Redis和MongoDB。这里先介绍一下Redis。
Redis 是一种运行在内存的数据库,很多时候我们都会把从数据库查询出来的数据放入Redis,当用户再次查询相同数据的时候,优先使用Redis中存在的数据,因为是存放在内存中,所以速度很快。另外Redis还可以将数据持久化到磁盘中,很多网站甚至放弃了后台数据库,完全使用Redis来进行数据存储。
安装Redis
笔者为了学(fan)习(qiang),特地买了一个廉价的VPS,这里正好利用起来,在服务器上安装了mariadb 和 redis。这里不详细介绍安装流程,大家可以本机安装,网上资料很多。
PS 笔者使用的是centos7 对于安全的限制很严,安装完成mariadb和redis之后,如果需要远程访问,需要开启防火墙端口。
PS2 这两个东西需要远程访问都需要做一些设置,比如redis需要去掉bind 127.0.0.1的配置等。
Spring 中引入redis
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<!--不依赖Redis的异步客户端lettuce-->
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--引入Redis的客户端驱动jedis-->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
引入上面的依赖,对于redis的依赖我们选择了jedis而spring默认使用的是lettuce。至于jedis就类似于jdbc这种,相当于连接redis的驱动。redis类似一个数据库,c++连接它需要自己的封装,java当然也要自己的封装,这就是jedis了。
查看网上的各种讲解和例子,一般都是使用jedis,所以当然跟随大众科技了。
第一个入门demo
老夫写代码就是一把梭!开个玩笑……先来一个列子,当然你大概会 “???”
首先在application.properties中添加配置项
#配置连接池属性
spring.redis.jedis.pool.min-idle=5
spring.redis.jedis.pool.max-active=10
spring.redis.jedis.pool.max-idle=10
spring.redis.jedis.pool.max-wait=2000
#配置Redis服务器属性
spring.redis.port=6379
spring.redis.host=xxx.xxx.xxx.xxx
#spring.redis.password=123456
#Redis连接超时时间,单位毫秒
spring.redis.timeout=1000
然后修改XXApplication
@SpringBootApplication
public class RedisApplication {
@Autowired
private RedisTemplate redisTemplate = null;
@PostConstruct
public void init(){
initRedisTemplate();
}
private void initRedisTemplate(){
RedisSerializer redisSerializer = redisTemplate.getStringSerializer();
redisTemplate.setKeySerializer(redisSerializer);
redisTemplate.setHashKeySerializer(redisSerializer);
}
....
}
最后我们需要一个controller来测试
@Controller
@RequestMapping("/redis")
public class RedisController {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@RequestMapping("/test")
@ResponseBody
public String testStringAndHash() {
redisTemplate.opsForValue().set("username","yxwang");
return "OK";
}
访问http://localhost:8080/redis/test 页面输出ok
然后通过redis看看有没有存入,由于我是远程登录 redis-cli -h xxx.xx.xx.xx -p 6379
然后 get username 发现有输出,这就表示已经存进去了。
来看下我们的auto-config(spring-boot-autoconfigure就是spring帮我们做自动配置的核心包)帮我们做了什么。在application.properties中spring.redis相关的配置项目会被读取到 RedisProperties 这个类中。
而我们的配置类 JedisConnectionConfiguration又会读取类 RedisProperties 中的内容。通过IoC向外暴露了这个么一个bean
@Bean
@ConditionalOnMissingBean(RedisConnectionFactory.class)
public JedisConnectionFactory redisConnectionFactory() throws UnknownHostException {
return createJedisConnectionFactory();
}
而JedisConnectionFactory这个类继承与RedisConnectionFactory,通过它,可以生成一个RedisConnection的接口对象,这个对象就是对Redis底层接口的封装。
在RedisAutoConfiguration中提供了两个bean
public class RedisAutoConfiguration {
@Bean
@ConditionalOnMissingBean(name = "redisTemplate")
public RedisTemplate<Object, Object> redisTemplate(
RedisConnectionFactory redisConnectionFactory) throws UnknownHostException {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
//注入了 RedisConnectionFactory 这个Factory主要用于生成RedisConnection,用于和Redis建立连接
template.setConnectionFactory(redisConnectionFactory);
return template;
}
@Bean
@ConditionalOnMissingBean
public StringRedisTemplate stringRedisTemplate(
RedisConnectionFactory redisConnectionFactory) throws UnknownHostException {
StringRedisTemplate template = new StringRedisTemplate();
template.setConnectionFactory(redisConnectionFactory);
return template;
}
}
这两个bean就是我们用于最终操作Redis的类,它首先从RedisConnectionFactory中获取Redis连接,然后执行Redis操作,最终还会关闭Redis连接。
所以我们解决了application.properties的作用流程,也知道了 redisTemplate stringRedisTemplate的注入流程。
PS 当不适用spring boot时,我们也完全可以拷贝上面的代码,手动生成RedisConnectionFactory,然后再手动生成redisTemplate
我们发现,当输出get username获取redis中存储的值时,返回的是 "\xac\xed\x00\x05t\x00\x06yxwang" 这么一串东西。这是怎么回事呢?首先需要清楚的是,Redis 是一种基于字符串存储的 NoSQL,而 Java 是基于对象的语言,对象是无法存储到 Redis 中的,不过 Java 提供了序列化机制,只要类实现了 java.io.Serializable 接口,就代表类的对象能够进行序列化,通过将类对象进行序列化就能够得到二进制字符串,这样 Redis 就可以将这些类对象以字符串进行存储。
Spring 提供了序列化器的机制,并且实现了几个序列化器
而上面这个奇怪的字符串就是因为String对象通过了JdkSerializationRedisSerializer序列化之后存入的。但是我们的key "username" 为什么没有变得奇怪呢?因为我们在XXApplication主动设置了序列化的接口 StringRedisSerializer。
RedisTemplate可以设置以下序列化器
属 性 | 描 述 | 备 注 |
---|---|---|
defaultSerializer | 默认序列化器 | 如果没有设置,则使用 JdkSerializationRedisSerializer |
keySerializer | Redis 键序列化器 | 如果没有设置,则使用默认序列化器 |
valueSerializer | Redis 值序列化器 | 如果没有设置,则使用默认序列化器 |
hashKeySerializer | Redis 散列结构 field 序列化器 | 如果没有设置,则使用默认序列化器 |
hashValueSerializer | Redis 散列结构 value 序列化器 | 如果没有设置,则使用默认序列化器 |
stringSerializer | 字符串序列化器 | RedisTemplate 自动赋值为 StringRedisSerializer 对象 |
那么对于上面例子,最后我们还需要聊一下的就是@Controller中是如何将数据存入redis中的了。
我们通过redisTemplate进行操作(也可以通过stringRedisTemplate,区别就是stringRedisTemplate 相当于redisTemplate<String,String>),首先redisTemplate获取redis连接,然后进行操作,然后关闭连接(上面有提到)。
redis 能够支持7种类型的数据结构,这7种类型是字符串、散列、列表(链表)、集合、有序集合、基数和地理位置。为此 Spring 针对每一种数据结构的操作都提供了对应的操作接口.
PS 最新版本还有一种和分布式相关的 ClusterOperations 这里我们暂且不表。如有需要可以看这里
操 作 接 口 | 功 能 | 备 注 | 获取接口方法 | 连续操作接口 | 获取连续操作接口 |
---|---|---|---|---|---|
GeoOperations | 地理位置操作接口 | 使用不多,本书不再介绍 | redisTemplate.opsForGeo(); | BoundGeoOperations | redisTemplate.boundGeoOps("geo"); |
HashOperations | 散列操作接口 | redisTemplate.opsForHash(); | BoundHashOperations | redisTemplate.boundHashOps("hash"); | |
HyperLogLogOperations | 基数操作接口 | 使用不多,本书不再介绍 | redisTemplate.opsForHyperLogLog(); | ||
ListOperations | 列表(链表)操作接口 | redisTemplate.opsForList(); | BoundListOperations | redisTemplate.boundListOps("list"); | |
SetOperations | 集合操作接口 | redisTemplate.opsForSet(); | BoundSetOperations | redisTemplate.boundSetOps("set"); | |
ValueOperations | 字符串操作接口 | redisTemplate.opsForValue(); | BoundValueOperations | redisTemplate.boundValueOps("string"); | |
ZSetOperations | 有序集合操作接口 | redisTemplate.opsForZSet(); | BoundZSetOperations | redisTemplate.boundZSetOps("zset"); |
这里有必要介绍下所谓的连续操作。redis中可与存放多个Hash(list set等都一样),比如我们存在一个hash,名字叫做 "hash1",那么我们会像这样添加数据 stringRedisTemplate.opsForHash().put("hash1", "field3", "value3"); 于是hash1这个hash表中,存在一个key是field3 ,value是 value3的键值对。
如果我需要继续添加那么还是需要 stringRedisTemplate.opsForHash().put("hash1", "xxxx", "xxx"); 所以通过stringRedisTemplate.opsForHash() 返回的HashOperations并不会和hash1绑定,我们可以用它操作所有的hash表。
可以通过stringRedisTemplate.boundHashOps("hash1"); 返回一个 BoundHashOperations ,它自动和hash1绑定,可以直接操作hashOps.delete("field1", "field2");
这里各种数据类型就不再介绍了,基本上需要使用的时候学习下就行。不过有一点ZSet,可能在java中没有对应的数据结构,它是用来做有权重的列表的,比如用来做排行榜。
这里帖一段测试代码,用到的时候可以当做参考学习。
@RequestMapping("/zset")
@ResponseBody
public Map<String, Object> testZset() {
Set<TypedTuple<String>> typedTupleSet = new HashSet<>();
for (int i=1; i<=9; i++) {
// 分数
double score = i*0.1;
// 创建一个TypedTuple对象,存入值和分数
TypedTuple<String> typedTuple
= new DefaultTypedTuple<String>("value" + i, score);
typedTupleSet.add(typedTuple);
}
// 往有序集合插入元素
stringRedisTemplate.opsForZSet().add("zset1", typedTupleSet);
// 绑定zset1有序集合操作
BoundZSetOperations<String, String> zsetOps
= stringRedisTemplate.boundZSetOps("zset1");
// 增加一个元素
zsetOps.add("value10", 0.26);
Set<String> setRange = zsetOps.range(1, 6);
// 按分数排序获取有序集合
Set<String> setScore = zsetOps.rangeByScore(0.2, 0.6);
// 定义值范围
Range range = new Range();
range.gt("value3");// 大于value3
// range.gte("value3");// 大于等于value3
// range.lt("value8");// 小于value8
range.lte("value8");// 小于等于value8
// 按值排序,请注意这个排序是按字符串排序
Set<String> setLex = zsetOps.rangeByLex(range);
// 删除元素
zsetOps.remove("value9", "value2");
// 求分数
Double score = zsetOps.score("value8");
// 在下标区间下,按分数排序,同时返回value和score
Set<TypedTuple<String>> rangeSet = zsetOps.rangeWithScores(1, 6);
// 在分数区间下,按分数排序,同时返回value和score
Set<TypedTuple<String>> scoreSet = zsetOps.rangeByScoreWithScores(1, 6);
// 按从大到小排序
Set<String> reverseSet = zsetOps.reverseRange(2, 8);
Map<String, Object> map = new HashMap<String, Object>();
map.put("success", true);
return map;
}
SessionCallback和RedisCallback 接口
和sql一样,每次我们调用一个操作就会建立一条链接,比如
redisTemplate.opsForValue().set("key1", "value1");
redisTemplate.opsForHash().put("hash", "field", "hvalue");
上面代码进行了两次操作,这个时候回建立两条和redis的链接,这样是比较浪费资源的,为此redis推出了两个接口。它们的作用是让 RedisTemplate 进行回调,通过它们可以在同一条连接下执行多个 Redis 命令。其中 SessionCallback 提供了良好的封装,对于开发者比较友好,因此在实际的开发中应该优先选择使用它;相对而言,RedisCallback 接口比较底层,需要处理的内容也比较多,可读性较差,所以在非必要的时候尽量不选择使用它。
// 需要处理底层的转换规则,如果不考虑改写底层,尽量不使用它
public void useRedisCallback(RedisTemplate redisTemplate) {
redisTemplate.execute(new RedisCallback() {
@Override
public Object doInRedis(RedisConnection rc)
throws DataAccessException {
rc.set("key1".getBytes(), "value1".getBytes());
rc.hSet("hash".getBytes(), "field".getBytes(), "hvalue".getBytes());
return null;
}
});
}
// 高级接口,比较友好,一般情况下,优先使用它
public void useSessionCallback(RedisTemplate redisTemplate) {
redisTemplate.execute(new SessionCallback() {
@Override
public Object execute(RedisOperations ro)
throws DataAccessException {
ro.opsForValue().set("key1", "value1");
ro.opsForHash().put("hash", "field", "hvalue");
return null;
}
});
}
事务
Redis中的事务有是哪个关联命令,watch 用于监听Redis中的几个键,然后通过multi表示开启事务(注意是开启,事务还没有被执行),然后通过exe命令执行事务。但是在事务执行之前会检查被watch监听的键是否发生变化,如果发生了变化,那么就不会执行事务。当事务执行时原子性的不会被其他客户端打断。
另外,一般如果需要执行事务,都会有多个语句,所以绝大多数情况会和SessionCallback一起使用。
@RequestMapping("/test/translation")
@ResponseBody
public String testTranslation() {
redisTemplate.execute(new SessionCallback() {
@Override
public Object execute(RedisOperations operations)
throws DataAccessException {
// 设置要监控key1 key2
operations.watch(Arrays.asList("key1","key2"));
operations.multi();
operations.opsForValue().set("key2", "value2");
operations.opsForValue().set("key1", "value1");
return operations.exec();
}
});
return "OK";
}
上面代码有一个地方需要特别注意,我们看下execute方法源码
public <T> T execute(SessionCallback<T> session) {
Assert.isTrue(this.initialized, "template not initialized; call afterPropertiesSet() before using it");
Assert.notNull(session, "Callback object must not be null");
RedisConnectionFactory factory = this.getRequiredConnectionFactory();
RedisConnectionUtils.bindConnection(factory, this.enableTransactionSupport);
Object var3;
try {
var3 = session.execute(this);
} finally {
RedisConnectionUtils.unbindConnection(factory);
}
return var3;
}
注意到没,execute的返回值,就是SessionCallback的返回值,而且……是同步的。所以redisTamplate.execute是同步执行。
Pipeline
不论是使用事务,还是使用SessionCallback,redis还是将命令一条一条送到服务端进行处理,这是相对比较慢的。我们可以将所有的命令进行打包,这样就只会传输一次。
@RequestMapping("/test/pipeline")
@ResponseBody
public String testPipeline(){
redisTemplate.executePipelined(new SessionCallback<Object>() {
@Override
public Object execute(RedisOperations redisOperations) throws DataAccessException {
for (int i=1; i<=1000; i++) {
redisOperations.opsForValue().set("pipeline_" + i, "value_" + i);
}
return null;
}
});
return "OK";
}
Redis订阅发布
个人觉得这个Redis最不务正业的功能,因为不管在使用上还是过程中这都和数据没有太大的直接联系。(很有可能是我没有深入学习原理)
首先是 Redis 提供一个渠道,让消息能够发送到这个渠道上,而多个系统可以监听这个渠道,如短信、微信和邮件系统都可以监听这个渠道,当一条消息发送到渠道,渠道就会通知它的监听者,这样短信、微信和邮件系统就能够得到这个渠道给它们的消息了,这些监听者会根据自己的需要去处理这个消息
大概是就是这么张图
首先需要定义一个监听器,这很简单
@Component
public class RedisMessageListener implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
// 消息体
String body = new String(message.getBody());
// 渠道名称
String topic = new String(pattern);
System.out.println(body);
System.out.println(topic);
}
}
然后就是通过redis注册监听。
然后再XXXApplication中添加如下代码
@Bean
public ThreadPoolTaskScheduler initTaskScheduler() {
if (taskScheduler != null) {
return taskScheduler;
}
taskScheduler = new ThreadPoolTaskScheduler();
taskScheduler.setPoolSize(20);
return taskScheduler;
}
/**
* 定义Redis的监听容器
* @return 监听容器
*/
@Bean
public RedisMessageListenerContainer initRedisContainer() {
RedisMessageListenerContainer container
= new RedisMessageListenerContainer();
// Redis连接工厂
container.setConnectionFactory(connectionFactory);
// 设置运行任务池
container.setTaskExecutor(initTaskScheduler());
// 定义监听渠道,名称为topic1
Topic topic = new ChannelTopic("topic1");
// 使用监听器监听Redis的消息
container.addMessageListener(redisMessageListener, topic);
return container;
}
PS: 一些自动注入的东西这里没列出来
这里我有个疑惑,需要提供一个返回RedisMessageListenerContainer的Bean,如果直接运行initRedisContainer的代码,没有提供bean,那么注册不会生效。
也就是Redis内部是通过依赖注入获取RedisMessageListenerContainer对象,然后将其注册到某个地方的。
最后我们可以通过命令行运行 publish topic1 msg 往 topic1通道发送msg消息。
也可以通过代码发送
redisTemplate.convertAndSend(channel, message);
Lua脚本
为了增强 Redis 的计算能力,Redis 在2.6版本后提供了 Lua 脚本的支持,而且执行 Lua 脚本在 Redis 中还具备原子性,所以在需要保证数据一致性的高并发环境中,我们也可以使用 Redis 的 Lua 语言来保证数据的一致性,且 Lua 脚本具备更加强大的运算功能,在高并发需要保证数据一致性时,Lua 脚本方案比使用 Redis 自身提供的事务要更好一些。
在 Redis 中有两种运行 Lua 的方法,一种是直接发送 Lua 到 Redis 服务器去执行,另一种是先把 Lua 发送给 Redis,Redis 会对 Lua 脚本进行缓存,然后返回一个 SHA1 的32位编码回来,之后只需要发送 SHA1 和相关参数给 Redis 便可以执行了。这里需要解释的是为什么会存在通过32位编码执行的方法。如果 Lua 脚本很长,那么就需要通过网络传递脚本给 Redis 去执行了,而现实的情况是网络的传递速度往往跟不上 Redis 的执行速度,所以网络就会成为 Redis 执行的瓶颈。如果只是传递32位编码和参数,那么需要传递的消息就少了许多,这样就可以极大地减少网络传输的内容,从而提高系统的性能。
为了支持 Redis 的 Lua 脚本,Spring 提供了 RedisScript 接口,与此同时也有一个 DefaultRedisScript 实现类。
public interface RedisScript<T> {
// 获取脚本的Sha1
String getSha1();
// 获取脚本返回值
Class<T> getResultType();
// 获取脚本的字符串
String getScriptAsString();
}
这里 Spring 会将 Lua 脚本发送到 Redis 服务器进行缓存,而此时 Redis 服务器会返回一个32位的 SHA1 编码,这时候通过 getSha1 方法就可以得到 Redis 返回的这个编码了;getResultType 方法是获取 Lua 脚本返回的 Java 类型;getScriptAsString 是返回脚本的字符串.
@RequestMapping("/lua")
@ResponseBody
public Map<String, Object> testLua() {
DefaultRedisScript<String> rs = new DefaultRedisScript<String>();
// 设置脚本
rs.setScriptText("return 'Hello Redis'");
// 定义返回类型。注意:如果没有这个定义,Spring 不会返回结果
rs.setResultType(String.class);
RedisSerializer<String> stringSerializer
= redisTemplate.getStringSerializer();
// 执行 Lua 脚本
String str = (String) redisTemplate.execute(
rs, stringSerializer, stringSerializer, null);
Map<String, Object> map = new HashMap<String, Object>();
map.put("str", str);
return map;
}
上面代码执行了一个非常简单的Lua脚本 ,就是返回Hello Redis字符串。
redisTemplate 中,execute 方法执行脚本的方法有两种
public <T> T execute(RedisScript<T> script, List<K> keys, Object... args)
public <T> T execute(RedisScript<T> script, RedisSerializer<?> argsSerializer,
RedisSerializer<T> resultSerializer, List<K> keys, Object... args)
从参数的名称可以知道,script 就是我们定义的 RedisScript 接口对象,keys 代表 Redis 的键,args 是这段脚本的参数。两个方法最大区别是一个存在序列化器的参数,另外一个不存在。对于不存在序列化参数的方法,Spring 将采用 RedisTemplate 提供的 valueSerializer 序列化器对传递的键和参数进行序列化。这里我们采用了第二个方法调度脚本,并且设置为字符串序列化器,其中第一个序列化器是键的序列化器,第二个是参数序列化器,这样键和参数就在字符串序列化器下被序列化了。
下面我们再考虑存在参数的情况。例如,我们写一段 Lua 脚本用来判断两个字符串是否相同
redis.call('set', KEYS[1], ARGV[1])
redis.call('set', KEYS[2], ARGV[2])
local str1 = redis.call('get', KEYS[1])
local str2 = redis.call('get', KEYS[2])
if str1 == str2 then
return 1
end
return 0
@RequestMapping("/lua2")
@ResponseBody
public Map<String, Object> testLua2(String key1, String key2, String value1, String value2) {
// 定义Lua脚本
String lua = "redis.call('set', KEYS[1], ARGV[1]) \n"
+ "redis.call('set', KEYS[2], ARGV[2]) \n"
+ "local str1 = redis.call('get', KEYS[1]) \n"
+ "local str2 = redis.call('get', KEYS[2]) \n"
+ "if str1 == str2 then \n"
+ "return 1 \n"
+ "end \n"
+ "return 0 \n";
System.out.println(lua);
// 结果返回为Long
DefaultRedisScript<Long> rs = new DefaultRedisScript<Long>();
rs.setScriptText(lua);
rs.setResultType(Long.class);
// 采用字符串序列化器
RedisSerializer<String> stringSerializer = redisTemplate.getStringSerializer();
// 定义key参数
List<String> keyList = new ArrayList<>();
keyList.add(key1);
keyList.add(key2);
// 传递两个参数值,其中第一个序列化器是key的序列化器,第二个序列化器是参数的序列化器
Long result = (Long) redisTemplate.execute(
rs, stringSerializer, stringSerializer, keyList, value1, value2);
Map<String, Object> map = new HashMap<String, Object>();
map.put("result", result);
return map;
}
在Spring中使用注解操作Redis
Redis在web开发中最重要的作用大概就是用来作为缓存存储数据,加快查询速度。
启用缓存和CacheManager
首先缓存处理器 CacheManager有很多的实现类,它并不是为Redis特别定制的。但是由于我们使用Redis,所以自然我们的缓存就会选择RedisCacheManager这个实现类。
在Spring Boot中有以下配置项可以用于CacheManager配置
# SPRING CACHE (CacheProperties)
spring.cache.cache-names= # 如果由底层的缓存管理器支持创建,以逗号分隔的列表来缓存名称
spring.cache.caffeine.spec= # caffeine 缓存配置细节
spring.cache.couchbase.expiration=0ms # couchbase 缓存超时时间,默认是永不超时
spring.cache.ehcache.config= # 配置 ehcache 缓存初始化文件路径
spring.cache.infinispan.config= #infinispan 缓存配置文件
spring.cache.jcache.config= #jcache 缓存配置文件
spring.cache.jcache.provider= #jcache 缓存提供者配置
spring.cache.redis.cache-null-values=true # 是否允许 Redis 缓存空值
spring.cache.redis.key-prefix= # Redis 的键前缀
spring.cache.redis.time-to-live=0ms # 缓存超时时间戳,配置为0则不设置超时时间
spring.cache.redis.use-key-prefix=true # 是否启用 Redis 的键前缀
spring.cache.type= # 缓存类型,在默认的情况下,Spring 会自动根据上下文探测
就使用Redis来说,我们只需要关注这些
spring.cache.cache-names= # 如果由底层的缓存管理器支持创建,以逗号分隔的列表来缓存名称
spring.cache.redis.cache-null-values=true # 是否允许 Redis 缓存空值
spring.cache.redis.key-prefix= # Redis 的键前缀
spring.cache.redis.time-to-live=0ms # 缓存超时时间戳,配置为0则不设置超时时间
spring.cache.redis.use-key-prefix=true # 是否启用 Redis 的键前缀
spring.cache.type= # 缓存类型,在默认的情况下,Spring 会自动根据上下文探测
对于刚开始使用,我们先简单配置下,比如
spring.cache.type=REDIS
spring.cache.cache-names=redisCache
这里的 spring.cache.type 配置的是缓存类型,为 Redis,Spring Boot 会自动生成 RedisCacheManager 对象,而 spring.cache.cache-names 则是配置缓存名称,多个名称可以使用逗号分隔,以便于缓存注解的引用。
另外为了启用缓存管理器,需要在XXXApplication中,需要添加@EnableCaching注解
Demo
我们使用mybatis章节中使用过的demo进行,扩展我们的 MyBatisUserDao
@Repository
public interface MyBatisUserDao {
// 获取单个用户
User getUser(Long id);
// 保存用户
int insertUser(User user);
// 修改用户
int updateUser(User user);
// 查询用户,指定MyBatis的参数名称
List<User> findUsers(@Param("userName") String userName,
@Param("note") String note);
// 删除用户
int deleteUser(Long id);
}
然后需要在userMapper.xml中注册相关操作接口
<select id="getUser" parameterType="long" resultType="user">
select id, user_name as userName, sex, note from t_user where id = #{id}
</select>
<insert id="insertUser" useGeneratedKeys="true" keyProperty="id"
parameterType="user">
insert into t_user(user_name, note,sex)
values(#{userName}, #{note},#{sex})
</insert>
<update id="updateUser">
update t_user
<set>
<if test="userName != null">user_name =#{userName},</if>
<if test="note != null">note =#{note}</if>
</set>
where id = #{id}
</update>
<select id="findUsers" resultType="user">
select id, user_name as userName, note from t_user
<where>
<if test="userName != null">
and user_name = #{userName}
</if>
<if test="note != null">
and note = #{note}
</if>
</where>
</select>
<delete id="deleteUser" parameterType="long">
delete from t_user where id = #{id}
</delete>
通过将属性 useGeneratedKeys 设置为 true,代表将通过数据库生成主键,而将 keyProperty 设置为 POJO 的 id 属性,MyBatis 就会将数据库生成的主键回填到 POJO 的 id 属性中。
再然后修改我们的MyBatisService接口和实现
@Service
public class MyBatisUserServiceImpl implements MyBatisUserService {
@Autowired
private MyBatisUserDao myBatisUserDao = null;
@Override
@Transactional
public User getUser(Long id) {
return myBatisUserDao.getUser(id);
}
@Override
@Transactional
public User insertUser(User user) {
myBatisUserDao.insertUser(user);
return user;
}
@Override
@Transactional
public User updateUserName(Long id, String userName) {
// 此处调用 getUser 方法,该方法缓存注解失效,
// 所以这里还会执行 SQL,将查询到数据库最新数据
User user =this.getUser(id);
if (user == null) {
return null;
}
user.setUserName(userName);
myBatisUserDao.updateUser(user);
return user;
}
@Override
@Transactional
public List<User> findUsers(String userName, String note) {
return myBatisUserDao.findUsers(userName, note);
}
@Override
@Transactional
public int deleteUser(Long id) {
return myBatisUserDao.deleteUser(id);
}
}
最后修改MyBatisController
@Controller
@RequestMapping("/mybatis")
public class MyBatisController {
@Autowired
private MyBatisUserService myBatisUserService = null;
@RequestMapping("/getUser")
@ResponseBody
public User getUser(Long id) {
return myBatisUserService.getUser(id);
}
@RequestMapping("/insertUser")
@ResponseBody
public User insertUser(String userName, String note) {
User user = new User();
user.setUserName(userName);
user.setNote(note);
user.setSex(SexEnum.FEMALE);
myBatisUserService.insertUser(user);
return user;
}
@RequestMapping("/findUsers")
@ResponseBody
public List<User> findUsers(String userName, String note) {
return myBatisUserService.findUsers(userName, note);
}
@RequestMapping("/updateUserName")
@ResponseBody
public Map<String, Object> updateUserName(Long id, String userName) {
User user = myBatisUserService.updateUserName(id, userName);
boolean flag = user != null;
String message = flag? "更新成功" : "更新失败";
return resultMap(flag, message);
}
@RequestMapping("/deleteUser")
@ResponseBody
public Map<String, Object> deleteUser(Long id) {
int result = myBatisUserService.deleteUser(id);
boolean flag = result == 1;
String message = flag? "删除成功" : "删除失败";
return resultMap(flag, message);
}
private Map<String, Object> resultMap(boolean success, String message) {
Map<String, Object> result = new HashMap<String, Object>();
result.put("success", success);
result.put("message", message);
return result;
}
}
准备工作完成,接下去就开始添加我们的缓存。首先引入依赖,添加application.properties配置(上面有列出,这里不细说了)。
然后再Application中添加@EnableCaching
修改MyBatisUserServiceImpl的insert方法
// 插入用户,最后 MyBatis 会回填 id,取结果 id 缓存用户
@Override
@Transactional
@CachePut(value ="redisCache", key = "'redis_user_'+#result.id")
public User insertUser(User user) {
userDao.insertUser(user);
return user;
}
@CachePut表示将方法结果返回存放到缓存中。 value表示要存入的缓存名,着我们在application.properties中配置了。key当然表示建值,其中的写法是Spring EL中定义的写法,比如#result表示返回值的id字段。
然后修改getUser方法
@RequestMapping("/getUser")
@ResponseBody
@Cacheable(value ="redisCache", key = "'redis_user_'+#id")
public User getUser(Long id) {
return myBatisUserService.getUser(id);
}
@Cacheable 表示先从缓存中通过定义的键查询,如果可以查询到数据,则返回,否则执行该方法,返回数据,并且将返回结果保存到缓存中。
PS:这里可能会遇到错误 DefaultSerializer requires a Serializable payload but received an object of type 原因在于我们的User类无法被序列化,所以User类需要继承 Serializable 接口
修改deleteUser
@Override
@Transactional
@CacheEvict(value ="redisCache", key = "'redis_user_'+#id",
beforeInvocation = false)
public int deleteUser(Long id) {
return userDao.deleteUser(id);
}
@CacheEvict 通过定义的键移除缓存,它有一个 Boolean 类型的配置项 beforeInvocation,表示在方法之前或者之后移除缓存。因为其默认值为 false,所以默认为方法之后将缓存移除。
在 updateUserName 方法里面我们先调用了 getUser 方法,因为是更新数据,所以需要慎重一些。一般我们不要轻易地相信缓存,因为缓存存在脏读的可能性,这是需要注意的,在需要更新数据时我们往往考虑先从数据库查询出最新数据,而后再进行操作。因此,这里使用了 getUser 方法。但是这里有个无解,有人任务由于getUser使用了@Cacheable注解,所以会先从缓存中读取数据,这就导致了脏数据的可能。实际上这里的@Cacheable是失效了的,因为 Spring 的缓存机制也是基于 Spring AOP 的原理,而在 Spring 中 AOP 是通过动态代理技术来实现的,这里的 updateUserName 方法调用 getUser 方法是类内部的自调用,并不存在代理对象的调用,这样便不会出现 AOP,也就不会使用到标注在 getUser 上的缓存注解去获取缓存的值了,这是需要注意的地方。
PS 解决类内部自调用问题可以使用双服务互相调用的方法克服。
缓存脏数据以及超时设置
使用缓存可以使得系统性能大幅度地提高,但是也引发了很多问题,其中最为严重的问题就是脏数据问题,比如
时 刻 | 动 作 1 | 动 作 2 | 备 注 |
---|---|---|---|
T1 | 修改 id 为1的用户 | ||
T2 | 更新数据库数据 | ||
T3 | 使用 key_1 为键保存数据 | ||
T4 | 修改 id 为1的用户 | 与动作1操作同一数据 | |
T5 | 更新数据库数据 | 此时修改数据库数据 | |
T6 | 使用 key_2 为键保存数据 | 这样 key_1为键的缓存就已经是脏数据 |
对于数据的读操作,一般而言是允许不是实时数据,如一些电商网站还存在一些排名榜单,而这个排名往往都不是实时的,它会存在延迟,其实对于查询是可以存在延迟的,也就是存在脏数据是允许的。但是如果一个脏数据始终存在就说不通了,这样会造成数据失真比较严重。一般对于查询而言,我们可以规定一个时间,让缓存失效,在 Redis 中也可以设置超时时间,当缓存超过超时时间后,则应用不再能够从缓存中获取数据,而只能从数据库中重新获取最新数据,以保证数据失真不至于太离谱。
我们可以通过设置属性 spring.cache.redis.time-to-live=600000 来设置超时时间,比如这里设置超时时间10分钟。
对于数据的写操作,往往采取的策略就完全不一样,需要我们谨慎一些,一般会认为缓存不可信,所以会考虑从数据库中先读取最新数据,然后再更新数据,以避免将缓存的脏数据写入数据库中,导致出现业务问题。
有时候,在自定义时可能存在比较多的配置,也可以不采用 Spring Boot 自动配置的缓存管理器,而是使用自定义的缓存管理器。
// 注入连接工厂,由Spring Boot自动配置生成
@Autowired
private RedisConnectionFactory connectionFactory = null;
// 自定义Redis缓存管理器
@Bean(name = "redisCacheManager" )
public RedisCacheManager initRedisCacheManager() {
// Redis加锁的写入器
RedisCacheWriter writer= RedisCacheWriter.lockingRedisCacheWriter(connectionFactory);
// 启动Redis缓存的默认设置
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
// 设置JDK序列化器
config = config.serializeValuesWith(
SerializationPair.fromSerializer(new JdkSerializationRedisSerializer()));
// 禁用前缀
config = config.disableKeyPrefix();
//设置10 min超时
config = config.entryTtl(Duration.ofMinutes(10));
// 创建缓Redis存管理器
RedisCacheManager redisCacheManager = new RedisCacheManager(writer, config);
return redisCacheManager;
}
这里首先注入了 RedisConnectionFactory 对象,该对象是由 Spring Boot 自动生成的。在创建 Redis 缓存管理器对象 RedisCacheManager 的时候,首先创建了带锁的 RedisCacheWriter 对象,然后使用 RedisCacheConfiguration 对其属性进行配置,这里设置了禁用前缀,并且超时时间为 10 min;最后就通过 RedisCacheWriter 对象和 RedisCacheConfiguration 对象去构建 RedisCacheManager 对象了,这样就完成了 Redis 缓存管理器的自定义。
总结
就Reids,虽然上面贴的代码很多,demo也比较依赖mybatis章节的原有demo,但是总得来说知识点还是相对完整的。注解不是唯一的选择,但是注解确实有不错的收益。
Redis的注解也是通过AOP生效的。