关于Redis的学习

关于Redis的 学习

一、Redis的介绍

Redis是一个开源的使用ANSI C语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API。
Redis是一种键值型的NoSql数据库。
Redis可用作数据库,高速缓存和消息队列代理。

1.特性

  1. 速度快
  • 单节点读110000次/S,写81000/S
  • 数据存放在内存中
  • 用C语言实现,离操作系统更近
  • 单线程架构,6.0开始支持多线程(CPU、IO读写负荷)
  1. 持久化
  • 数据的更新将异步的保存到硬盘
  1. 简单稳定
  • 源码少、单线程模型
  1. 主从复制
  2. 多数据结构
  • 支持字符串、Hash、List、set、zset数据结构
  1. 支持多种编程语言
  2. 功能丰富
  • HyperLogLog(Redis版本高于2.8.9):基数统计算法,提供不精确的去重计数方案,可以统计 2^64 个元素。
  • GEO:用于存储地理位置信息,并进行操作。
  • 发布订阅:消息队列、消息管道。
  • Lua脚本:Redis将Lua作为脚本语言可帮助开发者定制自己的Redis命令。
  • 事务:redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。
  • Pipeline:流水线机制,将一组命令一次性传输服务器,在服务器端逐条执行,然后将结果按顺序返回。
  • BitMaps:单独提供一套命令,对字符串的为进行操作。
  1. Redis支持数据的备份与集群,并拥有哨兵监控机制

2.Redis数据类型

Redis支持五种数据类型:string(字符串),hash(哈希),list(列表),set(集合)及zset(sorted set:有序集合)。
还支持三种特殊的数据结构:Bitmap、HyperLogLog、GEO。

2.1 String(字符串)
  • String是Redis最基本的数据类型。最大能储存512MB。是二进制安全的(Redis的字符串可以包含任何数据)。
  • 命令:set(存),get(取)
  • 实例
redis 127.0.0.1:6379> SET name "张三"
OK
redis 127.0.0.1:6379> GET name
"张三"
2.2 Hash(哈希)
  • hash是一个键值对集合,每个hash可以存储2^32-1对键值对。是一个String类型的映射表,适用于存储对象。
  • 命令:HMSET(存),HGET(取)
  • 实例
redis 127.0.0.1:6379> HMSET user name "张三" age "22"
"OK"
redis 127.0.0.1:6379> HGET user name
"张三"
redis 127.0.0.1:6379> HGET user age
"22"
2.3 List(列表)
  • Redis列表是简单的字符串列表,按照插入顺序排序。列表最多可以存储2^32-1个元素。后续可以往列表的头部或者尾部添加元素。
  • 命令:lpush(往列表的头部插入值),lrange(获取指定区间的元素)
  • 实例
redis 127.0.0.1:6379> lpush mylist 张三
(integer) 1
redis 127.0.0.1:6379> lpush mylist 李四
(integer) 2
redis 127.0.0.1:6379> lrange mylist 0 10
1) "李四"
2) "张三"
2.4 Set(集合)
  • set是String类型的无序集合,且不可重复。列表最多可以存储2^32-1个元素。添加一个 string 元素到 key 对应的 set 集合中,成功返回 1,如果元素已经在集合中返回 0。
  • 命令:sadd(添加),smembers(获取)
  • 实例
redis 127.0.0.1:6379> sadd address "胜利路"
(integer) 1
redis 127.0.0.1:6379> sadd address "名族路"
(integer) 1
redis 127.0.0.1:6379> sadd address "名族路"
(integer) 0
redis 127.0.0.1:6379> smembers address 
1) "胜利路"
2) "名族路"
2.5 ZSet(有序集合)
  • zset是String类型的有序且不可重复的集合。与set不同的是每个元素都会关联一个double类型的分数。redis正是通过分数来为集合中的成员进行从小到大的排序。zset的成员是唯一的,但分数(score)却可以重复。
  • 命令:zadd (添加),ZRANGEBYSCORE(获取)
  • 实例
redis 127.0.0.1:6379> zadd shop 0 "超市"
(integer) 1
redis 127.0.0.1:6379> zadd shop 0 "早餐"
(integer) 1
redis 127.0.0.1:6379> zadd shop 0 "服装"
(integer) 1
redis 127.0.0.1:6379> zadd shop 0 "服装"
(integer) 0
redis 127.0.0.1:6379> ZRANGEBYSCORE shop 0 10
1) "超市"
2) "早餐"
3) "服装"

3. 基本命令

序号命令描述返回结果
1exists key检查key是否存在0/1
2expire key seconds为给定key设置过期时间(以秒计)0/1
3expireat key timestamp设置key的过期时间戳(以秒计)0/1
4pexpire key milliseconds设置key的过期时间(以毫秒计)0/1
5pexpireat key milliseconds-timestamp设置key过期时间的时间戳(以毫秒计)0/1
6persist key移除key的过期时间,key将持久保持0/1
7pttl key以毫秒为单位返回key的剩余的过期时间,没有过期时间的返回-1【毫秒】/-1
8ttl key以秒为单位返回key的剩余的过期时间,没有过期时间的返回-1【秒】/-1
9rename key newkey修改key的名称。如果新的名称已存在,则覆盖valueOK
10renamenx key newkey仅当newkey不存在时,将key改名为newkey0/1
11type key返回key对应的值的类型【数据类型】
12set key value设置指定key的值OK
13get key获取指定key的值value
14del key在key存在时删除key0/1
15getrange key start end返回key中字符串对应的子字符【指定位置的字符】
16getset key value将给定key的值设为value,并返回key的旧值【旧的值】
17mget key1 key2 …获取一个或多个key的值【对应key的值列表】
18setex key seconds value给key赋值并设置过期时间(秒为单位)OK
19setnx key value只有在key不存在时设置key的值0/1
20strlen key返回key对应值的长度【字符串长度】
21mset key1 value1 key2 value2 …同时设置多对key-valueOK
22msetnx key1 value1 key2 value2 …同时设置多对key-value,所有的key都不存在才会生效0/1
23psetex key milliseconds value给key赋值并设置过期时间(毫秒为单位)OK
24

二、Redis的应用场景

数据类型字符串使用场景
  • 缓存数据,提高查询性能。比如登录用户信息、电商种存储商品信息。
  • 可以做计数器,短信限流。
  • 共享session
数据类型Hash使用场景
  • 可用于对象存储。比如存储用户信息、商品信息。与字符串不同的是,字符串类型需要将对象进行序列化之后保存。
数据类型List使用场景
  • 热销榜,文档列表
  • 实现工作队列(消息队列)
  • 最新列表,比如最新评论
数据类型Set使用场景
  • 给用户添加标签
  • 给标签添加用户
数据类型ZSet使用场景
  • 排行榜单,积分排行榜、成绩表、优先级任务列表

三、Redis流程图

Redis执行命令

在这里插入图片描述

Pipeline命令执行流程

在这里插入图片描述

四、Redis的持久化

  • Redis是一个基于内存的数据库,它的数据是存放在内存中,内存有个问题就是关闭服务或者断电会丢失。Redis的数据也支持写到硬盘中,这个过程就叫做持久化。

持久化方式

  • Redis提供了2种不同形式的持久化方式:
    RDB(Redis DataBase) :简而言之,就是在指定的时间间隔内,定时的将 redis 存储的数据生成Snapshot快照并存储到磁盘等介质上;
    AOF(Append Of File) :将 redis 执行过的所有写指令记录下来,在下次 redis 重新启动时,只要把这些写指令从前到后再重复执行一遍,就可以实现数据恢复了。
  • 同时允许使用两种方式: 其实 RDB 和 AOF 两种方式也可以同时使用,在这种情况下,如果 redis 重启的话,则会优先采用 AOF 方式来进行数据恢复,这是因为 AOF 方式的数据恢复完整度更高。
  • 可以选择关闭持久化: 如果你没有数据持久化的需求,也完全可以关闭 RDB 和 AOF 方式,这样的话,redis 将变成一个纯内存数据库。
RDB
1. RDB快照原理
  • 在服务线上请求的同时,Redis 还需要进行内存快照,内存快照要求 Redis 必须进行文件 IO 操作,这意味着单线程同时在服务线上的请求还要进行文件 IO 操作,文件 IO 操作会严重拖垮服务器请求的性能。还有个重要的问题是为了不阻塞线上的业务,就需要边持久化边响应客户端请求。
  • fork多进程:Redis 在持久化时会调用 glibc 的函数 fork 产生一个子进程,快照持久化完全交给子进程来处理,父进程继续处理客户端请求。子进程刚刚产生时,它和父进程共享内存里面的代码段和数据段。这时你可以将父子进程想像成一个连体婴儿,共享身体。这是 Linux 操作系统的机制,为了节约内存资源,所以尽可能让它们共享起来。在进程分离的一瞬间,内存的增长几乎没有明显变化。
    在这里插入图片描述
2. RDB配置
  1. 指定备份文件的名称
    在redis.conf中,可以修改rdb备份文件的名称,默认为dump.rdb。
  2. 指定备份文件存放的目录
    在redis.conf中,rdb文件的保存的目录是可以修改的,默认为Redis启动命令所在的目录。
3. 触发RDB备份
  1. 自动备份
  • 可在redis.conf中配置自动备份的规则。
  • save用来配置备份的规则,save的格式:[save] [秒钟] [写操作次数]。
    在这里插入图片描述
  1. 手动执行备份命令
  • save:save时只管保存,其它不管,全部阻塞,手动保存,不建议使用。
  • bgsave:Redis会在后台异步进行快照操作,快照同时还可以响应客户端情况。
  • lastsave:获取最后一次生成快照的时间(时间戳)。
4. RDB备份恢复
  1. 先通过"config get dir"查询rdb文件的目录,这其实就是查的redis.conf文件当中设置的目录。
  2. 停止Redis服务。
  3. 拷贝迁移的Redis备份文件(dump.rdb)到查出的目录下。
  4. 重新启动Redis服务
5. RDB的优缺点
  1. 优点
  • 适合大规模数据恢复
  • 对数据完整性和一致性要求不高
  • 节省磁盘空间
  • 基于二进制存储的,恢复速度更快。
  1. 缺点
  • Fork的时候,内存中的数据会被克隆一份,占用的内存扩大近2倍。
  • 然Redis在fork的时候使用了写时拷贝技术,但是如果数据庞大时还是比较消耗性能。
  • 在备份周期在一定间隔时间做一次备份,所以如果Redis意外down的话,就会丢失最后一次快照后所有修改。
AOF
1. AOF原理
  • AOF日志存储的是Redis服务器的顺序指令序列,AOF日志只记录对内存进行修改的指令记录。
  • 假设 AOF 日志记录了自 Redis 实例创建以来所有的修改性指令序列,那么就可以通过对一个空的 Redis 实例顺序执行所有的指令,也就是「重放」,来恢复 Redis 当前实例的内存数据结构的状态。
  1. 写入机制
    Redis 在收到客户端修改命令后,先进行相应的校验,如果没问题,就立即将该命令存追加到 .aof 文件中,也就是先存到磁盘中,然后服务器再执行命令。这样就算遇到了突发的宕机情况情况,也只需将存储到 .aof 文件中的命令,进行一次“命令重演”就可以恢复到宕机前的状态。
  2. 写入缓存
    Redis 为了提升写入效率,它不会将内容直接写入到磁盘中,而是将其放到一个内存缓存区(buffer)中,等到缓存区被填满时采用异步真正将缓存区中的内容写入到磁盘里。这就意味着如果机器突然宕机,AOF 日志内容可能还没有来得及完全刷到磁盘中,这个时候就会出现日志丢失。
  • Always:服务器每写入一个命令,就调用一次 fsync函数,将缓冲区里面的命令写入到硬盘。这种模式下,服务器出现故障,也不会丢失任何已经成功执行的命令数据,但是其执行速度较慢;
  • Everysec(默认):服务器每一秒调用一次 fsync 函数,将缓冲区里面的命令写入到硬盘。这种模式下,服务器出现故障,最多只丢失一秒钟内的执行的命令数据,通常都使用它作为 AOF 配置策略;
  • No:服务器不主动调用 fsync 函数,由操作系统决定何时将缓冲区里面的命令写入到硬盘。这种模式下,服务器遭遇意外停机时,丢失命令的数量是不确定的,所以这种策略,不确定性较大,不安全。
    在这里插入图片描述
  1. 重写机制
  • Redis 在长期运行的过程中,aof 文件会越变越长。如果机器宕机重启,“重演”整个 aof 文件会非常耗时,导致长时间 Redis 无法对外提供服务。为了让 aof 文件的大小控制在合理的范围内,Redis 提供了 AOF 重写机制,手动执行BGREWRITEAOF命令,开始重写 aof 文件。
  • 通过 bgrewriteaof 操作后,服务器会生成一个新的 aof 文件,该文件具有以下特点:
1.新的 aof 文件记录的数据库数据和原 aof 文件记录的数据库数据完全一致;
2.新的 aof 文件会使用尽可能少的命令来记录数据库数据,因此新的 aof 文件的体积会小很多;
3.AOF 重写期间,服务器不会被阻塞,它可以正常处理客户端发送的命令;
4.即使 Bgrewriteaof 执行失败,也不会有任何数据丢失,因为旧的 AOF 文件在 Bgrewriteaof 成功之前不会被修改;
  1. 自动触发AOF重写
  • Redis提供了自动触发AOF重写功能,提供了相应的配置策略。修改Redis配置文件,让服务器自动执行重写命令。
    在这里插入图片描述
  • 该配置项表示:触发重写所需要的 aof 文件体积百分比,只有当 aof 文件的增量大于 100% 时才进行重写,也就是大一倍。比如,第一次重写时文件大小为 64M,那么第二次触发重写的体积为 128M,第三次重写为 256M,以此类推。如果将百分比值设置为 0 就表示关闭 AOF 自动重写功能。
  1. 执行流程
  • 客户端的请求写命令会被append追加到AOF缓冲区内
  • AOF缓冲区会根据AOF持久化策略[always,everysec,no]将操作sync同步到磁盘的AOF文件中
  • AOF文件大小超过重写策略或手动重写时,会对AOF文件进行重写(rewrite),压缩AOF文件容量
  • redis服务器重启时,会重新load加载AOF文件中的写操作达到数据恢复的目的
2. AOF配置

AOF默认不开启,可以在 redis.conf 文件中对AOF进行配置开启:

appendonly no # 是否开启AOF,yes:开启,no:不开启,默认为no
appendfilename "appendonly.aof" # aof文件名称,默认为appendonly.aof
dir ./ # aof文件所在目录,默认./,表示执行启动命令时所在的目录
3. AOF的备份恢复

AOF的备份机制和性能虽然和RDB不同,但是备份和恢复的操作同RDB一样,都是拷贝备份文件,需要恢复时再拷贝到Redis工作目录下,启动系统即加载。

  1. 正常恢复
  • 修改默认的appendonly no,改为yes
  • 将有数据的aof文件复制一份保存到对应的目录(查看目录:config get dir)
  • 恢复:重启redis然后重新加载
  1. 异常恢复
  • 修改默认的appendonly no,改为yes
  • 如遇到aof文件损坏,通过"redis-check-aof --fix appendonly.aof" 进行恢复,appendonly.aof是文件名
4. AOF优缺点
  1. 优点
  • 备份机制更稳健,丢失数据概率更低
  • 可读的日志文本,通过操作AOF文件,可以处理误操作
  1. 缺点
  • 比RDB占用更多的磁盘空间
  • 恢复备份速度要慢
  • 每次读写都同步的话,有一定的性能压力
  • 存在个别bug,造成不能恢复
AOF与RDB对比
对比

在这里插入图片描述

  • 官方推荐2个都启用。
  • 如果对数据不敏感,可以单独用RDB。
  • 不建议单独使用AOF,因为可能会出现BUG。
  • 如果只是做纯内存缓存,可以都不用。
官方建议
  • RDB持久化方式能够在指定的时间间隔对你的数据进行快照存储
  • AOF持久化方式记录每次对服务器写的操作,当服务器重启的时候会重新执行这些命令来恢复原始数据,AOF命令以redis协议追加保存每次写的操作到AOF文件末尾
  • Redis还能对AOF文件进行后台重写,使得AOF文件的体积不至于过大
  • 只做缓存:如果你只希望你的数据在服务器运行的时候存在,你也可以不使用任何持久化方式
  • 同时开启两种持久化方式:在这种情况下,当redis重启的时候会优先载入AOF文件来恢复原始的数据,因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整
  • RDB的数据不实时,同时使用两者时服务器重启也只会找AOF文件,那要是只用AOF呢?
    建议不要,因为RDB更适合用于备份数据库,快速重启,而且不会有AOF可能潜在的bug,留着作为一个万一的手段
  • 性能建议
    因为RDB文件只用作后备用途,建议只在Slave上持久化RDB文件,而且只要15分钟备份一次就够了,只保留 save 900 1 这一条
    如果使用AOF,好处是在最恶劣的情况下也只会丢失不超过两秒数据,启动脚本较简单只load自己的AOF文件就可以了
    AOF的代价,一是带来持续的IO,二是AOF rewrite的最后将rewrite过程中产生的新数据(aof_rewrite_buf)写到文件造成的阻塞几乎是不可避免的
    只要硬盘许可,应该尽量减少AOF rewrite的频率,AOF重写的基数大小默认值64M(autoaof-rewrite-min-size)太小了,可以设置到5G以上
    默认超过原大小100%(auto-aof-rewrite-percentage)大小时重写可以改到适当的数值。

通常 Redis 的主节点是不会进行持久化操作,持久化操作主要在从节点进行。从节点是备份节点,没有来自客户端请求的压力,它的操作系统资源往往比较充沛。
但是如果出现网络分区,从节点长期连不上主节点,就会出现数据不一致的问题,特别是在网络分区出现的情况下又不小心主节点宕机了,那么数据就会丢失,所以在生产环境要做好实时监控工作,保证网络畅通或者能快速修复。另外还应该再增加一个从节点以降低网络分区的概率,只要有一个从节点数据同步正常,数据也就不会轻易丢失。

Redis4.0混合持久化
  1. 混合持久化原理
  • 重启 Redis 时,我们很少使用 rdb 来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 rdb 来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。
  • Redis 4.0 为了解决这个问题,带来了一个新的持久化选项——混合持久化。将 rdb 文件的内容和增量的 AOF 日志文件存在一起。这里的 AOF 日志不再是全量的日志,而是自持久化开始到持久化结束的这段时间发生的增量 AOF 日志,通常这部分 AOF 日志很小。
  • 于是在 Redis 重启的时候,可以先加载 rdb 的内容,然后再重放增量AOF日志就可以完全替代之前的AOF全量文件重放,重启效率因此大幅得到提升。
    在这里插入图片描述
  1. 混合持久化配置
    在redis的配置文件当中有一个aof-use-rdb-preamble参数来开启 混合持久化,默认是yes开启的。混合持久化结合了 RDB 和 AOF 的优点,Redis 5.0 默认是开启的。
  2. 混合持久化优缺点
  • 优点:
    混合持久化结合了 RDB 和 AOF 持久化的优点,开头为 RDB 的格式,使得 Redis 可以更快的启动,同时结合 AOF的优点,有减低了大量数据丢失的风险。
  • 缺点:
    AOF 文件中添加了 RDB 格式的内容,使得 AOF 文件的可读性变得很差;
    兼容性差,如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本了。

五、Redis的主从复制(Master&Slave)

1. Redis的主从复制原理
1.1 常规Master->Slave
  • 主机数据更新后根据配置和策略,自动同步到备机的master/slaver机制,Master以写为主,Slave以读为主。一个master节点下面挂若干个slave节点,写操作将数据写到master节点上面去,然后在master写完之后,通过异步操作的方式将数据同步到所有的slave节点上面去,保证所有节点的数据是一致的。
    在这里插入图片描述
1.2 优化Master->Slave
  • 如果一个master节点下挂载太多个slave节点,会导致master在同步数据的时候耗时会很长,可在master节点和众多slave节点之间添加一个slave节点,由这个节点去同步数据给其它slave节点。但是因为多了一层数据同步,导致数据的延迟更高,出现数据不一致的概率更高。
    在这里插入图片描述
1.3 Slave->Master
  • slave发起数据同步可避免一个master节点给多个slave节点同步数据的系列问题,只需在每个slave中维护一个master的地址即可。
    在这里插入图片描述
  • 全量数据同步
  1. slave节点根据配置的master节点信息,连接上master节点,并向master节点发送SYNC命令;
  2. master节点收到SYNC命令后,执行BGSAVE命令异步将内存数据生成到RDB快照文件中,同时将生成RDB文件期间所有的写命令记录到一个缓冲区,保证数据同步的完整性;
  3. master节点的RDB快照文件生成完成后,将该RDB文件发送给slave节点;
  4. slave节点收到RDB快照文件后,丢弃所有内存中的旧数据,并将RDB文件中的数据载入到内存中;
  5. master节点将RDB快照文件发送完毕后,开始将缓冲区中的写命令发送给slave节点;
  6. slave节点完成RDB文件数据的载入后,开始执行接收到的写命令。
  • 增量数据同步
  1. 增量数据同步是指slave节点初始化完成后,master节点执行的写命令同步到slave节点的过程。master节点每执行一个写命令后就会将该命令发送给slave节点执行,从而达到数据同步的目的。
  2. 为防止复制过程中发生异常导致同步失败。需要在master节点和slave节点分别维护一个复制偏移量(offset),代表master向slave节点同步的字节数。master节点每次向slave节点发送N个字节后,master节点的offset增加N;slave节点每次接收到master节点发送过来的N个字节后,slave节点的offset增加N。
2. Redis主从复制介绍
1.1 复制特性
  • 一个主节点可以有多个从服务器。
  • 一个从服务器可以有自己的从服务器。
  • 复制功能不回阻塞主服务器。
  • 可以通过复制功能来让主服务器免于执行持久化操作,而由从服务器执行持久化操作。
  • 关闭主服务器持久化时,复制功能的数据是安全的。
  • 当配置Redis复制功能时,建议同事打开主服务器的持久化功能,否则由于延迟等问题应该要避免部署服务自动拉起。
1.2 Redis的策略
  • 首先尝试进行部分同步。若失败,要求从服务器进行全同步,并启动BGSAVE,结束后传输RDB文件;若成功,允许从服务器进行部分同步,并传输挤压空间中的数据。
  • 主从同步的机制:
1.从服务器向主服务器发送SYNC命令。
2.接到SYNC命令的主服务器会调用BGSAVE命令,创建一个RDB文件,并使用缓冲区记录接下来执行的所有命令。
3.当主服务器执行完BGSAVE命令时,它会向从服务器发送RDB文件,而从服务器会接受并载入这个文件。
4.从服务器将缓冲区储存的所有写的命令发送给从服务器执行。
1.3 Redis命令的传播
  • 在从服务器完成同步之后,主服务器每执行一个写命令,它都会将被执行的写命令发送给从服务器执行,这个操作被称为“命令传播”。
  • 客户端–>发送写命令–>主服务器 -->发送写命令–>从服务器
  • 命令传播是一个持续的过程,只要复制扔在继续,命令传播就会一直进行,使得主从服务器的状态可以一直保持一致。
1.4 Redis复制的一致性问题
  • 在读写分离环境下,客户端向主服务器发送写命令,主服务器在执行这个写命令后,向客户端回复的同时,会将这个写命令传播给从服务器。
  • 接到回复的客户端继续向从服务器发送读命令,但是可能因为网络状态的原因,客户端的读命令比主服务器传播的写命令更快的达到从服务器,或者写的命令丢失未发送到从服务器。从服务器执行读命令获取到不是正确的值。
1.5 Redis复制安全性提升
  • 从Redis2.8开始,为了保证数据的安全性,可以通过配置,让主服务器只在由至少N个当前已连接从服务器的情况下,才执行写命令。
  • 保证数据安全的两个配置文件参数:min-slaves-to-write / min-slaves-max-lag
    至少有一个从服务器数据复制和同步的延迟不能超过10秒,如果一旦所有的从服务器数据复制和同步的延迟都超过10秒时,主服务器就不会再接受任何请求。
  • 如果主服务器与从服务器断开连接,那么min-slaves-to-write / min-slaves-max-lag配置可以确保,如果不能继续给指定数量的从服务器发送数据,而且从服务器超过10秒没有给主服务器回复消息,则直接拒绝客户端的写请求,最多丢失10秒的数据。
3. 主从复制搭建
3.1 master与slave配置

有以下三种方式可触发主从复制流程:

  1. redis.conf 中配置 slaveof ;
  2. redis-server 命令启动服务时指定参数 --slaveof [masterip] [masterport];
  3. 对一个实例执行 slaveof [masterip] [masterport] 命令。
3.2 配置配置步骤
  • 配置Redis,复制Redis,生成两个从Redis,并修改配置文件redis.conf端口
  • 分别启动三个redis
  • 在两个从Redis连接指令中输入指令:slaveof 127.0.0.1 6379
  • 主Redis输入指令:info replication,命令行输出内容出现connected_slaves:2,表示两个从Redis都连上主Redis。在从Redis输入:info replication,命令行输出内容吹按master_link_status:up,表示从Redis与主Redis连接成功。
3.3 验证主从结构是否可用
  • 在master节点上执行命令:info replication,可以看到slave节点信息
  • 查看master与slave中数据是否为空
  • 在master节点中添加一条数据,查看slave节点是否有数据同步
  • 在slave节点上执行写命令会报错
4. Redis的哨兵监控机制
4.1 哨兵运行原理
  • 在哨兵模式架构中,client端在首次访问Redis服务时,实际上访问的是哨兵(sentinel),sentinel会将自己监控的Redis实例的master节点信息返回给client端,client后续就会直接访问Redis的master节点,并不是每次都从哨兵处获取master节点的信息。
  • 哨兵会实时监控所有的Redis实例是否可用,当监控到Redis的master节点发生故障后,会从剩余的slave节点中选举出一个作为新的master节点提供服务,并将新master节点的地址通知给client端,其他的slave节点会通过slaveof命令重新挂载到新的master节点下。当原来的master节点恢复后,也会作为slave节点挂在新的master节点下。
    在这里插入图片描述
4.2 哨兵监控
  • 哨兵通过在配置文件中配置 sentinel monitor 选项来指定要监控的redis master节点的地址,然后在启动哨兵时,会创建与redis master节点的连接并向master节点发送一个info命令,master节点在收到info命令后,会将自身节点的信息和自己下面所有的slave节点的信息返回给sentinel,哨兵收到反馈后,会与新的slave节点创建连接,接下来就会每隔10秒钟向所有的redis节点发送info命令来获取最新的redis主从结构信息。
  • 有了redis实例的主从信息后,哨兵就会以每秒钟一次的频率向所有redis实例发送一个PING命令,而且如果哨兵是集群部署的话,每个哨兵还会以同样的频率向其他哨兵实例发送PING命令。当redis实例和sentinel实例收到PING命令后,会向哨兵返回一个有效的回复:+PONG 、-LOADING 或者 -MASTERDOWN,若返回其他的回复,或者在指定时间内(sentinel down-after-milliseconds 选项配置)没有回复,那么哨兵认为实例的回复无效。如果实例在 sentinel down-after-milliseconds 时间内未返回过一次有效的回复,那该实例就会被sentinel标记为下线状态(主观下线/客观下线)。
4.3 自动切换主库流程
  • 主库挂了之后,哨兵需要从众多从库中,按照一定规则选择一个从库实例,把它作为新的主库。这一步完成后,集群里就会有新的主库了。
  • 在执行任务通知时,哨兵会把新主库的信息发送给其他所有从库,让他们执行slaveof命令,和新主库建立好连接,并且进行数据复制。同时,哨兵会把新主库连接信息给客户端,让他把新的请求发送到新主库上。
4.4 主从切换机制
  • 哨兵选择新主库的过程可以称为“筛选+打分”。就是在多个实例从库中,按照一定的筛选条件,把不符合条件的从库去掉。然后在按照一定的规则,给剩下的从库逐个打分,将得分最高的从库选为新主库。
  1. 筛选条件
  • 判断从库当前的状态和之前的网络连接状态。如果之前从库跟主库断联次数超过了阈值,那么有理由相信这个从库的网络连接状态不是很好,可以把它筛选掉。虽然现在它在运行,但随时肯能会断掉,又需要重新选主库,所以需要判断它之前的状态。
  • 使用配置项down-after-milliseconds*10。其中,down-after-milliseconds是我们认定是从库断连的最大时间。如果在down-after-milliseconds毫秒内,主从节点都没与网络连接上,那么就认为从库断联了。如果断联时间超过了10次就认为,这个从库的网络转态不是很好,不适合做主库。
  1. 打分规则
    可以分别按照三个规则进行打分:从库优先级、从库复制进度、以及从库ID号。
  • 优先级slave-priority高的从库得分高:可以手动设置从库的优先级,优先级高的直接成为新的主库,如果优先级相同,则进行下一项判断。
  • 与原主库数据同步程度:主从库同步时有个命令传播的过程,在这个过程中,主库会用master-repl-offset记录,当前的最新写操作在repl-backlog-buffer中的位置,而从库会用slave-repl-offset记录复制的进度。得分就高,就会被选为新主库。如果slave-repl-offset相同,就会进行下一轮的打分。
  • ID号小的从库得分高:每个实例都会有一个id,这个ID类似于从库的编号。Redis选主时,有一个规定:在优先级和复制进度相同的情况下,ID越小的得分越高。
5. 哨兵集群

一旦多个实例组成了哨兵集群,即使有哨兵实例出现故障挂掉了,其他哨兵还能继续协作完成主库切换的工作。

5.1 基于发布/订阅机制的哨兵集群的组成

哨兵能被互相发现的原因:Redis提供的pub/sub机制(发布/订阅机制)

  1. 哨兵之间互相被发现过程
  • 哨兵只要跟主库建立起连接,就可以在主库上发布信息,发布自己的连接信息(IP和端口号)。同时也可以从主库上订阅信息,获取其它哨兵发布的连接信息。所有连接到主库的哨兵都彼此知道连接信息。
  • 不同应用程序的哨兵通过频道(主题)进行分组,同一组的哨兵就形成了集群。

在这里插入图片描述

6. Redis 哨兵配置

哨兵配置参考https://www.jb51.net/database/293782myv.htm

在redis安装目录下,除了有redis本身的一个配置文件外,还有一个sentinel.conf,该文件就是sentinel的配置文件。在该文件中,主要有以下几个配置:

  • port:sentinel的端口,默认为26379;
  • daemonize:是否后台启动,yes表示以后台方式启动运行sentinel,默认为no;
  • logfile:sentinel日志文件存放路径;
  • sentinel monitor :sentinel监控的master节点的名称、地址和端口号,最后一个quorums表示至少需要多少个sentinel判定master节点故障才进行故障转移。一般配置为sentinel数量/2+1。
  • sentinel down-after-milliseconds :sentinel向其他实例发送PING命令后到获得响应的超时时间,单位为毫秒;
  • sentinel failover-timeout :sentinel在对master进行故障转移时的超时时间,单位毫秒;
  • sentinel parallel-syncs :在执行故障转移时, 最多可以有多少个从服务器同时对新的主服务器进行同步,这个数字越小,完成故障转移所需的时间就越长;
  • sentinel auth-pass :如果master节点设置了密码,则需要在这里配置master节点的密码,否则sentinel无法连接master进行监控。
7. Redis sentinel启动
  • 在所有redis服务器上分别执行命令:./bin/redis-sentinel sentinel.conf

六、Redis的分布式锁

6.1 单线程的Redis为什么需要分布式锁

  • 虽然一个redis是单进程单线程模式,但请求并不是一定按先后顺序处理的,多个请求会被redis交叉着执行,对于check and set这种操作,可能在check之后被其余的线程抢去了执行权,之后在set就会出现问题。
    (就像单个cpu,在一个时间点只能执行一个命令,为什么多个线程执行的时候需要考虑线程安全的问题,因为程序执行的时候往往是一段代码,并不具有原子性,所以在执行一个命令后,就可能被其他的线程抢去执行权,那么就会造成线程安全的问题)

6.2 java如何实现Redis分布式锁

  1. 初始代码
public class IndexController {
    @Autowired
    private Redisson redisson;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/deduct_stock")
    public String deductStock() throws InterruptedException {
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); //相当于 jedis.get("stock")
        if (stock > 0) {
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock",realStock + ""); //相当于 jedis.set(key,value)
            System.out.println("扣减成功,剩余库存:" + realStock + "");
        } else {
            System.out.println("扣减失败,库存不足");
                }
        return "end";
    }
}

但是当多个线程同时要访问deductStock()函数的时候,会存在超卖的现象。
主要因为某一个线程正好在get和set之间访问deductStock()函数,获取的还是原来的值。

  1. 利用setnx加锁
public class IndexController {
    @Autowired
    private Redisson redisson;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/deduct_stock")
    public String deductStock() throws InterruptedException {
        String lockKey = "protuct_001";
        // 如果lockKey没值则设为name,返回设置结果true/false
        Boolean result = stringRedisTemplate.opsForValue().selfAbsent(lockKey, "name");
        if (!result) {
            return "error";
        }
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); 
        if (stock > 0) {
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock",realStock + ""); 
            System.out.println("扣减成功,剩余库存:" + realStock + "");
        } else {
            System.out.println("扣减失败,库存不足");
        }
        stringRedisTemplate.delete(lockKey);
        return "end";
    }
}

当程序未执行delete()释放锁就出现了异常,该锁会一直存在,不会删除,造成死锁。

  1. 使用try-catch来避免死锁
public class IndexController {
    @Autowired
    private Redisson redisson;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/deduct_stock")
    public String deductStock() throws InterruptedException {
        String lockKey = "protuct_001";
        try {
            Boolean result = stringRedisTemplate.opsForValue().selfAbsent(lockKey, "name");
            if (!result) {
                return "error";
            }
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            if (stock > 0) {
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock",realStock + "");
                System.out.println("扣减成功,剩余库存:" + realStock + "");
            } else {
                System.out.println("扣减失败,库存不足");
            }
        } finally {
            //释放锁
            stringRedisTemplate.delete(lockKey);
        }
        return "end";
    }
}

虽然在代码块中出现异常能释放锁,但是在执行finally之前程序出现异常,仍然没有释放锁。

  1. 给锁加上超时时间
public class IndexController {
    @Autowired
    private Redisson redisson;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/deduct_stock")
    public String deductStock() throws InterruptedException {
        String lockKey = "protuct_001";
        try {
             Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "name", 10, TimeUnit.SECONDS);
            if (!result) {
                return "error";
            }
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            if (stock > 0) {
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock",realStock + "");
                System.out.println("扣减成功,剩余库存:" + realStock + "");
            } else {
                System.out.println("扣减失败,库存不足");
            }
        } finally {
            //释放锁
            stringRedisTemplate.delete(lockKey);
        }
        return "end";
    }
}

当程序A执行时间超过了锁的失效时间,锁被释放。此时程序B进入,获取到锁,而程序A执行了释放锁的操作。程序C又可以进来获取锁,从而产生混乱。

  1. 只能让获取锁的线程释放锁
public class IndexController {
    @Autowired
    private Redisson redisson;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/deduct_stock")
    public String deductStock() throws InterruptedException {
        String lockKey = "protuct_001";
        String clientId = UUID.randomUUID().toString();
        try {
            //将两行代码封装成一个原子块代码
            Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 10, TimeUnit.SECONDS);
            if (!result) {
                return "error";
            }
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            if (stock > 0) {
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock",realStock + "");
                System.out.println("扣减成功,剩余库存:" + realStock + "");
            } else {
                System.out.println("扣减失败,库存不足");
            }
        } finally {
            //判断是否是设置锁的线程
            if (clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {  
                //释放锁
                stringRedisTemplate.delete(lockKey);
            }
        }
        return "end";
    }
}

有A1和A2两个请求,假如A1在执行完了上图的if代码后,锁因为超过过期时间而被释放了,这时A2获取到锁执行业务逻辑,生成了自己的uuid,在执行的过程中,A1又接着往下进行,尽管此时两者uuid已经不同,但是由于A1已经进行过if判断,所以可以直接删除掉A2的锁。

  1. 使用Lua脚本的原子性
public class IndexController {
    @Autowired
    private Redisson redisson;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/deduct_stock")
    public String deductStock() throws InterruptedException {
        String lockKey = "protuct_001";
        String clientId = UUID.randomUUID().toString();
        try {
            //将两行代码封装成一个原子块代码
            Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 10, TimeUnit.SECONDS);
            if (!result) {
                return "error";
            }
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            if (stock > 0) {
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock",realStock + "");
                System.out.println("扣减成功,剩余库存:" + realStock + "");
            } else {
                System.out.println("扣减失败,库存不足");
            }
        } finally {
           //在极端情况下仍然会误删除锁
            //因此使用lua脚本的方式来防止误删除
            String script = "if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
                    "then\n" +
                    "    return redis.call(\"del\",KEYS[1])\n" +
                    "else\n" +
                    "    return 0\n" +
                    "end";
            DefaultRedisScript defaultRedisScript = new DefaultRedisScript();
            defaultRedisScript.setScriptText(script);
            defaultRedisScript.setResultType(Long.class);
            redisTemplate.execute(defaultRedisScript, Arrays.asList(lockKey), uuid);
        }
        return "end";
    }
}

但是当代码块执行的时间大于锁的失效时间,仍然存在其余线程在锁失效代码块未执行完之间进入。

  1. 设置守护线程,延长锁的失效时间
  • redisson依赖
<dependency>
   <groupId>org.redisson</groupId>
   <artifactId>redisson</artifactId>
</dependency>
public class IndexController {
    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/deduct_stock")
    public String deductStock() throws InterruptedException {
        String lockKey = "protuct_001";
        RLock redisLock = redissonClient.getLock(lockKey);
        try {
            //设置锁
            redisLock.lock(10, TimeUnit.SECONDS);
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            if (stock > 0) {
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock",realStock + "");
                System.out.println("扣减成功,剩余库存:" + realStock + "");
            } else {
                System.out.println("扣减失败,库存不足");
            }
        } finally {
            //释放锁
            redisLock.unlock();
        }
        return "end";
    }
}

七、Redis的应用

spring + redis

  • Spring-data-redis是spring大家族的一部分,提供了在srping应用中通过简单的配置访问redis服务,对reids底层开发包进行了高度封装。
  • Jedis是Redis官方推出的一款面向Java的客户端,提供了很多接口供Java语言调用。
  • RedisTemplate提供了redis各种操作,异常处理及序列化,支持发布订阅,并对spring 3.1 cache进行了实现。

Redis引用

1. pom.xml依赖
 <!--springboot-Redis-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    
    <!--Redis-->
    <dependency>
           <groupId>org.springframework.data</groupId>
           <artifactId>spring-data-redis</artifactId>
           <version>2.0.6.RELEASE</version>
    </dependency>
    <!--jedis-->
    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
        <version>2.9.0</version>
    </dependency>
2. 配置文件
  	# Redis服务器连接端口
    spring.redis.port=6379
    # Redis服务器地址
    spring.redis.host=127.0.0.1
    # Redis数据库索引(默认为0)
    spring.redis.database=0
    # Redis服务器连接密码(默认为空)
    spring.redis.password=
    # 连接池最大连接数(使用负值表示没有限制)
    spring.redis.jedis.pool.max-active=8
    # 连接池最大阻塞等待时间(使用负值表示没有限制)
    spring.redis.jedis.pool.max-wait=-1ms
    # 连接池中的最大空闲连接
    spring.redis.jedis.pool.max-idle=8
    # 连接池中的最小空闲连接
    spring.redis.jedis.pool.min-idle=0
    # 连接超时时间(毫秒)
    spring.redis.timeout=5000ms
3. spring-data-redis针对jedis提供的功能
  1. 连接池自动管理,提供了一个高度封装的“RedisTemplate”类。
  2. 针对jedis客户端中大量api进行了归类封装,将同一类型操作封装为operation接口。
  3. 提供了对key的“bound”(绑定)便捷化操作API,可以通过bound封装指定的key,然后进行一系列的操作而无须“显式”的再次指定Key。
  4. 将事务操作封装,有容器控制。
  5. 针对数据的“序列化/反序列化”,提供了多种可选择策略(RedisSerializer)。

RedisTemplate

  • RedisTemplate 是 Spring Boot 访问 Redis 的核心组件,底层通过 RedisConnectionFactory 对多种 Redis 驱动进行集成,上层通过 XXXOperations 提供丰富的 API ,并结合 Spring4 基于泛型的 bean 注入,极大的提供了便利,成为日常开发的一大利器。
  • RedisTemplate 继承自 RedisAccessor(主要完成 RedisConnectionFactory 的管理,并对其进行非空校验) , 实现 RedisOperations(对 Redis 操作进行封装) 和 BeanClassLoaderAware(主要完成 ClassLoader 的注入) 两个接口。
1. 对象
  1. RedisTemplate 对 Redis 数据结构操作的一种高级 API
调用返回值类型说明
redisTemplate.opsForValue()ValueOperations操作String类型数据
redisTemplate.opsForHash()HashOperations操作Hash类型数据
redisTemplate.opsForList()ListOperations操作List类型数据
redisTemplate.opsForSet()SetOperations操作Set类型数据
redisTemplate.opsForZSet()ZSetOperations操作SortedSet类型数据
redisTemplate通用
  1. 绑定了指定key的操作对象
  • BoundValueOperations:String类型
  • BoundHashOperations:hash类型
  • BoundSetOperations:set类型
  • BoundListOperations:list类型
  • BoundZSetOperations:zset类型
2. Redis描述
2.1 Redis的过期机制

Redis中的过期机制是通过设置key的过期时间来实现。当设置了过期时间的key到达过期时间时,Redis会自动将这个key删除。

3. 使用
3.1 设置
    1. String类型设置key
//1.通过BoundValueOperations设置值
BoundValueOperations stringKey = redisTemplate.boundValueOps("Key");
stringKey.set("Vaule");
//2.通过ValueOperations设置值、过期时间
ValueOperations ops = redisTemplate.opsForValue();
ops.set("Key", "Vaule");
    1. hash类型设置key
//1.通过redisTemplate设置值
redisTemplate.boundHashOps("HashKey").put("Key", "Vaue");
//2.通过BoundValueOperations设置值
BoundHashOperations hashKey = redisTemplate.boundHashOps("HashKey");
hashKey.put("Key", "Vaue");
//3.通过ValueOperations设置值
HashOperations hashOps = redisTemplate.opsForHash();
hashOps.put("HashKey", "Key", "Vaue");
    1. set设置key
BoundSetOperations setKey = redisTemplate.boundSetOps("setKey");
setKey.add("setValue1", "setValue2", "setValue3");
SetOperations setOps = redisTemplate.opsForSet();
setOps.add("setKey", "SetValue1", "setValue2", "setValue3");
    1. list设置key的值
ArrayList<String> list = new ArrayList<>();
redisTemplate.boundListOps("listKey").rightPushAll(list);
redisTemplate.boundListOps("listKey").leftPushAll(list);
    1. list向key中添加元素
redisTemplate.boundListOps("listKey").leftPush("listLeftValue1");
redisTemplate.boundListOps("listKey").rightPush("listRightValue2");

redisTemplate.opsForList().leftPush("listKey", "listLeftValue3");
redisTemplate.opsForList().rightPush("listKey", "listRightValue4");
    1. zset添加元素
// value相同,分数不同,value会被覆盖
redisTemplate.boundZSetOps("zSetKey").add("Vaule", 1D);
redisTemplate.opsForZSet().add("zSetKey", "Vaule", 1D);
    1. zset插入多个元素,并设置分数
DefaultTypedTuple<String> p1 = new DefaultTypedTuple<>("Vaule1", 2D);
DefaultTypedTuple<String> p2 = new DefaultTypedTuple<>("Vaule2", 3D);
redisTemplate.boundZSetOps("zSetKey" add(new HashSet<>(Arrays.asList(p1,p2)));
    1. String/hash/set设置key的失效时间
//指定key的失效时间(key,失效时长,时间单位)
redisTemplate.expire("Key",1,TimeUnit.MINUTES);
redisTemplate.boundValueOps("key").expire(1,TimeUnit.MINUTES);
    1. String设置key的值和失效时间
BoundValueOperations stringKey = redisTemplate.boundValueOps("StringKey");
stringKey.set("StringValue",1, TimeUnit.MINUTES);

ValueOperations ops = redisTemplate.opsForValue();
ops.set("StringKey","StringVaule",1, TimeUnit.MINUTES);
    1. 获取key的过期时间
//根据key获取过期时间(添加单位,则根据单位返回数值)
Long expire1 = redisTemplate.getExpire("key");
Long expire2 = redisTemplate.getExpire("key",TimeUnit.MINUTES);
3.2 获取
    1. String获取值
String str1 = (String) redisTemplate.boundValueOps("StringKey").get();   
String str2 = (String) redisTemplate.opsForValue().get("StringKey");
    1. hash获取子集的key
Set keys1 = redisTemplate.boundHashOps("HashKey").keys();
Set keys2 = redisTemplate.opsForHash().keys("HashKey");
    1. hash获取key对应的所有的值
List values1 = redisTemplate.boundHashOps("HashKey").values();
List values2 = redisTemplate.opsForHash().values("HashKey");
    1. hash获取子集对应key的值
String value1 = (String) redisTemplate.boundHashOps("HashKey").get("SmallKey");
String value2 = (String) redisTemplate.opsForHash().get("HashKey", "SmallKey");
    1. hash获取key子集的所有键值对集合
Map<Object, Object> entries = redisTemplate.boundHashOps("HashKey").entries();
    1. set获取key所有值
Set set1 = redisTemplate.boundSetOps("setKey").members();
Set set2 = redisTemplate.opsForSet().members("setKey");
    1. list获取指定区域子集
List listKey = redisTemplate.boundListOps("listKey").range(0, 10); 
    1. list获取首尾元素
//最左侧元素
String listKey1 = (String) redisTemplate.boundListOps("listKey").leftPop(); 
//最右侧元素 
String listKey2 = (String) redisTemplate.boundListOps("listKey").rightPop();
    1. list获取指定位置的元素
String listKey = (String) redisTemplate.boundListOps("listKey").index(1);
    1. zset获取指定区间的元素,-1为获取所有
Set<String> range = redisTemplate.boundZSetOps("zSetKey").range(0, -1);
3.3 删除
    1. String/hash/set刪除key
// 删除单个key
Boolean result = redisTemplate.delete("key");
// 删除多个key
String[] arr = {"key1","key2","key3"};
redisTemplate.delete(arr);
    1. hash删除key中子元素的key
//删除子集元素key
redisTemplate.boundHashOps("HashKey").delete("Key");
    1. set删除key中的指定元素
Long result = redisTemplate.boundSetOps("setKey").remove("Value");
    1. list删除N个值为value的元素
redisTemplate.boundListOps("listKey").remove(N,"value");
    1. zset删除指定元素
redisTemplate.boundZSetOps("zSetKey").remove("Vaule");
    1. zset删除指定索引返回的元素(Long类型)
redisTemplate.boundZSetOps("zSetKey").removeRange(0L,3L);
    1. zset删除指定分数范围的元素(Double类型)
redisTemplate.boundZSetOps("zSetKey").removeRangeByScorssse(0D,2.2D);
3.4 其它
    1. 判断key是否存在
//判断key是否存在
boolean result = redisTemplate.hasKey("key");
    1. hash判断子集中是否含有指定key
Boolean isEmpty = redisTemplate.boundHashOps("HashKey").hasKey("Key");
    1. set判断key的值中是否存在指定的值
Boolean isEmpty = redisTemplate.boundSetOps("setKey"). isMember("Value");
    1. set获取key的值长度
Long size = redisTemplate.boundSetOps("setKey").size();
    1. list获取key值的长度
Long size = redisTemplate.boundListOps("listKey").size();
    1. list更改某子元素的值(通过索引【index】从首位添加)
redisTemplate.boundListOps("listKey").set(index,"listLeftValue");
    1. zset获取指定元素的分数
Double score = redisTemplate.boundZSetOps("zSetKey").score("Vaule");
    1. zset获取子元素的个数
Long size = redisTemplate.boundZSetOps("zSetKey").size();
    1. zset获取指定分数范围的成员个数
// 分数范围从小到大
Set byScore = redisTemplate.boundZSetOps("zSetKey").rangeByScore(1D, 2.2D);
    1. zset给指定元素加分
Double score = redisTemplate.boundZSetOps("zSetKey").incrementScore("Vaule",1.1D);
4. 拓展
4.1 RedisTemplate+HyperLogLog
  1. 介绍
  • RedisTemplate.opsForHyperLogLog()是RedisTemplate类提供的用于操作HyperLogLog类型的方法。HyperLogLog是一种基数估算算法,用于统计集合中元素的数量。它可以用于对Redis中的HyperLogLog数据结构进行各种操作,如添加元素、获取基数估计值等。
  1. 用例
  • add:向HyperLogLog中添加一个或多个元素
redisTemplate.opsForHyperLogLog().add("key", "element1", "element2", "element3");
  • size:获取给定HyperLogLog的基数估计值
Long size = redisTemplate.opsForHyperLogLog().size("key");
  • addAll:将多个HyperLogLog合并为一个
redisTemplate.opsForHyperLogLog().addAll("key1", "key2", "key3");
  • union:计算多个HyperLogLog的并集,并返回基数估计值
Long unionSize = redisTemplate.opsForHyperLogLog().union("key1", "key2", "key3");
  • delete:删除指定的HyperLogLog
redisTemplate.opsForHyperLogLog().delete("key");
4.2 RedisTemplate+Geo
  1. 介绍
    RedisTemplate.opsForGeo()是RedisTemplate类提供的用于操作Geo类型(地理位置)的方法。它可以用于对Redis中的Geo数据结构进行各种操作,如添加地理位置、获取距离、获取位置信息等。
  2. 样例
  • add:添加一个或多个地理位置到指定的Geo键中
redisTemplate.opsForGeo().add("key", new Point(116.397128, 39.916527), "Beijing");
redisTemplate.opsForGeo().add("key", new Point(121.472641, 31.231707), "Shanghai");
redisTemplate.opsForGeo().add("key", new Point(113.264435, 23.129163), "Guangzhou");
  • position:获取指定成员的地理位置
Point position = redisTemplate.opsForGeo().position("key", "Beijing");
  • distance:计算两个成员之间的距离(默认以米为单位)
Distance distance = redisTemplate.opsForGeo().distance("key", "Beijing", "Shanghai");
double distanceInKm = distance.getValue() / 1000;
  • hash:获取指定成员的Geohash值
String geohash = redisTemplate.opsForGeo().hash("key", "Beijing");
  • radius:根据给定的中心点,返回与中心点距离在指定范围内的成员(按距离由近到远排序)
Circle circle = new Circle(new Point(116.123456,39.654321),new Distance(200,Metrics.KILOMETERS));
GeoResults<GeoLocation<Object>> geoResults = redisTemplate.opsForGeo().radius("key", circle);
  • radiusByMember:根据给定的成员,返回与该成员距离在指定范围内的其他成员(按距离由近到远排序)
GeoResults<GeoLocation<Object>> geoResults = redisTemplate.opsForGeo().radiusByMember("key", "Beijing", new Distance(200, Metrics.KILOMETERS));
  • remove:从指定的Geo键中移除一个或多个成员
Long removedMembers = redisTemplate.opsForGeo().remove("key", "Beijing", "Shanghai");
4.3 RedisTemplate + 发布订阅
  1. 介绍
  • 发布订阅模式就是一种生产者消费者模式,Publisher负责生产消息,而Subscriber则负责消费它所订阅的消息。
  1. 样例
// 发送消息
redisTemplate.convertAndSend("topic", "message");

// 接收消息
RedisConnection redisConnection = redisTemplate.getConnectionFactory().getConnection();
redisConnection.subscribe((message, bytes) -> {
    // 收到消息的处理逻辑
    log.info("Receive message : " + message);
    // 下面这里需要配置接收的CHANNEL名称
}, "topic".getBytes(StandardCharsets.UTF_8));
4.4 RedisTemplate + Lua
  1. 介绍
  • Redis从2.6版本开始引入对Lua脚本的支持,通过在服务器中嵌入Lua环境,Redis客户端可以使用Lua脚本,直接在服务端原子的执行多个Redis命令。
  1. 优点
  • 减少网络开销:多次的网络请求的操作,可以用一个请求完成,原先多次请求的逻辑放在redis服务器上完成。使用脚本,减少了网络往返时延。
  • 原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。
  • 复用:客户端发送的脚本会永久存储在Redis中,意味着其他客户端可以复用这一脚本而不需要使用代码完成同样的逻辑。
  1. 样例
Long result = null;
try {
    //调用lua脚本并执行
    DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
    redisScript.setResultType(Long.class);//返回类型是Long
    //lua文件存放在resources目录下的redis文件夹内(Lua脚本返回的结果数据类型为Long类型)
    redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("redis/redis_lock4.lua")));
    // lua_key:key   100、300:参数 
    result = redisTemplate.execute(redisScript, Arrays.asList("lua_key"), 100, 300);
    System.out.println("lock==" + result);
} catch (Exception e) {
    e.printStackTrace();
}
4.5 RedisTemplate + 事务
  1. 介绍
  • redis从输入Multi命令开始,输入的命令都会依次进入命令队列中,但不会执行,直到输入了执行过程Exec后,redis会将之前的命令队列中的命令依次执行,组队的过程中可以通过discard放弃组队。
  1. CMD指令
  • 执行事务
    在这里插入图片描述
  • 取消事务
    在这里插入图片描述
  1. 样例
	@Autowired
	RedisTemplate redisTemplate;
	@ApiOperation(value = "multi测试接口", notes = "redis事务测试接口")
    @RequestMapping(value = "/multi", method = RequestMethod.GET)
    @ResponseBody
    public Map<String, Object> testmulti() {
        redisManager.setStr("key", "小兔");
        List list = (List) redisTemplate.execute((RedisOperations res) -> {
            //设置监控key,在exec执行前如果这个key对应的值,发生了变化,事务bu执行
            //通常监控的key可以是ID,也可以是一个对象
            res.watch("key");
            // 其实watch可以注释掉,或者设置成不监控
            res.unwatch();
            //开启事务,在exec执行前
            res.multi();
            res.opsForValue().increment("key", 1);
            res.opsForValue().set("key2", "小兔1");
            Object value2 = res.opsForValue().get("key2");
            System.out.println("命令在队列,所以取值为空" + value2 + "----");
            res.opsForValue().set("key3", "小兔3");
            Object value3 = res.opsForValue().get("key3");
            System.out.println("命令在队列,所以取值为空" + value3 + "----");
            return res.exec();
        });
        System.out.println(list);
        Map<String, Object> map = new HashMap<>();
        map.put("success", true);
        return map;
    }
4.6 RedisTemplate + Pipeline
  1. 介绍
  • 可以一次性发送多条命令并在执行完后一次性将结果返回,pipeline 通过减少客户端与 redis 的通信次数来实现降低往返延时时间,而且 Pipeline 实现的原理是队列,而队列的原理是时先进先出,这样就保证数据的顺序性。
  1. 样例
@Test
public void testRedisPipeline() {
    List<Object> resultList = customRedisTemplate.executePipelined(
            new RedisCallback<Object>() {
                @Override
                public Object doInRedis(RedisConnection connection) throws DataAccessException{
                    // 1、通过connection打开pipeline
                    connection.openPipeline();
                    // 2、给本次pipeline添加一次性要执行的多条命令
                    // 2.1、一个 set key value 的操作
                    byte[] key = "name".getBytes();
                    byte[] value = "qinyi".getBytes();
                    connection.set(key, value);
                    // 2.2、执行一个错误的命令
                    connection.lPop("xyzabc".getBytes());
                    // 2.3、mset 操作
                    Map<byte[], byte[]> tuple = new HashMap<>();
                    tuple.put("id".getBytes(), "1".getBytes());
                    tuple.put("age".getBytes(), "19".getBytes());
                    connection.mSet(tuple);
                    /**
                     * 1、不能关闭pipeline
                     * 2、返回值为null
                     */
                    // 3. 关闭 pipeline
                    // connection.closePipeline();
                    return null;
                }
            }
    );
    resultList.forEach(System.out::println);
}

// 控制台输出
true
null
true
4.7 RedisTemplate + BitMaps
  1. 介绍
  • Redis的Bitmaps这个“数据结构”可以实现对位的操作。Bitmaps本身不是一种数据结构,实际上就是字符串,但是它可以对字符串的位进行操作
  • 可把Bitmaps想象成一个以位为单位数组,数组中的每个单元只能存0或者1,数组的下标在bitmaps中叫做偏移量
  • 单个bitmaps的最大长度是512MB,即2^32个比特位。bitmaps的最大优势是节省存储空间。比如在一个以自增id代表不同用户的系统中,我们只需要512MB空间就可以记录40亿用户的某个单一信息,相比mysql节省了大量的空间
  • 有两种类型的位操作:一类是对特定bit位的操作,比如设置/获取某个特定比特位的值。另一类是批量bit位操作,例如在给定范围内统计为1的比特位个数
  1. 常用命令
  • setbit(binary类型查看)
setbit student:20240116 1 1
"0"
setbit student:20240116 2 1
"0"
setbit student:20240116 3 1
"0"
  • getbit(获取bitmap的值,指定offset下标,有则返回1,没有则返回0,不存在的下标也返回0。)
getbit student:20240116 1
"1"
getbit student:20240116 2
"1"
getbit student:20240116 4
"0"
  1. 样例
// ****** controller层 ******
   /**
     * 签到,可以补签
     *
     * @param access_token
     * @param date
     * @return
     */
    @PostMapping
    public ResultInfo sign(String access_token,@RequestParam(required = false) String date) {
        int count = signService.doSign(access_token, date);
        return ResultInfoUtil.buildSuccess(request.getServletPath(), count);
    }

//**** service层(signService) *****
 	/**
     * 用户签到
     *
     * @param accessToken
     * @param dateStr
     * @return
     */
    public int doSign(String accessToken, String dateStr) {
        // 获取登录用户信息
        SignInUserInfo userInfo = loadSignInUserInfo(accessToken);
        // 获取日期
        Date date = getDate(dateStr);
        // 获取日期对应的天数,多少号( 从 0 开始,0就代表1号)
        int offset = DateUtil.dayOfMonth(date) - 1;
        // 构建 Key user:sign:5:yyyyMM
        String signKey = buildSignKey(userInfo.getId(), date);
        // 查看是否已签到
        boolean isSigned = redisTemplate.opsForValue().getBit(signKey, offset);
        AssertUtil.isTrue(isSigned, "当前日期已完成签到,无需再签");
        // 签到
        redisTemplate.opsForValue().setBit(signKey, offset, true);
        // 统计连续签到的次数
        int count = getContinuousSignCount(userInfo.getId(), date);
        return count;
    }

    /**
     * 统计连续签到的次数
     *
     * @param userId
     * @param date
     * @return
     */
    private int getContinuousSignCount(Integer userId, Date date) {
        // 获取日期对应的天数,多少号,假设是 30
        int dayOfMonth = DateUtil.dayOfMonth(date);
        // 构建 Key
        String signKey = buildSignKey(userId, date);
        // bitfield user:sign:5:202212 u30 0
        BitFieldSubCommands bitFieldSubCommands = BitFieldSubCommands.create()
                .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
                .valueAt(0);
        List<Long> list = redisTemplate.opsForValue().bitField(signKey, bitFieldSubCommands);
        if (list == null || list.isEmpty()) {
            return 0;
        }
        int signCount = 0;
        long v = list.get(0) == null ? 0 : list.get(0);
        // i 表示位移操作次数
        for (int i = dayOfMonth; i > 0; i--) {
            // 右移再左移,如果等于自己说明最低位是 0,表示未签到
            if (v >> 1 << 1 == v) {
                // 低位 0 且非当天说明连续签到中断了
                if (i != dayOfMonth) {
                    break;
                }
            } else {
                signCount++;
            }
            // 右移一位并重新赋值,相当于把最低位丢弃一位
            v >>= 1;
        }
        return signCount;
    }

    /**
     * 构建 Key -- user:sign:5:yyyyMM
     *
     * @param userId
     * @param date
     * @return
     */
    private String buildSignKey(Integer userId, Date date) {
        return String.format("user:sign:%d:%s", userId,
                DateUtil.format(date, "yyyyMM"));
    }

    /**
     * 获取日期
     *
     * @param dateStr
     * @return
     */
    private Date getDate(String dateStr) {
        if (StrUtil.isBlank(dateStr)) {
            return new Date();
        }
        try {
            return DateUtil.parseDate(dateStr);
        } catch (Exception e) {
            throw new ParameterException("请传入yyyy-MM-dd的日期格式");
        }
    }

    /**
     * 获取登录用户信息
     *
     * @param accessToken
     * @return
     */
    private SignInUserInfo loadSignInUserInfo(String accessToken) {
        // 必须登录
        AssertUtil.mustLogin(accessToken);
        String url = oauthServerName + "user/me?access_token={accessToken}";
        ResultInfo resultInfo = restTemplate.getForObject(url, ResultInfo.class, accessToken);
        if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {
            throw new ParameterException(resultInfo.getCode(), resultInfo.getMessage());
        }
        SignInUserInfo userInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),
                new SignInUserInfo(), false);
        if (userInfo == null) {
            throw new ParameterException(ApiConstant.NO_LOGIN_CODE, ApiConstant.NO_LOGIN_MESSAGE);
        }
        return userInfo;
    }
  1. 更详细使用

StringRedisTemplate

  • StringRedisTemplate继承自RedisTemplate。是 RedisTemplate 在 key 和 value 都是 String 类型的一种泛化。
  • 通过继承 RedisTemplate<String, String>,指定 key 和 value 都为 String 类型。
  • 将 key、value、 hashKey、hashValue 的序列化器统一设置为 RedisSerializer.string()。
优缺点
优点

相对于RedisTemplate,为了在反序列化时知道对象的类型,JSON序列化器会将类的class类型写入json结果中,存入Redis,会带来额外的内存开销。StringRedisTemplate则节省了此处内存开销。

缺点

采用String的序列化器,需要手动序列化和反序列化。

常用方法
  • 检查key是否存在,返回boolean值
    stringRedisTemplate.hasKey(String key)
  • 根据key获取缓存中value
    stringRedisTemplate.opsForValue().get(Object key)
  • 截取key键对应缓存中的字符串,从开始下标位置开始到结束下标的位置(包含结束下标)
    stringRedisTemplate.opsForValue().get(String key,long start,long end)
  • 向缓存中存入key值对应的value数据
    stringRedisTemplate.opsForValue().set(String key,String value)
  • 覆盖从指定位置开始的值
    stringRedisTemplate.opsForValue().set(String key,String value,long offset)
  • 向缓存中存入数据,并指定过期时间,默认毫秒
    stringRedisTemplate.opsForValue().set(String key,String value,Duration timeout)
  • 向缓存中存入数据 ,并指定过期时间,可指定过期时间的单位,如TimeUnit.MINUTES ,表示过期时间单位是分钟
    stringRedisTemplate.opsForValue().set(String key,String value,long timeout,TimeUnit unit)
  • 向缓存中存入数据,以map形式存储
    stringRedisTemplate.opsForHash().putAll(String key,Map<??>m)
  • 向缓存中存入数据
    stringRedisTemplate.opsForHash().put(String key,Object hashkey,Object value)
  • 删除缓存
    stringRedisTemplate.delete(String key,Object …hashkeys)
  • 以增量形式存储
    stringRedisTemplate.opsForValue().increment(String key)
  • 以增量的方式将long值存储在变量中,相当于一个计数器 (计算+)
    stringRedisTemplate.opsForValue().increment(String key,long delta)
  • 以增量的方式将double值存储在变量中,相当于一个计数器计算 (计算+)
    stringRedisTemplate.opsForValue().increment(String key,double delta)
  • 获取key对应的map
    stringRedisTemplate.opsForHash().entries(String key)
  • 设置过期时间
    stringRedisTemplate.expire(String key,Duration timeout)
    stringRedisTemplate.expire(String key,long timeout,TimeUnit unit)

redis的缓存击穿、穿透、雪崩、宕机

缓存击穿
  1. 原因
  • key过期。某key失效后,大量的数据请求压在数据库中,导致数据库崩了。
  • key被页面置换淘汰。
  1. 解决方案
  • 过期时间+随机数。对于热点数据不设置过期时间,或者对过期时间再加一个随机数(偏移量,避免同一时间的大量key失效,从而导致某一时间段的请求量暴增,导致数据库崩了)。
  • 预热。预先把热门的数据提前存入redis中,并设置热门数据的过期时间为最大值。
  • 使用锁。当发现缓存失效的时候,不是立即从数据库加载数据,而是先尝试获取分布式锁,只有获取锁成功才执行数据库的操作。
  1. 流程图
    在这里插入图片描述
  2. 结合缓存穿透的实例
/**
 * 1. 查询指定key的值,判断值是否为有效数据
 * 2. 存在且为有效的数据,直接返回;
 * 3. 存在但值为"",返回null;
 * 4. 不存在,获取互斥锁。获取失败,睡眠一段时间,重试;
 * 4. 获取成功,查询数据库相应的数据,判断是否找到对应数据。
 * 5. 未找到,向Redis缓存中添加key值为""。
 * 6. 查询到数据,更行Redis中key数据。
 */
        public Shop lockRedisTest(Long id){
        // 缓存中的key
        String key = "" +id;
        // 获取到的value
        String valueJson = stringRedisTemplate.opsForValue().get(key);
        // 判断是否存在
        if(null != valueJson){
            return JSONUtil.toBean(valueJson,Shop.class);
        }
        // key存在,但是值为"",表示数据库中也不存在该数据,直接返回null
        if("".equals(valueJson)){
        	return null;
        }
       // 获取互斥锁
        String lockKey = "lock_" + id;
        Shop shop = null;
        try {
            boolean isLock = getLock(lockKey);
            // 判断是否获取到锁
            if(!isLock){
                // 获取失败,则休眠一段时间
                Thread.sleep(50);
                // 递归重试
                return lockRedisTest(id);
            }
            /* 查询数据库的操作 */
            shop = getShopInfo(id);
            if(null == shop){
                // 如果数据库中不存在该数据,则向Redis中添加值为空的key
                stringRedisTemplate.opsForValue().set(key,"",60*24,TimeUnit.MINUTES);
                return null;
            }else{
                // 如果查询到数据,则将数据更新到key中
                stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),60*24,TimeUnit.MINUTES);
                return shop;
            }
        }catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            unLock(lockKey);
        }
        return shop;
    }
    // 查询数据库的方法,返回查询的结果
    public Shop getShopInfo(Long id){
    	/*
    	查询数据库的代码块
    	*/
        return null;
    }
   public boolean getLock(String lockKey){
    	/*
    	获取互斥锁的代码块
    	*/
        return true;
   }
   public void unLock(String lockKey){
    	/*
    	释放互斥锁的代码块
    	*/
   }
缓存穿透
  1. 原因
  • 缓存穿透的主要原因是很多请求都在访问数据库中不存在的数据。未能从Redis缓存中找到数据,从而流转到数据库并返回空的查询结果。导致每次请求都要访问缓存和数据库。
  1. 解决方案
  • 缓存空值。当请求的数据不在Redis中并且数据库中也不存在的时候,设置一个缺省值,当后续再次进行查询时则直接返回空值或者缺省值。
  • 对访问请求加一层过滤器。在数据写入数据库的同时将id同步到过滤器中,当请求的id不在过滤器中则说明数据库中不存在该查询数据,则不用再去查询数据库。
  • 添加一些参数校验,对参数的真实性进行校验等。比如请求的id=-1,直接跳过该请求。
  1. 实例
/**
 * 1. 查询指定key的值,判断值是否为有效数据
 * 2. 存在且为有效的数据,直接返回;
 * 3. 存在但值为"",返回null;
 * 4. 不存在,查询数据库相应的数据,判断是否找到对应数据。
 * 5. 未找到,向Redis缓存中添加key值为""。
 * 6. 查询到数据,更行Redis中key数据。
 */
    public Shop redisTest(Long id){
        // 缓存中的key
        String key = "shop_" +id;
        // 获取到的value
        String valueJson = stringRedisTemplate.opsForValue().get(key);
        // 判断是否为有效的值
        if(null != valueJson){
            return JSONUtil.toBean(valueJson,Shop.class);
        }else{
            // 如果key存在,但不是有效数据
            if("".equals(valueJson)){
                return null;
            }else {
                /* 查询数据库的操作 */
                Shop shop = getShopInfo(id);
                if(null == shop){
                    // 如果数据库中不存在该数据,则向Redis中添加值为空的key
                    stringRedisTemplate.opsForValue().set(key,"",60*24,TimeUnit.MINUTES);
                    return null;
                }else{
                    // 如果查询到数据,则将数据更新到key中
                    stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),60*24,TimeUnit.MINUTES);
                    return shop;
                }
            }
        }
    }
    // 查询数据库的方法,返回查询的结果
    public Shop getShopInfo(Long id){
    	/*
    	查询数据库的代码块
    	*/
        return null;
    }
缓存雪崩
  1. 原因
  • 缓存雪崩是大量的热点key在一瞬间失效,从而使大量的请求流向数据库。缓存雪崩与缓存击穿类似,不同的是缓存雪崩是发生在大量数据同时失效的场景,缓存击穿是某一个热点数据失效的场景。
  1. 解决方案
  • 过期时间添加随机值。避免同一时间的大量key失效,从而导致某一时间段的请求量暴增。
  • 接口限流。控制一定时间内的请求数。当访问的不是核心数据时,在查询的方法上加上接口限流保护或者直接返回错误信息,不在去访问数据库。如果访问的是核心数据接口,当缓存不存在是允许获得多资源的请求从数据库中查询并设置到缓存中(核心接口未获取到缓存信息,则加锁查数据库,并设置到缓存中)。
Redis故障宕机
  1. 原因
    一个Redis实例能支撑10万的QPS(10W/S),而一个数据库实例只有1000的QPS。一旦Redis宕机,会导致大量的请求转到数据库,从而造成缓存雪崩。
  2. 解决方案
  • 服务熔断和接口限流(已经发生缓存雪崩后的降低影响的方案)。在缓存数据获取异常时,直接返回错误的数据给前端,而不是访问数据库。
  • 构建高可用的缓存集群系统(Redis哨兵集群、Redis Cluster集群等)。在主节点故障宕机时,从节点升级成主节点,继续提供缓存服务。
  • 28
    点赞
  • 36
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值