面经-Redis

背诵目录

 1.说说常见的Redis的数据结构?对应的常用命令有哪些?对应的底层是如何实现的?

①、字符串string                        ②、列表List                        ③集合Set                         ④hash表                 ⑤有序集合zset

        -命令                                   -命令                                   -命令                                 -命令                        -命令 

        -底层数据结构                    -底层数据结构                     -底层数据结构                   -底层数据结构          -底层数据结构

                -优点

2.说说Redis的持久化机制(两种)

 问题汇总

1.说说常见的Redis的数据结构?对应的常用命令有哪些?对应的底层是如何实现的?

①字符串String

常用命令:set <key><value>添加键值对

                  setex                   添加键值对的同时,设置过期时间

                  setnx                   当key 不存在时,添加成功

                  mset                    批量添加

                  get/mget             查询/批量查询

                  incr/decr             自增/自减(默认步长是1)

                   incrby / decrby <key><步长>将 key 中储存的数字值增减。自定义步长。

底层原理:String 的数据结构为简单动态字符串(Simple Dynamic String,缩写 SDS)。是可以 修改的字符串,内部结构实现上类似于 Java 的 ArrayList,采用预分配冗余空间的方式 来减少内存的频繁分配。SDS包含三个属性,char数组buf,记录数字已使用的字节数量len,数组未使用字节数量free

struct sdshdr{ 
  int len;//记录buf数组中已使用字节的数量 
  int free; //记录 buf 数组中未使用字节的数量 
  char buf[];//字符数组,用于保存字符串
}

SDS的优点:

1.二进制安全。

C语言字符串的特点是遇零则止,所以当读一个字符串的时候,只要遇到'\0',就认为到达了末尾。如果保存的是图片或视频等二进制文件,就会被强行截断,那么数据就不完整了。

SDS不是这么判断结束的,是通过len与buf[]数组的长度比较,如果len+1等于buf的长度,就说明这个字符串读完了

2.获取字符串长度的操作-直接读取len的值,其时间复杂度为O(1)。

3.杜绝缓存区溢出

因为SDS表头的free成员记录着buf字符数据中未使用的数量,所以,在进行append命令的时候,先判断free是否够用,如果够用,就直接添加字符,如果不够用,就先进行内存扩展,再进行添加字符串。

②列表List

单键多值 Redis 列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头 部(左边)或者尾部(右边)。

常用命令:lpush(左插入)、lrange(查询指定范围元素)、rpush(右插入)、lpop(左移除)、rpop(右移除)、lindex(查询指定下标元素)、llen(获取列表长度)

底层原理:List 的底层快速链表 quickList。快速链表是压缩列表+双链表,包括表头和多个使用双向指针串起来的ziplist

表头几个重要的属性是头指针,尾指针和compress,compress表示从两端开始,有多少个节点不做LZF压缩,默认是0,全不压缩

压缩列表的特点就是根据每个节点的实际存储的内容决定内存的大小,不会造成空间浪费

③集合(Set)

常用命令:sadd <key><value1><value2><value3>(批量添加)、smembers <key>(查看所有元素)、sdiff(差集)、sinter(交集)、sunion(并集)操作

sismember <key><value>(判断是否存在指定的k v)、scard <key>(查看长度)、srem(移除指定元素)

 底层原理:集合采用了整数集合和字典两种方式来实现的,当满足如下两个条件的时候,采用整数集合实现;一旦有一个条件不满足时则采用字典来实现。

  • Set 集合中的所有元素都为整数

  • Set 集合中的元素个数不大于 512(默认 512,可以通过修改 set-max-intset-entries 配置调整集合大小)

④哈希(Hash)

常用命令:hset(添加hash)、hget(查询)、hgetall(查询所有)、hdel(删除hash中指定的值)、hlen(获取hash的长度)、hexists(判断key是否存在)操作

底层原理:hash的底层主要是采用字典dict的结构,主要的属性就是数组结构体dictht和rehashidx渐进式hash标记,

dictht包括两个数组,一个是存放数据的,另一个和rehashidx用于扩容。并且在扩容的时候,可以随时停止,对外服务,rehashidx其实是一个标志量,如果为-1说明当前没有扩容,如果不为-1则表示当前扩容到哪个下标位置,方便下次进行从该下标位置继续扩容

⑤有序集合 Zset

zset 与普通集合 set 非常相似,是一个没有重复元素的字符串集合。 不同之处是有序集合的每个成员都关联了一个评分(score),这个评分(score)被用 来按照从最低分到最高分的方式排序集合中的成员。集合的成员是唯一的,但是评分 可以是重复了

常用命令:zadd <key><score1><value1><key><score2><value2>(添加)、zrange <key><start><stop>[WITHSCORES](查询)带 WITHSCORES,可以让分数(score)一起和值返回到结果集。

底层原理:底层是跳跃表

跳跃表由三部分组成   表头、管理所有节点层数level的数组、数据节点

跳跃表的查询流程:从最高层向右找,知道碰到比自己分数大的元素,然后换到下一层继续向右,一直到最下面那一层

2.说说Redis的持久化机制

持久化就是把内存中的数据持久化到本地磁盘,防止服务器宕机了内存数据丢失。Redis 提供两种持久化机制 RDB(默认) 和 AOF 机制

①RDB

什么是RDB:RDB是Redis默认的持久化方式。在指定的时间间隔内将内存中的数据集快照写入磁盘

RDB过程:Redis 会单独创建一个子进程(fork)来进行持久化,会先将数据写入到 一个临时文件 中,待持久化过程都结束了,再用这个临时文件替换上次持久化好的文件。

②AOF

什么是AOF:将 Redis 执行过的所有写指令记录下 来(读操作不记录), 只许追加文件但不可以改写文件,redis 启动之初会读取该文件重 新构建数据

AOF过程:

(1)客户端的请求写命令会被 append 追加到 AOF 缓冲区内;

(2)AOF 缓冲区根据 AOF 持久化策略[always,everysec,no]将操作 sync 同步到磁盘的 AOF 文件中;

(3)AOF 文件大小超过重写策略或手动重写时,会对 AOF 文件 rewrite 重写,压缩 AOF 文件容量

(4)Redis 服务重启时,如果AOF文件异常,需要异常恢复。之后会重新 load 加载 AOF 文件中的写操作达到数据恢复的目的;

关于AOF注意点(详见下面)

两种持久化机制对比

RBD:

优势:适合大规模的数据恢复、对数据完整性和一致性要求不高 、节省磁盘空间,恢复速度快

缺点:数据安全性低,RDB 是间隔一段时间进行持久化,如果持久化之间 redis 发生故障,会发生数据丢失。   

AOF:

优点:

1)备份机制更稳健,丢失数据概率更低。

2)通过 append 模式写文件,即使中途服务器宕机,可以通过 redis-check-aof 工具解决数据一致性问题。

缺点:

1)AOF 文件比 RDB 文件大,且恢复速度慢。

2)数据集大的时候,比 rdb 启动效率低

3.对MySQL修改时,应该如何对Redis做修改?会出现什么问题?

修改缓存有两种模式:双写模式和失效模式

双写模式:修改数据库之后,同时修改缓存中的数据

缺点:假如此时有两个线程修改数据,线程1,修改了数据库的数据,还没来得及修改缓存,线程2就已经修改完数据库并且修改缓存,会出现脏数据

解决方法:①加锁,使得修改数据库和更新缓存整个流程执行完,后面的线程才能修改数据库和更新缓存

                ②如果业务允许出现暂时性的数据不一致,可以给缓存添加过期时间,等过了过期时间,缓存自动删除,重写读取数据库数据后,两者的数据就一致了

失效模式:更新完数据库之后,删除缓存

问题:线程A正在读取缓存,发现缓存没有,去数据库读取,还没来得及更新缓存,线程B,更新数据库数据数据,然后立马删除缓存,线程A更新缓存,此时缓存中的数据还是B修改前的数据

解决方法:这是读写的并发问题,当然我们可以加读写锁来解决。但是如果要求实时性,一致性高,可以不放入缓存,直接操作数据库

我们系统的—致性解决方案:
1、缓存的所有数据都有过期时间,数据过期下一次查询触发主动更新

2、读写数据的时候,加上分布式的读写锁。经常写,经常读,就直接读取数据库

但是缓存一致性是满足最终一致性的,无论怎么设计,最终的数据都是一致的

 4、「内存淘汰策略」

LRU,LFU,RANDOM

LFU 最近最不常用的,根据数据访问次数来淘汰数据的,它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。

所以, LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。

LRU最近最少使用

如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很小。也就是说,当限定的空间已存满数据时,应当把最久没有被访问到的数据淘汰。

 5、缓存击穿

一个 key,某一时间,要么过期了,要么 LRU 清除,然后,就突然有大量并发来访问它。查缓存没查到,大量并发请求打到数据库上

解决方法:后来我使用分布式锁,假设会有 1w 个并发来访问一个 key,那么它们就会先查询 redis,如果发现,这个 key 不存在;它们就会对应的,往 redis 用 setnx 设置一个 key并添加过期时间,来表示这是一把锁,然后,只有一个线程,会设置成功,然后去读取数据库,写回 redis;其他的 9999 个线程,则 sleep 一小会,然后再去访问我们的 redis。

但是假如我们设置过期时间为20s,但是查询数据库要用了30s.线程A开始查询数据库,20s过去了,锁自动释放,线程B拿到锁开始查询数据库,过了10s后线程A的查询结束,执行删除锁的逻辑 ; 这样就会删除线程B的锁,然后线程C就会拿到锁。

解决方法:在加锁了之后,由于锁会有过期时间,然而又不能保证,锁一定不会在执行结束过后过期,那么,我们就可以采用多线程的方案,让锁每隔一定时间,就重新设置它的超时时间。

6.缓存雪崩

大面积的 key 同时过期,导致大量并发打到我们的数据库

解决方法:设置随机过期时间

不过也有特殊情况就是,如果你的业务对必须每天的指定时间,去更新我们的数据。就比如游戏每日零点更新,或者财报记录……等等等等。

就是缓存必须在指定的时间全部失效。

解决方法:既然 redis 无法分散过期时间,那么,我们去查数据的时候,是不是可以把时间稍微地分散一下?

就是在客户端设置随机延迟时间,这样,查询的操作,就被分散了开来;少量的请求,先查询,就会读数据库,然后存入 redis;其他请求,由于随机时间,稍稍慢了点,就可以去 redis 读出数据。

7.缓存穿透

请求去查询数据库根本不存在的数据

解决方法:布隆过滤器

布隆过滤器底层是一个大型位数组(二进制数组)+多个无偏hash函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。

多个无偏hash函数:无偏hash函数就是能把元素的hash值计算的比较均匀的hash函数,能使得计算后的元素下标比较均匀的映射到位数组中。

往布隆过滤器增加元素:添加的key需要根据k个无偏hash函数计算得到k个hash值,然后对数组长度进行取模得到数组下标的位置,然后将对应数组下标的k个位置的值置为1

查询元素:

通过k个无偏hash函数计算得到k个hash值,依次取模数组长度,得到数组索引,判断索引处的值是否全部为1,如果全部为1则存在(这种存在可能是误判),如果存在一个0则必定不存在

布隆过滤器的优点:

        时间复杂度低,增加和查询元素的时间复杂为O(N),(N为哈希函数的个数,通常情况比较小)

        存储空间小,布隆过滤器是非常节省空间的(相比其他数据结构如Set集合)
        保密性强,布隆过滤器不存储元素本身

布隆过滤器的缺点:

        有点一定的误判率,但是可以通过调整参数来降低
        无法获取元素本身
        很难删除元素

关于误判:其实非常好理解,hash函数在怎么好,也无法完全避免hash冲突,也就是说可能会存在多个元素计算的hash值是相同的,那么它们取模数组长度后的到的数组索引也是相同的,这就是误判的原因。

8. 主从模式

在主从模式下,只从复制保证多台服务器的数据一致性,且主从服务器之间采用的是「读写分离」的方式

步骤/原理:

①通过从服务器发送到PSYNC命令给主服务器

②如果是首次连接,触发一次全量复制。此时主服务器会启动一个后台线程,生成 RDB 快照文件

③主服务器会将这个 RDB 发送给从服务器,从服务器会先写入本地磁盘,再从本地磁盘加载到内存中

④主服务器会将此过程中的写命令写入缓存,从服务器实时同步这些数据

⑤如果网络断开了连接,自动重连后主服务器通过命令传播增量复制给从服务器部分缺少的数据

 9.哨兵模式

由一个或多个sentinel实例组成sentinel集群可以监视一个或多个主服务器和多个从服务器

1、优点
①哨兵集群,基于主从复制模式 ,所有的主从配置优点,它全有
②主从可以切换,故障可以转移 ,系统的 可用性 就会更好
③哨兵模式就是主从模式的升级,手动到自动,更加健壮!
2、缺点
①Redis 不好在线扩容 的,集群容量一旦到达上限,在线扩容就十分麻烦!
②实现哨兵模式的配置其实是很 麻烦 的,里面有很多选择!

主从复制:写一定是在主服务器上,然后主服务器同步给从服务器。缺点:当主服务器挂掉的时候,不能自动切换到从服务器上。主从服务器存储数据一样,内存可用性差。优点:在一定程度上分担主服务器读的压力。哨兵模式:构建多个哨兵节点监视主从服务器,当主服务器挂掉的时候,自动将对应的从服务器切换成主服务器。优点:实现自动切换,可用性高。缺点:主从服务器存储数据一致,内存可用性差。还要额外维护一套哨兵系统,较为麻烦。集群模式:采用无中心节点的方式实现。多个主服务器相连,一个主服务器可以有多个从服务器,不同的主服务器存储不同的数据。优点:可用性更高,内存可用性高。


2022年最新版 68道Redis面试题(收藏)-常见问题-PHP中文网

Redis数据类型

字符串String

常用命令

添加查询追加获取长度判断是否存在的操作

set <key><value>添加键值对

        *NX:当数据库中 key 不存在时,可以将 key-value 添加数据库

        *XX:当数据库中 key 存在时,可以将 key-value 添加数据库,与 NX 参数互斥

        *EX:key 的超时秒数

        *PX:key 的超时毫秒数,与 EX 互斥

get <key>查询对应键值

append <key><value>将给定的<value>追加到原值的末尾

strlen <key>获得值的长度

setnx <key><value>只有在 key 不存在时 设置 key 的值

127.0.0.1:6379> set name dingdada  #插入一个key为‘name’值为‘dingdada’的数据
OK
127.0.0.1:6379> get name  #获取key为‘name’的数据
"dingdada"
127.0.0.1:6379> get key1
"hello world!"
127.0.0.1:6379> keys *  #查看当前库的所有数据
1) "name"
127.0.0.1:6379> EXISTS name  #判断key为‘name’的数据存在不存在,存在返回1
(integer) 1
127.0.0.1:6379> EXISTS name1  #不存在返回0
(integer) 0
127.0.0.1:6379> APPEND name1 dingdada1  #追加到key为‘name’的数据后拼接值为‘dingdada1’,如果key存在类似于java中字符串‘+’,不存在则新增一个,类似于Redis中的set name1 dingdada1 ,并且返回该数据的总长度
(integer) 9
127.0.0.1:6379> get name1
"dingdada1"
127.0.0.1:6379> STRLEN name1  #查看key为‘name1’的字符串长度
(integer) 9
127.0.0.1:6379> APPEND name1 ,dingdada2  #追加,key存在的话,拼接‘+’,返回总长度
(integer) 19
127.0.0.1:6379> STRLEN name1
(integer) 19
127.0.0.1:6379> get name1
"dingdada1,dingdada2"
127.0.0.1:6379> set key1 "hello world!"  #注意点:插入的数据中如果有空格的数据,请用“”双引号,否则会报错!
OK
127.0.0.1:6379> set key1 hello world!  #报错,因为在Redis中空格就是分隔符,相当于该参数已结束
(error) ERR syntax error
127.0.0.1:6379> set key1 hello,world!  #逗号是可以的
OK

自增自减操作

incr <key>将 key 中储存的数字值增 1 只能对数字值操作,如果为空,新增值为 1

decr <key>将 key 中储存的数字值减 1 只能对数字值操作,如果为空,新增值为-1

incrby / decrby <key><步长>将 key 中储存的数字值增减。自定义步长。

一般用来做文章浏览量、点赞数、收藏数等功能

127.0.0.1:6379> set num 0  #插入一个初始值为0的数据
OK
127.0.0.1:6379> get num
"0"
127.0.0.1:6379> incr num  #指定key为‘num’的数据自增1,返回结果  相当于java中 i++
(integer) 1
127.0.0.1:6379> get num  #一般用来做文章浏览量、点赞数、收藏数等功能
"1"
127.0.0.1:6379> incr num
(integer) 2
127.0.0.1:6379> incr num
(integer) 3
127.0.0.1:6379> get num
"3"
127.0.0.1:6379> decr num  #指定key为‘num’的数据自减1,返回结果  相当于java中 i--
(integer) 2
127.0.0.1:6379> decr num
(integer) 1
127.0.0.1:6379> decr num
(integer) 0
127.0.0.1:6379> decr num  #可以一直减为负数~
(integer) -1
127.0.0.1:6379> decr num  #一般用来做文章取消点赞、取消收藏等功能
(integer) -2
127.0.0.1:6379> decr num
(integer) -3
127.0.0.1:6379> INCRBY num 10  #后面跟上by  指定key为‘num’的数据自增‘参数(10)’,返回结果
(integer) 7
127.0.0.1:6379> INCRBY num 10
(integer) 17
127.0.0.1:6379> DECRBY num 3  #后面跟上by  指定key为‘num’的数据自减‘参数(3)’,返回结果
(integer) 14
127.0.0.1:6379> DECRBY num 3
(integer) 11

截取替换字符串操作

getrange <key><起始位置><结束位置> 获得值的范围,类似 java 中的 substring,前包,后包

setrange <key><起始位置><value> 用<value>覆写所储存的字符串值,从开始(索引从 0 开始)。

#截取
127.0.0.1:6379> set key1 "hello world!"
OK
127.0.0.1:6379> get key1
"hello world!"
127.0.0.1:6379> GETRANGE key1 0 4  #截取字符串,相当于java中的subString,下标从0开始,不会改变原有数据
"hello"
127.0.0.1:6379> get key1
"hello world!"
127.0.0.1:6379> GETRANGE key1 0 -1  #0至-1相当于 get key1,效果一致,获取整条数据
"hello world!"
#替换
127.0.0.1:6379> set key2 "hello,,,world!"
OK
127.0.0.1:6379> get key2
"hello,,,world!"
127.0.0.1:6379> SETRANGE key2 5 888  #此语句跟java中replace有点类似,下标也是从0开始,但是有区别:java中是指定替换字符,Redis中是从指定位置开始替换,替换的数据根据你所需替换的长度一致,返回值是替换后的长度
(integer) 14
127.0.0.1:6379> get key2
"hello888world!"
127.0.0.1:6379> SETRANGE key2 5 67  #该处只替换了两位
(integer) 14
127.0.0.1:6379> get key2
"hello678world!"

设置过期时间不存在设置操作

setex <key><过期时间><value>设置键值的同时,设置过期时间,单位秒。

getset <key><value>以新换旧,设置了新值同时获得旧值。

#设置过期时间,跟Expire的区别是前者设置已存在的key的过期时间,而setex是在创建的时候设置过期时间
127.0.0.1:6379> setex name1 15  dingdada  #新建一个key为‘name1’,值为‘dingdada’,过期时间为15秒的字符串数据
OK
127.0.0.1:6379> ttl name1  #查看key为‘name1’的key的过期时间
(integer) 6
127.0.0.1:6379> ttl name1
(integer) 5
127.0.0.1:6379> ttl name1
(integer) 3
127.0.0.1:6379> ttl name1
(integer) 1
127.0.0.1:6379> ttl name1
(integer) 0
127.0.0.1:6379> ttl name1  #返回为-2时证明该key已过期,即不存在
(integer) -2
#不存在设置
127.0.0.1:6379> setnx name2 dingdada2  #如果key为‘name2’不存在,新增数据,返回值1证明成功
(integer) 1
127.0.0.1:6379> get name2
"dingdada2"
127.0.0.1:6379> keys *
1) "name2"
127.0.0.1:6379> setnx name2 "dingdada3"  #如果key为‘name2’的已存在,设置失败,返回值0,也就是说这个跟set的区别是:set会替换原有的值,而setnx不会,存在即不设置,确保了数据误操作~
(integer) 0
127.0.0.1:6379> get name2
"dingdada2"

⑤ mset <key1><value1><key2><value2> 同时设置一个或多个 key-value 对

mget <key1><key2><key2> 同时获取一个或多个 value

msetnx <key1><value1><key2><value2> 同时设置一个或多个 key-value 对,当且仅当所有给定 key 都不存在。

原子性,有一个失败则都失败

127.0.0.1:6379> mset k1 v1 k2 v2 k3 v3  #插入多条数据
OK
127.0.0.1:6379> keys *  #查询所有数据
1) "k2"
2) "k3"
3) "k1"
127.0.0.1:6379> mget k1 k2 k3  #查询key为‘k1’,‘k2’,‘k3’的数据
1) "v1"
2) "v2"
3) "v3"
127.0.0.1:6379> MSETNX k1 v1 k4 v4  #msetnx是一个原子性的操作,在一定程度上保证了事务!要么都成功,要么都失败!相当于if中的条件&&(与)
(integer) 0
127.0.0.1:6379> keys *
1) "k2"
2) "k3"
3) "k1"
127.0.0.1:6379> MSETNX k5 v5 k4 v4  #全部成功
(integer) 1
127.0.0.1:6379> keys *
1) "k2"
2) "k4"
3) "k3"
4) "k5"
5) "k1"

String的数据结构

String 的数据结构为简单动态字符串(Simple Dynamic String,缩写 SDS)。是可以 修改的字符串,内部结构实现上类似于 Java 的 ArrayList,采用预分配冗余空间的方式 来减少内存的频繁分配

struct sdshdr{ 
  int len;//记录buf数组中已使用字节的数量 
  int free; //记录 buf 数组中未使用字节的数量 
  char buf[];//字符数组,用于保存字符串
}

举个栗子:

我们之前设置一个名为str1的字符串,值为redis,其实他在内存上的结构大致如下:

len为5,表示这个sds长度为5个字节。

free为2,表示这个sds还有2个字节未使用的空间。

buf为char[]的数组,分配了(len+1+free)个字节的长度,前len个字节保存redis这5个字符串,接下来1个字节保存了'\0',剩下的free个字节未使用。

优点

1.二进制安全。

因为传统的C语言字符串符合ASCII编码,而他的特点是遇零则止,所以当读一个字符串的时候,只要遇到'\0',就认为到达了末尾。这个问题就来了,如果保存的是图片或视频等二进制文件,就会被强行截断,那么数据就不完整了。

那现在不能通过遇零则止来判断是否这个字符串读完了,但是现在可以通过len与buf[]数组的长度比较,如果len+1等于buf的长度,就说明这个字符串读完了

2.获取字符串长度的操作,其时间复杂度为O(1)。

原来传统的C字符串获得长度的做法是遍历字符串的长度,如果遇零就返回,其时间复杂度为O(n)。

而SDS表头的len成员就保存了字符串的长度,其时间复杂度为O(1)。

3.杜绝缓存区溢出

因为SDS表头的free成员记录着buf字符数据中未使用的数量,所以,在进行append命令的时候,先判断free是否够用,如果够用,就直接添加字符,如果不够用,就先进行内存扩展,再进行添加字符串。

 

列表(List)

单键多值 Redis 列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头 部(左边)或者尾部(右边)。

①lpush(左插入)、lrange(查询集合)、rpush(右插入)操作

#lpush
127.0.0.1:6379> lpush list v1  #新增一个集合
(integer) 1
127.0.0.1:6379> lpush list v2
(integer) 2
127.0.0.1:6379> lpush list v3
(integer) 3
#lrange
127.0.0.1:6379> LRANGE list 0 -1  #查询list的所有元素值
1) "v3"
2) "v2"
3) "v1"
127.0.0.1:6379> lpush list1 v1 v2 v3 v4 v5  #批量添加集合元素
(integer) 5
127.0.0.1:6379> LRANGE list1 0 -1
1) "v5"
2) "v4"
3) "v3"
4) "v2"
5) "v1"
###这里大家有没有注意到,先进去的会到后面,也就是我们的lpush的意思是左插入,l--left
#rpush
127.0.0.1:6379> LRANGE list 0 1  #指定查询列表中的元素,从下标零开始,1结束,两个元素
1) "v3"
2) "v2"
127.0.0.1:6379> LRANGE list 0 0  #指定查询列表中的唯一元素
1) "v3"
127.0.0.1:6379> rpush list rv0  #右插入,跟lpush相反,这里添加进去元素是在尾部!
(integer) 4
127.0.0.1:6379> lrange list 0 -1  #查看集合所有元素
1) "v3"
2) "v2"
3) "v1"
4) "rv0"
##联想:这里我们是不是可以做一个,保存的记录值(如:账号密码的记录),
每次都使用lpush,老的数据永远在后面,我们每次获取 0 0 位置的元素,是不是相当于更新了
数据操作,但是数据记录还在?想要查询记录即可获取集合所有元素!

##联想:这里我们是不是可以做一个,保存的记录值(如:账号密码的记录),
每次都使用lpush,老的数据永远在后面,我们每次获取 0 0 位置的元素,是不是相当于更新了
数据操作,但是数据记录还在?想要查询记录即可获取集合所有元素!

②lpop(左移除)、rpop(右移除)操作

#lpop
127.0.0.1:6379> LRANGE list 0 -1
1) "v5"
2) "v4"
3) "v3"
4) "v2"
5) "v1"
127.0.0.1:6379> lpop list  #从头部开始移除第一个元素
"v5"
##################
#rpop
127.0.0.1:6379> LRANGE list 0 -1
1) "v4"
2) "v3"
3) "v2"
4) "v1"
127.0.0.1:6379> rpop list
"v1"
127.0.0.1:6379> LRANGE list 0 -1  #从尾部开始移除第一个元素
1) "v4"
2) "v3"
3) "v2"

③lindex(查询指定下标元素)、llen(获取集合长度) 操作

#lindex
127.0.0.1:6379> LRANGE list 0 -1
1) "v4"
2) "v3"
3) "v2"
127.0.0.1:6379> lindex list 1  #获取指定下标位置集合的元素,下标从0开始计数
"v3"
127.0.0.1:6379> lindex list 0  #相当于java中的indexof
"v4"
#llen
127.0.0.1:6379> llen list  #获取指定集合的元素长度,相当于java中的length或者size
(integer) 3

④lrem(根据value移除指定的值)

127.0.0.1:6379> LRANGE list 0 -1
1) "v4"
2) "v3"
3) "v2"
127.0.0.1:6379> lrem list 1 v2  #移除集合list中的元素是v2的元素1个
(integer) 1
127.0.0.1:6379> LRANGE list 0 -1
1) "v4"
2) "v3"
127.0.0.1:6379> lrem list 0 v3 #移除集合list中的元素是v2的元素1个,这里的0和1效果是一致的
(integer) 1
127.0.0.1:6379> LRANGE list 0 -1
1) "v4"
127.0.0.1:6379> lpush list  v3 v2 v2 v2
(integer) 4
127.0.0.1:6379> LRANGE list 0 -1
1) "v2"
2) "v2"
3) "v2"
4) "v3"
5) "v4"
127.0.0.1:6379> lrem list 3 v2  #移除集合list中元素为v2 的‘3’个,这里的参数数量,如果实际中集合元素数量不达标,不会报错,全部移除后返回成功移除后的数量值
(integer) 3
127.0.0.1:6379> LRANGE list 0 -1
1) "v3"
2) "v4"

⑥ltrim(截取元素)、rpoplpush(移除指定集合中最后一个元素到一个新的集合中)操作

#ltrim
127.0.0.1:6379> lpush list v1 v2 v3 v4
(integer) 4
127.0.0.1:6379> LRANGE list 0 -1
1) "v4"
2) "v3"
3) "v2"
4) "v1"
127.0.0.1:6379> ltrim list 1 2  #通过下标截取指定的长度,这个list已经被改变了,只剩下我们所指定截取后的元素
OK
127.0.0.1:6379> LRANGE list 0 -1
1) "v3"
2) "v2"
################
#rpoplpush
127.0.0.1:6379> lpush list v1 v2 v3 v4 v5
(integer) 5
127.0.0.1:6379> LRANGE list 0 -1
1) "v5"
2) "v4"
3) "v3"
4) "v2"
5) "v1"
127.0.0.1:6379> rpoplpush list newlist  #移除list集合中的最后一个元素到新的集合newlist中,返回值是移除的最后一个元素值
"v1"
127.0.0.1:6379> LRANGE list 0 -1
1) "v5"
2) "v4"
3) "v3"
4) "v2"
127.0.0.1:6379> LRANGE newlist 0 -1  #确实存在该newlist集合并且有刚刚移除的元素,证明成功
1) "v1"

⑦lset(更新)、linsert操作

#lset
127.0.0.1:6379> LRANGE list 0 -1
1) "v5"
2) "v4"
3) "v3"
4) "v2"
127.0.0.1:6379> 
127.0.0.1:6379> lset list 1 newV5  #更新list集合中下标为‘1’的元素为‘newV5’
OK
127.0.0.1:6379> LRANGE list 0 -1  #查看证明更新成功
1) "v5"
2) "newV5"
3) "v3"
4) "v2"
##注意点:
127.0.0.1:6379> lset list1 0 vvvv  #如果指定的‘集合’不存在,报错
(error) ERR no such key
127.0.0.1:6379> lset list 8 vvv  #如果集合存在,但是指定的‘下标’不存在,报错
(error) ERR index out of range
########################
#linsert
127.0.0.1:6379> LRANGE list 0 -1
1) "v5"
2) "newV5"
3) "v3"
4) "v2"
127.0.0.1:6379> LINSERT list after v3 insertv3  #在集合中的‘v3’元素 ‘(after)之后’ 加上一个元素
(integer) 5
127.0.0.1:6379> LRANGE list 0 -1
1) "v5"
2) "newV5"
3) "v3"
4) "insertv3"
5) "v2"
127.0.0.1:6379> LINSERT list before v3 insertv3  #在集合中的‘v3’元素 ‘(before)之前’ 加上一个元素
(integer) 6
127.0.0.1:6379> LRANGE list 0 -1
1) "v5"
2) "newV5"
3) "insertv3"
4) "v3"
5) "insertv3"
6) "v2"

List数据结构

首先要了解什么是压缩表内存节省到极致的Redis压缩表,值得了解..._学习Java的小姐姐的博客-CSDN博客_redis压缩列表 优点

传统的数组,存储不同长度的字符时,会选择最大的字符长度作为每个节点的内存大小。

问题就是:如果只有一个元素的长度超大,但是其他的元素长度都比较小,那么我们所有元素的内存都用超大的数字就会导致内存的浪费。

所以出现了压缩列表

Redis引入了压缩列表的概念,即多大的元素使用多大的内存,一切从实际出发,拒绝浪费。

如下图,根据每个节点的实际存储的内容决定内存的大小,即第一个节点占用5个字节,第二个节点占用5个字节,第三个节点占用1个字节,第四个节点占用4个字节,第五个节点占用3个字节。

还有一个问题,我们在遍历的时候不知道每个元素的大小,无法准确计算出下一个节点的具体位置。实际存储不会出现上图的横线,我们并不知道什么时候当前节点结束,什么时候到了下一个节点。所以在redis中添加length属性,用来记录前一个节点的长度。

如下图,如果需要从头开始遍历,取某个节点后面的数字,比如取“hello”的起始地址,但是不知道其结束地址在哪里,我们取后面数字5,即可知道"hello"占用了5个字节,即可顺利找到下一节点“world”的起始位置。

压缩列表图解分析

整个压缩列表图解如下,大家可以大概看下,具体的后面部分会详细说明。

表头

表头包括四个部分,分别是内存字节数zlbytes,尾节点距离起始地址的字节数zltail_offset,节点数量zllength,标志结束的记号zlend。

  • zlbytes:记录整个压缩列表占用的内存字节数。
  • zltail_offset:记录压缩列表尾节点距离压缩列表的起始地址的字节数(目的是为了直接定位到尾节点,方便反向查询)
  • zllength:记录了压缩列表的节点数量。即在上图中节点数量为2。
  • zlend:保存一个常数255(0xFF),标记压缩列表的末端。

数据节点

数据节点包括三个部分,分别是前一个节点的长度prev_entry_len,当前数据类型和编码格式encoding,具体数据指针value。

  • prev_entry_len:记录前驱节点的长度。
  • encoding:记录当前数据类型和编码格式
  • value:存放具体的数据。

List 的数据结构为快速链表 quickList。Redis源码剖析之快速列表(quicklist)_xindoo的博客-CSDN博客

在这里插入图片描述

首先在列表元素较少的情况下会使用一块连续的内存存储,这个结构是 ziplist,也即是 压缩列表。 压缩列表是顺序结构,它将所有的元素紧挨着一起存储,分配的是一块连续的内存。

当数据量比较多的时候才会改成 quicklist。

quickList 是 zipList 和 linkedList 的混合体,也就是将多个 ziplist 使用双向指 针串起来使用。

注意:quickList通常情况下不对两端的节点压缩,因为lpush,rpush,lpop,rpop等命令都是在两端操作,如果频繁压缩或解压缩会代码不必要的性能损耗

应用  

  • 消息排队!消息队列 (Lpush Rpop), 栈( Lpush Lpop)!

 集合(Set)

①sadd <key><value1><value2><value3>(批量添加)、

smembers <key>(查看所有元素)

sismember <key><value>(判断是否存在)

scard <key>(查看长度)

srem(移除指定元素)

#set中所有的元素都是唯一的不重复的!
127.0.0.1:6379> sadd set1 ding da mian tiao  #添加set集合(可批量可单个,写法一致,不再赘述)
(integer) 4
127.0.0.1:6379> SMEMBERS set1  #查看set中所有元素
1) "mian"
2) "da"
3) "tiao"
4) "ding"
127.0.0.1:6379> SISMEMBER set1 da  #判断某个值在不在set中,在返回1
(integer) 1
127.0.0.1:6379> SISMEMBER set1 da1  #不在返回0
(integer) 0
127.0.0.1:6379> SCARD set1  #查看集合的长度,相当于size、length
(integer) 4
127.0.0.1:6379> srem set1 da  #移除set中指定的元素
(integer) 1     #移除成功
127.0.0.1:6379> SMEMBERS set1  
1) "mian"
2) "tiao"
3) "ding"

②srandmember <key><value>(抽随机 value是抽取的数量)操作

127.0.0.1:6379> sadd myset 1 2 3 4 5 6 7  #在set中添加7个元素
(integer) 7
127.0.0.1:6379> SMEMBERS myset
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"
6) "6"
7) "7"
127.0.0.1:6379> SRANDMEMBER myset 1  #随机抽取myset中1个元素返回
1) "4"
127.0.0.1:6379> SRANDMEMBER myset 1  #随机抽取myset中1个元素返回
1) "1"
127.0.0.1:6379> SRANDMEMBER myset 1  #随机抽取myset中1个元素返回
1) "5"
127.0.0.1:6379> SRANDMEMBER myset  #不填后参数,默认抽1个值,但是下面返回不会带序号值
"3"
127.0.0.1:6379> SRANDMEMBER myset 3  #随机抽取myset中3个元素返回
1) "1"
2) "2"
3) "3"
127.0.0.1:6379> SRANDMEMBER myset 3  #随机抽取myset中3个元素返回
1) "6"
2) "3"
3) "5"

③spop <key><value>(随机删除元素,value是随机删除的数量)、smove(移动指定元素到新的集合中)操作

127.0.0.1:6379> SMEMBERS myset
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"
6) "6"
7) "7"
127.0.0.1:6379> spop myset  #随机删除1个元素,不指定参数值即删除1个
"2"
127.0.0.1:6379> spop myset 1  #随机删除1个元素
1) "7"
127.0.0.1:6379> spop myset 2  #随机删除2个元素
1) "3"
2) "5"
127.0.0.1:6379> SMEMBERS myset  #查询删除后的结果
1) "1"
2) "4"
3) "6"
127.0.0.1:6379> smove myset myset2 1  #移动指定set中的指定元素到新的set中
(integer) 1
127.0.0.1:6379> SMEMBERS myset  #查询原来的set集合
1) "4"
2) "6"
127.0.0.1:6379> SMEMBERS myset2  #查询新的set集合,如果新的set存在,即往后加,如果不存在,则自动创建set并且加入进去
1) "1"

④sdiff(差集)、sinter(交集)、sunion(并集)操作   可实现共同好友、共同关注等需求。

127.0.0.1:6379> sadd myset1 1 2 3 4 5
(integer) 5
127.0.0.1:6379> sadd myset2 3 4 5 6 7
(integer) 5
127.0.0.1:6379> SMEMBERS myset1
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"
127.0.0.1:6379> SMEMBERS myset2
1) "3"
2) "4"
3) "5"
4) "6"
5) "7"
127.0.0.1:6379> SDIFF myset1 myset2  #查询指定的set之间的差集,可以是多个set
1) "1"
2) "2"
127.0.0.1:6379> SINTER myset1 myset2  #查询指定的set之间的交集,可以是多个set
1) "3"
2) "4"
3) "5"
127.0.0.1:6379> sunion myset1 myset2  #查询指定的set之间的并集,可以是多个set
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"
6) "6"
7) "7"

数据结构

Set 数据结构是 dict 字典,字典是用哈希表实现的。

Java 中 HashSet 的内部实现使用的是 HashMap,只不过所有的 value 都指向同一个对象。 Redis 的 set 结构也是一样,它的内部也使用 hash 结构,所有的 value 都指向同一个内 部值。

哈希(Hash)

①hset(添加hash)、hget(查询)、hgetall(查询所有)、hdel(删除hash中指定的值)、hlen(获取hash的长度)、hexists(判断key是否存在)操作

127.0.0.1:6379> hset myhash name dingdada age 23  #添加hash,可多个
(integer) 2
127.0.0.1:6379> hget myhash name  #获取hash中key是name的值
"dingdada"
127.0.0.1:6379> hget myhash age  #获取hash中key是age的值
"23"
127.0.0.1:6379> hgetall myhash  #获取hash中所有的值,包含key
1) "name"
2) "dingdada"
3) "age"
4) "23"
127.0.0.1:6379> hset myhash del test  #添加
(integer) 1
127.0.0.1:6379> hgetall myhash
1) "name"
2) "dingdada"
3) "age"
4) "23"
5) "del"
6) "test"
127.0.0.1:6379> hdel myhash del age  #删除指定hash中的key(可多个),key删除后对应的value也会被删除
(integer) 2
127.0.0.1:6379> hgetall myhash
1) "name"
2) "dingdada"
127.0.0.1:6379> hlen myhash  #获取指定hash的长度,相当于length、size
(integer) 1
127.0.0.1:6379> HEXISTS myhash name  #判断key是否存在于指定的hash,存在返回1
(integer) 1
127.0.0.1:6379> HEXISTS myhash age  #判断key是否存在于指定的hash,不存在返回0
(integer) 0

②hkeys(获取所有key)、hvals(获取所有value)、hincrby(给值加增量)、hsetnx(存在不添加)操作

127.0.0.1:6379> hset myhash age 23 high 173
(integer) 2
127.0.0.1:6379> hgetall myhash
1) "name"
2) "dingdada"
3) "age"
4) "23"
5) "high"
6) "173"
127.0.0.1:6379> hkeys myhash  #获取指定hash中的所有key
1) "name"
2) "age"
3) "high"
127.0.0.1:6379> hvals myhash   #获取指定hash中的所有value
1) "dingdada"
2) "23"
3) "173"
127.0.0.1:6379> hincrby myhash age 2  #让hash中age的value指定+2(自增)
(integer) 25
127.0.0.1:6379> hincrby myhash age -1  #让hash中age的value指定-1(自减)
(integer) 24
127.0.0.1:6379> hsetnx myhash nokey novalue  #添加不存在就新增返回新增成功的数量(只能单个增加哦)
(integer) 1 
127.0.0.1:6379> hsetnx myhash name miaotiao  #添加存在则失败返回0
(integer) 0
127.0.0.1:6379> hgetall myhash
1) "name"
2) "dingdada"
3) "age"
4) "24"
5) "high"
6) "173"
7) "nokey"
8) "novalue"

比String更适合存储对象 

数据结构 

 Hash 类型对应的数据结构是两种:ziplist(压缩列表),hashtable(哈希表)。当 field-value 长度较短且个数较少时,使用 ziplist,否则使用 hashtable。

面试官:说说Redis的Hash底层 我:......(来自阅文的面试题)_学习Java的小姐姐的博客-CSDN博客

hash的底层主要是采用字典dict的结构

 扩容过程和渐进式Hash图解

随着数据量的增加,hash碰撞发生的就越频繁,每个数组后面的链表就越长,查询性能就会越来越底

这时候要进行扩容,所以第一个数组存放真正的数据,第二个数组用于扩容用。第一个数组中的节点经过hash运算映射到第二个数组上,然后依次进行。那么过程中还能对外提供服务吗?答案是可以的,因为他可以随时停止,这就到了下一个变量rehashidx。

rehashidx其实是一个标志量,如果为-1说明当前没有扩容,如果不为-1则表示当前扩容到哪个下标位置,方便下次进行从该下标位置继续扩容。

例子可以看面试官:说说Redis的Hash底层 我:......(来自阅文的面试题)_学习Java的小姐姐的博客-CSDN博客

有序集合 Zset(sorted set) 

Redis 有序集合 zset 与普通集合 set 非常相似,是一个没有重复元素的字符串集合。 不同之处是有序集合的每个成员都关联了一个评分(score),这个评分(score)被用 来按照从最低分到最高分的方式排序集合中的成员。集合的成员是唯一的,但是评分 可以是重复了 。

①zadd <key><score1><value1><key><score2><value2>(添加)

zrange <key><start><stop>[WITHSCORES](查询)带 WITHSCORES,可以让分数(score)一起和值返回到结果集。

zrangebyscore key min maxx [withscores] [limit offset count](排序小-大):返回有序集 key 中,所有 score 值介于 min 和 max 之间(包括等于 min 或 max )的成员。 有序集成员按 score 值递增(从小到大)次序排列。

zrevrangebyscore key maxmin [withscores] [limit offset count](排序大-小)  同上,改为从大到小排列

zrangebyscore withscores(查询所有值包含key)   

127.0.0.1:6379> zadd myzset 1 one 2 two 3 three  #添加zset值,可多个
(integer) 3
127.0.0.1:6379> ZRANGE myzset 0 -1  #查询所有的值
1) "one"
2) "two"
3) "three"
#-inf 负无穷  +inf 正无穷
127.0.0.1:6379> ZRANGEBYSCORE myzset -inf +inf  #将zset的值根据key来从小到大排序并输出
1) "one"
2) "two"
3) "three"
127.0.0.1:6379> ZRANGEBYSCORE myzset 0 1  #只查询key<=1的值并且排序从小到大
1) "one"
127.0.0.1:6379> ZREVRANGE myzset 1 -1  #从大到小排序输出
1) "two"
2) "one"
127.0.0.1:6379> ZRANGEBYSCORE myzset -inf +inf withscores  #查询指定zset的所有值,包含序号的值
1) "one"
2) "1"
3) "two"
4) "2"
5) "three"
6) "3"

②zrem(移除元素)、zcard(查看元素个数)、zcount(查询指定区间内的元素个数)

127.0.0.1:6379> zadd myset 1 v1 2 v2 3 v3 4 v4
(integer) 4
127.0.0.1:6379> ZRANGE myset 0 -1
1) "v1"
2) "v2"
3) "v3"
4) "v4"
127.0.0.1:6379> zrem myset v3  #移除指定的元素,可多个
(integer) 1
127.0.0.1:6379> ZRANGE myset 0 -1
1) "v1"
2) "v2"
3) "v4"
127.0.0.1:6379> zcard myset  #查看zset的元素个数,相当于长度,size。
(integer) 3
127.0.0.1:6379> zcount myset 0 100  #查询指定区间内的元素个数
(integer) 3
127.0.0.1:6379> zcount myset 0 2  #查询指定区间内的元素个数
(integer) 2

数据结构

面试准备 -- Redis 跳跃表_LuckyToMeet-Dian叶的博客-CSDN博客_redis 跳表

zset 底层使用了两个数据结构

(1)hash,hash 的作用就是关联元素 value 和权重 score,保障元素 value 的唯 一性,可以通过元素 value 找到相应的 score 值。

(2)跳跃表,跳跃表的目的在于给元素 value 排序,根据 score 的范围获取元素 列表。

Redis的跳跃表确定不了解下_学习Java的小姐姐的博客-CSDN博客

跳跃表由三部分组成   表头、管理所有节点层数level的数组、数据节点

跳跃表的查询流程:

从最高层向右找,直到碰到比自己分数大的元素,然后换到下一层继续向右,一直到最下面那一层

.

Redis数据类型的应用

Redis(二)基础:三大特殊数据类型的学习和理解_大鱼等于负的博客-CSDN博客

Redis可用性

1、redis持久化

持久化就是把内存中的数据持久化到本地磁盘,防止服务器宕机了内存数据丢失

Redis 提供两种持久化机制 RDB(默认)AOF 机制

RDB(默认)

是什么?

RDB是Redis默认的持久化方式。在指定的时间间隔内将内存中的数据集快照写入磁盘

底层是如何实现的?

Redis 会单独创建一个子进程(fork)来进行持久化,会先将数据写入到 一个临时文件 中,待持久化过程都结束了,再用这个临时文件替换上次持久化好的文件。 整个过程 中,主进程是不进行任何 IO 操作的,这就确保了极高的性能 如果需要进行大规模数 据的恢复,且对于数据恢复的完整性不是非常敏感,那 RDB 方式要比 AOF 方式更加 的高效。

这种先同步到临时文件,在覆盖原文件的技术,我们称之为“写时复制技术”

AOF:(Append Only File)

是什么?

以日志的形式来记录每个写操作(增量保存),将 Redis 执行过的所有写指令记录下 来(读操作不记录), 只许追加文件但不可以改写文件,redis 启动之初会读取该文件重 新构建数据

AOF中几个重要点:

①AOF默认是不开启的,如果要开启,需要在redis.conf中配置appendonly.aof。AOF文件保存在redis的根目录下

②AOF 同步频率:三种频率

appendfsync always       始终同步,每次 Redis 的写入都会立刻记入日志;性能较差但数据完整性比较好

appendfsync everysec   每秒同步,每秒记入日志一次,如果宕机,本秒的数据可能丢失。

appendfsync no             redis 不主动进行同步,把同步时机交给操作系统。

③Rewrite 压缩

AOF 采用文件追加方式,文件会越来越大,为避免出现此种情况,新增了重写机制, 当 AOF 文件的大小超过所设定的阈值时,Redis 就会启动 AOF 文件的内容压缩, 只保留 可以恢复数据的最小指令集.举个例子,什么是最小指令集,就是比如增加操作50次,删除25次,那么最终就只有25条数据,那么AOF重写之后之后记录保留下来的25条数据的写操作

那么触发机制是什么,何时重写? 

Redis 会记录上次重写时的 AOF 大小,默认配置是当 AOF 文件大小是上次 rewrite 后大 小的一倍且文件大于 64M 时触发

④异常恢复:如遇到 AOF 文件损坏,通过/usr/local/bin/redis-check-aof--fix appendonly.aof 进行恢复

流程

(1)客户端的请求写命令会被 append 追加到 AOF 缓冲区内;

(2)AOF 缓冲区根据 AOF 持久化策略[always,everysec,no]将操作 sync 同步到磁盘的 AOF 文件中;

(3)AOF 文件大小超过重写策略或手动重写时,会对 AOF 文件 rewrite 重写,压缩 AOF 文件容量

(4)Redis 服务重启时,如果AOF文件异常,需要异常恢复。之后会重新 load 加载 AOF 文件中的写操作达到数据恢复的目的;

两种方法的优缺点:

RBD:

优势:适合大规模的数据恢复

         对数据完整性和一致性要求不高更适合使用 

         节省磁盘空间,恢复速度快(直接保存的是数据,AOF保存的是操作)

缺点

数据安全性低,RDB 是间隔一段时间进行持久化,如果持久化之间 redis 发生故障,会发生数据丢失。

                为什么丢失。假如我们设置save 20 3 : 如果20s内修改3次就持久化,我们一共修改5次,前三次都是在前20s内,修改第4次第5次的时候,未到达20s内修改3次的条件,就不持久化,那么后面两次的数据就丢失了

AOF:

优点:

1)备份机制更稳健,丢失数据概率更低。

2)通过 append 模式写文件,即使中途服务器宕机,可以通过 redis-check-aof 工具解决数据一致性问题。

缺点:

1)AOF 文件比 RDB 文件大,且恢复速度慢。

2)数据集大的时候,比 rdb 启动效率低

2、redis事务

事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。在Redis单条命令式保证原子性的,但是事务不保证原子性

Redis如何实现事务

Redis 事务的本质是通过MULTI、EXEC、WATCH等一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,如果出现异常,分为两种情况

①编译时异常,代码有问题,或者命令有问题,所有的命令都不会被执行

127.0.0.1:6379> multi  #开启事务
OK
127.0.0.1:6379> set name dingyongjun  #添加数据
QUEUED
127.0.0.1:6379> set age 23  #添加数据
QUEUED
127.0.0.1:6379> getset name  #输入一个错误的命令,这时候已经报错了,但是这个还是进入了事务的队列当中
(error) ERR wrong number of arguments for 'getset' command
127.0.0.1:6379> set high 173  #添加数据
QUEUED
127.0.0.1:6379> exec  #执行事务,报错,并且所有的命令都不会执行
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> get name  #获取数据为空,证明没有执行
(nil)

②运行时异常,除了语法错误不会被执行且抛出异常后,其他的正确命令可以正常执行

127.0.0.1:6379> multi  #开启事务
OK
127.0.0.1:6379> set name dingyongjun  #添加字符串数据
QUEUED
127.0.0.1:6379> incr name  #对字符串数据进行自增操作
QUEUED
127.0.0.1:6379> set age 23  #添加数据
QUEUED
127.0.0.1:6379> get age  #获取数据
QUEUED 
127.0.0.1:6379> exec  #执行事务。虽然对字符串数据进行自增操作报错了,但是其他的命令还是可以正常执行的
1) OK
2) (error) ERR value is not an integer or out of range
3) OK
4) "23"
127.0.0.1:6379> get age  #获取数据成功
"23"

事务命令:

MULTI:用于开启一个事务,它总是返回OK。MULTI执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当EXEC命令被调用时,所有队列中的命令才会被执行。

EXEC:执行所有事务块内的命令。返回事务块内所有命令的返回值,按命令执行的先后顺序排列。当操作被打断时,返回空值 nil 。

WATCH :是一个乐观锁,可以为 Redis 事务提供 check-and-set (CAS)行为。可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行,监控一直持续到EXEC命令。(秒杀场景

DISCARD:调用该命令,客户端可以清空事务队列,并放弃执行事务,且客户端会从事务状态中退出。

UNWATCH:命令可以取消watch对所有key的监控。

3、redis失效策略 

**内存淘汰策略**

1)全局的键空间选择性移除

​    **noeviction**:当内存不足以容纳新写入数据时,新写入操作会报错。(字典库常用)

​    **allkeys-lru**:在键空间中,移除最近最少使用的key。(缓存常用)

​    **allkeys-random**:在键空间中,随机移除某个key。

2)设置过期时间的键空间选择性移除

​    **volatile-lru**:在设置了过期时间的键空间中,移除最近最少使用的key。

​    **volatile-random**:在设置了过期时间的键空间中,随机移除某个key。

​    **volatile-ttl**:在设置了过期时间的键空间中,有更早过期时间的key优先移除。

**缓存失效策略**

​    **定时清除:**针对每个设置过期时间的key都创建指定定时器

​    **惰性清除:**访问时判断,对内存不友好

​    **定时扫描清除:**定时100ms随机20个检查过期的字典,若存在25%以上则继续循环删除。

3.缓存数据一致性问题

Redis作用,对MySQL修改时,应该如何对Redis做修改?我回答的是先删Redis再更新MySQL再更新Redis,被问有什么问题,应该怎么解决?
这个是数据库与缓存双写会由于并发修改导致不一致的问题,比如MySQL先改A再改B但Redis先改了B再改了A就出现数据不一致了。怎么解决呢?

修改缓存有两种模式:双写模式和失效模式

双写模式:修改数据库之后,同时修改缓存中的数据

缺点:假如此时有两个线程修改数据,线程1,修改了数据库的数据,还没来得及修改缓存,线程2就已经修改完数据库并且修改缓存,会出现脏数据

解决方法:①加锁,使得修改数据库和更新缓存整个流程执行完,后面的线程才能修改数据库和更新缓存

                ②如果业务允许出现暂时性的数据不一致,可以给缓存添加过期时间,等过了过期时间,缓存自动删除,重写读取数据库数据后,两者的数据就一致了

 

 失效模式:更新完数据库之后,删除缓存

问题:线程A正在读取缓存,发现缓存没有,去数据库读取,此时线程B,更新数据,然后立马删除缓存,线程A更新缓存,此时缓存中的数据还是B修改前的数据

解决方法:这是读写的并发问题,当然我们可以加读写锁来解决。但是如果要求实时性,一致性高,,可以不放入缓存,直接操作数据库

 但是缓存一致性是满足最终一致性的,无论怎么设计,最终的数据都是一致的

完美的解决方法就是使用canal订阅binlog的方式

我们系统的—致性解决方案:
,1、缓存的所有数据都有过期时间,数据过期下一次查询触发主动更新

2、读写数据的时候,加上分布式的读写锁。
经常写,经常读,就直接读取数据库

4、LRU?redis里的具体实现? LRU、LFU,清除冷数据

不该这么嚣张的,B站面试官水平真高,手写LRU算法失算了_哔哩哔哩_bilibili

力扣LRU
LRU全称是Least Recently Used,即最近最久未使用的意思。

LRU算法的设计原则是:如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很小。也就是说,当限定的空间已存满数据时,应当把最久没有被访问到的数据淘汰。

redis原始的淘汰算法简单实现:当需要淘汰一个key时,随机选择3个key,淘汰其中间隔时间最长的key。**基本上,我们随机选择key,淘汰key效果很好。后来随机3个key改成一个配置项"N随机key"。但把默认值提高改成5个后效果大大提高。考虑到它的效果,你根本不用修改他。

class LRUCache {
    private HashMap<Integer, Node> map;
    private DoublyLinkedList list;
    private int capacity;
    //初始化,capacity队列容量
    public LRUCache(int capacity) {
        map = new HashMap<>();
        list = new DoublyLinkedList();
        this.capacity = capacity;
    }
    //读取缓存
    public int get(int key) {
        //1.如果缓存中没有,返回-1
        if (!map.containsKey(key)) return -1;
        Node node = map.get(key);
        //2.将被读取到的缓存,放到队尾
        list.moveToTail(node, node.val);
        //3.读取成功,返回内容
        return node.val;
    }
    //写入缓存
    public void put(int key, int value) {
        //1.先判断redis是是否已经存在
        if (map.containsKey(key))
            //1.1如果已经存在,那就放到队尾
            list.moveToTail(map.get(key), value);
        else {
        //2.如果缓存不存在
            //2.1判断缓存队列是否已经满了
            if (capacity == map.size())
                //如果满了删除队头元素
                map.remove(list.deleteHead().key);
            //2.2添加到队尾
            Node node = new Node(key, value);
            list.appendToTail(node);
            map.put(key, node);
        }
    }
    class DoublyLinkedList {
        //虚拟头结点,虚拟尾结点   方便增删
        private Node dummyHead;
        private Node dummyTail;

        public DoublyLinkedList() {
            dummyHead = new Node();
            dummyTail = new Node();
            dummyHead.next = dummyTail;
            dummyTail.prev = dummyHead;
        }
        //存在队列中的缓存被读取,为什么要加value,因为可能key相同但是value修改了
        public void moveToTail(Node node, int val) {
            deleteNode(node);
            //是重点,如果先插入2,1 再插入2,2。需要将2的value赋值,赋值缓存里存的还是1
            node.val = val;
            appendToTail(node);
        }
        //缓存满了,删除最近最少使用的缓存,是放在头结点里
        public Node deleteHead() {
            Node node = dummyHead.next;
            deleteNode(node);
            //因为同时map也需要删除,所以需要返回node
            return node;
        }
        //辅助函数,移动结点和删除头结点的时候都会使用
        private void deleteNode(Node node) {
            node.prev.next = node.next;
            node.next.prev = node.prev;
        }
        //尾插法,最新读取的缓存,放在队尾
        public void appendToTail(Node node) {
            node.next = dummyTail;
            node.prev = dummyTail.prev;
            node.prev.next = node;
            node.next.prev = node;
        }
    }

    class Node {
        int key;
        int val;
        Node next;
        Node prev;

        public Node() {}

        public Node(int key, int val) {
            this.key = key;
            this.val = val;
        }
    }
}

加上泛型
 

public class LRUCache<K,V> {
    HashMap<K,Node> map;
    DoubleLinkedList list;
    int capacity;

    public LRUCache(int capacity){
        map=new HashMap<>();
        list=new DoubleLinkedList();
        this.capacity=capacity;
    }


    public V get(K key){
        if(!map.containsKey(key)){ return null;}
        Node node = map.get(key);
        list.moveToTail(node,node.value);
        return (V) node.value;
    }
    public void put(K key,V value){
        if(map.containsKey(key)){
            list.moveToTail(map.get(key),value);
        }else {
            if(map.size()==capacity){
                map.remove(list.deleteHead().key);
            }
            Node node = new Node(key, value);
            list.appendToTail(node);
            map.put(key,node);
        }
    }
}
class DoubleLinkedList<K,V> {
    private Node<K,V> dummyHead;
    private Node<K,V> dummyTail;


    public DoubleLinkedList(){
        dummyHead=new Node();
        dummyTail=new Node();
        dummyHead.next=dummyTail;
        dummyTail.prev=dummyHead;
    }
    public Node deleteHead(){
        Node node = dummyHead.next;
        deleteNode(node);

        return node;
    }
    public void deleteNode(Node node){
        node.prev.next=node.next;
        node.next.prev=node.prev;
    }
//    public void moveToTail(Node node){
//        deleteNode(node);
//        appendToTail(node);
//    }
    //为什么要加value,因为可能key相同但是value修改了
    public void moveToTail(Node node,V value){
        deleteNode(node);
        node.value=value;
        appendToTail(node);
    }
    public void appendToTail(Node node){
        dummyTail.prev.next=node;
        node.prev=dummyTail.prev;
        node.next=dummyTail;
        dummyTail.prev=node;
    }
}
class Node<K,V>{
    K key;
    V value;
    Node next;
    Node prev;

    public Node() {}
    public Node(K key, V value) {
        this.key = key;
        this.value = value;
    }
}

5.缓存问题 

首先我们要明白redis在整个系统中是干嘛的: redis,在整个系统体系中,作为缓存,就是尽可能抗住大多数的请求,过滤掉,使最后的数据库的压力降到最小

缓存击穿

那么既然 redis 是作为缓存,那要么就会给 key 设置过期时间,在一段时间后清除;或者,就是 LRU、LFU,清除冷数据。
所以说,只要是作为缓存,那么就一定存在这种情况:一个 key,某一时间,要么过期了,要么 LRU 清除,然后,就突然有大量并发来访问它。查缓存没查到,大量并发请求打到数据库上,就好像是在 redis 上打了一个窟窿,击穿了,穿过去了,这就是缓存击穿。

那应该如何解决呢?

一开始,我看到这么一个答案:热点数据永不过期。

设置 key 永不过期,在修改数据库时,同时更新缓存;

但是我觉得key 永不失效的解决方案,是不可行。为什么呢?

因为,对于一个需要解决缓存击穿问题的企业,他们的数据量是巨大的,并不知道什么是热点数据。

真正的环境中,热点数据是在时时变化着的,我们可以对一些热点做一些预估,但是,我们永远无法保证我们能预估到多少。
比如说微博,一个明星干了点什么事,就能掀起你无法想象的流量;
所以,数据是流动的。我们的 redis 缓存,不可能让 key 永远不会过期。

还有一个方法就是:加锁

首先我尝试使用synchronized 加锁,使用一台服务器压测,基本不存在什么问题,但是当我换成集群环境时,一把 Java 锁,是不可能锁住一个集群的。

后来我使用分布式锁,
假设会有 1w 个并发来访问一个 key,那么它们就会先查询 redis,如果发现,这个 key 不存在;
它们就会对应的,往 redis 用 setnx 设置一个 key,来表示这是一把锁;redisTemplate.opsForValue().setIfAbsent("lock", "111");
然后,只有一个线程,会设置成功,然后去读取数据库,写回 redis;其他的 9999 个线程,则 sleep 一小会,然后再去访问我们的 redis。依次循环

 但是有一个问题:就是拿到锁的线程,在执行业务代码时出错。或者程序宕机,没能成功执行删除锁的逻辑,那么其他线程就一直等待,造成死锁。

死锁问题

解决方法:用 redis 的设置过期时间,来保证,即使宕机,业务异常,锁也能在超时过后自动释放。注意为了避免,有锁的线程还没来得及设置锁的过期时间,就挂了,这里需要保证原子性:使用setIfAbsent这个API,redisTemplate.opsForValue().setIfAbsent("lock", "111", 3, TimeUnit.SECONDS);

但是依旧存在问题,假如我们设置过期时间为20s,但是查询数据库要用了30s.线程A开始查询数据库,20s过去了,锁自动释放,线程B拿到锁开始查询数据库,过了10s后线程A的查询结束,执行删除锁的逻辑 ; 这样就会删除线程B的锁,然后线程C就会拿到锁。

解决方法:在加锁了之后,由于锁会有过期时间,然而又不能保证,锁一定不会在执行结束过后过期,
那么,我们就可以采用多线程的方案,让锁每隔一定时间,就重新设置它的超时时间。

这样就基本解决了缓存击穿的问题。

缓存雪崩

其实把缓存击穿搞清楚了,理解缓存雪崩也会容易许多。

缓存雪崩,指的是大面积的 key 同时过期,导致大量并发打到我们的数据库。不像击穿,只是因为 1 个 key 的过期。那么可以把把雪崩的所有 key 拆成一个一个 key 来看,也就是雪崩可以拆分成一个一个缓存击穿的集合。但雪崩时,可能一个 key 几十几百的并发,不想缓存击穿那么极端,一个 key 就成千上万的并发,直接把数据库打垮了。

所以解决方法并不也是加锁。

首先一个很常见的做法就是,分散 key 的过期时间

这么做是可行的,因为这个问题的本质,就是要让瞬间到来的并发,把它分散开。而给了一定的随机过期时间之后,就能够使得 key 会分散开,一个一个过期,
所以,并发量就会分成一部分,一部分,少量的打到数据库上。

不过也有特殊情况就是,如果你的业务对必须每天的指定时间,去更新我们的数据。就比如游戏每日零点更新,或者财报记录……等等等等。

就是缓存必须在指定的时间全部失效。

解决方法:既然 redis 无法分散过期时间,那么,我们去查数据的时候,是不是可以把时间稍微地分散一下?

就是在客户端设置随机延迟时间,这样,查询的操作,就被分散了开来;少量的请求,先查询,就会读数据库,然后存入 redis;其他请求,由于随机时间,稍稍慢了点,就可以去 redis 读出数据。

缓存穿透

概念:请求去查询数据库根本不存在的数据。

缓存穿透,意味着,这个数据,数据库里也没有。
所以,就不可能会把数据存到 redis 缓存里,因此只要有人来查询,就一定缓存中查不到,所以就一定要走数据库。

那么,想要解决缓存穿透,就必须想办法,能够识别出,哪些请求的数据,是数据库没有的,然后,对这些请求的查询,进行过滤。

解决方法:最简单的,当用户查询不存在的数据时,将这个 key,存入 redis,然后用一个特殊的 value 来表示,这是一个不存在的数据。 

但是,如果有大量的请求,都请求各不相同的不存在的数据,那么,redis 的缓存,就会用来存储大量没用的数据,就会造成空间的浪费。并且数据是无限的

所以我们可以额外开辟一块缓存set,将数据中所有数据的key保存,舍弃value。每次要访问数据库前,先去  set 中查询时候存在,如果存在,那么再去访问数据库。

但是数据是不可估量的,这种解决方法的成本很高。

那有什么办法可以压缩空间吗

有:bitmap,因为一个 key 只占用一个 bit,所以,假设我们花费 1 个字节的空间,就能存储 8 个 key;

我们的每一个 key,都能通过哈希函数,转换成一个数组的下标,存储在 底层数组中

采用哈希映射,就可以将那些所有存在的 key,全部对应到这个 bitmap 的每一个槽位上,
这样,我们可以将所有存在的 key,把它映射到一个 bit 槽位上,然后用 1 表示,其他剩余的部分,就用 0 表示,
这样,当一个查询的 key 被映射到 0 这个槽位,那么就代表这个数据不存在,所以就可以直接返回。
因此,就可以实现对请求数据的过滤。

但这也存在hash最常见的问题:就是冲突问题,我们不可能因为hash冲突,增加数组的长度

所以有了布隆过滤器布隆(Bloom Filter)过滤器——全面讲解,建议收藏_李子捌的博客-CSDN博客_布隆过滤器

布隆过滤器

Redis中的布隆过滤器底层是一个大型位数组(二进制数组)+多个无偏hash函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。

数组就是一个二进制数组

多个无偏hash函数:无偏hash函数就是能把元素的hash值计算的比较均匀的hash函数,能使得计算后的元素下标比较均匀的映射到位数组中。

如下就是一个简单的布隆过滤器示意图,其中k1、k2代表增加的元素,a、b、c即为无偏hash函数,最下层则为二进制数组。

布隆过滤器.png

3.2 空间计算


在布隆过滤器增加元素之前,首先需要初始化布隆过滤器的空间,也就是上面说的二进制数组,除此之外还需要计算无偏hash函数的个数。布隆过滤器提供了两个参数,分别是预计加入元素的大小n,运行的错误率f。布隆过滤器中有算法根据这两个参数会计算出二进制数组的大小l,以及无偏hash函数的个数k。
它们之间的关系比较简单:

        错误率越低,位数组越长,控件占用较大
        错误率越低,无偏hash函数越多,计算耗时较长

 3.3 增加元素

往布隆过滤器增加元素,添加的key需要根据k个无偏hash函数计算得到k个hash值,

然后对数组长度进行取模得到数组下标的位置,

然后将对应数组下标的k个位置的值置为1

例如,key = Liziba,无偏hash函数的个数k=3,分别为hash1、hash2、hash3。三个hash函数计算后得到三个数组下标值,并将其值修改为1.
如图所示:

增加元素.png

3.4 查询元素


布隆过滤器最大的用处就在于判断某样东西一定不存在或者可能存在,而这个就是查询元素的结果。其查询元素的过程如下:

        通过k个无偏hash函数计算得到k个hash值
        依次取模数组长度,得到数组索引
        判断索引处的值是否全部为1,如果全部为1则存在(这种存在可能是误判),如果存在一个0则必定不存在
关于误判,其实非常好理解,hash函数在怎么好,也无法完全避免hash冲突,也就是说可能会存在多个元素计算的hash值是相同的,那么它们取模数组长度后的到的数组索引也是相同的,这就是误判的原因。例如李子捌和李子柒的hash值取模后得到的数组索引都是1,但其实这里只有李子捌,如果此时判断李子柒在不在这里,误判就出现啦!因此布隆过滤器最大的缺点误判只要知道其判断元素是否存在的原理就很容易明白了!

布隆过滤器的优点:

        时间复杂度低,增加和查询元素的时间复杂为O(N),(N为哈希函数的个数,通常情况比较小)
        保密性强,布隆过滤器不存储元素本身
        存储空间小,如果允许存在一定的误判,布隆过滤器是非常节省空间的(相比其他数据结构如Set集合)


布隆过滤器的缺点:

        有点一定的误判率,但是可以通过调整参数来降低
        无法获取元素本身
        很难删除元素

集群模式 

Redis集群详解_最爱喝酸奶的博客-CSDN博客_redis集群

主从复制

从模式最大的优点是部署简单,最少两个节点便可以构成主从模式,并且可以通过读写分离避免读和写同时不可用。不过,一旦 Master 节点出现故障,主从节点就无法自动切换,直接导致 SLA 下降。所以,主从模式一般适合业务发展初期,并发量低,运维成本低的情况

一、概念

主从复制,是指将一台Redis服务器的数据,复制到其他的Redis服务器。前者称为主节点(master/leader),后者称为从节点(slave/follower);数据的复制是单向的,只能由主节点到从节点。Master以写为主,Slave 以读为主。
主要作用:
①数据冗余:主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
②故障恢复:当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复;实际上是一种服务的冗余。
③负载均衡:在主从复制的基础上,配合读写分离,可以由主节点提供服务,由从节点提供服务(即写Redis数据时应用连接主节点,读Redis数据时应用连接从节点),分担服务器负载;尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高Redis服务器的并发量。
④高可用(集群)基石:除了上述作用以外,主从复制还是哨兵和集群能够实施的基础,因此说主从复制是Redis高可用的基础。

二、注意点

②如果主机断开,但是从机还是可以正常读取原先就有的数据的;如果断开的主机重新连接上,从机也可正常连接上主机。

④如果从机断开重连,不会自动连接上主机!因为我们的配置是在从机上写的,而且是命令写的,重启时会重置!

③从机只能读操作,不能写操作

三、原理

①通过从服务器发送到PSYNC命令给主服务器

②如果是首次连接,触发一次全量复制。此时主节点会启动一个后台线程,生成 RDB 快照文件

③主节点会将这个 RDB 发送给从节点,slave 会先写入本地磁盘,再从本地磁盘加载到内存中

④master会将此过程中的写命令写入缓存,从节点实时同步这些数据

⑤如果网络断开了连接,自动重连后主节点通过命令传播增量复制给从节点部分缺少的数据

其中,

全量复制:**而slave服务在接收到数据库文件数据后,将其存盘并加载到内存中。

增量复制: Master 继续将新的所有收集到的修改命令依次传给slave,完成同步但是只要是重新连接master,一次完全同步(全量复制)将被自动执行! 我们的数据一定可以在从机中看到!

缺点

所有的slave节点数据的复制和同步都由master节点来处理,会照成master节点压力太大。

主服务器宕机后,需要手动把一台从服务器切换为主服务器,这就需要人工干预,费事费力,还会造成一段时间内服务不可用。

集群之哨兵模式

一、概念

主从切换技术的方法是:当主服务器宕机后,需要手动把一台从服务器切换为主服务器,这就需要人工干预,费事费力,还会造成一段时间内服务不可用。这不是一种推荐的方式,更多时候,我们优先考虑 哨兵模式 。Redis从2.8开始正式提供了Sentinel(哨兵) 架构来解决这个问题。
谋朝篡位 的自动版,能够后台监控主机是否故障,如果故障了根据投票数自动将从库转换为主库
哨兵模式是一种特殊的模式,由一个或多个sentinel实例组成sentinel集群可以监视一个或多个主服务器和多个从服务器。哨兵模式适合读请求远多于写请求的业务场景,比如在秒杀系统中用来缓存活动信息。 如果写请求较多,当集群 Slave 节点数量多了后,Master 节点同步数据的压力会非常大。
二、注意点

主机宕机后自动选取新的主机,如果主机此时回来了,只能归并到新的主机下,当做从机,这就是哨兵模式的规则!

1、优点
①哨兵集群,基于主从复制模式 ,所有的主从配置优点,它全有
②主从可以切换,故障可以转移 ,系统的 可用性 就会更好
③哨兵模式就是主从模式的升级,手动到自动,更加健壮!
2、缺点
①Redis 不好在线扩容 的,集群容量一旦到达上限,在线扩容就十分麻烦!
②实现哨兵模式的配置其实是很 麻烦 的,里面有很多选择!

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值