在现今互联网应用中,NoSQL已经广为应用,在互联网中起到加速系统的作用。有两种NoSQL使用最为广泛,那就是Redis和MongoDB。本章将介绍Redis和Spring Boot的结合。Redis是一种运行在内存的数据库,支持7种数据类型的存储。Redis是一个开源、使用ANSI C语言编写、遵守BSD协议、支持网络、可基于内存亦可持久化的日志型、键值数据库,并提供多种语言的API。Redis是基于内存的,所以运行速度很快,大约是关系数据库几倍到几十倍的速度。在我的测试中,Redis可以在1 s内完成10万次的读写,性能十分高效。如果我们将常用的数据存储在Redis中,用来代替关系数据库的查询访问,网站性能将可以得到大幅提高。在现实中,查询数据要远远多于更新数据,一般一个正常的网站查询和更新的比例大约是1∶9到3∶7,在查询比例较大的网站使用Redis可以数倍地提升网站的性能。例如,当一个会员登录网站,我们就把其常用数据从数据库一次性查询出来存放在Redis中,那么之后大部分的查询只需要基于Redis完成便可以了,这样将很大程度上提升网站的性能。除此之外,Redis还提供了简单的事务机制,通过事务机制可以有效保证在高并发的场景下数据的一致性。Redis自身数据类型比较少,命令功能也比较有限,运算能力一直不强,所以Redis在2.6版本之后开始版本之后开始增加Lua语言的支持,这样Redis的运算能力就大大提高了,而且在Redis中Lua语言的执行是原子性的,也就是在Redis执行Lua时,不会被其他命令所打断,这样就能够保证在高并发场景下的一致性,在未来高并发的场景我们会再次看到它的威力。要使用Redis,需要先加入关于Redis的依赖,同样,Spring Boot也会为其提供stater,然后允许我们通过配置文件application.properties进行配置,这样就能够以最快的速度配置并且使用Redis了。所以下面先在Maven中增加依赖,如代码清单7-1所示。

代码清单7-1 引入spring-boot-starter-data-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>

这样我们就引入了Spring对Redis的starter,只是在默认的情况下,spring-boot-starter-data-redis(版本2.x)会依赖Lettuce的Redis客户端驱动,而在一般的项目中,我们会使用Jedis,所以在代码中使用了<exclusions>元素将其依赖排除了,与此同时引入了Jedis的依赖,这样就可以使用Jedis进行编程了。那么应该如何配置它呢?关于这些,需要进一步了解Spring是如何集成Redis的。Spring是通过spring-data-redis项目对Redis开发进行支持的,在讨论SpringBoot如何使用Redis之前,很有必要简单地介绍一下这个项目的,这样才能更好地在Spring中使用Redis。

Redis是一种键值数据库,而且是以字符串类型为中心的,当前它能够支持多种数据类型,包括字符串、散列、列表(链表)、集合、有序集合、基数和地理位置等。我们将讨论字符串、散列、列表、集合和有序集合的使用,因为它们的使用率比较高。

7.1 spring-data-redis项目简介

这里我们先讨论在Spring中如何使用Redis,学习一些底层的内容还是很有帮助的。因为SpringBoot的配置虽然已经简化,但是如果弄不懂一些内容,读者很快就会备感迷茫,很多特性也将无法清晰地去讨论,所以这里先探讨在一个普通的Spring工程中如何使用Redis。这对于讨论Spring Boot中如何集成Redis是很有帮助的。

7.1.1 spring-data-redis项目的设计

在Java中与Redis连接的驱动存在很多种,目前比较广泛使用的是Jedis,其他的还有Lettuce、Jredis和Srp。Lettuce目前使用得比较少,而Jredis和Srp则已经不被推荐使用,在Spring中已经被标注@Deprecated,所以本书只讨论Spring推荐使用的类库Jedis的使用。Spring提供了一个RedisConnectionFactory接口,通过它可以生成一个RedisConnection接口对象,而RedisConnection接口对象是对Redis底层接口的封装。例如,本章使用的Jedis驱动,那么Spring就会提供RedisConnection接口的实现类JedisConnection去封装原有的Jedis(redis.clients.jedis.Jedis)对象。我们不妨看一下它们的类的关系,如图7-1所示。

从图7-1可以看出,在Spring中是通过RedisConnection接口操作Redis的,而RedisConnection则对原生的Jedis进行封装。要获取RedisConnection接口对象,是通过RedisConnectionFactory接口去生成的,所以第一步要配置的便是这个工厂了,而配置这个工厂主要是配置Redis的连接池,对于连接池可以限定其最大连接数、超时时间等属性。下面开发一个简单的RedisConnectionFactory接口对象,如代码清单7-2所示。

代码清单7-2 创建RedisConnectionFactory对象

 

/**** imports ****/
@Configuration
public class RedisConfig {

    private RedisConnectionFactory connectionFactory = null;

    @Bean(name = "RedisConnectionFactory")
    public RedisConnectionFactory initRedisConnectionFactory() {
        if (this.connectionFactory != null) {
            return this.connectionFactory;
        }
        JedisPoolConfig poolConfig = new JedisPoolConfig();
        // 最大空闲数
        poolConfig.setMaxIdle(30);
        // 最大连接数
        poolConfig.setMaxTotal(50);
        // 最大等待毫秒数
        poolConfig.setMaxWaitMillis(2000);
        // 创建Jedis连接工厂
        JedisConnectionFactory connectionFactory = new JedisConnectionFactory(poolConfig);
        // 获取单机的Redis配置
        RedisStandaloneConfiguration rsCfg = connectionFactory.getStandaloneConfiguration();
        ConnectionFactory.setHostName("192.168.11.131");
        ConnectionFactory.setPort(6379);
        ConnectionFactory.setPassword("123456");
        this.connectionFactory = connectionFactory;
        return connectionFactory;
    }
    ......
}

这里我们通过一个连接池的配置创建了RedisConnectionFactory,通过它就能够创建RedisConnection接口对象。但是我们在使用一条连接时,要先从RedisConnectionFactory工厂获取,然后在使用完成后还要自己关闭它。Spring为了进一步简化开发,提供了RedisTemplate。

7.1.2 RedisTemplate

应该说RedisTemplate是使用得最多的类,所以它也是Spring操作Redis的重点内容。RedisTemplate是一个强大的类,首先它会自动从RedisConnectionFactory工厂中获取连接,然后执行对应的Redis命令,在最后还会关闭Redis的连接。这些在RedisTemplate中都被封装了,所以并不需要开发者关注Redis连接的闭合问题。只是为了更好地使用RedisTemplate,我们还需要掌握它内部的一些细节。不过,无论如何我们需要先创建它,在代码清单7-2的基础上加入代码清单7-3。

代码清单7-3 创建RedisTemplate

@Bean(name="redisTemplate")
public RedisTemplate&lt;Object, Object&gt; initRedisTemplate() {
    RedisTemplate&lt;Object, Object&gt; redisTemplate = new RedisTemplate&lt;&gt;();
    redisTemplate.setConnectionFactory(initConnectionFactory());
    return redisTemplate;
}

然后测试它,如代码清单7-4所示。

代码清单7-4 测试RedisTemplate

package com.springboot.chapter7.main;
/**** imports ****/
public class Chapter7Main {
    public static void main(String[] args) {
        ApplicationContext ctx 
             = new AnnotationConfigApplicationContext(RedisConfig.class);
        RedisTemplate redisTemplate = ctx.getBean(RedisTemplate.class);
        redisTemplate.opsForValue().set("key1", "value1");
        redisTemplate.opsForHash().put("hash", "field", "hvalue");
    }
}

这里使用了Java配置文件RedisConfig来创建Spring IoC容器,然后从中获取RedisTemplate对象,接着设置一个键为"key1"而值为"value1"的键值对。运行这段代码后,可以在Redis客户端输入命令keys *key1,如图7-2所示。

 

图7-2 使用Redis命令查询键信息

图7-2 使用Redis命令查询键信息

可以看到,Redis存入的并不是"key1"这样的字符串,这是怎么回事呢?首先需要清楚的是,Redis是一种基于字符串存储的NoSQL,而Java是基于对象的语言,对象是无法存储到Redis中的,不过Java提供了序列化机制,只要类实现了java.io.Serializable接口,就代表类的对象能够进行序列化,通过将类对象进行序列化就能够得到二进制字符串,这样Redis就可以将这些类对象以字符串进行存储。Java也可以将那些二进制字符串通过反序列化转为对象,通过这个原理,Spring提供了序列化器的机制,并且实现了几个序列化器,其设计如图7-3所示。

 

 

图7-3 Spring关于Redis的序列化器设置

 

从图7-3可以看出,对于序列化器,Spring提供了RedisSerializer接口,它有两个方法。这两个方法,一个是serialize,它能把那些可以序列化的对象转换为二进制字符串;另一个是deserialize,它能够通过反序列化把二进制字符串转换为Java对象。图7-3中的JacksonJsonRedisSerializer因为API过时,已经不推荐使用,我们这里主要讨论StringRedisSerializer和JdkSerializationRedisSerializer,其中JdkSerializationRedisSerializer是RedisTemplate默认的序列化器,代码清单的"key1"这个字符串就是被它序列化变为一个比较奇怪的字符串的,其原理如图7-4所示。

图7-4 spring-data-redis序列化器原理示意图

 

图7-4 spring-data-redis序列化器原理示意图

RedisTemplate提供了如表7-1所示几个可以配置的属性。

表7-1 RedisTemplate中的序列化器属性

通过上述讲解我们可以看到,在代码清单7-4中,由于我们什么都没有配置,因此它会默认使用JdkSerializationRedisSerializer对对象进行序列化和反序列化。这就是图7-2得到那些复杂字符串的原因,只是这样使用会给我们查询Redis数据带来很大的困难。为了克服这个困难,我们希望RedisTemplate可以将Redis的键以普通字符串保存。为了达到这个目的,可以将代码清单7-3修改为代码清单7-5。

代码清单7-5 使用字符串序列化器

@Bean(name = "redisTemplate")
public RedisTemplate&lt;Object, Object&gt; initRedisTemplate() {
    RedisTemplate&lt;Object, Object&gt; redisTemplate = new RedisTemplate&lt;&gt;();
    // RedisTemplate会自动初始化StringRedisSerializer,所以这里直接获取
    RedisSerializer stringRedisSerializer = redisTemplate.getStringSerializer();
    // 设置字符串序列化器,这样Spring就会把Redis的key当作字符串处理了
    redisTemplate.setKeySerializer(stringRedisSerializer);
    redisTemplate.setHashKeySerializer(stringRedisSerializer);
    redisTemplate.setHashValueSerializer(stringRedisSerializer);
    redisTemplate.setConnectionFactory(initConnectionFactory());
    return redisTemplate;
}

这里,我们通过主动将Redis的键和散列结构的field和value均采用了字符串序列化器,这样把它们转换出来时就会采用字符串了。运行代码清单7-4中的代码后,再次查询Redis的数据,就可以看到图7-5了。

从图7-5可以看到Redis的键已经从复杂的编码变为简单的字符串了,而hash数据类型则全部采用了字符串的形式,这是因为我们设置了使用StringRedisSerializer序列化器操作它们。值得注意的是代码清单7-4中的如下两行代码:

redisTemplate.opsForValue().set("key1", "value1");
redisTemplate.opsForHash().put("hash", "field", "hvalue");

它们还存在一些值得我们探讨的细节。例如,上述的两个操作并不是在同一个Redis的连接下完成的,什么意思?让我们更加详细地阐述代码运行的过程,首先在操作key1时,redisTemplate会先从连接工厂(RedisConnectionFactory)中获取一个连接,然后执行对应的Redis命令,再关闭这条连接;其次在操作hash时,它也是从连接工厂中获取另一条连接,然后执行命令,再关闭该连接。所以我们可以看到这个过程是两条连接的操作,这样显然存在资源的浪费,我们更加希望的是在同一条连接中就执行两个命令。为了克服这个问题,Spring为我们提供了RedisCallback和SessionCallback两个接口。不过在此之前我们需要了解Spring对Redis数据类型的封装。Redis使用得最多的是字符串,因此在spring-data-redis项目中,还提供了一个StringRedisTemplate类,这个类继承RedisTemplate,只是提供了字符串的操作而已,对于复杂Java对象还需要自行处理。

7.1.3 Spring对Redis数据类型操作的封装

Redis能够支持7种类型的数据结构,这7种类型是字符串、散列、列表(链表)、集合、有序集合、基数和地理位置。为此Spring针对每一种数据结构的操作都提供了对应的操作接口,如表7-2所示。

 

https://res.weread.qq.com/wrepub/epub_25916621_69

它们都可以通过RedisTemplate得到,得到的方法很简单,如代码清单7-6所示。

代码清单7-6 获取Redis数据类型操作接口

// 获取地理位置操作接口
redisTemplate.opsForGeo();
// 获取散列操作接口
redisTemplate.opsForHash();
// 获取基数操作接口
redisTemplate.opsForHyperLogLog();
// 获取列表操作接口
redisTemplate.opsForList();
// 获取集合操作接口
redisTemplate.opsForSet();
// 获取字符串操作接口
redisTemplate.opsForValue();
// 获取有序集合操作接口
redisTemplate.opsForZSet();

这样就可以通过各类的操作接口来操作不同的数据类型了,当然这需要你熟悉Redis的各种命令。有时我们可能需要对某一个键值对(key-value)做连续的操作,例如,有时需要连续操作一个散列数据类型或者列表多次,这时Spring也提供支持,它提供了对应的BoundXXXOperations接口,如表7-3所示。

https://res.weread.qq.com/wrepub/epub_25916621_70

 

同样地,RedisTemplate也对获取它们提供了对应的方法,如代码清单7-7所示。代码清单7-7 获取绑定键的操作类

// 获取地理位置绑定键操作接口
redisTemplate.boundGeoOps("geo");
// 获取散列绑定键操作接口
redisTemplate.boundHashOps("hash");
// 获取列表(链表)绑定键操作接口
redisTemplate.boundListOps("list");
// 获取集合绑定键操作接口
redisTemplate.boundSetOps("set");
// 获取字符串绑定键操作接口
redisTemplate.boundValueOps("string");
// 获取有序集合绑定键操作接口
redisTemplate.boundZSetOps("zset");

获取其中的操作接口后,我们就可以对某个键的数据进行多次操作,这样我们就知道如何有效地通过Spring操作Redis的各种数据类型了。

7.1.4 SessionCallback和RedisCallback接口

在7.1.2节的最后,我们谈到了SessionCallback接口和RedisCallback接口,它们的作用是让RedisTemplate进行回调,通过它们可以在同一条连接下执行多个Redis命令。其中SessionCallback提供了良好的封装,对于开发者比较友好,因此在实际的开发中应该优先选择使用它;相对而言,RedisCallback接口比较底层,需要处理的内容也比较多,可读性较差,所以在非必要的时候尽量不选择使用它。下面使用这两个接口实现代码清单7-4的功能,如代码清单7-8所示。

代码清单7-8 使用RedisCallback和SessionCallback接口

 

// 需要处理底层的转换规则,如果不考虑改写底层,尽量不使用它
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;
        }
    });
}

上述代码中,我们采用了匿名类的方式去使用它们。从代码中可以看出,RedisCallback接口并不是那么友好,但是它能够改写一些底层的东西,如序列化的问题,所以在需要改写那些较为底层规则时,可以使用它。使用SessionCallback接口则比较友好,这也是我在大部分情况下推荐使用的接口,它提供了更为高级的API,使得我们的使用更为简单,可读性也更佳。如果采用的是Java 8或者以上的版本,则还可以使用Lambda表达式改写上述代码,这样代码就会更加清爽。代码清单7-9就是对代码清单7-8的改写。

代码清单7-9 使用Lambda表达式

public void useRedisCallback(RedisTemplate redisTemplate) {
    redisTemplate.execute((RedisConnection rc) -&gt; {
        rc.set("key1".getBytes(), "value1".getBytes());
        rc.hSet("hash".getBytes(), "field".getBytes(), "hvalue".getBytes());
        return null;
    });
}

public void useSessionCallback(RedisTemplate redisTemplate) {
    redisTemplate.execute((RedisOperations ro) -&gt; {
        ro.opsForValue().set("key1", "value1");
        ro.opsForHash().put("hash", "field", "hvalue");
        return null;
    });
}

显然这样就更为清晰明朗了,看起来也更为简单。它们都能够使得RedisTemplate使用同一条Redis连接进行回调,从而可以在同一条Redis连接下执行多个方法,避免RedisTemplate多次获取不同的连接。在后面的流水线和事务内容介绍中我们还会看到它们的身影。

 7.2 在Spring Boot中配置和使用Redis

通过上述spring-data-redis项目的讨论,相信读者对Spring如何集成Redis有了更为深入的理解,虽然在Spring Boot中配置没有那么烦琐,但是了解更多的底层细节能更好地处理问题。本节开始讨论Spring Boot如何整合和使用Redis。

7.2.1 在Spring Boot中配置Redis

在Spring Boot中集成Redis更为简单。例如,要配置代码清单7-2中的Redis服务器,我们只需要在配置文件application.properties中加入代码清单7-10所示的代码即可。

代码清单7-10 在Spring Boot中配置Redis

#配置连接池属性
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=192.168.11.131
spring.redis.password=123456
#Redis连接超时时间,单位毫秒
spring.redis.timeout=1000

这里我们配置了连接池和服务器的属性,用以连接Redis服务器,这样Spring Boot的自动装配机制就会读取这些配置来生成有关Redis的操作对象,这里它会自动生成RedisConnectionFactory、RedisTemplate、StringRedisTemplate等常用的Redis对象。我们知道,RedisTemplate会默认使用JdkSerializationRedisSerializer进行序列化键值,这样便能够存储到Redis服务器中。如果这样,Redis服务器存入的便是一个经过序列化后的特殊字符串,有时候对于我们跟踪并不是很友好。如果我们在Redis只是使用字符串,那么使用其自动生成的StringRedisTemplate即可,但是这样就只能支持字符串了,并不能支持Java对象的存储。为了克服这个问题,可以通过设置RedisTemplate的序列化器来处理。下面我们在Spring Boot的启动文件中修改RedisTemplate的序列化器,如代码清单7-11所示。

代码清单7-11 修改RedisTemplate的序列化器

package com.springboot.chapter7.main;
/**** imports ****/
@SpringBootApplication(scanBasePackages = "com.springboot.chapter7")
public class Chapter7Application {

    // 注入RedisTemplate
    @Autowired
    private RedisTemplate redisTemplate = null;

    // 定义自定义后初始化方法
    @PostConstruct

    public void init() {
        initRedisTemplate();
    }

    // 设置RedisTemplate的序列化器
    private void initRedisTemplate() {
        RedisSerializer stringSerializer = redisTemplate.getStringSerializer();
        redisTemplate.setKeySerializer(stringSerializer);
        redisTemplate.setHashKeySerializer(stringSerializer);
    }

    ......
}

首先通过@Autowired注入由Spring Boot根据配置生成的RedisTemplate对象,然后利用Spring Bean生命周期的特性使用注解@PostConstruct自定义后初始化方法。在这个方法里,把RedisTemplate中的键序列化器修改为StringRedisSerializer。因为之前我们讨论过,在RedisTemplate中它会默认地定义了一个StringRedisSerializer对象,所以这里我并没有自己创建一个新的StringRedisSerializer对象,而是从RedisTemplate中获取。然后把RedisTemplate关于键和其散列数据类型的filed都修改为了使用StringRedisSerializer进行序列化,这样我们在Redis服务器上得到的键和散列的field就都以字符串存储了。

7.2.2 操作Redis数据类型

上面的内容主要是讨论如何让Spring Boot集成Redis,下面来增加实践能力。这节主要演示常用Redis数据类型(如字符串、散列、列表、集合和有序集合)的操作,但是主要是从RedisTemplate的角度,而不是从SessionCallback和RedisCallback接口的角度。这样做的目的,是让读者更加熟悉RedisTemplate的使用方法,因为在大部分的场景下,并不需要很复杂地操作Redis,而仅仅是很简单地使用而已,也就是只需要操作一次Redis,这个时候使用RedisTemplate的操作还是比较多的。如果需要多次执行Redis命令,可以选择使用SessionCallback或者RedisCallback接口。在后面介绍Redis特殊用法时,我们会再次看到这两个接口。首先开始操作字符串和散列,这是Redis最为常用的数据类型,如代码清单7-12所示。

代码清单7-12 操作Redis字符串和散列数据类型

package com.springboot.chapter7.controller;
/**** imports ****/
@Controller
@RequestMapping("/redis")
public class RedisController {

    @Autowired
    private RedisTemplate redisTemplate = null;

    @Autowired
    private StringRedisTemplate stringRedisTemplate = null;

    @RequestMapping("/stringAndHash")
    @ResponseBody
    public Map&lt;String, Object&gt; testStringAndHash() {
        redisTemplate.opsForValue().set("key1", "value1");
        // 注意这里使用了JDK的序列化器,所以Redis保存时不是整数,不能运算
        redisTemplate.opsForValue().set("int_key", "1");
        stringRedisTemplate.opsForValue().set("int", "1");
        // 使用运算
        stringRedisTemplate.opsForValue().increment("int", 1);
        // 获取底层Jedis连接
        Jedis jedis = (Jedis) stringRedisTemplate.getConnectionFactory()
                   .getConnection().getNativeConnection();
        // 减1操作,这个命令RedisTemplate不支持,所以我先获取底层的连接再操作
        jedis.decr("int");
        Map&lt;String, String&gt; hash = new HashMap&lt;String, String&gt;();
        hash.put("field1", "value1");
        hash.put("field2", "value2");
        // 存入一个散列数据类型
        stringRedisTemplate.opsForHash().putAll("hash", hash);
        // 新增一个字段
        stringRedisTemplate.opsForHash().put("hash", "field3", "value3");
        // 绑定散列操作的key,这样可以连续对同一个散列数据类型进行操作
        BoundHashOperations hashOps = stringRedisTemplate.boundHashOps("hash");
        // 删除两个字段
        hashOps.delete("field1", "field2");
        // 新增一个字段
        hashOps.put("filed4", "value5");
        Map&lt;String, Object&gt; map = new HashMap&lt;String, Object&gt;();
        map.put("success", true);
        return map;
    }
    ....
}

这里的@Autowired注入了Spring Boot为我们自动初始化RedisTemplate和StringRedisTemplate对象。看到testStringAndHash方法,首先是存入了一个“key1”的数据,然后是“int_key”。但是请注意这个“int_key”存入到Redis服务器中,因为采用了JDK序列化器,所以在Redis服务器中它不是整数,而是一个被JDK序列化器序列化后的二进制字符串,是没有办法使用Redis命令进行运算的。为了克服这个问题,这里使用StringRedisTemplate对象保存了一个键为“int”的整数,这样就能够运算了。接着进行了加一运算,但是因为RedisTemplate并不能支持底层所有的Redis命令,所以这里先获取了原始的Redis连接的Jedis对象,用它来做减一运算。然后是操作散列数据类型,在插入多个散列的field时可以采用Map,然后为了方便对同一个数据操作,这里代码还获取了BoundHashOperations对象进行操作,这样对同一个数据操作就方便许多了。

列表也是常用的数据类型。在Redis中列表是一种链表结构,这就意味着查询性能不高,而增删节点的性能高,这是它的特性。在Redis中存在从左到右或者从右到左的操作,为了方便测试,我们在代码清单7-12中插入代码清单7-13所示。

代码清单7-13 使用Spring操作列表(链表)

@RequestMapping("/list")
@ResponseBody
public Map&lt;String, Object&gt; testList() {
    // 插入两个列表,注意它们在链表的顺序
    // 链表从左到右顺序为v10,v8,v6,v4,v2
    stringRedisTemplate.opsForList().leftPushAll(
          "list1", "v2", "v4", "v6", "v8", "v10");
    // 链表从左到右顺序为v1,v2,v3,v4,v5,v6
    stringRedisTemplate.opsForList().rightPushAll(
          "list2", "v1", "v2", "v3", "v4", "v5", "v6");
    // 绑定list2链表操作
    BoundListOperations listOps = stringRedisTemplate.boundListOps("list2");
    // 从右边弹出一个成员
    Object result1 = listOps.rightPop();
    // 获取定位元素,Redis从0开始计算,这里值为v2
    Object result2 = listOps.index(1);
    // 从左边插入链表
    listOps.leftPush("v0");
    // 求链表长度
    Long size = listOps.size();
    // 求链表下标区间成员,整个链表下标范围为0到size-1,这里不取最后一个元素
    List elements = listOps.range(0, size-2);
    Map&lt;String, Object&gt; map = new HashMap&lt;String, Object&gt;();
    map.put("success", true);
    return map;
}

上述操作是基于StringRedisTemplate的,所以保存到Redis服务器的都是字符串类型,只是这里有两点需要注意。首先是列表元素的顺序问题,是从左到右还是从右到左,这是容易弄糊涂的问题;其次是下标问题,在Redis中是以0开始的,这与Java中的数组类似。接着是集合。对于集合,在Redis中是不允许成员重复的,它在数据结构上是一个散列表的结构,所以对于它而言是无序的,对于两个或者以上的集合,Redis还提供了交集、并集和差集的运算。为了进行测试,我们可以在代码清单7-12的基础上加入代码清单7-14的程序片段。

代码清单7-14 使用Spring操作集合

@RequestMapping("/set")
@ResponseBody
public Map&lt;String, Object&gt; testSet() {
    // 请注意:这里v1重复两次,因为集合不允许重复,所以只是插入5个成员到集合中
    stringRedisTemplate.opsForSet().add("set1", 
         "v1","v1","v2","v3","v4","v5");
    stringRedisTemplate.opsForSet().add("set2", "v2","v4","v6","v8");
    // 绑定set1集合操作
    BoundSetOperations setOps = stringRedisTemplate.boundSetOps("set1");
    // 增加两个元素
    setOps.add("v6", "v7");
    // 删除两个元素
    setOps.remove("v1", "v7");
    // 返回所有元素
    Set set1 = setOps.members();
    // 求成员数
    Long size = setOps.size();
    // 求交集
    Set inter = setOps.intersect("set2");
    // 求交集,并且用新集合inter保存
    setOps.intersectAndStore("set2", "inter");
    // 求差集
    Set diff = setOps.diff("set2");
    // 求差集,并且用新集合diff保存
    setOps.diffAndStore("set2", "diff");
    // 求并集
    Set union = setOps.union("set2");
    // 求并集,并且用新集合union保存
    setOps.unionAndStore("set2", "union");
    Map&lt;String, Object&gt; map = new HashMap&lt;String, Object&gt;();
    map.put("success", true);
    return map;
}

这里在添加集合set1时,存在两个v1一样的元素。因为集合不允许重复,所以实际上在集合只算是一个元素。然后可以看到对集合各类操作,在最后还有交集、差集和并集的操作,这些是集合最常用的操作。在一些网站中,经常会有排名,如最热门的商品或者最大的购买买家,都是常常见到的场景。对于这类排名,刷新往往需要及时,也涉及较大的统计,如果使用数据库会太慢。为了支持集合的排序,Redis还提供了有序集合(zset)。有序集合与集合的差异并不大,它也是一种散列表存储的方式,同时它的有序性只是靠它在数据结构中增加一个属性——score(分数)得以支持。为了支持这个变化,Spring提供了TypedTuple接口,它定义了两个方法,并且Spring还提供了其默认的实现类DefaultTypedTuple,其内容如图7-6所示

https://res.weread.qq.com/wrepub/epub_25916621_71

在TypedTuple接口的设计中,value是保存有序集合的值,score则是保存分数,Redis是使用分数来完成集合的排序的,这样如果把买家作为一个有序集合,而买家花的钱作为分数,就可以使用Redis进行快速排序了。下面我们把代码清单7-15插到代码清单7-12中。

代码清单7-15 操作Redis有序集合

@RequestMapping("/zset")
@ResponseBody
public Map&lt;String, Object&gt; testZset() {
    Set&lt;TypedTuple&lt;String&gt;&gt; typedTupleSet = new HashSet&lt;&gt;();
    for (int i=1; i&lt;=9; i++) {
        // 分数
        double score = i*0.1;
        // 创建一个TypedTuple对象,存入值和分数
        TypedTuple&lt;String&gt; typedTuple 
            = new DefaultTypedTuple&lt;String&gt;("value" + i, score);
        typedTupleSet.add(typedTuple);
    }
    // 往有序集合插入元素
    stringRedisTemplate.opsForZSet().add("zset1", typedTupleSet);
    // 绑定zset1有序集合操作
    BoundZSetOperations&lt;String, String&gt; zsetOps 
          = stringRedisTemplate.boundZSetOps("zset1");
    // 增加一个元素
    zsetOps.add("value10", 0.26);
    Set&lt;String&gt; setRange = zsetOps.range(1, 6);
    // 按分数排序获取有序集合
    Set&lt;String&gt; 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&lt;String&gt; setLex = zsetOps.rangeByLex(range);
    // 删除元素
    zsetOps.remove("value9", "value2");
    // 求分数
    Double score = zsetOps.score("value8");
    // 在下标区间下,按分数排序,同时返回value和score
    Set&lt;TypedTuple&lt;String&gt;&gt; rangeSet = zsetOps.rangeWithScores(1, 6);
    // 在分数区间下,按分数排序,同时返回value和score
    Set&lt;TypedTuple&lt;String&gt;&gt; scoreSet = zsetOps.rangeByScoreWithScores(1, 6);
    // 按从大到小排序
    Set&lt;String&gt; reverseSet = zsetOps.reverseRange(2, 8);
    Map&lt;String, Object&gt; map = new HashMap&lt;String, Object&gt;();
    map.put("success", true);
    return map;
}

代码中使用了TypedTuple保存有序集合的元素,在默认的情况下,有序集合是从小到大地排序的,按下标、分数和值进行排序获取有序集合的元素,或者连同分数一起返回,有时候还可以进行从大到小的排序,只是在使用值排序时,我们可以使用Spring为我们创建的Range类,它可以定义值的范围,还有大于、等于、大于等于、小于等于等范围定义,方便我们筛选对应的元素。地理位置和基数不是我们常用的功能,所以这里不再赘述了。

7.3 Redis的一些特殊用法

Redis除了操作那些数据类型的功能外,还能支持事务、流水线、发布订阅和Lua语等功能,这些也是Redis常用的功能。在高并发的场景中,往往我们需要保证数据的一致性,这时考虑使用Redis事务或者利用Redis执行Lua的原子性来达到数据一致性的目的,所以这里让我们对它们展开讨论。在需要大批量执行Redis命令的时候,我们可以使用流水线来执行命令,这样可以极大地提升Redis执行的速度。

7.3.1 使用Redis事务

首先Redis是支持一定事务能力的NoSQL,在Redis中使用事务,通常的命令组合是watch...multi...exec,也就是要在一个Redis连接中执行多个命令,这时我们可以考虑使用SessionCallback接口来达到这个目的。其中,watch命令是可以监控Redis的一些键;multi命令是开始事务,开始事务后,该客户端的命令不会马上被执行,而是存放在一个队列里,这点是需要注意的地方,也就是在这时我们执行一些返回数据的命令,Redis也是不会马上执行的,而是把命令放到一个队列里,所以此时调用Redis的命令,结果都是返回null,这是初学者容易犯的错误;exe命令的意义在于执行事务,只是它在队列命令执行前会判断被watch监控的Redis的键的数据是否发生过变化(即使赋予与之前相同的值也会被认为是变化过),如果它认为发生了变化,那么Redis就会取消事务,否则就会执行事务,Redis在执行事务时,要么全部执行,要么全部不执行,而且不会被其他客户端打断,这样就保证了Redis事务下数据的一致性。图7-7就是Redis事务执行的过程。

https://res.weread.qq.com/wrepub/epub_25916621_72

 

                      图7-7 Redis事务执行过程

下面我们就测试这样的一个过程,只是这里需要保证RedisTemplate的键和散列结构的field使用字符串序列化器(StringRedisSerializer)。如代码清单7-16所示。

代码清单7-16 通过Spring使用Redis事务机制

 

@RequestMapping("/multi")
@ResponseBody
public Map<String, Object> testMulti() {
    redisTemplate.opsForValue().set("key1", "value1");     
    List list = (List)redisTemplate.execute((RedisOperations operations) -> {
        // 设置要监控key1
        operations.watch("key1");
        // 开启事务,在exec命令执行前,全部都只是进入队列
        operations.multi();
        operations.opsForValue().set("key2", "value2");
        // operations.opsForValue().increment("key1", 1);//// 获取值将为null,因为redis只是把命令放入队列
        Object value2 = operations.opsForValue().get("key2");
        System.out.println("命令在队列,所以value为null【"+ value2 +"】");
        operations.opsForValue().set("key3", "value3");
        Object value3 = operations.opsForValue().get("key3");
        System.out.println("命令在队列,所以value为null【"+ value3 +"】");
        // 执行exec命令,将先判别key1是否在监控后被修改过,如果是则不执行事务,否则就执行事务
        return operations.exec();//
});
    System.out.println(list);
    Map<String, Object> map = new HashMap<String, Object>();
    map.put("success", true);
    return map;
}

为了揭示Redis事务的特性,我们对这段代码做以下两种测试。

•先在Redis客户端清空key2和key3两个键的数据,然后在②处设置断点,在调试的环境下让请求达到断点,此时在Redis上修改key1的值,然后再跳过断点,在请求完成后在Redis上查询key2和key3值,可以发现key2、key3返回的值都为空(nil),因为程序中先使得Redis的watch命令监控了key1的值,而后的multi让之后的命令进入队列,而在exec方法运行前我们修改了key1,根据Redis事务的规则,它在exec方法后会探测key1是否被修改过,如果没有则会执行事务,否则就取消事务,所以key2和key3没有被保存到Redis服务器中。

•继续把key2和key3两个值清空,把①处的注释取消,让代码可以运行,因为key1是一个字符串,所以这里的代码是对字符串加一,这显然是不能运算的。同样地,我们运行这段代码后,可以看到服务器抛出了异常,然后我们去Redis服务器查询key2和key3,可以看到它们已经有了值。注意,这就是Redis事务和数据库事务的不一样,对于Redis事务是先让命令进入队列,所以一开始它并没有检测这个加一命令是否能够成功,只有在exec命令执行的时候,才能发现错误,对于出错的命令Redis只是报出错误,而错误后面的命令依旧被执行,所以key2和key3都存在数据,这就是Redis事务的特点,也是使用Redis事务需要特别注意的地方。为了克服这个问题,一般我们要在执行Redis事务前,严格地检查数据,以避免这样的情况发生。

 

7.3.2 使用Redis流水线

在默认的情况下,Redis客户端是一条条命令发送给Redis服务器的,这样显然性能不高。在关系数据库中我们可以使用批量,也就是只有需要执行SQL时,才一次性地发送所有的SQL去执行,这样性能就提高了许多。对于Redis也是可以的,这便是流水线(pipline)技术,在很多情况下并不是Redis性能不佳,而是网络传输的速度造成瓶颈,使用流水线后就可以大幅度地在需要执行很多命令时提升Redis的性能。

下面我们使用Redis流水线技术测试10万次读写的功能,如代码清单7-17所示。

代码清单7-17 使用Redis流水线测试性能

 

@RequestMapping("/pipeline")
@ResponseBody
public Map<String, Object> testPipeline() {
    Long start = System.currentTimeMillis();
    List list = (List)redisTemplate.executePipelined((RedisOperations operations) -> {
        for (int i=1; i<=100000; i++) {
            operations.opsForValue().set("pipeline_" + i, "value_" + i);
            String value = (String) operations.opsForValue().get("pipeline_" + i);
            if (i == 100000) {
                System.out.println("命令只是进入队列,所以值为空【" + value +"】");
            }
        }
        return null;
    });
    Long end = System.currentTimeMillis();
    System.out.println("耗时:" + (end - start) + "毫秒。");
    Map<String, Object> map = new HashMap<String, Object>();
    map.put("success", true);
    return map;
}

这里还是沿用SessionCallback接口执行写入和读出各10万次Redis命令,只是修改为了Lambda表达式而已。如果你的JDK达不到8的版本以上,那么只能采用匿名类的形式来改写这段代码了。为了测试性能,这里记录了开始执行时间和结束执行时间,并且打出了耗时。在我的测试中,这10万次读写基本在300~600 ms,大约平均值在400~500 ms,也就是不到1 s就能执行10万次读和写命令,这个速度还是十分快的。在使用非流水线的情况下,我的测试大约每秒只能执行2万~3万条命令,可见使用流水线后可以提升大约10倍的速度,它十分适合大数据量的执行。

这里需要注意的是以下两点。

•代码清单7-17只是运用于测试,在运行如此多的命令时,需要考虑的另外一个问题是内存空间的消耗,因为对于程序而言,它最终会返回一个List对象,如果过多的命令执行返回的结果都保存到这个List中,显然会造成内存消耗过大,尤其在那些高并发的网站中就很容易造成JVM内存溢出的异常,这个时候应该考虑使用迭代的方法执行Redis命令。

•与事务一样,使用流水线的过程中,所有的命令也只是进入队列而没有执行,所以执行的命令返回值也为空,这也是需要注意的地方。

7.3.3 使用Redis发布订阅

发布订阅是消息的一种常用模式。例如,在企业分配任务之后,可以通过邮件、短信或者微信通知到相关的责任人,这就是一种典型的发布订阅模式。首先是Redis提供一个渠道,让消息能够发送到这个渠道上,而多个系统可以监听这个渠道,
如短信、微信和邮件系统都可以监听这个渠道,当一条消息发送到渠道,渠道就会通知它的监听者,这样短信、微信和邮件系统就能够得到这个渠道给它们的消息了,这些监听者会根据自己的需要去处理这个消息,于是我们就可以得到各种各样的通知了。其原理如图7-8所示。

https://res.weread.qq.com/wrepub/epub_25916621_73


                               图7-8 发布订阅模式

 代码清单7-18 Redis消息监听器

package com.springboot.chapter7.listener;
/**** imports ****/
@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);
    }
}

这里的onMessage方法是得到消息后的处理方法,其中message参数代表Redis发送过来的消息,pattern是渠道名称,onMessage方法里打印了它们的内容。这里因为标注了@Component注解,所以在Spring Boot扫描后,会把它自动装配到IoC容器中。

接着我们在Spring Boot的启动文件中配置其他信息,让系统能够监控Redis的消息,如代码清单7-19所示。

代码清单7-19 监听Redis发布的消息

package com.springboot.chapter7.main;
/**** imports ****/
@SpringBootApplication(scanBasePackages = "com.springboot.chapter7")
@MapperScan(basePackages = "com.springboot.chapter7", annotationClass = Repository.class)
public class Chapter7Application {

    ......

    // RedisTemplate
    @Autowired
    private RedisTemplate redisTemplate = null;

    // Redis连接工厂
    @Autowired 
    private RedisConnectionFactory connectionFactory = null;

    // Redis消息监听器
    @Autowired
    private MessageListener redisMsgListener = null;

    // 任务池
    private ThreadPoolTaskScheduler taskScheduler = null;

    /**
     * 创建任务池,运行线程等待处理Redis的消息
     * @return
     */
    @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(redisMsgListener, topic);
        return container;
    }
}

这里RedisTemplate和RedisConnectionFactory对象都是Spring Boot自动创建的,所以这里只是把它们注入进来,只需要使用@Autowired注解即可。然后定义了一个任务池,并设置了任务池大小为20,这样它将可以运行线程,并进行阻塞,等待Redis消息的传入。接着再定义了一个Redis消息监听的容器RedisMessageListenerContainer,并且往容器设置了Redis连接工厂和指定运行消息的线程池,定义了接收“topic1”渠道的消息,这样系统就可以监听Redis关于“topic1”渠道的消息了。

启用Spring Boot项目后,在Redis的客户端输入命令:

publish topic1 msg

在Spring中,我们也可以使用RedisTemplate来发送消息,例如:

redisTemplate.convertAndSend(channel, message);

其中,channel代表渠道,message代表消息,这样就能够得到Redis发送过来的消息了。我对代码进行了调试,如图7-9所示

                              https://res.weread.qq.com/wrepub/epub_25916621_74

                                                图7-9 处理Redis发布的消息

从图7-9可见,我们的监听者对象(RedisMessageListener)已经获取到Redis发送过来的消息,并且将消息进行了转换。

7.3.4 使用Lua脚本

Redis中有很多的命令,但是严格来说Redis提供的计算能力还是比较有限的。为了增强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实现类。让我们先来看看RedisScript接口的源码,如代码清单7-20所示。

代码清单7-20 RedisScript接口定义

 

package org.springframework.data.redis.core.script;
public interface RedisScript<T> {
     // 获取脚本的Sha1
    String getSha1();

    // 获取脚本返回值
    Class<T> getResultType();

    // 获取脚本的字符串
    String getScriptAsString();
}

这里Spring会将Lua脚本发送到Redis服务器进行缓存,而此时Redis服务器会返回一个32位的SHA1编码,这时候通过getSha1方法就可以得到Redis返回的这个编码了;getResultType方法是获取Lua脚本返回的Java类型;getScriptAsString是返回脚本的字符串,以便我们观看脚本。

下面我们采用RedisScript接口执行一个十分简单的Lua脚本,这个脚本只是简单地返回一个字符串,如代码清单7-21所示。

 

代码清单7-21 执行简易Lua脚本

 

@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只是定义了一个简单的字符串,然后就返回了,而返回类型则定义为字符串。这里必须定义返回类型,否则对于Spring不会把脚本执行的结果返回。接着获取了由RedisTemplate自动创建的字符串序列化器,而后使用RedisTemplate的execute方法执行了脚本。在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序列化器对传递的键和参数进行序列化。这里我们采用了第二个方法调度脚本,并且设置为字符串序列化器,其中第一个序列化器是键的序列化器,第二个是参数序列化器,这样键和参数就在字符串序列化器下被序列化了。图7-10所示是我对这段代码的测试。

https://res.weread.qq.com/wrepub/epub_25916621_75

图7-10 测试Lua脚本

从断点中的监控来看,RedisScript对象已经存放了对应的SHA1的字符串对象,这样就可以通过它执行Lua脚本了。由于返回已经是“Hello Redis”,显然测试是成功的。

下面我们再考虑存在参数的情况。例如,我们写一段Lua脚本用来判断两个字符串是否相同,如代码清单7-22所示。

代码清单7-22 带有参数的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

这里的脚本中使用了两个键去保存两个参数,然后对这两个参数进行比较,如果相等则返回1,否则返回0。注意脚本中KEYS[1]和KEYS[2]的写法,它们代表客户端传递的第一个键和第二个键,而ARGV[1]和ARGV[2]则表示客户端传递的第一个和第二个参数。下面我们用代码清单7-23测试这个脚本

代码清单7-23 测试带有参数的Lua脚本

 

 

@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;
}

这里使用keyList保存了各个键,然后通过Redis的execute方法传递,参数则可以使用可变化的方式传递,且设置了给键和参数的序列化器都为字符串序列化器,这样便能够运行这段脚本了。我们的脚本返回为一个数字,这里值得注意的是,因为Java会把整数当作长整型(Long),所以这里返回值设置为Long型。

7.4 使用Spring缓存注解操作Redis

为了进一步简化Redis的使用,Spring还提供了缓存注解,使用这些注解可以有效简化编程过程,本节我们就学习一下缓存注解。

7.4.1 缓存管理器和缓存的启用

Spring在使用缓存注解前,需要配置缓存管理器,缓存管理器将提供一些重要的信息,如缓存类型、超时时间等。Spring可以支持多种缓存的使用,因此它存在多种缓存处理器,并提供了缓存处理器的接口CacheManager和与之相关的类,如图7-11所示。

                                    https://res.weread.qq.com/wrepub/epub_25916621_76

                                        图7-11 缓存管理器设计

从图7-11中可以看到,Spring可以支持多种缓存管理机制,但是因为当前Redis已经广泛地使用,所以基于实用原则,本书将只介绍Redis缓存的应用,毕竟其他的缓存技术没有广泛地使用起来。而使用Redis,主要就是以使用类RedisCacheManager为主。

在Spring Boot的starter机制中,允许我们通过配置文件生成缓存管理器,它提供的配置如代码清单7-24所示。

# 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,所以其他的缓存并不需要我们去关注,这里只是关注加粗的6个配置项。下面我们可以在application.properties配置Redis的缓存管理器,如代码清单7-25所示。

spring.cache.type=REDIS
spring.cache.cache-names=redisCache

这样我们就配置完了缓存管理器,这里的spring.cache.type配置的是缓存类型,为Redis,Spring Boot会自动生成RedisCacheManager对象,而spring.cache.cache-names则是配置缓存名称,多个名称可以使用逗号分隔,以便于缓存注解的引用。

 

为了使用缓存管理器,需要在Spring Boot的配置文件中加入驱动缓存的注解@EnableCaching,这样就可以驱动Spring缓存机制工作了,类似于代码清单7-26所示的代码。

代码清单7-26 启用缓存机制

package com.springboot.chapter7.main;
/**** imports ****/
@SpringBootApplication(scanBasePackages = "com.springboot.chapter7")
@MapperScan(basePackages = "com.springboot.chapter7", annotationClass = Repository.class)
@EnableCaching

public class Chapter7Application {
    ......
}

这样就能够驱动缓存机制了。然后我们需要搭建关于MyBatis框架的整合来测试缓存机制的使用,不过在此之前我们需要搭建测试环境。关于缓存注解的应用我们将在测试环境中阐述。

7.4.2 开发缓存注解

 

 

#数据库配置
spring.datasource.url=jdbc:mysql://localhost:3306/spring_boot_chapter7
spring.datasource.username=root
spring.datasource.password=123456
#可以不配置数据库驱动,Spring Boot会自己发现
#spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.tomcat.max-idle=10
spring.datasource.tomcat.max-active=50
spring.datasource.tomcat.max-wait=10000
spring.datasource.tomcat.initial-size=5
#设置默认的隔离级别为读写提交
spring.datasource.tomcat.default-transaction-isolation=2

#mybatis配置
mybatis.mapper-locations=classpath:com/springboot/chapter7/mapper/*.xml
mybatis.type-aliases-package=com.springboot.chapter7.pojo

#日志配置为DEBUG级别,这样日志最为详细
logging.level.root=DEBUG
logging.level.org.springframework=DEBUG
logging.level.org.org.mybatis=DEBUG

#Redis配置
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
spring.redis.port=6379
spring.redis.host=192.168.11.131
spring.redis.password=123456

#缓存配置
spring.cache.type=REDIS
spring.cache.cache-names=redisCache

 

这样就配置好了各类资源。现在创建一个POJO——User来对应数据库的表,如代码清单7-28所示。

package com.springboot.chapter7.pojo;
/*** imports ***/
@Alias("user")
public class User implements Serializable  {
    private static final long serialVersionUID = 7760614561073458247L;
    private Long id;
    private String userName;
    private String note;
    /**setter and getter **/
}

这里的注解@Alias定义了别名,因为我们在application.properties文件中定义了这个包作为别名的扫描,所以它能够被MyBatis机制扫描,并且将“user”作为这个类的别名载入到MyBatis的体系中。这个类还实现了Serializable接口,说明它可以进行序列化。

为了提供操作,需要设计一个接口用来操作MyBaits,如代码清单7-29所示

代码清单7-29 MyBatis用户操作接口

 

 

 

package com.springboot.chapter7.dao;
/**** imports ****/
@Repository
public interface UserDao {
    // 获取单个用户
    User getUser(Long id);

    // 保存用户
    int insertUser(User user);

    // 修改用户
    int updateUser(User user);

    // 查询用户,指定MyBatis的参数名称
    List&lt;User&gt; findUsers(@Param("userName") String userName,
            @Param("note") String note);    

    // 删除用户
    int deleteUser(Long id); 
}

 

这里看到了注解@Repository,它是Spring用来标识DAO层的注解,在MyBatis体系中则是使用注解@Mapper来标识,这里无论使用哪种注解都是允许的,只是我偏爱@Repository而已,将来我们可以定义扫描来使得这个接口被扫描为Spring的Bean装配到IoC容器中;这里还可以看到增删查改的方法,通过它们就可以测试Spring的缓存注解了,为了配合这个接口一起使用,需要使用一个XML来定义SQL、映射关系、参数和返回等信息,如代码清单7-30所示。

代码清单7-30 定义用户SQL和映射关系

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
  PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.springboot.chapter7.dao.UserDao">

    <select id="getUser" parameterType="long" resultType="user">
        select id, user_name as userName, note from t_user 
        where id = #{id}
    </select>

    <insert id="insertUser" useGeneratedKeys="true" keyProperty="id"

        parameterType="user">
        insert into t_user(user_name, note) 
        values(#{userName}, #{note})
    </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>
</mapper>

这里需要注意的是加粗的代码,它通过将属性useGeneratedKeys设置为true,代表将通过数据库生成主键,而将keyProperty设置为POJO的id属性,MyBatis就会将数据库生成的主键回填到POJO的id属性中。这样对于MyBatis就已经可以运行了。为了整合它,我们还需要使用Spring的机制,为此定义一个Spring的服务接口UserService,如代码清单7-31所示。

package com.springboot.chapter7.service;
/**** imports ****/
public interface UserService {
    // 获取单个用户
    User getUser(Long id);

    // 保存用户
    User insertUser(User user);

    // 修改用户,指定MyBatis的参数名称
    User updateUserName(Long id, String userName);

    // 查询用户,指定MyBatis的参数名称
    List<User> findUsers(String userName, String note);    

    // 删除用户
    int deleteUser(Long id);  
}

这样就定义了Spring服务接口的方法,接着需要实现这个接口。在这个接口里我们将使用缓存注解,因此UserService的实现类就是本节最重要的代码,如代码清单7-32所示。

代码清单7-32 用户实现类使用Spring缓存注解

package com.springboot.chapter7.service.impl;
/**** imports ****/
@Service
public class UserServiceImpl implements UserService {

    @Autowired
    private UserDao userDao = null;

    // 插入用户,最后MyBatis会回填id,取结果id缓存用户
    @Override
    @Transactional
    
    public User insertUser(User user) {
        userDao.insertUser(user);
        return user;
    }


    // 获取id,取参数id缓存用户
    @Override
    @Transactional
    @Cacheable(value ="redisCache", key = "'redis_user_'+#id")

    public User getUser(Long id) {
        return userDao.getUser(id);
    }

    // 更新数据后,更新缓存,如果condition配置项使结果返回为null,不缓存
    @Override
    @Transactional
    @CachePut(value ="redisCache",         condition="#result != 'null'", key = "'redis_user_'+#id")
    public User updateUserName(Long id, String userName) {
        // 此处调用getUser方法,该方法缓存注解失效,
        // 所以这里还会执行SQL,将查询到数据库最新数据
        User user =this.getUser(id);
        if (user == null) {
            return null;
        }
        user.setUserName(userName);
        userDao.updateUser(user);
        return user;

    }

    // 命中率低,所以不采用缓存机制
    @Override
    @Transactional
    public List<User> findUsers(String userName, String note) {
        return userDao.findUsers(userName, note);
    }

    // 移除缓存
    @Override
    @Transactional
    @CacheEvict(value ="redisCache", key = "'redis_user_'+#id",        beforeInvocation = false)
    public int deleteUser(Long id) {
        return userDao.deleteUser(id);
    }
}

这段代码有比较多的地方值得探讨,需要注意的地方都进行了加粗处理,下面我们一步步对它进行讨论。

首先是注解@CachePut、@Cacheable和@CacheEvict,先来了解它们的含义。

•@CachePut表示将方法结果返回存放到缓存中。

•@Cacheable 表示先从缓存中通过定义的键查询,如果可以查询到数据,则返回,否则执行该方法,返回数据,并且将返回结果保存到缓存中。

•@CacheEvict 通过定义的键移除缓存,它有一个Boolean类型的配置项beforeInvocation,表示在方法之前或者之后移除缓存。因为其默认值为false,所以默认为方法之后将缓存移除。

其次,读者可以看到3个缓存中都配置了value ="redisCache",因为我们在Spring Boot中配置了对应的缓存名称为“redisCache”,这样它就能够引用到对应的缓存了,而键配置项则是一个Spring EL,很多时候可以看到配置为'redis_user_'+#id,其中#id代表参数,它是通过参数名称来匹配,所以这样配置要求方法存在一个参数且名称为id,除此之外还可以这样引用参数,如#a[0]或者#p[0]代表第一个参数,#a[1]或者#p[1]代表第二个参数……但是这样引用可读性较差,所以我们一般不这么写,通过这样定义,Spring就会用EL返回字符串作为键去操作缓存了。

再次,有时候我们希望使用返回结果的一些属性缓存数据,如insertUser方法。在插入数据库前,对应的用户是没有id的,而这个id值会在插入数据库后由MyBatis的机制回填,所以我们希望使用返回结果,这样使用#result就代表返回的结果对象了,它是一个User对象,所以#result.id是取出它的属性id,这样就可以引用这个由数据库生成的id了。

第四,看到updateUserName方法,从代码中可以看到方法,可能返回null。如果为null,则不需要缓存数据,所以在注解@CachePut中加入了condition配置项,它也是一个Spring EL表达式,这个表达式要求返回Boolean类型值,如果为true,则使用缓存操作,否则就不使用。这里的表达式为#result != 'null',意味着如果返回null,则方法结束后不再操作缓存。同样地,@Cacheable和@CacheEvict也具备这个配置项。

第五,在updateUserName方法里面我们先调用了getUser方法,因为是更新数据,所以需要慎重一些。一般我们不要轻易地相信缓存,因为缓存存在脏读的可能性,这是需要注意的,在需要更新数据时我们往往考虑先从数据库查询出最新数据,而后再进行操作。因此,这里使用了getUser方法,这里会存在一个误区,很多读者认为getUser方法因为存在了注解@Cacheable,所以会从缓存中读取数据,而从缓存中读取去更新数据,是一个比较危险的行为,因为缓存的数据可能存在脏数据,然后这里的事实是这个注解@Cacheable失效了,也就是说使用updateUserName方法调用getUser方法的逻辑,并不存在读取缓存的可能,它每次都会执行SQL查询数据。关于这个缓存注解失效的问题,在后续章节再给予说明,这里只是提醒读者,更新数据时应该谨慎一些,尽量避免读取缓存数据,因为缓存会存在脏数据的可能。

最后,我们看到findUsers方法,这个方法并没有使用缓存,因为查询结果随着用户给出的查询条件变化而变化,导致命中率很低。对于命中率很低的场景,使用缓存并不能有效提供系统性能,所以这个方法并不采用缓存机制。此外,对于大数据量等消耗资源的数据,使用缓存也应该谨慎一些。

7.4.3 测试缓存注解

我们可以编写对应的Controller或者测试方法对它们进行测试。代码清单7-33是我写的Controller,然后就可以对这些缓存注解进行测试了。

代码清单7-33 使用用户控制器测试缓存注解

 

 

package com.springboot.chapter7.controller;
/****imports ****/
@Controller
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService = null;

    @RequestMapping("/getUser")
    @ResponseBody
    public User getUser(Long id) {
        return userService.getUser(id);
    }

    @RequestMapping("/insertUser")
    @ResponseBody
    public User insertUser(String userName, String note) {
        User user = new User();
        user.setUserName(userName);
        user.setNote(note);
        userService.insertUser(user);
        return user;
    }

    @RequestMapping("/findUsers")
    @ResponseBody
    public List<User> findUsers(String userName, String note) {
        return userService.findUsers(userName, note);
    }

    @RequestMapping("/updateUserName")
    @ResponseBody
    public Map<String, Object> updateUserName(Long id, String userName) {
        User user = userService.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 = userService.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;
    }
}

 

我们需要修改一下Spring Boot的启动文件以驱动缓存机制的运行,如代码清单7-34所示。

代码清单7-34 Spring Boot启动文件

package com.springboot.chapter7.main;
/**** imports ****/
@SpringBootApplication(scanBasePackages = "com.springboot.chapter7")
// 指定扫描的MyBatis Mapper
@MapperScan(basePackages = "com.springboot.chapter7", annotationClass = Repository.class)
// 使用注解驱动缓存机制
@EnableCaching
public class Chapter7Application {

    @Autowired
    private RedisConnectionFactory connectionFactory = null;

    @Autowired
    private RedisTemplate redisTemplate = null;
    // 自定义初始化
    @PostConstruct
    public void init() {
        initRedisTemplate();
    }

    // 改变RedisTemplate对于键的序列化策略
    private void initRedisTemplate() {
        RedisSerializer stringSerializer = redisTemplate.getStringSerializer();
        redisTemplate.setKeySerializer(stringSerializer);
        redisTemplate.setHashKeySerializer(stringSerializer);
    }

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

}

这里定义了MyBatis Mapper的扫描包,并限定了在标注有@Repository的接口才会被扫描,同时使用@EnableCaching驱动Spring缓存机制运行,并且通过@PostConstruct定义自定义初始化方法去自定义RedisTemplate的一些特性。

运行Spring Boot的启动文件后,通过请求代码清单7-33中的方法,就能够测试缓存注解了。在使用编号为1作为参数测试getUser方法后,我们打开Redis客户端,然后查询可以看到对应的缓存信息,如图7-12所示。

从图7-12我们看到,Redis缓存机制会使用#{cacheName}:#{key}的形式作为键保存数据,其次对于这个缓存是永远不超时的,这样会带来缓存不会被刷新的问题,这在某些时候会存在刷新不及时的问题,未来我们需要克服这些问题。

https://res.weread.qq.com/wrepub/epub_25916621_77

 

图7-12 测试缓存注解

7.4.4 缓存注解自调用失效问题

在代码清单7-32的使用updateUserName方法调用getUser方法中,我曾经说明过在getUser方法上的注解将会失效,为什么会这样呢?其实在数据库事务中我们已经探讨过其原理,只要回顾一下就清楚了,那是因为Spring的缓存机制也是基于Spring AOP的原理,而在Spring中AOP是通过动态代理技术来实现的,这里的updateUserName方法调用getUser方法是类内部的自调用,并不存在代理对象的调用,这样便不会出现AOP,也就不会使用到标注在getUser上的缓存注解去获取缓存的值了,这是需要注意的地方。要克服这个问题,可以参考6.5节关于数据库事务那样用两个服务(Service)类相互调用,或者直接从Spring IoC容器中获取代理对象来操作,这样就能成功克服自调用的问题了。在实际的工作和学习中我们需要注意这些问题。

7.4.5 缓存脏数据说明

使用缓存可以使得系统性能大幅度地提高,但是也引发了很多问题,其中最为严重的问题就是脏数据问题,表7-4演示了这个过程。

表7-4 缓存脏数据

https://res.weread.qq.com/wrepub/epub_25916621_78

从表7-4中我们可以看到,T6时刻,因为使用了key_2为键缓存数据,所以会致使动作1以key_1为键的缓存数据为脏数据。这样使用key_1为键读取时,就只能获取脏数据了,这只是存在脏数据的可能性之一,还可能存在别的可能,如Redis事务问题,或者有其他系统操作而没有刷新Redis缓存等诸多问题。对于数据的读和写采取的策略是不一样的。

对于数据的读操作,一般而言是允许不是实时数据,如一些电商网站还存在一些排名榜单,而这个排名往往都不是实时的,它会存在延迟,其实对于查询是可以存在延迟的,也就是存在脏数据是允许的。但是如果一个脏数据始终存在就说不通了,这样会造成数据失真比较严重。一般对于查询而言,我们可以规定一个时间,让缓存失效,在Redis中也可以设置超时时间,当缓存超过超时时间后,则应用不再能够从缓存中获取数据,而只能从数据库中重新获取最新数据,以保证数据失真不至于太离谱。对于那些要求实时性比较高的数据,我们可以把缓存时间设置得更少一些,这样就会更加频繁地刷新缓存,而不利的是会增加数据库的压力;对于那些要求不是那么高的,则可以使超时时间长一些,这样就可以降低数据库的压力。

对于数据的写操作,往往采取的策略就完全不一样,需要我们谨慎一些,一般会认为缓存不可信,所以会考虑从数据库中先读取最新数据,然后再更新数据,以避免将缓存的脏数据写入数据库中,导致出现业务问题。

这里我们读缓存谈到了超时时间,而在Spring Boot中,如果采取代码清单7-27的配置,则RedisCacheManager会采用永不超时的机制,这样便不利于数据的及时更新。从图7-12的测试结果来看,有时候我们并不采用Redis缓存机制所定义的键的生成规则,这个时候我们可以采用自定义缓存管理器的方法。下一节我们将讨论如何使用自定义缓存管理器。

7.4.6 自定义缓存管理器

正如之前出现的问题,例如,我们并不希望采用Spring Boot机制带来的键命名方式,也不希望缓存永不超时,这时我们可以自定义缓存管理器。在Spring中,我们有两种方法定制缓存管理器,一种是像代码清单7-27那样通过配置消除缓存键的前缀和自定义超时时间的属性来定制生成RedisCacheManager;另一种方法是不采用Spring Boot为我们生成的方式,而是完全通过自己的代码创建缓存管理器,尤其是当需要比较多自定义的时候,更加推荐你采用自定义的代码。

首先我们在代码清单7-27的配置基础上,增加对应的新配置,使得Spring Boot为我们生成的RedisCacheManager对象的时候,消除前缀的设置并且设置超时时间,如代码清单7-35所示。

代码清单7-35 重置Redis缓存管理器

# 禁用前缀
spring.cache.redis.use-key-prefix=false
# 允许保存空值
#spring.cache.redis.cache-null-values=true
# 自定义前缀
#spring.cache.redis.key-prefix=
# 定义超时时间,单位毫秒
spring.cache.redis.time-to-live=600000

这里通过spring.cache.redis.use-key-prefix=false的配置,消除了前缀的配置,而通过属性spring.cache.redis.time-to-live=600000将超时时间设置为10 min(600000 ms),这样10min过后Redis的键就会超时,就不能从Redis中读取到数据了,而只能重新从数据库读取数据,这样就能有效刷新数据了。

经过上面的修改,清除Redis的数据,重启Spring Boot应用,重新测试控制器的getUser方法,然后在10 min内打开Redis客户端依次输入以下命令:

这样就可以看到类似于图7-13所示的结果。

https://res.weread.qq.com/wrepub/epub_25916621_79

图7-13 测试自定义缓存管理器

Spring Boot为我们自定义的前缀消失了,而我们也成功地设置了超时时间。有时候,在自定义时可能存在比较多的配置,也可以不采用Spring Boot自动配置的缓存管理器,而是使用自定义的缓存管理器,这也是没有问题的。首先需要删除代码清单7-27和代码清单7-35中关于Redis缓存管理器的配置,然后在代码清单7-34中添加代码清单7-36所示的代码,给IoC容器增加缓存管理器。

代码清单7-36 自定义缓存管理器

 

 

// 注入连接工厂,由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缓存管理器的自定义。

 

posted on 2020-05-31 22:42  JonRain0625  阅读(567)  评论(0编辑  收藏  举报