当请求的数量达到一定的规模,这时候频繁的数据库请求就算使用数据库集群也很难承受的了,这时候可以将一些信息存于缓存当中,避免请求直接大量的到数据库上。而如何实现缓存的策略是使用数据库 + 缓存当中的至关重要的一步,Spring Boot 对缓存的支持也是很方便的。

Redis

和 DataTemplate 一样对数据库打开关闭操作的简化,对于 Redis 的打开关闭,有着 RedisTemplate 类,结合 RedisConnectionFactory(实现用 JedisConnectionFactory) 用于处理一些打开关闭连接的繁琐工作。

首先添加 redis 的 starter 和其 jedis client:

1
2
3
4
5
6
7
8
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
</dependency>

可以使用 RedisTemplate 和泛型,或者直接使用 StringRedisTemplate,这需要根据实际存储的 Redis 数据而决定,这里会将数据序列化为 JSON 格式,所以使用 RedisTemplate<String, String> 泛型,新建一个 config.RedisConfig.java 配置类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Configuration
public class RedisConfig {
    @Bean
    public JedisConnectionFactory redisConnectionFactory() {
        return new JedisConnectionFactory();
    }

    @Bean
    public RedisTemplate<String, String> redisTemplate() {
        StringRedisTemplate template = new StringRedisTemplate(redisConnectionFactory());
        return template;
    }
}

由于是配置类,上面的 redisTemplate 里面的调用 redisConnectionFactory 的方法调用会被 Spring 拦截然后注入,详情见 配置类细则。Redis 的配置在 application.properties 即可配置 redis 的连接信息,简单的示例如下:

1
2
3
spring.redis.host=localhost
spring.redis.port=6379
spring.redis.timeout=30000

之后就可以在其地方注入使用,如这里简单的将请求里的 kv 存到 redis 当中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@RestController
@RequestMapping("/redis")
public class RedisController {
    @Autowired
    RedisTemplate<String, String> redisTemplate;

    @PostMapping("/set")
    public String setKV(@RequestParam String key, @RequestParam String val) {
        redisTemplate.opsForValue().set(key, val);
        return redisTemplate.opsForValue().get(key);
    }
}

然后就可以在 redis-cli 终端中进行下面的测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ redis-cli
redis 127.0.0.1:6379> MONITOR
OK
1547536551.338767 "MONITOR"

# 新终端
$ curl -X POST 'http://localhost:8080/redis/set/?key=name&val=Jack'

# redis-cli 终端
1547536657.984896 "SET" "name" "Jack"
1547536657.985396 "GET" "name"

除了 opsForValue 之外,还有下列的操作:

1
2
3
4
5
6
redisTemplate.opsForGeo();      //操作地址位置
redisTemplate.opsForValue();    //操作字符串
redisTemplate.opsForHash();     //操作 hash
redisTemplate.opsForList();     //操作 list
redisTemplate.opsForSet();      //操作 set
redisTemplate.opsForZSet();     //操作有序 set

缓存注解

直接使用 redisTemplate 操作缓存,虽然简化了 Redis 的连接和关闭,但是在实际过程中还是需要编写大量的业务代码。这时候可以使用 缓存注解 + RESTful 风格接口 来简化。

在使用 Spring 缓存注解之前需要使用配置缓存管理器 CacheManager,缓存管理器将提供如缓存类型,在上面的配置类里面相关 bean,并为了后续的对象的缓存修改下 RedisTemplate 的序列化方式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Configuration
@EnableCaching
public class RedisConfig {
    @Bean
    public CacheManager cacheManager() {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(redisTemplate().getValueSerializer()));
        return RedisCacheManager.builder(redisConnectionFactory()).cacheDefaults(redisCacheConfiguration).build();
    }

    @Bean
    public JedisConnectionFactory redisConnectionFactory() {
        return new JedisConnectionFactory();
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());

        // 也可用 bean
        GenericJackson2JsonRedisSerializer genericJackson2JsonRedisJsonSerializer = new GenericJackson2JsonRedisSerializer();
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

        redisTemplate.setKeySerializer(stringRedisSerializer);
        // 默认是 JdkSerializationRedisSerializer
        redisTemplate.setValueSerializer(genericJackson2JsonRedisJsonSerializer);
        // 照样可以设置其他的 serializer
        redisTemplate.setHashKeySerializer(stringRedisSerializer);
        redisTemplate.setHashValueSerializer(genericJackson2JsonRedisJsonSerializer);
        return redisTemplate;
    }
}

上面的代码的解释如下:

  • 相比 SpringBoot 1 的利用 RedisTemplate (对于序列化这样设置 CacheManager 时候会方便很多)初始化 RedisCacheManager,在 SpringBoot 2 中 变更 为使用 RedisConnectionFactory,如果想要设置 RedisCacheManager 需要使用 RedisCacheManager.builder,也可在配置文件里配置。SpringBoot 1 的配置方式 参考
  • @EnableCaching 注解能使缓存机制。

上面的代码其实是有问题的,因为自己指定了 redisConnectionFactory ,这样的方式是使用的默认配置(localhost)没有用 properties 里面的配置,所以要想能够使用配置有两种方法,一是自己讲配置里面的值注入到自己的 redisConnectionFactory() 函数里的 JedisConnectionFactory 的构造当中。而是使用 data-redis 的 JedisConnectionFactory。

这里使用第二种方法试试,就需要去掉 redisConnectionFactory Bean,然后加入 autowire 的 JedisConnectionFactory redisConnectionFactory; 之后重启会发现 Bean method 'redisConnectionFactory' in 'JedisConnectionConfiguration' not loaded because @ConditionalOnMissingBean 错误,查看到源码之后会发现 org.springframework.boot.autoconfigure.data.redis.JedisConnectionConfiguration#redisConnectionFactory 的上面有一个注解 @ConditionalOnMissingBean({RedisConnectionFactory.class}) ,这样说明已有 RedisConnectionFactory 的存在,所以改为注入 RedisConnectionFactory 即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@Configuration
@EnableCaching
public class RedisConfig {
    @Autowired
    RedisConnectionFactory redisConnectionFactory;

    @Bean
    public CacheManager cacheManager() {
        // ...
        return RedisCacheManager.builder(redisConnectionFactory.cacheDefaults(redisCacheConfiguration).build();
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        // ...
    }
}

开发 service,注意要为 User POJO 加上 implements Serializable

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Service
public class MybatisUserServiceImpl implements UserService {
    @Override
    @CachePut(value = "redisCache", key = "'user_'+#user.id")
    public User insertUser(User user) {
        userMapper.createUser(user);
        return user;
    }

    @Override
    @Cacheable(value = "redisCache", key = "'user_'+#id")
    public User getUser(int id) {
        return userMapper.getUserById(id);
    }

    @Override
    @CachePut(value = "redisCache", condition = "#result != 'null'", key = "'user_'+#user.id")
    public User updateUser(User user) {
        userMapper.updateUser(user);
        return user;
    }

    @Override
    @CacheEvict(value = "redisCache", key = "'user_'+#id", beforeInvocation = false)
    public int deleteUser(int id) {
        return userMapper.deleteUser(id);
    }
    // 批量获取缓存命中率低,忽略
}

最后可以请求测试一下,比如请求两次已有数据,创建一个数据之后请求一次,在 redis 中是这样的:

1
2
3
4
5
6
7
8
9
# 这里是第一次访问,Redis 缓存该方法返回值
1547550684.227003 "GET" "redisCache::user_1"
1547550684.235508 "SET" "redisCache::user_1" "{\"@class\":\"contacts.model.User\",\"id\":1,\"name\":\"Jack\",\"address\":\"London\"}"
# 第二次访问,直接使用缓存
1547550689.227003 "GET" "redisCache::user_1"
# CachePut 时候缓存
1547550690.958848 "SET" "redisCache::user_44" "{\"@class\":\"contacts.model.User\",\"id\":44,\"name\":\"Scott\",\"address\":\"Beijing\"}"
# 再次访问时直接获取
1547550693.902872 "GET" "redisCache::user_44"

对于上面代码的解释:

  • @CachePut 注解将方法返回数据放入缓存。

  • @Cacheable 注解先根据指定的键查询缓存,如存在直接将缓存反序列化后返回,如不存在,再缓存方法返回值。

  • @CacheEvict 通过定义的键移除缓存。

  • 可以看到上面的 Redis 的键格式如 redisCache::user_X 其中的 redisCache 即是使用 value 参数指定的,value 支持数组;后面的 user_X 即是 key 指定的,key 里面是可以使用 SpEL 表达式 的,condition 的 SpEL 表达式可以指定操作缓存的条件。

  • 由于采用了 JSON 方式的序列化,所以缓存中的序列化数据可读性还是较强的。

  • 更多参考 Cache Abstraction

对于设置过期时间可以在 cacheManager 里统一设置,配置里的 spring.cache.redis.time-to-live 好像没有作用?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class RedisConfig {
    @Bean
    public CacheManager cacheManager() {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(redisTemplate().getValueSerializer()))
                        .entryTtl(Duration.ofSeconds(60));
        return RedisCacheManager.builder(redisConnectionFactory()).cacheDefaults(redisCacheConfiguration).build();
    }
    // ...
}

也可以使用 @TimeToLive 注解 POJO 类的一个返回该类存活时间的方法或者属性来差异化存活,具体参考 Time To Live

CacheConfig

在上面的例子中一个 service 当中多个缓存注解都干了同一个事情,即指定 cacheName,对于一个 service 内的缓存一般来说其 cacheName 都是一致的,这时候就可以使用 @CacheConfig 注解来装饰类级别,这样方法内的缓存注解就不用指明缓存名了:

1
2
3
4
5
@CacheConfig(cacheNames={"addresses"})
public class CustomerDataService {
    @Cacheable
    public String getAddress(Customer customer) {...}
}

Caching

在 java 中是不允许同一个注解装饰重复使用的,但是利用 @Caching 注解就可实现多个缓存的注解:

1
2
3
4
@Caching(evict = {
  @CacheEvict("addresses"),
  @CacheEvict(value="directory", key="#customer.name") })
public String getAddress(Customer customer) {...}

缓存 Client

如果追求更加精细的缓存操作,可以自己再封装一层 RedisClient 然后自己在手动调用,如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
public class RedisClient {
    @Autowired
    private RedisTemplate redisTemplate;

    public <T> T get(String key) {
        return (T)redisTemplate.opsForValue().get(key);
    }

    public void set(String key, Object value) {
        redisTemplate.opsForValue().set(key, value);
    }

    public void set(String key, Object value, int timeout) {
        redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS);
    }

    public void expire(String key, int timeout) {
        redisTemplate.expire(key, timeout, TimeUnit.SECONDS);
    }
}

Lettuce VS Jedis

Jedis 在实现上是直接连接的 redis server,如果在多线程环境下是非线程安全的,这个时候只有使用连接池,为每个 Jedis 实例增加物理连接

Lettuce 的连接是基于 Netty 的,连接实例(StatefulRedisConnection)可以在多个线程间并发访问,应为 StatefulRedisConnection 是线程安全的,所以一个连接实例(StatefulRedisConnection)就可以满足多线程环境下的并发访问,当然这个也是可伸缩的设计,一个连接实例不够的情况也可以按需增加连接实例。

所以可在后续开发中使用 Lettuce 逐步替换掉 Jedis。

参考