重学SpringBoot系列之redis与spring cache缓存

x33g5p2x  于2021-12-03 转载在 Spring  
字(25.7k)|赞(0)|评价(0)|浏览(427)

使用docker安装redis

本节的目的不在于去教大家理解docker容器(讲docker就脱离了我们课程的核心,我们的课程是Spring Boot 不是docker),而是希望通过docker的方式快速的为大家搭建一个redis数据库,从而方便大家学习使用。

准备工作

  • 首先要安装好docker。CentOS7如何安装docker可以自行百度

获取 redis 镜像

docker search redis
docker pull redis:5.0.5
docker images

其实更形象点的理解docker镜像和容器之间的关系,更像是Class类与对象之间的关系。一个类可以构造多个对象,一个镜像可以构造多个容器。类和镜像是实实在在存在的字节码文件;对象和容器是在系统内存里面,作为运行时状态存在。

创建容器

创建持久化存储目录

容器可以运行在内存里面,但是容器存储的数据需要进行持久化。所以在宿主机上创建redis 容器的数据和配置文件存储目录。

# 这里我们在 /home/docker 下创建
mkdir /home/docker/redis/{conf,data} -p
cd /home/docker/redis

注意:后面所有的操作命令都要在这个目录/home/docker/redis下进行

获取 redis 的默认配置文件模版

# 获取 redis 的默认配置模版
# 这里主要是想设置下 redis 的 log / password / appendonly
# redis 的 docker 运行参数提供了 --appendonly yes 但没 password
wget https://gitee.com/hanxt/boot-launch/raw/master/src/main/resources/otherconfig/redis.conf -O conf/redis.conf

# 直接替换编辑
sed -i 's/logfile ""/logfile "access.log"/' conf/redis.conf;
sed -i 's/# requirepass foobared/requirepass 123456/' conf/redis.conf;
sed -i 's/appendonly no/appendonly yes/' conf/redis.conf;
sed -i 's/bind 127.0.0.1/bind 0.0.0.0/' conf/redis.conf;
  • sed -ilinux文件替换命令,替换格式为s/被替换的内容/替换之后的内容/
  • 替换logfile ""logfile "access.log",指定日志文件名称为access.log---->指定日志文件的名称
  • 替换# requirepass foobaredrequirepass 123456,指定访问密码为123456—>配置登录密码,auth 123456
  • 替换“appendonly no“”appendonly yes”,开启appendonly模式–》持久化配置
  • 替换绑定**IP“bind 127.0.0.1”为“bind 0.0.0.0”**—>任意ip可以访问

protected-mode 是在没有显式定义 bind 地址(即监听全网段),又没有设置密码 requirepass时,protected-mode 只允许本地回环 127.0.0.1 访问。所以改为bind 0.0.0.0

使用镜像创建一个容器

创建并运行一个名为 myredis 的容器,放到start-redis.sh脚本里面

# 创建并运行一个名为 myredis 的容器
docker run \
-p 6379:6379 \
-v $PWD/data:/data \
-v $PWD/conf/redis.conf:/etc/redis/redis.conf \
--privileged=true \
--name myredis \
-d redis:5.0.5 redis-server /etc/redis/redis.conf
# 命令分解
docker run \
-p 6379:6379 \ # 端口映射 宿主机:容器
-v $PWD/data:/data:rw \ # 映射磁盘目录 rw 为读写,宿主机目录:容器目录
-v $PWD/conf/redis.conf:/etc/redis/redis.conf:ro \ # 挂载配置文件 ro 为readonly
--privileged=true \ # 给与一些权限
--name myredis \ # 给容器起个名字
-d redis redis-server /etc/redis/redis.conf # deamon 运行容器 并使用配置文件启动容器内的 redis-server
  • $PWD是当前目录,也就是/home/docker/redis

查看活跃的容器

# 查看活跃的容器
docker ps
# 如果没有 myredis 说明启动失败 查看错误日志
docker logs myredis
# 查看 myredis 的 ip 挂载 端口映射等信息
docker inspect myredis
# 查看 myredis 的端口映射
docker port myredis

访问 redis 容器服务

安装好之后,可以进行访问测试

docker exec -it myredis bash
redis-cli

上面的测试是在宿主机内访问docker容器。如果在宿主机上可以访问到redis服务,在宿主机之外的主机无法访问该redis服务的话,可能是因为宿主机的防火墙没有打开。参考下面的做法。

开启防火墙端口,提供外部访问

开启docker容器所在的宿主机端口,提供给外部服务进行访问

firewall-cmd --zone=public --add-port=6379/tcp --permanent
firewall-cmd --reload
firewall-cmd --query-port=6379/tcp

redis数据结构与应用场景

Redis 是开源免费, key-value 内存数据库,主要解决高并发、大数据场景下,热点数据访问的性能问题,提供高性能的数据快速访问。

项目中部分数据访问比较频繁,对下游 DB(例如 MySQL)造成服务压力,这时候可以使用缓存来提高效率。

Redis 的主要特点包括:

  • Redis数据存储在内存中,可以提高热点数据的访问效率
  • Redis 除了支持 key-value 类型的数据,同时还支持其他多种数据结构的存储;
  • Redis 支持数据持久化存储,可以将数据存储在磁盘中,机器重启数据将从磁盘重新加载数据;

Redis 作为缓存数据库和 MySQL 这种结构化数据库进行对比。

  • 从数据库类型上,Redis 是 NoSQL 半结构化缓存数据库, MySQL 是结构化关系型数据库;
  • 从读写性能上,MySQL 是持久化硬盘存储,读写速度较慢, Redis数据存储读取都在内存,同时也可以持久化到磁盘,读写速度较快;
  • 从使用场景上,Redis 一般作为 MySQL 数据读取性能优化的技术选型,彼此配合使用。Redis用于存储热数据或者缓存数据,并不存在相互替换的关系。

Redis 基本数据结构与实战场景

  • redis的数据结构可以理解为Java数据类型中的Map<String,Object>,key是String类型,value是下面的类型。只不过作为一个独立的数据库单独存在,所以Java中的Map怎么用,redis就怎么用,大同小异。
  • 字符串类型的数据结构可以理解为Map<String,String>
  • list类型的数据结构可以理解为Map<String,List<String>>
  • set类型的数据结构可以理解为Map<String,Set<String>>
  • hash类型的数据结构可以理解为Map<String,HashMap<String,String>>

上图中命令行更正:lrange,不是lrang

redis应用场景解析

String 类型使用场景

场景一:商品库存数

从业务上,商品库存数据是热点数据,交易行为会直接影响库存。而 Redis 自身 String 类型提供了:

incr key     #增加一个库存
decr key    # 减少一个库存
incrby key 10 # 增加20个库存
decrby key 15   # 减少15个库存
  • set goods_id 10; 设置 id 为 good_id 的商品的库存初始值为 10;
  • decr goods_id; 当商品被购买时候,库存数据减 1。

依此类推的场景:商品的浏览次数,问题或者回复的点赞次数等。这种计数的场景都可以考虑利用 Redis 来实现。

场景二:时效信息存储

Redis 的数据存储具有自动失效能力。也就是存储的 key-value 可以设置过期时间SETEX mykey 60 "value"中的第2个参数就是过期时间。

比如,用户登录某个 App 需要获取登录验证码, 验证码在 30 秒内有效。

  • 生成验证码:生成验证码并使用 String 类型在reids存储验证码,同时设置 30 秒的失效时间。如:SETEX validcode 30 “value”
  • 验证过程:用户获得验证码之后,我们通过get validcode获取验证码,如果获取不到说明验证码过期了。

List 类型使用场景

list 是按照插入顺序排序的字符串链表。可以在头部和尾部插入新的元素(双向链表实现,两端添加元素的时间复杂度为 O(1)) 。

场景一:消息队列实现

目前有很多专业的消息队列组件 Kafka、RabbitMQ 等。 我们在这里仅仅是使用 list 的特征来实现消息队列的要求。在实际技术选型的过程中,大家可以慎重思考。

list 存储就是一个队列的存储形式:

  • lpush key value; 在 key 对应 list 的头部添加字符串元素;
  • rpop key; 移除列表的最后一个元素,返回值为移除的元素。

场景二:最新上架商品

在交易网站首页经常会有新上架产品推荐的模块, 这个模块是存储了最新上架前 100 名。这时候使用 Redis 的 list 数据结构,来进行 TOP 100 新上架产品的存储。

Redis ltrim 指令对一个列表进行修剪(trim),这样 list 就会只包含指定范围的指定元素。

ltrim key start end

start 和 end 都是由 0 开始计数的,这里的 0 是列表里的第一个元素(表头),1 是第二个元素。

如下伪代码演示:

//把新上架商品添加到链表里
ret = r.lpush("new:goods", goodsId)
//保持链表 100 位
ret = r.ltrim("new:goods", 0, 99)
//获得前 100 个最新上架的商品 id 列表
newest_goods_list = r.lrange("new:goods", 0, 99)

set 类型使用场景

set 也是存储了一个集合列表功能。和 list 不同,set 具备去重功能(和Java的Set数据类型一样)。当需要存储一个列表信息,同时要求列表内的元素不能有重复,这时候使用 set 比较合适。与此同时,set 还提供的交集、并集、差集。

例如,在交易网站,我们会存储用户感兴趣的商品信息,在进行相似用户分析的时候, 可以通过计算两个不同用户之间感兴趣商品的数量来提供一些依据。

//userid 为用户 ID , goodID 为感兴趣的商品信息。 
    sadd "user:userId" goodID

    sadd "user:101" 1
    sadd "user:101" 2
    sadd "user:102" 1
    Sadd "user:102" 3

    sinter "user:101" "user:102"    # 返回值是1

获取到两个用户相似的产品, 然后确定相似产品的类目就可以进行用户分析。类似的应用场景还有, 社交场景下共同关注好友, 相似兴趣 tag 等场景的支持。

Hash 类型使用场景

Redis 在存储对象(例如:用户信息)的时候需要对对象进行序列化转换然后存储,还有一种形式,就是将对象数据转换为 JSON 结构数据,然后存储 JSON 的字符串到 Redis。

对于一些对象类型,还有另外一种比较方便的类型,那就是按照 Redis 的 Hash 类型进行存储。

hset key field value

例如,我们存储一些网站用户的基本信息, 我们可以使用:

hset user101 name "小明"
    hset user101 phone "123456"
    hset user101 sex "男"

这样就存储了一个用户基本信息,存储信息有:{name : 小明, phone : “123456”,sex : “男”}

当然这种类似场景还非常多, 比如存储订单的数据,产品的数据,商家基本信息等。大家可以参考来进行存储选型。但是不适合存储关联关系比较复杂的数据,那种场景还得用关系型数据库比较方便。

Sorted Set 类型使用场景

Redis sorted set 的使用场景与 set 类似,区别是 set 不是自动有序的,而 sorted set 可以通过提供一个 score 参数来为存储数据排序并且是自动排序,插入既有序。

业务中如果需要一个有序且不重复的集合列表,就可以选择 sorted set 这种数据结构。

比如:商品的购买热度可以将购买总量 num 当做商品列表的 score,这样获取最热门的商品时就是可以自动按售卖总量排好序。

单例哨兵及集群模式整合

redis集群模式和哨兵模式高可用的安装与运维,需要你去专门的redis课程里面去学习。我们的主要是面向Spring Boot开发人员,不讲redis集群高可用及运维知识。

也就是说,本节为大家介绍的内容是:当架构师或者运维人员将redis 哨兵或cluster集群搭建好之后,在Spring Boot应用中你该如何去连接及使用这些redis实例。

spring-data-redis简介

Spring Boot 提供了对 Redis 集成的组件包:spring-boot-starter-data-redis,它依赖于 spring-data-redislettuce。Spring Boot 1.0 默认使用的是 Jedis 客户端,2.0 替换成了 Lettuce,但如果你从 Spring Boot 1.5.X 切换过来,几乎感受不到差异,这是因为 spring-boot-starter-data-redis 为我们隔离了其中的差异性。

  • Lettuce:是一个可伸缩线程安全的 Redis 客户端,多个线程可以共享同一个 RedisConnection,它利用优秀
    Netty NIO 框架来高效地管理多个连接。
  • Spring Data:是 Spring 框架中的一个主要项目,目的是为了简化构建基于 Spring
    框架应用的数据访问,包括非关系数据库、Map-Reduce 框架、云数据服务等,另外也包含对关系数据库的访问支持。
  • Spring Data Redis:是 Spring Data 项目中的一个主要模块,实现了对 Redis 客户端 API
    的高度封装,使对 Redis 的操作更加便捷。

整合spring data redis

引入依赖包

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

引入 commons-pool 2 是因为 Lettuce 需要使用 commons-pool 2 创建 Redis 连接池。

redis单例模式连接配置

application全局配置,使用我们前面安装好的测试redis服务。redis的单节点实例,可以通过下面的配置连接redis单节点实例数据库

spring:
  redis:
    database: 0 # Redis 数据库索引(默认为 0)
    host: 192.168.161.3 # Redis 服务器地址
    port: 6379 # Redis 服务器连接端口
    password: 123456 # Redis 服务器连接密码(默认为空)
    timeout:  5000  # 连接超时,单位ms
    lettuce:
      pool:
        max-active: 8 # 连接池最大连接数(使用负值表示没有限制) 默认 8
        max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制) 默认 -1
        max-idle: 8 # 连接池中的最大空闲连接 默认 8
        min-idle: 0 # 连接池中的最小空闲连接 默认 0

redis哨兵模式连接配置

redis另外一种非常常用的部署模式是哨兵模式,如果你的公司使用的是这种部署模式,它相对于单实例模式更加的高可用。

  • redis哨兵模式实际上是两种模式的组合,即主从模式和哨兵模式。当Master节点离线后,哨兵sentinel监控节点会把Slave节点切换为Master节点,保证服务的高可用
  • 哨兵模式是在主从模式的基础上增加了哨兵sentinel监控节点。最简单的哨兵模式需要一个redis的Master节点、一个redis的Slave、另外三个哨兵监控节点。

需要注意的是,当我们使用spring boot连接哨兵模式的redis集群,连接的是sentinel节点,而不是redis服务实例节点。注意上图的连接顺序。 Application Client是我们的应用程序,sentinel node是哨兵节点。

spring:
  redis:
    password: 123456
    timeout: 5000
    sentinel:     # 哨兵模式连接配置
      master: mymaster    #master节点名称,redis sentinel模式安装的时候会配置
      nodes: 192.168.1.201:26379,192.168.1.202:26379,192.168.1.203:26379      # 哨兵的IP:Port列表
    lettuce
      pool:
        max-active: 8
        max-wait: -1
        max-idle: 8
        min-idle: 0
主从模式和哨兵模式参考文章推荐

Redis6—主从复制篇

redis集群模式连接配置

Redis Cluster是Redis的分布式解决方案,在Redis 3.0版本正式推出的,有效解决了Redis分布式方面的需求。当遇到单机内存、并发、流量等瓶颈时,可以采用Cluster架构达到负载均衡的目的。分布式集群首要解决问题是:把整个数据集按照分区规则映射到多个节点上,即把数据集按照一定的规则划分到多个节点上,每个节点只保存整个数据集的一个子集。

  • 当程序客户端随意访问一个redis node节点时,可能会发现其操作的数据或者应该写入的数据位置,并不在当前node节点上。
  • 此时,当前被访问的redis node节点会告知客户端,你应该去哪个节点访问数据或写入数据
  • 然后客户端获取目标node节点的地址,重定向到该节点的地址,去访问或写入数据。

下面的配置,是针对redis集群模式连接访问的配置。

spring:
  redis:
    password: 123456
    timeout: 5000
    database: 0
    cluster:     #集群模式配置
      nodes: 192.168.1.11:6379,192.168.1.12:6379,192.168.1.13:6379,192.168.1.14:6379,192.168.1.15:6379,192.168.1.16:6379
      max-redirects: 3  # 重定向的最大次数
    lettuce:
      pool:
        max-active: 8
        max-wait: -1
        max-idle: 8
        min-idle: 0
cluster集群模式参考文章推荐

Redis–集群

使用redisTemplate操作数据

redis模板封装类

RedisTemplate 的封装使我们能够更方便的进行redis数据操作,比直接使用Jedis或者Lettuce的java SDK要方便很多。RedisTemplate作为java 操作redis数据库的API模板更通用,可以操作所有的redis数据类型。

// 注入RedisTemplate,更通用
@Resource
private RedisTemplate<String, Object> redisTemplate;

ValueOperations<String,Object> ValueOperations = redisTemplate.opsForValue();//操作字符串
HashOperations<String, String, Object> hashOperations = redisTemplate.opsForHash();//操作 hash
ListOperations<String, Object> listOperations = redisTemplate.opsForList();//操作 list
SetOperations<String, Object> setOperations = redisTemplate.opsForSet();//操作 set
ZSetOperations<String, Object> zSetOperations = redisTemplate.opsForZSet();//操作有序 set

ListOperations、ValueOperations、HashOperations、SetOperations、ZSetOperations等都是针对专有数据类型进行操作,使用起来更简洁。

@Resource(name = "redisTemplate")
    private ValueOperations<String,Object> valueOperations;   //以redis string类型存取Java Object(序列化反序列化)

    @Resource(name = "redisTemplate")
    private HashOperations<String, String, Object> hashOperations; //以redis的hash类型存储java Object

    @Resource(name = "redisTemplate")
    private ListOperations<String, Object> listOperations; //以redis的list类型存储java Object

    @Resource(name = "redisTemplate")
    private SetOperations<String, Object> setOperations;   //以redis的set类型存储java Object

    @Resource(name = "redisTemplate")
    private ZSetOperations<String, Object> zSetOperations;  //以redis的zset类型存储java Object

基础数据Java类

为了方便后面写代码解释API的使用方法,写测试用例。我们需要先准备数据对象Person,注意要实现Serializable接口,为什么一定要实现这个接口?我们下文解释。

@Data
public class Person implements Serializable {

  private static final long serialVersionUID = -8985545025228238754L;

  String id;
  String firstname;
  String lastname;
  Address address;   //注意这里,不是基础数据类型

  public Person(String firstname, String lastname) {
    this.firstname = firstname;
    this.lastname = lastname;
  }
}

准备数据对象Address

@Data
public class Address implements Serializable {

  private static final long serialVersionUID = -8985545025228238771L;

  String city;
  String country;

  public Address(String city, String country) {
    this.city = city;
    this.country = country;
  }
}

StringRedisTemplate

除了RedisTemplate模板类,还有另一个模板类叫做StringRedisTemplate 。二者都提供了用来操作redis数据库的API。

@SpringBootTest
public class RedisConfigTest {

    @Resource
    private StringRedisTemplate stringRedisTemplate;   //以String序列化方式保存数据的通用模板类

    @Resource
    private RedisTemplate<String, Person> redisTemplate;   //默认以JDK二进制方式保存数据的通用模板类

    @Test
    public void stringRedisTemplate() {
        Person person = new Person("kobe","byrant");
        person.setAddress(new Address("洛杉矶","美国"));
        //将数据存入redis数据库
        stringRedisTemplate.opsForValue().set("player:srt","kobe byrant",20, TimeUnit.SECONDS);
        redisTemplate.opsForValue().set("player:rt",person,20, TimeUnit.SECONDS); 
    }
}

二者的区别在于

  • 操作的数据类型不同,以List类型为例:RedisTemplate操作List< Object >,StringRedisTemplate操作List< String >
  • 序列化数据的方式不同,RedisTemplate使用的是JdkSerializationRedisSerializer存入数据会将数据先序列化成字节数组然后在存入Redis数据库。StringRedisTemplate使用的是StringRedisSerializer

解决key-value乱码问题

其实这个不是严格意义上的乱码,是JDK的二进制序列化之后的存储方式。人看不懂,但是程序是能看懂的。

那有没有人一种人能看懂,程序也能看懂的序列化结果?看下文的配置类代码

  • 采用StringRedisSerializerkey进行序列化(字符串格式)
  • 采用Jackson2JsonRedisSerializervalue将进行序列化(JSON格式)
@Configuration
public class RedisConfig {

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

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

        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

        //重点在这四行代码
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);

        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}

乱码问题的症结在于对象的序列化问题:RedisTemplate默认使用的是JdkSerializationRedisSerializer(二进制存储),StringRedisTemplate默认使用的是StringRedisSerializer(redis字符串格式存储)。

序列化方式对比:

  • JdkSerializationRedisSerializer: 使用JDK提供的序列化功能。 优点是反序列化时不需要提供类型信息(class),但缺点是需要实现Serializable接口,还有序列化后的结果非常庞大,是JSON格式的5倍左右,这样就会消耗redis服务器的大量内存。而且是以二进制形式保存,自然人无法理解。
  • Jackson2JsonRedisSerializer: 使用Jackson库将对象序列化为JSON字符串。优点是速度快,序列化后的字符串短小精悍,不需要实现Serializable接口。似乎没啥缺点。
  • StringRedisSerializer序列化之后的结果,自然人也是可以理解,但是value只能是String类型,不能是Object

使用redisTemplate存取redis各种数据类型

下面的各种数据类型操作的api和redis命令行api的含义几乎是一致的。

@SpringBootTest
public class RedisConfigTest2 {

    @Resource(name = "redisTemplate")
    private ValueOperations<String,Object> valueOperations;   //以redis string类型存取Java Object(序列化反序列化)

    @Resource(name = "redisTemplate")
    private HashOperations<String, String, Object> hashOperations; //以redis的hash类型存储java Object

    @Resource(name = "redisTemplate")
    private ListOperations<String, Object> listOperations; //以redis的list类型存储java Object

    @Resource(name = "redisTemplate")
    private SetOperations<String, Object> setOperations;   //以redis的set类型存储java Object

    @Resource(name = "redisTemplate")
    private ZSetOperations<String, Object> zSetOperations;  //以redis的zset类型存储java Object

    @Test
    public void testValueObj() {
        Person person = new Person("boke","byrant");
        person.setAddress(new Address("南京","中国"));
        //向redis数据库保存数据(key,value),数据有效期20秒
        valueOperations.set("player:1",person,20, TimeUnit.SECONDS); //20秒之后数据消失
        //根据key把数据取出来
        Person getBack = (Person)valueOperations.get("player:1");
        System.out.println(getBack);
    }

    @Test
    public void testSetOperation() {
        Person person = new Person("kobe","byrant");
        Person person2 = new Person("curry","stephen");

        setOperations.add("playerset",person,person2);  //向Set中添加数据项
        //members获取Redis Set中的所有记录
        Set<Object> result = setOperations.members("playerset");
        System.out.println(result);  //包含kobe和curry的数组
    }

    @Test
    public void HashOperations() {
        Person person = new Person("kobe","byrant");
        //使用hash的方法存储对象数据(一个属性一个属性的存,下节教大家简单的方法)
        hashOperations.put("hash:player","firstname",person.getFirstname());
        hashOperations.put("hash:player","lastname",person.getLastname());
        hashOperations.put("hash:player","address",person.getAddress());
        //取出一个对象的属性值,有没有办法一次将整个对象取出来?有,下节介绍
        String firstName = (String)hashOperations.get("hash:player","firstname");
        System.out.println(firstName);   //kobe
    }

    @Test
    public void  ListOperations() {
        //将数据对象放入队列
        listOperations.leftPush("list:player",new Person("kobe","byrant"));
        listOperations.leftPush("list:player",new Person("Jordan","Mikel"));
        listOperations.leftPush("list:player",new Person("curry","stephen"));
        //从左侧存,再从左侧取,所以取出来的数据是后放入的curry
        Person person = (Person) listOperations.leftPop("list:player");
        System.out.println(person); //curry对象
    }
}

redisTemplate详细用法参考文章

RedisTemplate操作Redis,这一篇文章就够了(一)

redis原生数据类型操作大全

使用Redis Repository操作数据

通过集成spring-boot-starter-data-redis之后一共有三种redis hash数据操作方式可以供我们选择

  • 一个属性、一个属性的存取
  • 使用Jackson2HashMapper存取对象
  • 使用RedisRepository的对象操作

一个属性、一个属性的存取

@Test
public void HashOperations() {
    Person person = new Person("kobe","byrant");
    person.setAddress(new Address("洛杉矶","美国"));
    //使用hash的方法存储对象数据(一个属性一个属性的存,下节教大家简单的方法)
    hashOperations.put("hash:player","firstname",person.getFirstname());
    hashOperations.put("hash:player","lastname",person.getLastname());
    hashOperations.put("hash:player","address",person.getAddress());

    String firstName = (String)hashOperations.get("hash:player","firstname");
    System.out.println(firstName);
}

数据在redis数据库里面存储结构是下面这样的

  • 一个hash代表一个对象的数据
  • 一个对象有多个属性key、value键值对数据,每一组键值对都可以单独存取

使用Jackson2HashMapper存取对象

上一小节我们操作hash对象的时候是一个属性一个属性设置的,那我们有没有办法将对象一次性hash入库呢?

我们可以使用jacksonHashOperationsJackson2HashMapper

import static org.junit.jupiter.api.Assertions.assertEquals;

@SpringBootTest
public class RedisConfigTest3 {

    @Resource(name="redisTemplate")
    private HashOperations<String, String, Object> jacksonHashOperations;
    //注意这里的false,下文会讲解
    private HashMapper<Object, String, Object> jackson2HashMapper = new Jackson2HashMapper(false);

    @Test
    public void testHashPutAll(){

        Person person = new Person("kobe","bryant");
        person.setId("kobe");
        person.setAddress(new Address("洛杉矶","美国"));

        //将对象以hash的形式放入redis数据库
        Map<String,Object> mappedHash = jackson2HashMapper.toHash(person);
        jacksonHashOperations.putAll("player:" + person.getId(), mappedHash);

        //将对象从数据库取出来
        Map<String,Object> loadedHash = jacksonHashOperations.entries("player:" + person.getId());
        Object map = jackson2HashMapper.fromHash(loadedHash);
        Person getback = new ObjectMapper().convertValue(map,Person.class);

        //Junit5,验证放进去的和取出来的数据一致
        assertEquals(person.getFirstname(),getback.getFirstname());
    }
}

使用这种方式可以一次性的存取java 对象为redis数据库的hash数据类型。需要注意的是:执行上面的测试用例,Person和Address一定要有public无参构造方法,在将map转换成Person或Address对象的时候用到,如果没有的话会报错。

  • new Jackson2HashMapper(false),注意属性对象Address的存储格式(两张图对比观察)

  • new Jackson2HashMapper(true),注意属性对象Address的存储格式(两张图对比观察)

需要注意的是:使用这种方法存储hash数据,需要多出一个键值对@class说明该hash数据对应的java类。在反序列化的时候会使用到,用于将hash数据转换成java对象。

使用RedisRepository的对象操作

下面为大家介绍使用RedisRepository进行redis数据操作,它不只是能简单的存取数据,还可以做很多的CURD操作。使用起来和我们用JPA进行关系型数据库的单表操作,几乎是一样的。

首先,我们需要在需要操作的java实体类上面加上@RedisHash注解,并使用@Id为该实体类指定id。是不是和JPA挺像的?

@RedisHash("people")   //注意这里的person,下文会说明
public class Person {
  @Id
  String id;
  
  //其他和上一节代码一样

}

然后写一个PersonRepository ,继承CrudRepository,是不是也和JPA差不多?

//泛型第二个参数是id的数据类型
public interface PersonRepository extends CrudRepository<Person, String> {
 // 继承CrudRepository,获取基本的CRUD操作
}

CrudRepository默认为我们提供了下面的这么多方法,我们直接调用就可以了。

在项目入口方法上加上注解@EnableRedisRepositories(笔者测试,在比较新的版本中这个注解已经不需要添加了,默认支持),然后进行
下面的测试

@SpringBootTest
public class RedisRepositoryTest {

    @Resource
    PersonRepository personRepository;

    @Test
    public void test(){

        Person rand = new Person("zimug", "汉神");
        rand.setAddress(new Address("杭州", "中国"));
        personRepository.save(rand);  //存

        Optional<Person> op = personRepository.findById(rand.getId()); //取
        Person p2 = op.get();

        personRepository.count(); //统计Person的数量
        personRepository.delete(rand);  //删除person对象rand

    }

}

测试结果:需要注意的是RedisRepository在存取对象数据的时候,实际上使用了redis的2种数据类型

  • 第一种是Set类型,用于保存每一个存入redis的对象(Person)的id。我们可以利用这个Set实现person对象集合类的操作,比如说:count()统计,统计redis数据库中一共保存了多少个person。注意:下图中set集合的key名称,就是通过上文代码中

@RedisHash("people")指定的。

  • 第二种是Hash类型,是用来保存Java对象的,id是RedisRepository帮我们生成的,这个id和上图中set集合中保存的id是一致的。

spring cache缓存基本用法

为什么要做缓存

  • 提升性能

绝大多数情况下,关系型数据库select查询是出现性能问题最大的地方。一方面,select 会有很多像 join、group、order、like 等这样丰富的语义,而这些语义是非常耗性能的;另一方面,大多数应用都是读多写少,所以加剧了慢查询的问题。

分布式系统中远程调用也会耗很多性能,因为有网络开销,会导致整体的响应时间下降。为了挽救这样的性能开销,在业务允许的情况(不需要太实时的数据)下,使用缓存是非常必要的事情。

  • 缓解数据库压力

当用户请求增多时,数据库的压力将大大增加,通过缓存能够大大降低数据库的压力。

常用缓存操作流程

使用缓存最关键的一点就是保证:缓存与数据库的数据一致性,该怎么去做?下图是一种最常用的缓存操作模式,来保证数据一致性。

  • 更新写数据:先把数据存到数据库中,然后再让缓存失效或更新。缓存操作失败,数据库事务回滚。
  • 删除写数据: 先从数据库里面删掉数据,再从缓存里面删掉。缓存操作失败,数据库事务回滚。
  • 查询读数据
    缓存命中:先去缓存 cache 中取数据,取到后返回结果。
    缓存失效:应用程序先从 cache 取数据,没有得到,则从数据库中取数据,成功后,在将数据放到缓存中。

如果上面的这些更新、删除、查询操作流程全都由程序员通过编码来完成的话

  • 因为加入缓存层,程序员的编码量大大增多
  • 缓存层代码和业务代码耦合,造成难以维护的问题。

整合Spring Cache

Spring cache相关注解

我们可以使用Spring cache解决上面遇到的两个问题,Spring cache通过注解的方式来操作缓存,一定程度上减少了程序员缓存操作代码编写量。注解添加和移除都很方便,不与业务代码耦合,容易维护。

第一步:pom.xml 添加 Spring Boot 的 jar 依赖:

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

第二步:添加入口启动类 @EnableCaching 注解开启 Caching,实例如下。

@EnableCaching

Spring Boot中通过@EnableCaching注解自动化配置合适的缓存管理器(CacheManager),Spring Boot根据下面的顺序去侦测缓存提供者,也就是说Spring Cache支持下面的这些缓存框架:

  • Generic
  • JCache (JSR-107) (EhCache 3, Hazelcast, Infinispan, and others)
  • EhCache 2.x
  • Hazelcast
  • Infinispan
  • Couchbase
  • Redis(因为我们之前引入了Redis,所以使用redis作为缓存)
  • Caffeine
  • Simple

在ArticleController类上实现一个简单的例子

下面的例子第一次访问走数据库(代码上断点断下来),第二次访问就走缓存了(不走函数代码)。可以自己下断点试一下。

@Cacheable(value="article")
@GetMapping( "/article/{id}")
public @ResponseBody  AjaxResponse getArticle(@PathVariable Long id) {

使用redis缓存,被缓存的对象(函数返回值)有几个非常需要注意的点:

  • 必须实现无参的构造函数
  • 需要实现Serializable 接口和定义serialVersionUID (因为缓存需要使用JDK的方式序列化和反序列化)。

更改Redis缓存的序列化方式

让缓存使用JDK默认的序列化和反序列化方式非常不友好,我们完全可以修改为使用JSON序列化与反序列化的方式,可读性更强,体积更小,速度更快

@Configuration
public class RedisConfig {
   //这个函数是上一节的内容
   @Bean
   public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
       RedisTemplate redisTemplate = new RedisTemplate();
       redisTemplate.setConnectionFactory(redisConnectionFactory);
       Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);

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

       jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

       //重点在这四行代码
       redisTemplate.setKeySerializer(new StringRedisSerializer());
       redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
       redisTemplate.setHashKeySerializer(new StringRedisSerializer());
       redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);

       redisTemplate.afterPropertiesSet();
       return redisTemplate;
   }

    //本节的重点配置,让Redis缓存的序列化方式使用redisTemplate.getValueSerializer()
   //不在使用JDK默认的序列化方式
   @Bean
   public RedisCacheManager redisCacheManager(RedisTemplate redisTemplate) {
       RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(redisTemplate.getConnectionFactory());
       RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
               .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisTemplate.getValueSerializer()));
       return new RedisCacheManager(redisCacheWriter, redisCacheConfiguration);
   }
}

详述缓存声明式注解的使用

缓存注解-增删改查

一定要把这张图理解的非常透彻,才能把缓存注解用好。

  • @Cacheable:针对查询方法配置,能够根据查询方法的请求参数对其结果进行缓存(完成上图中的蓝色连线箭头的缓存流程)
  • @CacheEvict:被注解的方法执行前或者执行之后,删除缓存(红色连线箭头3:让缓存失效、删除)
  • @CachePut:调用被注解的方法,对其返回结果进行缓存更新(红色连线箭头3:更新数据库后更新缓存)
  • @Caching:可以将上面三种注解,组合起来使用

单个对象的查询缓存

仍然以我们之前一直使用的ArtivleServiceImpl为例(包含增删改查方法),添加缓存注解。

@Cacheable注解的方法,在第一次被请求的时候执行方法体,并将方法的返回值放入缓存。在第二次请求的时候,由于缓存中已经包含该数据,将不执行被注解的方法的方法体,直接从缓存中获取数据。对于查询过程的缓存操作,要满足上图中的蓝色箭头线指引的操作流程,所有的操作流程只需要加上一个@Cacheable就可以实现。

public static final String CACHE_OBJECT = "article";  //缓存名称

@Cacheable(value = CACHE_OBJECT,key = "#id")   //这里的value和key参考下面的redis数据库截图理解
public ArticleVO getArticle(Long id) {
  return dozerMapper.map(articleMapper.selectById(id),ArticleVO.class);
}

需要注意的是:缓存注解的key是一个SPEL表达式“#id”表示获取函数的参数id的值作为缓存的key值。如果参数id=1,那么最终redis缓存的key就是:“article::1”。下图是redis缓存数据库中这条缓存记录的截图:

集合对象的查询缓存

大家要注意ObjectList<Object>是两种不同的业务数据,所以对应的缓存也是两种缓存。注意下文中,缓存注解的key是字符串list,因为缓存注解默认使用SPEL表达式,如果我们想使用字符串需要加上斜杠。

public static final String CACHE_LIST_KEY  = "\"list\"";

@Cacheable(value = CACHE_OBJECT,key = CACHE_LIST_KEY)
public List<ArticleVO> getAll() {
  List<Article> articles = articleMapper.selectList(null);
  return DozerUtils.mapList(articles,ArticleVO.class);
}

对于查询过程的缓存操作,要满足上图中的蓝色箭头线指引的操作流程,所有的操作流程只需要加上一个@Cacheable就可以实现。目前MySQL数据库的article表有4条数据,所以缓存结果是一个包含4个article元素的数组

删除单个对象及其缓存

如下面的代码所示,将在函数执行成功之后删除redis key为“article::1”的缓存(假设删除id=1的记录)。

@Override
@Caching(evict = {
        @CacheEvict(value = CACHE_OBJECT,key = CACHE_LIST_KEY),   //删除List集合缓存
        @CacheEvict(value = CACHE_OBJECT,key = "#id")  //删除单条记录缓存
})
public void deleteArticle(Long id) {
  articleMapper.deleteById(id);
}
  • 执行该方法传递参数id=1,先执行方法去操作删除MySQL数据;成功之后在《1.1.单个对象的查询缓存》缓存到redis中的key为“article::1”的缓存也将被删除。
  • 任何一个artilce记录被删除,都会引起article::list缓存与MySQL数据库记录不一致的情况,所以需要把article::list集合缓存也删除掉。
  • 因为Java 语法不允许在同一个方法上使用两个同样的注解@CacheEvict,所以我们用@Caching注解把两个@CacheEvict包起来。

新增一个对象

  • 新增MySQL数据的时候新增redis缓存么?不是的,缓存是在获得查询结果时候回写到缓存里面的,不在新增的时候加缓存。
  • 新增的时候删除缓存么“?是的,因为我们缓存了List的集合,一旦新增一条记录。原来MySQL数据库有4条记录,新增之后MySQL数据库有5条记录,redis缓存数据库缓存结果”article::list“仍然有4条记录。redis缓存中的数据与MYSQL数据库中的数据不一致,所以把”article::list“缓存删掉。
@CacheEvict(value = CACHE_OBJECT,key = CACHE_LIST_KEY)   //删除List集合缓存
public void saveArticle(ArticleVO article) {
  Article articlePO = dozerMapper.map(article, Article.class);
  articleMapper.insert(articlePO);
}

执行完成上面的方法,MySQL数据库新增了一条article记录;成功之后在《1.2.集合对象的查询缓存》缓存到redis中的key为“article::list”的缓存也将被删除。

更新一个对象

注意更新对象的时候,我们在该方法上面加了两个缓存注解。

  • 下文的CachePut注解的作用是在方法执行成功之后,将其返回值放入缓存。key ="#article.getId()"表示使用参数articleid属性作为缓存key
  • 下文的CacheEvict注解用于将“article::list”的缓存删除,因为某一条记录的数据更新,就表示原来缓存的List集合数据与MySQL数据库中的数据不一致,所以把它删除掉。缓存数据可以没有,但是不能和后端被缓存的关系数据库数据不一致。
@CachePut(value = CACHE_OBJECT,key = "#article.getId()")
@CacheEvict(value = CACHE_OBJECT,key = CACHE_LIST_KEY)
public ArticleVO updateArticle(ArticleVO article) {
  Article articlePO = dozerMapper.map(article,Article.class);
  articleMapper.updateById(articlePO);
  return article;  //为了保证一致性,最后返回的更新结果,最好从数据库去查
}

执行完成该方法,假如ArticleVO参数对象的id=1

  • MySQL数据库中的id=1的记录将被更新
  • redis数据库中”article::1“的记录也将被更新(CachePut)
  • redis数据库中”article::list“的记录将被删除(CacheEvict)

更新一个对象(另一种方法)

需要特别注意的是:如果在更新方法上使用CachePut注解,该方法一定要有数据更新之后返回值,因为返回值就是缓存值

比较简单的做法是直接将不一致的缓存删掉,而不是去更新缓存。

这样操作对于程序员的要求更低,不容易出错。

缓存数据可以没有,但是不能和后端被缓存的关系数据库数据不一致。

@Override
@Caching(evict = {
        @CacheEvict(value = CACHE_OBJECT,key = CACHE_LIST_KEY),   //删除List集合缓存
        @CacheEvict(value = CACHE_OBJECT,key = "#article.getId()")  //删除单条记录缓存
})
public void updateArticle(ArticleVO article) {
  Article articlePO = dozerMapper.map(article,Article.class);
  articleMapper.updateById(articlePO);
}

方法不需要有返回值。执行完成该方法,假如ArticleVO参数对象的id=1

  • MySQL数据库中的id=1的记录将被更新
  • redis数据库中”article::1“的记录将被删除
  • redis数据库中”article::list“的记录将被删除

缓存注解配置说明

@Cacheable 通常应用到读取数据的查询方法上:先从缓存中读取,如果没有再调用方法获取数据,然后把数据查询结果添加到缓存中。如果缓存中查找到数据,被注解的方法将不会执行。

@CachePut通常应用于修改方法配置,能够根据方法的请求参数对其注解的函数返回值进行缓存,和 @Cacheable 不同的是,它每次都会触发被注解方法的调用。

@CachEvict 通常应用于删除方法配置,能够根据一定的条件对缓存进行删除。可以清除一条或多条缓存。

在实际的生产环境中,没有一定之规,哪种注解必须用在哪种方法上,@CachEvict 注解通常也用于更新方法上。数据的缓存策略,要根据资源的使用方式,做出合理的缓存策略规划。保证缓存与业务数据库的数据一致性。并做好测试,对于缓存的正确使用,测试才是王道!

缓存key的书写及取值

相关文章

目录