Spring boot中使用缓存

本地缓存Caffeine和分布式缓存Redis的使用方法

Posted by Sun Jianjiao on March 17, 2016

这篇文章写的太好了,放在最开始,先看完这篇文章再往下看:https://juejin.im/post/5b849878e51d4538c77a974a

在使用缓存之前,需要确认你的项目是否真的需要缓存。使用缓存会引入的一定的技术复杂度,后文也将会一一介绍这些复杂度。一般来说从两个方面来个是否需要使用缓存:

CPU占用:如果你有某些应用需要消耗大量的cpu去计算,比如正则表达式,如果你使用正则表达式比较频繁,而其又占用了很多CPU的话,那你就应该使用缓存将正则表达式的结果给缓存下来。 数据库IO占用:如果你发现你的数据库连接池比较空闲,那么不应该用缓存。但是如果数据库连接池比较繁忙,甚至经常报出连接不够的报警,那么是时候应该考虑缓存了。笔者曾经有个服务,被很多其他服务调用,其他时间都还好,但是在每天早上10点的时候总是会报出数据库连接池连接不够的报警,经过排查,发现有几个服务选择了在10点做定时任务,大量的请求打过来,DB连接池不够,从而报出连接池不够的报警。这个时候有几个选择,我们可以通过扩容机器来解决,也可以通过增加数据库连接池来解决,但是没有必要增加这些成本,因为只有在10点的时候才会出现这个问题。后来引入了缓存,不仅解决了这个问题,而且还增加了读的性能。

如果并没有上述两个问题,那么你不必为了增加缓存而缓存。

对于ConcurrentHashMap来说,比较适合缓存比较固定不变的元素,且缓存的数量较小的。虽然从上面表格中比起来有点逊色,但是其由于是jdk自带的类,在各种框架中依然有大量的使用,比如我们可以用来缓存我们反射的Method,Field等等;也可以缓存一些链接,防止其重复建立。在Caffeine中也是使用的ConcurrentHashMap来存储元素。

对于LRUMap来说,如果不想引入第三方包,又想使用淘汰算法淘汰数据,可以使用这个。

对于Ehcache来说,由于其jar包很大,较重量级。对于需要持久化和集群的一些功能的,可以选择Ehcache。笔者没怎么使用过这个缓存,如果要选择的话,可以选择分布式缓存来替代Ehcache。

对于Guava Cache来说,Guava这个jar包在很多Java应用程序中都有大量的引入,所以很多时候其实是直接用就好了,并且其本身是轻量级的而且功能较为丰富,在不了解Caffeine的情况下可以选择Guava Cache。

对于Caffeine来说,笔者是非常推荐的,其在命中率,读写性能上都比Guava Cache好很多,并且其API和Guava cache基本一致,甚至会多一点。在真实环境中使用Caffeine,取得过不错的效果。

总结一下:如果不需要淘汰算法则选择ConcurrentHashMap,如果需要淘汰算法和一些丰富的API,这里推荐选择Caffeine。

Redis如果挂了或者使用老版本的Redis,其会进行全量同步,此时Redis是不可用的,这个时候我们只能访问数据库,很容易造成雪崩。 访问Redis会有一定的网络I/O以及序列化反序列化,虽然性能很高但是其终究没有本地方法快,可以将最热的数据存放在本地,以便进一步加快访问速度。这个思路并不是我们做互联网架构独有的,在计算机系统中使用L1,L2,L3多级缓存,用来减少对内存的直接访问,从而加快访问速度。

所以如果仅仅是使用Redis,能满足我们大部分需求,但是当需要追求更高的性能以及更高的可用性的时候,那就不得不了解多级缓存。

使用缓存有两个前提条件:

  • 一是数据访问热点不均衡,某些数据会被更频繁的访问。
  • 而是数据在某个时间段内优先,不会很快过期。

1. 注解说明

Spring 框架提供了透明的缓存添加方法,通过抽象层屏蔽了不同缓存之间的差异。通过@EnableCaching注解,spring boot自动对缓存进行配置。

本文使用的spring boot版本2.1.0.RELEASE

  • @Cacheable 缓存的入口,首先检查缓存如果没有命中则执行方法并将方法结果缓存
  • @CacheEvict 缓存回收,清空对应的缓存数据
  • @CachePut 缓存更新,执行方法并将方法执行结果更新到缓存中
  • @Caching 组合多个缓存操作
  • @CacheConfig 类级别的公共配置

2. 缓存信息查看

确认如下信息:

  • 缓存的命中率
  • 是否是当前配置的缓存(缺省是ConcurrentMap)

可以通过actuator提供的缓存信息查看,可以参看文档Spring boot actuator系统监控

但是2.1.0的版本,caches是没有expose的,所以需要在application.yml中配置暴露caches:

1
2
3
4
5
management:
  endpoints:
    web:
      exposure:
        include: caches,info

3. Caffeine

Caffeine 是使用Java8对Guava缓存的重写版本,在Spring Boot 2.0中取代了Guava,作为本地缓存使用。

本地缓存的最大的优点是应用和cache是在同一个进程内部,请求缓存非常快速,没有过多的网络开销等,在单应用不需要集群支持或者集群情况下各节点无需互相通知的场景下使用本地缓存较合适;同时,它的缺点也是应为缓存跟应用程序耦合,多个应用程序无法直接的共享缓存,各应用或集群的各节点都需要维护自己的单独缓存,对内存是一种浪费。

3.1 caffeine 原理

  • FIFO:先进先出,在这种淘汰算法中,先进入缓存的会先被淘汰。这种可谓是最简单的了,但是会导致我们命中率很低。试想一下我们如果有个访问频率很高的数据是所有数据第一个访问的,而那些不是很高的是后面再访问的,那这样就会把我们的首个数据但是他的访问频率很高给挤出。

  • LRU:最近最少使用算法。在这种算法中避免了上面的问题,每次访问数据都会将其放在我们的队尾,如果需要淘汰数据,就只需要淘汰队首即可。但是这个依然有个问题,如果有个数据在1个小时的前59分钟访问了1万次(可见这是个热点数据),再后一分钟没有访问这个数据,但是有其他的数据访问,就导致了我们这个热点数据被淘汰。

  • LFU:最近最少频率使用。在这种算法中又对上面进行了优化,利用额外的空间记录每个数据的使用频率,然后选出频率最低进行淘汰。这样就避免了LRU不能处理时间段的问题。

  • W-TinyLFU 上面已经说过了传统的LFU是怎么一回事。在LFU中只要数据访问模式的概率分布随时间保持不变时,其命中率就能变得非常高。这里我还是拿爱奇艺举例,比如有部新剧出来了,我们使用LFU给他缓存下来,这部新剧在这几天大概访问了几亿次,这个访问频率也在我们的LFU中记录了几亿次。但是新剧总会过气的,比如一个月之后这个新剧的前几集其实已经过气了,但是他的访问量的确是太高了,其他的电视剧根本无法淘汰这个新剧,所以在这种模式下是有局限性。所以各种LFU的变种出现了,基于时间周期进行衰减,或者在最近某个时间段内的频率。同样的LFU也会使用额外空间记录每一个数据访问的频率,即使数据没有在缓存中也需要记录,所以需要维护的额外空间很大。

再回到LRU,我们的LRU也不是那么一无是处,LRU可以很好的应对突发流量的情况,因为他不需要累计数据频率。

所以W-TinyLFU结合了LRU和LFU,以及其他的算法的一些特点。

3.2 使用方法

3.2.1 添加依赖

build.gradle

1
2
    compile "org.springframework.boot:spring-boot-starter-cache"
    compile "com.github.ben-manes.caffeine:caffeine:2.6.2"

3.2.2 开启缓存的支持

1
2
3
4
5
6
7
8
@SpringBootApplication
@EnableCaching                //让spring boot开启对缓存的支持
public class Application {

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

3.2.3 配置文件

1
2
3
4
5
spring:
  cache:
    cache-names: outLimit,notOutLimit
    caffeine:
      spec: maximumSize=500, expireAfterAccess=600s

3.3 查看统计信息

对于缓存命中率非常重要,所以需要对缓存的命中率进行统计,至少在调试和开发阶段需要对命中率进行确认。

3.3.1 开启记录统计信息

application.yml中启用recoredStats, 对缓存信息进行统计。

1
2
3
4
5
6
7
spring:
  cache:
    cache-names: 
      - cacheName1
      - cacheName2
    caffeine:
      spec: maximumSize=500, expireAfterAccess=600s, recordStats

3.3.2 获取指定cache统计信息:

1
2
3
4
5
6
7
8
9
    @Autowired
    CacheManager cacheManager;

    private CacheStats stats(String cacheName) {
        Cache cache = (Cache) cacheManager.getCache(cacheName).getNativeCache();

        return cache.stats();
    }

3.3.3 获取所有cache统计信息:

由于CacheStats没有记录缓存的名字,所以需要对CacheStats增加name字段,封装成新的Dto:

1
2
3
4
5
6
7
8
9
10
11
12
13
import com.github.benmanes.caffeine.cache.stats.CacheStats;
import lombok.Data;

@Data
public class CacheStatsDto {
    String name;
    CacheStats cacheStats;

    public CacheStatsDto(String name, CacheStats cacheStats) {
        this.name = name;
        this.cacheStats = cacheStats;
    }
}

遍历所有的缓存,添加统计信息到链表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    public List<CacheStatsDto> stats() {
        Collection<String> names = cacheManager.getCacheNames();
        List<CacheStatsDto> cacheStatsDtoList = new LinkedList<>();

        for (String name: names) {
            CacheStats cacheStats = stats(name);

            CacheStatsDto cacheStatsDto = new CacheStatsDto(name, cacheStats);
            cacheStatsDtoList.add(cacheStatsDto);
        }

        return cacheStatsDtoList;
    }

3.4 对指定的操作进行缓存

    @Cacheable(value = "cacheName1", key="#id",sync = true)
    public String getNodeInfo(int id) {
        String node =  collectNodeClient.findDataType("315b24e65624620d996715d8e1eb1b41",
                "贴片机采集点1", "生产数");

        return node;
    }

缓存操作的接口和调用的函数分成独立的类,我这里在同一个类里面不生效,没有使用caffeine, 使用了缺省的ConcurrentMap。 修改为独立的类后,使用了caffeine。

4. Redis

4.1 过期机制

千万不要忘记设立过期时间,若不设,只能等内存满了,一个个查看Key有没有使用。

4.1.1 过期策略

Redis采用的是定期删除策略和懒汉式的策略互相配合。Redis内部自动完成!

  • 定期删除策略:每隔一段时间执行一次删除过期key操作
  • 懒惰淘汰策略:key过期的时候不删除,每次通过key获取值的时候去检查是否过期,若过期,则删除,返回null

懒惰淘汰机制会造成内存浪费,但是节省CPU资源。定时淘汰机制保证过期的数据一定会被释放掉,但是相对消耗CPU资源 所以,在实际中,如果我们要自己设计过期策略,在使用懒汉式删除+定期删除时,控制时长和频率这个尤为关键,需要结合服务器性能,已经并发量等情况进行调整,以致最佳。

4.2. 使用String还是Hash

STACK OVERFLOW 上一个对 String 和 Hash 的讨论: Redis strings vs Redis hashes to represent JSON: efficiency?

对于一个对象是把本身的数据序列化后用 String 存储,还是使用 Hash 来分别存储对象的各个属性:

  • 如果在大多数时候要访问对象的大部分数据:使用 String
  • 如果在大多数时候只要访问对象的小部分数据:使用 Hash
  • 如果对象里面还有对象这种结构复杂的,最好用 String。否则最外层用 Hash,里面又将对象序列化,两者混用可能导致混乱。

4.3. redisTemplate

spring RedisTemplate 是对redis的各种操作的封装,它支持所有的 redis 原生的 api。

4.3.1 StringRedisTemplate与RedisTemplate

两者的关系是StringRedisTemplate继承RedisTemplate。 两者的数据是不共通的;也就是说StringRedisTemplate只能管理StringRedisTemplate里面的数据,RedisTemplate只能管理RedisTemplate中的数据。

Redis的String数据结构,推荐使用StringRedisTemplate, 否则使用RedisTemplate需要更改序列化方式。

4.3.2 restTemplate的操作

在RedisTemplate中,提供了一个工厂方法:opsForValue(),这个方法会返回一个默认的操作类。

redisTemplate.opsForValue();//操作字符串
redisTemplate.opsForHash();//操作hash
redisTemplate.opsForList();//操作list
redisTemplate.opsForSet();//操作set
redisTemplate.opsForZSet();//操作有序set

4.4 使用举例

4.4.1 build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
dependencies {
    implementation('org.springframework.boot:spring-boot-starter-cache')
    implementation('org.springframework.boot:spring-boot-starter-data-redis')

    // https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind
    compile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.9.7'

    // https://mvnrepository.com/artifact/org.projectlombok/lombok
    implementation group: 'org.projectlombok', name: 'lombok', version: '1.18.4'



    compileOnly('org.projectlombok:lombok')
    testImplementation('org.springframework.boot:spring-boot-starter-test')
}

4.4.2 application.yml

1
2
3
4
5
6
7
8
9
10
11
12
spring:
    redis:
      time-to-live: 600000
      database: 0 # Database index used by the connection factory.
      host: localhost # Redis server host.
      jedis.pool.max-active: 8 # Maximum number of connections that can be allocated by the pool at a given time. Use a negative value for no limit.
      jedis.pool.max-idle: 8 # Maximum number of "idle" connections in the pool. Use a negative value to indicate an unlimited number of idle connections.
      jedis.pool.max-wait: -1ms # Maximum amount of time a connection allocation should block before throwing an exception when the pool is exhausted. Use a negative value to block indefinitely.
      jedis.pool.min-idle: 0 # Target for the minimum number of idle connections to maintain in the pool. This setting only has an effect if it is positive.
      password: # Login password of the redis server.
      port: 6379 # Redis server port.
      ssl: false # Whether to enable SSL support.

4.4.3 配置jackson序列化

用于写入和读取类,如下文中Test的User

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConf {
    @Bean
    public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate redisTemplate = new RedisTemplate();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);

        ObjectMapper objectMapper = new ObjectMapper();// <1>
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);

        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

        redisTemplate.setKeySerializer(new StringRedisSerializer()); // <2>
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer); // <2>

        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}

4.4.4 数据读取

package com.springexample.rediscache;

import lombok.Data;
import lombok.NoArgsConstructor;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.test.context.junit4.SpringRunner;


@Data
@NoArgsConstructor                      //Jackson进行转换的时候需要没有参数的构造函数
class User {
    int id;
    String name;

    public User(int id, String name) {
        this.id = id;
        this.name = name;
    }
}

@RunWith(SpringRunner.class)
@SpringBootTest
public class RedisCacheApplicationTests {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private RedisTemplate redisTemplate;

    @Test
    public void redistTemplateTest() {
        //字符串
        stringRedisTemplate.opsForValue().set("aaa", "111", 1, TimeUnit.HOURS);
        Assert.assertEquals("111", stringRedisTemplate.opsForValue().get("aaa"));

        //hash
        redisTemplate.opsForHash().put("hello", "yes", 0);
        redisTemplate.expire("hello",  30, TimeUnit.MINUTES);
        Assert.assertEquals(0, redisTemplate.opsForHash().get("hello", "yes"));

        //对象
        redisTemplate.opsForValue().set("xiaoming", new User(1, "xiaoming"), 60, TimeUnit.MINUTES);
        User user1 = (User) redisTemplate.opsForValue().get("xiaoming");

        //publish
        redisTemplate.convertAndSend("yeah", "hello");
    }
}

发布的时候可以在redis-cli中subscribe:

127.0.0.1:6379> subscribe yeah
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "yeah"
3) (integer) 1
1) "message"
2) "yeah"
3) "\"hello\""

通过ttl查看过期时间:

127.0.0.1:6379> ttl hello
(integer) 1747
127.0.0.1:6379> ttl hello
(integer) 1699
127.0.0.1:6379> ttl hello
(integer) 1698
127.0.0.1:6379> ttl aaa
(integer) 3480
127.0.0.1:6379> ttl aaa
(integer) 3479
127.0.0.1:6379> ttl xiaoming
(integer) 3468

4.5 高效使用Redis

Redis可以存放主流的数据结构的,包括String,list,hash,set,sorted-set。所以在使用Redis作为缓存的时候,也是和数据库设计一样,需要根据数据特点设计合适的数据结构。

4.5.1 适合List的场景举例

数据库中的关联表使用List存储更合适:

userId followerId
userA userC1
userA userC2
userB userC2

Redis中存储方式: |userId|followerId | |:—–|:—————-| | userA| userC,userC2 | | userB| userC2 |

4.5.2 Hash的场景

对于读多写少的场景,除了DB层的读写分离,缓存也是常见的解决方法。如果读取的数据有明显的时效性和热点,缓存数据过期机制天然的避免了陈旧数据对空间的占用。

post Id userId postTime content
…….. userA 14778340 hello

缓存设计首先需要确定一个问题:以什么作为key和value。最自然的方案是使用postId作为key, 帖子内容作为value。如果后续需要基于postTime时间段查询,就会有multipe-key的查询。可以设计为:

key:userId + 时间戳 value: redis的hash类型。 field: postId, value为帖子内容。

5. 数据库缓存

但是大多数情况下我会建议你不要使用查询缓存,为什么呢?因为查询缓存往往弊大于利

查询缓存的失效非常频繁,只要有对一个表的更新,这个表上所有的查询缓存都会被清空。因此很可能你费劲地把结果存起来,还没使用呢,就被一个更新全清空了。对于更新压力大的数据库来说,查询缓存的命中率会非常低。除非你的业务就是有一张静态表,很长时间才会更新一次。比如,一个系统配置表,那这张表上的查询才适合使用查询缓存。 好在 MySQL 也提供了这种“按需使用”的方式。你可以将参数 query_cache_type 设置成 DEMAND,这样对于默认的 SQL 语句都不使用查询缓存。而对于你确定要使用查询缓存的语句,可以用 SQL_CACHE 显式指定,像下面这个语句一样: mysql> select SQL_CACHE * from T where ID=10;

需要注意的是,MySQL 8.0 版本直接将查询缓存的整块功能删掉了,也就是说 8.0 开始彻底没有这个功能了。

6. 缓存穿透、击穿和雪崩

缓存并发的问题通常发生在高并发的场景下,当一个缓存key过期时,因为访问这个缓存key 的请求量较大,多个请求同时发现缓存过期,因此多个请求会同时访问数据库来查询最新数据,并且回写缓存,这样会造成应用和数据库的负载增加,性能降低,由于并发较高,甚至会导致数据库被压死。

加分布式锁:加载数据的时候可以利用分布式锁锁住这个数据的Key,在Redis中直接使用setNX操作即可,对于获取到这个锁的线程,查询数据库更新缓存,其他线程采取重试策略,这样数据库不会同时受到很多线程访问同一条数据。 异步加载:由于缓存击穿是热点数据才会出现的问题,可以对这部分热点数据采取到期自动刷新的策略,而不是到期自动淘汰。淘汰其实也是为了数据的时效性,所以采用自动刷新也可以。

7. 参考文档

  1. Spring Boot features: Caching
  2. spring caffeine cache tutorial