目录
前言
如果你想在网上再找一个这么详细的入门 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
org.springframework.boot spring-boot-starter-data-redis io.lettuce lettuce-core redis.clients jedis
引入上面的依赖,对于redis的依赖我们选择了jedis而spring默认使用的是lettuce。至于jedis就类似于jdbc这种,相当于连接redis的驱动。redis类似一个数据库,c++连接它需要自己的封装,java当然也要自己的封装,这就是jedis了。
查看网上的各种讲解和例子,一般都是使用jedis,所以当然跟随大众科技了。
第一个入门demo
老夫写代码就是一把梭!开个玩笑……先来一个列子,当然你大概会 “???”
首先在application.properties中添加配置项
#配置连接池属性spring.redis.jedis.pool.min-idle=5spring.redis.jedis.pool.max-active=10spring.redis.jedis.pool.max-idle=10spring.redis.jedis.pool.max-wait=2000#配置Redis服务器属性spring.redis.port=6379spring.redis.host=xxx.xxx.xxx.xxx#spring.redis.password=123456#Redis连接超时时间,单位毫秒spring.redis.timeout=1000
然后修改XXApplication
@SpringBootApplicationpublic 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
这两个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")@ResponseBodypublic MaptestZset() { Set > typedTupleSet = new HashSet<>(); for (int i=1; i<=9; i++) { // 分数 double score = i*0.1; // 创建一个TypedTuple对象,存入值和分数 TypedTuple typedTuple = new DefaultTypedTuple ("value" + i, score); typedTupleSet.add(typedTuple); } // 往有序集合插入元素 stringRedisTemplate.opsForZSet().add("zset1", typedTupleSet); // 绑定zset1有序集合操作 BoundZSetOperations zsetOps = stringRedisTemplate.boundZSetOps("zset1"); // 增加一个元素 zsetOps.add("value10", 0.26); Set setRange = zsetOps.range(1, 6); // 按分数排序获取有序集合 Set 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 setLex = zsetOps.rangeByLex(range); // 删除元素 zsetOps.remove("value9", "value2"); // 求分数 Double score = zsetOps.score("value8"); // 在下标区间下,按分数排序,同时返回value和score Set > rangeSet = zsetOps.rangeWithScores(1, 6); // 在分数区间下,按分数排序,同时返回value和score Set > scoreSet = zsetOps.rangeByScoreWithScores(1, 6); // 按从大到小排序 Set reverseSet = zsetOps.reverseRange(2, 8); Map map = new HashMap (); 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方法源码
publicT execute(SessionCallback 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() { @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 提供一个渠道,让消息能够发送到这个渠道上,而多个系统可以监听这个渠道,如短信、微信和邮件系统都可以监听这个渠道,当一条消息发送到渠道,渠道就会通知它的监听者,这样短信、微信和邮件系统就能够得到这个渠道给它们的消息了,这些监听者会根据自己的需要去处理这个消息
大概是就是这么张图
首先需要定义一个监听器,这很简单
@Componentpublic 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{ // 获取脚本的Sha1 String getSha1(); // 获取脚本返回值 Class getResultType(); // 获取脚本的字符串 String getScriptAsString();}
这里 Spring 会将 Lua 脚本发送到 Redis 服务器进行缓存,而此时 Redis 服务器会返回一个32位的 SHA1 编码,这时候通过 getSha1 方法就可以得到 Redis 返回的这个编码了;getResultType 方法是获取 Lua 脚本返回的 Java 类型;getScriptAsString 是返回脚本的字符串.
@RequestMapping("/lua")@ResponseBodypublic MaptestLua() { DefaultRedisScript rs = new DefaultRedisScript (); // 设置脚本 rs.setScriptText("return 'Hello Redis'"); // 定义返回类型。注意:如果没有这个定义,Spring 不会返回结果 rs.setResultType(String.class); RedisSerializer stringSerializer = redisTemplate.getStringSerializer(); // 执行 Lua 脚本 String str = (String) redisTemplate.execute( rs, stringSerializer, stringSerializer, null); Map map = new HashMap (); map.put("str", str); return map;}
上面代码执行了一个非常简单的Lua脚本 ,就是返回Hello Redis字符串。
redisTemplate 中,execute 方法执行脚本的方法有两种
publicT execute(RedisScript script, List keys, Object... args) public T execute(RedisScript script, RedisSerializer argsSerializer, RedisSerializer resultSerializer, List 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")@ResponseBodypublic MaptestLua2(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 rs = new DefaultRedisScript (); rs.setScriptText(lua); rs.setResultType(Long.class); // 采用字符串序列化器 RedisSerializer stringSerializer = redisTemplate.getStringSerializer(); // 定义key参数 List keyList = new ArrayList<>(); keyList.add(key1); keyList.add(key2); // 传递两个参数值,其中第一个序列化器是key的序列化器,第二个序列化器是参数的序列化器 Long result = (Long) redisTemplate.execute( rs, stringSerializer, stringSerializer, keyList, value1, value2); Map map = new HashMap (); 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
@Repositorypublic interface MyBatisUserDao { // 获取单个用户 User getUser(Long id); // 保存用户 int insertUser(User user); // 修改用户 int updateUser(User user); // 查询用户,指定MyBatis的参数名称 ListfindUsers(@Param("userName") String userName, @Param("note") String note); // 删除用户 int deleteUser(Long id);}
然后需要在userMapper.xml中注册相关操作接口
insert into t_user(user_name, note,sex) values(#{userName}, #{note},#{sex}) update t_user where id = #{id} user_name =#{userName}, note =#{note} delete from t_user where id = #{id}
通过将属性 useGeneratedKeys 设置为 true,代表将通过数据库生成主键,而将 keyProperty 设置为 POJO 的 id 属性,MyBatis 就会将数据库生成的主键回填到 POJO 的 id 属性中。
再然后修改我们的MyBatisService接口和实现
@Servicepublic 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 ListfindUsers(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 ListfindUsers(String userName, String note) { return myBatisUserService.findUsers(userName, note); } @RequestMapping("/updateUserName") @ResponseBody public Map 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 deleteUser(Long id) { int result = myBatisUserService.deleteUser(id); boolean flag = result == 1; String message = flag? "删除成功" : "删除失败"; return resultMap(flag, message); } private Map resultMap(boolean success, String message) { Map result = new HashMap (); 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自动配置生成@Autowiredprivate 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生效的。