zoukankan      html  css  js  c++  java
  • redis总结

    Redis

    redis优点

    • 支持复杂的数据类型
    • 原生支持集群模式
    • 存储小数据时性能更高(纯内存操作,核心是基于非阻塞的 IO 多路复用机制,C语言实现,单线程反而避免了多线程的频繁上下文切换)

    redis线程模型

    redis 内部使用文件事件处理器 file event handler,这个文件事件处理器是单线程的,所以 redis 才叫做单线程的模型。它采用 IO 多路复用机制同时监听多个 socket,将产生事件的 socket 压入内存队列中,事件分派器根据 socket 上的事件类型来选择对应的事件处理器进行处理。

    文件事件处理器的结构包含 4 个部分:

    • 多个 socket
    • IO 多路复用程序
    • 文件事件分派器
    • 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)

    多个 socket 可能会并发产生不同的操作,每个操作对应不同的文件事件,但是 IO 多路复用程序会监听多个 socket,会将产生事件的 socket 放入队列中排队,事件分派器每次从队列中取出一个 socket,根据 socket 的事件类型交给对应的事件处理器进行处理。

    客户端与 redis 的一次通信过程:

    首先,redis 服务端进程初始化的时候,会将 server socket 的 AE_READABLE 事件与连接应答处理器关联。

    客户端 socket01 向 redis 进程的 server socket 请求建立连接,此时 server socket 会产生一个 AE_READABLE 事件,IO 多路复用程序监听到 server socket 产生的事件后,将该 socket 压入队列中。文件事件分派器从队列中获取 socket,交给连接应答处理器。连接应答处理器会创建一个能与客户端通信的 socket01,并将该 socket01 的 AE_READABLE 事件与命令请求处理器关联。

    假设此时客户端发送了一个 set key value 请求,此时 redis 中的 socket01 会产生 AE_READABLE 事件,IO 多路复用程序将 socket01 压入队列,此时事件分派器从队列中获取到 socket01 产生的 AE_READABLE 事件,由于前面 socket01 的 AE_READABLE 事件已经与命令请求处理器关联,因此事件分派器将事件交给命令请求处理器来处理。命令请求处理器读取 socket01 的 key value 并在自己内存中完成 key value 的设置。操作完成后,它会将 socket01 的 AE_WRITABLE 事件与命令回复处理器关联。

    如果此时客户端准备好接收返回结果了,那么 redis 中的 socket01 会产生一个 AE_WRITABLE 事件,同样压入队列中,事件分派器找到相关联的命令回复处理器,由命令回复处理器对 socket01 输入本次操作的一个结果,比如 ok,之后解除 socket01 的 AE_WRITABLE 事件与命令回复处理器的关联。

    redis数据类型

    • string:

      set college szu
      
    • hash

      hset person name bingo
      hset person age 20
      hset person id 1
      hget person name
      
    • list

      通过 lrange 命令,读取某个闭区间内的元素,可以基于 list 实现分页查询

      # 0开始位置,-1结束位置,结束位置为-1时,表示列表的最后一个位置,即查看所有。
      lrange mylist 0 -1
      
      

      简单的消息队列

      lpush mylist 1
      lpush mylist 2
      lpush mylist 3 4 5
      
      # 1
      rpop mylist
      
    • set

      基于 set 玩儿交集、并集、差集的操作

      #-------操作一个set-------
      # 添加元素
      sadd mySet 1
      
      # 查看全部元素
      smembers mySet
      
      # 判断是否包含某个值
      sismember mySet 3
      
      # 删除某个/些元素
      srem mySet 1
      srem mySet 2 4
      
      # 查看元素个数
      scard mySet
      
      # 随机删除一个元素
      spop mySet
      
      #-------操作多个set-------
      # 将一个set的元素移动到另外一个set
      smove yourSet mySet 2
      
      # 求两set的交集
      sinter yourSet mySet
      
      # 求两set的并集
      sunion yourSet mySet
      
      # 求在yourSet中而不在mySet中的元素
      sdiff yourSet mySet
      
    • sorted set

      sorted set 是排序的 set,去重并可以排序,写进去的时候给一个分数,自动根据分数排序

      zadd board 85 zhangsan
      zadd board 72 lisi
      zadd board 96 wangwu
      zadd board 63 zhaoliu
      
      # 获取排名前三的用户(默认是升序,所以需要 rev 改为降序)
      zrevrange board 0 3
      
      # 获取某用户的排名
      zrank board zhaoliu
      
    • redis5.0 stream(消息需要手动删除,订阅可以阻塞)

    redis发布订阅

    redis常用命令 info(服务器信息包括主从信息) monitor(服务器上执行的所有命令)type(查看当前key的类型)

    redis 发布订阅 ,基本的mq功能,另外redis事件机制也是基于发布订阅,默认可以发送两种维度消息(某个key被删除和del或expired了某个数据),默认不开启时间通知,需要设置或者配置文件中打开

    redis数据结构封装类型

    先说命令

    type key //显示key的数据类型
    BJECT ENCODING    key  //显示底层数据结构
    

    Redis使用自己封装的数据类型来表示键和值,每次在Redis数据库中创建一个键值对时,至少会创建两个对象,一个是键对象,一个是值对象,而Redis中的每个对象都是由 redisObject 结构来表示:

    typedef struct redisObject{
         //数据类型
         unsigned type:4;
         //redis基于c之上的封装类型
         unsigned encoding:4;
         //指向底层数据结构的指针
         void *ptr;
         //引用计数
         int refcount;
         //记录最后一次被程序访问的时间
         unsigned lru:22;
    }robj
    

    在Redis中,键总是一个字符串对象,而值可以是字符串、列表、集合等对象,所以我们通常说的键为字符串键,表示的是这个键对应的值为字符串对象,我们说一个键为集合键时,表示的是这个键对应的值为集合对象

    对象的type属性记录了对象的类型,这个类型就是前面讲的五大数据类型。

    对象的 prt 指针指向对象底层的数据结构,而数据结构由 encoding 属性来决定。

    redis五大数据类型与底层封装类型对应关系

    简单动态字符串

    Redis 是用 C 语言写的,但是对于Redis的字符串,却不是 C 语言中的字符串(即以空字符’’结尾的字符数组),它是自己构建了一种名为 简单动态字符串(simple dynamic string,SDS)的抽象类型,并将 SDS 作为 Redis的默认字符串表示。

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

    1、len 保存了SDS保存字符串的长度

    2、buf[] 数组用来保存字符串的每个元素

    3、free j记录了 buf 数组中未使用的字节数量

    上面的定义相对于 C 语言对于字符串的定义,多出了 len 属性以及 free 属性。这样实现有什么好处?

    • 常数复杂度获取字符串长度:由于 len 属性的存在,我们获取 SDS 字符串的长度只需要读取 len 属性,时间复杂度为 O(1)。而对于 C 语言,获取字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n)。通过 strlen key 命令可以获取 key 的字符串长度。

    • 杜绝缓冲区溢出: 我们知道在 C 语言中使用 strcat 函数来进行两个字符串的拼接,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。而对于 SDS 数据类型,在进行字符修改的时候,会首先根据记录的 len 属性检查内存空间是否满足需求,如果不满足,会进行相应的空间扩展,然后在进行修改操作,所以不会出现缓冲区溢出。

    • 减少修改字符串的内存重新分配次数:C语言由于不记录字符串的长度,所以如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。而对于SDS,由于len属性和free属性的存在,对于修改字符串SDS实现了空间预分配和惰性空间释放两种策略:

      1、空间预分配:对字符串进行空间扩展的时候,扩展的内存比实际需要的多,这样可以减少连续执行字符串增长操作所需的内存重分配次数。

      2、惰性空间释放:对字符串进行缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是使用 free 属性将这些字节的数量记录下来,等待后续使用。(当然SDS也提供了相应的API,当我们有需要时,也可以手动释放这些未使用的空间。)

    • 二进制安全:因为C字符串以空字符作为字符串结束的标识,而对于一些二进制文件(如图片等),内容可能包括空字符串,因此C字符串无法正确存取;而所有 SDS 的API 都是以处理二进制的方式来处理 buf 里面的元素,并且 SDS 不是以空字符串来判断是否结束,而是以 len 属性表示的长度来判断字符串是否结束。

    • 兼容部分 C 字符串函数:虽然 SDS 是二进制安全的,但是一样遵从每个字符串都是以空字符串结尾的惯例,这样可以重用 C 语言库<string.h> 中的一部分函数。

    链表

    链表是一种常用的数据结构,C 语言内部是没有内置这种数据结构的实现,所以Redis自己构建了链表的实现。

    typedef  struct listNode{
           //前置节点
           struct listNode *prev;
           //后置节点
           struct listNode *next;
           //节点的值
           void *value;  
    }listNode
    

    通过多个 listNode 结构就可以组成链表,这是一个双向链表,Redis还提供了操作链表的数据结构:

    typedef struct list{
         //表头节点
         listNode *head;
         //表尾节点
         listNode *tail;
         //链表所包含的节点数量
         unsigned long len;
         //节点值复制函数
         void (*free) (void *ptr);
         //节点值释放函数
         void (*free) (void *ptr);
         //节点值对比函数
         int (*match) (void *ptr,void *key);
    }list;
    

    Redis链表特性:

    ①、双端:链表具有前置节点和后置节点的引用,获取这两个节点时间复杂度都为O(1)。

    ②、无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问都是以 NULL 结束。  

    ③、带链表长度计数器:通过 len 属性获取链表长度的时间复杂度为 O(1)。

    ④、多态:链表节点使用 void* 指针来保存节点值,可以保存各种不同类型的值。

    字典

    字典又称为符号表或者关联数组、或映射(map),是一种用于保存键值对的抽象数据结构。字典中的每一个键 key 都是唯一的,通过 key 可以对值来进行查找或修改。C 语言中没有内置这种数据结构的实现,所以字典依然是 Redis自己构建的。Redis 的字典使用哈希表作为底层实现。

    typedef struct dictht{
         //哈希表数组
         dictEntry **table;
         //哈希表大小
         unsigned long size;
         //哈希表大小掩码,用于计算索引值
         //总是等于 size-1
         unsigned long sizemask;
         //该哈希表已有节点的数量
         unsigned long used;
     
    }dictht
    

    哈希表是由数组 table 组成,table 中每个元素都是指向 dict.h/dictEntry 结构,dictEntry 结构定义如下:

    typedef struct dictEntry{
         //键
         void *key;
         //值
         union{
              void *val;
              uint64_tu64;
              int64_ts64;
         }v;
     
         //指向下一个哈希表节点,形成链表
         struct dictEntry *next;
    }dictEntry
    

    哈希表最大的问题是存在哈希冲突,如何解决哈希冲突,有开放地址法和链地址法。这里采用的便是链地址法,通过next这个指针可以将多个哈希值相同的键值对连接在一起,用来解决哈希冲突

    跳表

    跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其它节点的指针,从而达到快速访问节点的目的。具有如下性质:

    1、由很多层结构组成;

    2、每一层都是一个有序的链表,排列顺序为由高层到底层,都至少包含两个链表节点,分别是前面的head节点和后面的nil节点;

    3、最底层的链表包含了所有的元素;

    4、如果一个元素出现在某一层的链表中,那么在该层之下的链表也全都会出现(上一层的元素是当前层的元素的子集);

    5、链表中的每个节点都包含两个指针,一个指向同一层的下一个链表节点,另一个指向下一层的同一个链表节点;

    6、每一个节点会随机给一个层的level,假设调表有4层,那么每插入一个节点则在1-4中给一个随机值,这样就避免了二叉平衡树中插入节点时树的平衡问题。

    Redis中跳跃表节点定义如下:

    typedef struct zskiplistNode {
         //层
         struct zskiplistLevel{
               //前进指针
               struct zskiplistNode *forward;
               //跨度
               unsigned int span;
         }level[];
     
         //后退指针
         struct zskiplistNode *backward;
         //分值
         double score;
         //成员对象
         robj *obj;
     
    } zskiplistNode
    

    多个跳跃表节点构成一个跳跃表:

    typedef struct zskiplist{
         //表头节点和表尾节点
         structz skiplistNode *header, *tail;
         //表中节点的数量
         unsigned long length;
         //表中层数最大的节点的层数
         int level;
     
    }zskiplist;
    

    调表的各个操作执行流程:

    ①、搜索:从最高层的链表节点开始,如果比当前节点要大和比当前层的下一个节点要小,那么则往下找,也就是和当前层的下一层的节点的下一个节点进行比较,以此类推,一直找到最底层的最后一个节点,如果找到则返回,反之则返回空。

    ②、插入:首先确定插入的层数,有一种方法是假设抛一枚硬币,如果是正面就累加,直到遇见反面为止,最后记录正面的次数作为插入的层数。当确定插入的层数k后,则需要将新元素插入到从底层到k层。

    ③、删除:在各个层中找到包含指定值的节点,然后将节点从链表中删除即可,如果删除以后只剩下头尾两个节点,则删除这一层。

    整数集合

    整数集合(intset)是Redis用于保存整数值的集合抽象数据类型,它可以保存类型为int16_t、int32_t 或者int64_t 的整数值,并且保证集合中不会出现重复元素。

    typedef struct intset{
       //编码方式
       uint32_t encoding;
       //集合包含的元素数量
       uint32_t length;
      //保存元素的数组
       int8_t contents[];
    }intset;
    

    整数集合的每个元素都是 contents 数组的一个数据项,它们按照从小到大的顺序排列,并且不包含任何重复项。length 属性记录了 contents 数组的大小。

    压缩列表

    压缩列表(ziplist)是Redis为了节省内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构,一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。

    压缩列表的原理:压缩列表并不是对数据利用某种算法进行压缩,而是将数据按照一定规则编码在一块连续的内存区域,目的是节省内存。

    redis五大数据类型的实现原理

    字符串对象

    字符串是Redis最基本的数据类型,不仅所有key都是字符串类型,其它几种数据类型构成的元素也是字符串。注意字符串的长度不能超过512 字符串对象的编码可以是int,raw或者embstr。

    1、int 编码:保存的是可以用 long 类型表示的整数值。

    2、raw 编码:保存长度大于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)。

    3、embstr 编码:保存长度小于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)。

    int 编码是用来保存整数值,raw编码是用来保存长字符串,而embstr是用来保存短字符串。其实 embstr 编码是专门用来保存短字符串的一种优化编码,raw 和 embstr 的区别:

    embstr与raw都使用redisObject和sds保存数据,区别在于,embstr的使用只分配一次内存空间(因此redisObject和sds是连续的),而raw需要分配两次内存空间(分别为redisObject和sds分配空间)。因此与raw相比,embstr的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。而embstr的坏处也很明显,如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,因此redis中的embstr实现为只读。

    Redis中对于浮点数类型也是作为字符串保存的,在需要的时候再将其转换成浮点数类型。

    当 int 编码保存的值不再是整数,或大小超过了long的范围时,自动转化为raw。

    对于 embstr 编码,由于 Redis 没有对其编写任何的修改程序(embstr 是只读的),在对embstr对象进行修改时,都会先转化为raw再进行修改,因此,只要是修改embstr对象,修改后的对象一定是raw的,无论是否达到了44个字节。

    列表对象

    list 列表,它是简单的字符串列表,按照插入顺序排序,你可以添加一个元素到列表的头部(左边)或者尾部(右边),它的底层实际上是个链表结构。

    列表对象的编码可以是 ziplist(压缩列表) 和 linkedlist(双端链表)

    当同时满足下面两个条件时,使用ziplist(压缩列表)编码:

    1、列表保存元素个数小于512个

    2、每个元素长度小于64字节

    不能满足这两个条件的时候使用 linkedlist 编码。

    上面两个条件可以在redis.conf 配置文件中的 list-max-ziplist-value选项和 list-max-ziplist-entries 选项进行配置。

    哈希对象

    哈希对象的键是一个字符串类型,值是一个键值对集合。

    哈希对象的编码可以是 ziplist 或者 hashtable。当使用ziplist,也就是压缩列表作为底层实现时,新增的键值对是保存到压缩列表的表尾。

    hashtable 编码的哈希表对象底层使用字典数据结构,哈希对象中的每个键值对都使用一个字典键值对。

    压缩列表是Redis为了节省内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构,相对于字典数据结构,压缩列表用于元素个数少、元素长度小的场景。其优势在于集中存储,节省空间。

    和上面列表对象使用 ziplist 编码一样,当同时满足下面两个条件时,使用ziplist(压缩列表)编码:

    1、列表保存元素个数小于512个

    2、每个元素长度小于64字节

    不能满足这两个条件的时候使用 hashtable 编码。第一个条件可以通过配置文件中的 set-max-intset-entries 进行修改。

    集合对象

    集合对象 set 是 string 类型(整数也会转换成string类型进行存储)的无序集合。注意集合和列表的区别:集合中的元素是无序的,因此不能通过索引来操作元素;集合中的元素不能有重复。

    集合对象的编码可以是 intset 或者 hashtable。

    intset 编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合中。

    hashtable 编码的集合对象使用 字典作为底层实现,字典的每个键都是一个字符串对象,这里的每个字符串对象就是一个集合中的元素,而字典的值则全部设置为 null。这里可以类比Java集合中HashSet 集合的实现,HashSet 集合是由 HashMap 来实现的,集合中的元素就是 HashMap 的key,而 HashMap 的值都设为 null。

    当集合同时满足以下两个条件时,使用 intset 编码:

    1、集合对象中所有元素都是整数

    2、集合对象所有元素数量不超过512

    不能满足这两个条件的就使用 hashtable 编码。第二个条件可以通过配置文件的 set-max-intset-entries 进行配置。

    有序集合对象

    有序集合对象是有序的。与列表使用索引下标作为排序依据不同,有序集合为每个元素设置一个分数(score)作为排序依据。

    有序集合的编码可以是 ziplist 或者 skiplist。

    ziplist 编码的有序集合对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个节点保存元素的分值。并且压缩列表内的集合元素按分值从小到大的顺序进行排列,小的放置在靠近表头的位置,大的放置在靠近表尾的位置。

    skiplist 编码的有序集合对象使用 zet 结构作为底层实现,一个 zset 结构同时包含一个字典和一个跳跃表:

    typedef struct zset{
         //跳跃表
         zskiplist *zsl;
         //字典
         dict *dice;
    } zset;
    

    字典的键保存元素的值,字典的值则保存元素的分值;跳跃表节点的 object 属性保存元素的成员,跳跃表节点的 score 属性保存元素的分值。

    这两种数据结构会通过指针来共享相同元素的成员和分值,所以不会产生重复成员和分值,造成内存的浪费。

    说明:其实有序集合单独使用字典或跳跃表其中一种数据结构都可以实现,但是这里使用两种数据结构组合起来,原因是假如我们单独使用 字典,虽然能以 O(1) 的时间复杂度查找成员的分值,但是因为字典是以无序的方式来保存集合元素,所以每次进行范围操作的时候都要进行排序;假如我们单独使用跳跃表来实现,虽然能执行范围操作,但是查找操作有 O(1)的复杂度变为了O(logN)。因此Redis使用了两种数据结构来共同实现有序集合。

    当有序集合对象同时满足以下两个条件时,对象使用 ziplist 编码:

    1、保存的元素数量小于128;

    2、保存的所有元素长度都小于64字节。

    不能满足上面两个条件的使用 skiplist 编码。以上两个条件也可以通过Redis配置文件zset-max-ziplist-entries 选项和 zset-max-ziplist-value 进行修改。

    redis 过期策略

    redis 过期策略是:定期删除+惰性删除

    定期删除:当某个key被设置了过期时间之后,客户端每次对该key的访问(读写)都会事先检测该key是否过期,如果过期就直接删除;但有一些键只访问一次,因此需要主动删除,默认情况下redis每秒检测10次,检测的对象是所有设置了过期时间的键集合,每次从这个集合中随机检测20个键查看他们是否过期,如果过期就直接删除,如果删除后还有超过25%的集合中的键已经过期,那么继续检测过期集合中的20个随机键进行删除。这样可以保证过期键最大只占所有设置了过期时间键的25%。

    惰性删除:定期删除可能会导致很多过期 key 到了时间并没有被删除掉,那咋整呢?所以就是惰性删除了。这就是说,在你获取某个 key 的时候,redis 会检查一下 ,这个 key 如果设置了过期时间那么是否过期了?如果过期了此时就会删除,不会给你返回任何东西。

    redis 内存淘汰机制有以下几个:

    • noeviction: 当内存不足以容纳新写入数据时,新写入操作会报错。
    • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近使用最少的 key。
    • allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个 key。
    • allkeys-lfu:当内存不足以容纳新写入数据时,在键空间中,移除最近使用频率最少的 key。
    • volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key(这个一般不太合适)。
    • volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key。
    • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除。
    • volatile-lfu:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近使用频率最少的 key。

    LRU

    最近最少使用。优先淘汰最近未被使用的数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。

    java实现LRU可以依赖LinkedHashMap

    LinkedHashMap自身已经实现了顺序存储,默认情况下是按照元素的添加顺序存储,也可以启用按照访问顺序存储,即最近读取的数据放在最前面,最早读取的数据放在最后面,然后它还有一个判断是否删除最老数据的方法,默认是返回false,即不删除数据。

    //LinkedHashMap的一个构造函数,当参数accessOrder为true时,即会按照访问顺序排序,最近访问的放在最前,最早访问的放在后面
    public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) {
            super(initialCapacity, loadFactor);
            this.accessOrder = accessOrder;
    }
    
    //LinkedHashMap自带的判断是否删除最老的元素方法,默认返回false,即不删除老数据
    //我们要做的就是重写这个方法,当满足一定条件时删除老数据
    protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
            return false;
    }
    

    LRU底层结构是 hash 表 + 双向链表。hash 表用于保证查询操作的时间复杂度是O(1),双向链表用于保证节点插入、节点删除的时间复杂度是O(1)。

    LRU GET操作:如果节点存在,则将该节点移动到链表头部,并返回节点值;
    LRU PUT操作:节点不存在,则新增节点,并将该节点放到链表头部;节点存在,则更新节点,并将该节点放到链表头部。

    Java实现

    public class LRUCache1<K, V> {
    
        private final int MAX_CACHE_SIZE;
        private Entry first;
        private Entry last;
    
        private HashMap<K, Entry<K, V>> hashMap;
    
        public LRUCache1(int cacheSize) {
            MAX_CACHE_SIZE = cacheSize;
            hashMap = new HashMap<K, Entry<K, V>>();
        }
    
        public void put(K key, V value) {
            Entry entry = getEntry(key);
            if (entry == null) {
                if (hashMap.size() >= MAX_CACHE_SIZE) {
                    hashMap.remove(last.key);
                    removeLast();
                }
                entry = new Entry();
                entry.key = key;
            }
            entry.value = value;
            moveToFirst(entry);
            hashMap.put(key, entry);
        }
    
        public V get(K key) {
            Entry<K, V> entry = getEntry(key);
            if (entry == null) return null;
            moveToFirst(entry);
            return entry.value;
        }
    
        public void remove(K key) {
            Entry entry = getEntry(key);
            if (entry != null) {
                if (entry.pre != null) entry.pre.next = entry.next;
                if (entry.next != null) entry.next.pre = entry.pre;
                if (entry == first) first = entry.next;
                if (entry == last) last = entry.pre;
            }
            hashMap.remove(key);
        }
    
        private void moveToFirst(Entry entry) {
            if (entry == first) return;
            if (entry.pre != null) entry.pre.next = entry.next;
            if (entry.next != null) entry.next.pre = entry.pre;
            if (entry == last) last = last.pre;
    
            if (first == null || last == null) {
                first = last = entry;
                return;
            }
    
            entry.next = first;
            first.pre = entry;
            first = entry;
            entry.pre = null;
        }
    
        private void removeLast() {
            if (last != null) {
                last = last.pre;
                if (last == null) first = null;
                else last.next = null;
            }
        }
    
    
        private Entry<K, V> getEntry(K key) {
            return hashMap.get(key);
        }
    
        @Override
        public String toString() {
            StringBuilder sb = new StringBuilder();
            Entry entry = first;
            while (entry != null) {
                sb.append(String.format("%s:%s ", entry.key, entry.value));
                entry = entry.next;
            }
            return sb.toString();
        }
    
        class Entry<K, V> {
            public Entry pre;
            public Entry next;
            public K key;
            public V value;
        }
    }
    

    LFU

    使用频率最少的(最不经常使用的。优先淘汰最近使用的少的数据,其核心思想是“如果一个数据在最近一段时间很少被访问到,那么将来被访问的可能性也很小”。

    如果一条数据仅仅是突然被访问(有可能后续将不再访问),在 LRU 算法下,此数据将被定义为热数据,最晚被淘汰。但实际生产环境下,我们很多时候需要计算的是一段时间下key的访问频率,淘汰此时间段内的冷数据。LFU 算法相比 LRU,在某些情况下可以提升 数据命中率,使用频率更多的数据将更容易被保留。

    redis主从-哨兵

    salve of 命令 新版本中替换为 repleca of

    主从复制

    主从复制应用场景:

    实现reidis读写分离,

    salve设定为readonly,可以用在数据安全的场景下

    可以使用主从复制来避免master持久化带来的开销,master关闭持久化,slave设置为不定期保存或者启用aof,此时需注意master在重启后将从新的数据集开始,如果一个salve试图与他同步则会丢失原来持久化的数据。因此此时master节点不要配置自动重启

    当启动一个 slave node 的时候,它会发送一个 PSYNC 命令给 master node(信息包含已有的同步源id和同步进度offset)。master接收到请求后判断同步源id是否为自己,是的话则根据偏移量增量同步,同步源id不是当前master,则进入全量同步,那么会触发一次 full resynchronization 全量复制。此时 master 会执行bgsave,启动一个后台线程,开始生成一份 RDB 快照文件,同时还会将从客户端 client 新收到的所有写命令缓存在内存中。RDB 文件生成完毕后, master 会将这个 RDB 发送给 slave,slave 会先写入本地磁盘,然后再从本地磁盘加载到内存中,接着 master 会将内存中缓存的写命令发送到 slave,slave 也会同步这些数据。slave node 如果跟 master node 有网络故障,断开了连接,会自动重连,连接之后 master node 仅会复制给 slave 部分缺少的数据。

    如果主从复制过程中,网络连接断掉了,那么可以接着上次复制的地方。master node 会在内存中维护一个 backlog,master 和 slave 都会保存一个 replica offset 还有一个 master run id,offset 就是保存在 backlog 中的。如果 master 和 slave 网络连接断掉了,slave 会让 master 从上次 replica offset 开始继续复制,如果没有找到对应的 offset,那么就会执行一次 resynchronization

    redis默认使用异步复制,主从同步时在master侧是非阻塞的,slave初次同步会删除旧数据,加载新数据,在这个过程中会阻塞到来的连接请求。

    如果 slave node 开启了 AOF,那么会立即执行 BGREWRITEAOF,重写 AOF。

    如果根据 host+ip 定位 master node,是不靠谱的,如果 master node 重启或者数据出现了变化,那么 slave node 应该根据不同的 run id 区分。

    无磁盘化复制

    master 在内存中直接创建 RDB,然后发送给 slave,不会在自己本地落地磁盘了。只需要在配置文件中开启 repl-diskless-sync yes 即可。

    过期 key 处理

    slave 不会过期 key,只会等待 master 过期 key。如果 master 过期了一个 key,或者通过 LRU 淘汰了一个 key,那么会模拟一条 del 命令发送给 slave。

    heartbeat

    主从节点互相都会发送 heartbeat 信息。

    master 默认每隔 10秒 发送一次 heartbeat,slave node 每隔 1秒 发送一个 heartbeat。

    异步复制

    master 每次接收到写命令之后,先在内部写入数据,然后异步发送给 slave node。

    主从切换丢数据

    redis 哨兵主备切换的数据丢失问题

    • 异步复制导致的数据丢失,因为 master->slave 的复制是异步的,所以可能有部分数据还没复制到 slave,master 就宕机了,此时这部分数据就丢失了。
    • 脑裂导致的数据丢失,虽然某个 slave 被切换成了 master,但是可能 client 还没来得及切换到新的 master,还继续向旧 master 写数据。因此旧 master 再次恢复的时候,会被作为一个 slave 挂到新的 master 上去,自己的数据会清空,重新从新的 master 复制数据。而新的 master 并没有后来 client 写入的数据,因此,这部分数据也就丢失了

    数据丢失问题的解决方案

    min-slaves-to-write 1
    min-slaves-max-lag 10
    

    表示,要求至少有 1 个 slave与master数据复制和同步的延迟不能超过 10 秒。

    sentinel

    sentinel功能

    • 集群监控:负责监控 redis master 和 slave 进程是否正常工作。
    • 消息通知:如果某个 redis 实例有故障,那么哨兵负责发送消息作为报警通知给管理员。
    • 故障转移:如果 master node 挂掉了,会自动转移到 slave node 上。
    • 配置中心:如果故障转移发生了,通知 client 客户端新的 master 地址。

    sentinel知识点

    • 哨兵至少需要 3 个实例,来保证自己的健壮性。
    • 哨兵 + redis 主从的部署架构,是不保证数据零丢失的,只能保证 redis 集群的高可用性。

    客户端连接sentinel的步骤

    1.客户端遍历所有的 Sentinel 节点集合,获取一个可用的 Sentinel 节点.

    2.客户端向可用的 Sentinel 节点发送 get-master-addr-by-name 命令,获取Redis Master 节点.

    3.客户端向Redis Master节点发送role或role replication 命令,来确定其是否是Master节点,并且能够获取其 slave节点信息.

    4.客户端获取到确定的节点信息后,便可以向Redis发送命令来进行后续操作了

    需要注意的是:客户端是和Sentinel来进行交互的,通过Sentinel来获取真正的Redis节点信息,然后来操作.实际工作时,Sentinel 内部维护了一个主题队列,用来保存Redis的节点信息,并实时更新,客户端订阅了这个主题,然后实时的去获取这个队列的Redis节点信息。

    哨兵模式工作原理

    1、三个定时任务

    	每10秒每个 sentinel 对master 和 slave 执行info 命令:该命令第一个是用来发现slave节点,第二个是确定主从关系.
    
    	每2秒每个 sentinel 通过 master 节点的 channel(名称为_sentinel_:hello) 交换信息(pub/sub):用来交互对节点的看法(后面会介绍的节点主观下线和客观下线)以及自身信息.
    
    	每1秒每个 sentinel 对其他 sentinel 和 redis 执行 ping 命令,用于心跳检测,作为节点存活的判断依据.
    

    2、主观下线和客观下线

    	SDOWN:subjectively down,直接翻译的为”主观”失效,即当前sentinel实例认为某个redis服务为”不可用”状态.
    
    	ODOWN:objectively down,直接翻译为”客观”失效,即多个sentinel实例都认为master处于”SDOWN”状态,那么此时master将处于ODOWN,ODOWN可以简单理解为master已经被集群确定为”不可用”,将会开启故障转移机制.
    

    3、故障转移

    故障转移是由 sentinel 领导者节点来完成的(只需要一个sentinel节点),关于 sentinel 领导者节点的选取也是每个 sentinel 向其他 sentinel 节点发送我要成为领导者的命令,超过半数sentinel 节点同意,并且也大于quorum ,那么他将成为领导者,如果有多个sentinel都成为了领导者,则会过段时间在进行选举.

    slave 选举为master排序:

    • 按照 slave 优先级进行排序,slave priority 越低,优先级就越高。
    • 如果 slave priority 相同,那么看 replica offset,哪个 slave 复制了越多的数据,offset 越靠后,优先级就越高。
    • 如果上面两个条件都相同,那么选择一个 run id 比较小的那个 slave。

    4、quorum 和 majority

    quorum:确认odown的最少的哨兵数量

    majority:授权进行主从切换的最少的哨兵数量

    每次一个哨兵要做主备切换,首先需要quorum数量的哨兵认为odown(客观下线),然后选举出一个哨兵来做切换,这个哨兵还得得到majority哨兵的授权,才能正式执行切换

    如果quorum < majority,比如5个哨兵,majority就是3,quorum设置为2,那么就3个哨兵授权就可以执行切换,但是如果quorum >= majority,那么必须quorum数量的哨兵都授权,比如5个哨兵,quorum是5,那么必须5个哨兵都同意授权,才能执行切换
    5、configuration epoch

    哨兵会对一套 redis master+slaves 进行监控,有相应的监控的配置。

    执行切换的那个哨兵,会从要切换到的新 master(salve->master)那里得到一个 configuration epoch,这就是一个 version 号,每次切换的 version 号都必须是唯一的。

    如果第一个选举出的哨兵切换失败了,那么其他哨兵,会等待 failover-timeout 时间,然后接替继续执行切换,此时会重新获取一个新的 configuration epoch,作为新的 version 号。

    6、configuration 传播

    哨兵完成切换之后,会在自己本地更新生成最新的 master 配置,然后同步给其他的哨兵,就是通过之前说的 pub/sub 消息机制。

    这里之前的 version 号就很重要了,因为各种消息都是通过一个 channel 去发布和监听的,所以一个哨兵完成一次新的切换之后,新的 master 配置是跟着新的 version 号的。其他的哨兵都是根据版本号的大小来更新自己的 master 配置的。

    redis持久化

    redis 持久化的两种方式

    • RDB:RDB 持久化机制,是对 redis 中的数据执行周期性的持久化。
    • AOF:AOF 机制对每条写入命令作为日志,以 append-only 的模式写入一个日志文件中,在 redis 重启的时候,可以通过回放 AOF 日志中的写入指令来重新构建整个数据集。
    • 混合持久化RDB+AOF,redis5.0后默认采用这种方式

    通过 RDB 或 AOF,都可以将 redis 内存中的数据给持久化到磁盘上面来,然后可以将这些数据备份到别的地方去,比如说阿里云等云服务。

    如果 redis 挂了,服务器上的内存和磁盘上的数据都丢了,可以从云服务上拷贝回来之前的数据,放到指定的目录中,然后重新启动 redis,redis 就会自动根据持久化数据文件中的数据,去恢复内存中的数据,继续对外提供服务。

    如果同时使用 RDB 和 AOF 两种持久化机制,那么在 redis 重启的时候,会使用 AOF 来重新构建数据,因为 AOF 中的数据更加完整

    RDB持久化过程

    AOF持久化过程

    AOF REWRITE过程

    混合持久化过程

    RDB 优缺点

    • RDB 会生成多个数据文件,每个数据文件都代表了某一个时刻中 redis 的数据,这种多个数据文件的方式,非常适合做冷备,可以将这种完整的数据文件发送到一些远程的安全存储上去,比如说 Amazon 的 S3 云服务上去,在国内可以是阿里云的 ODPS 分布式存储上,以预定好的备份策略来定期备份 redis 中的数据。
    • RDB 对 redis 对外提供的读写服务,影响非常小,可以让 redis 保持高性能,因为 redis 主进程只需要 fork 一个子进程,让子进程执行磁盘 IO 操作来进行 RDB 持久化即可。
    • 相对于 AOF 持久化机制来说,直接基于 RDB 数据文件来重启和恢复 redis 进程,更加快速。
    • 如果想要在 redis 故障时,尽可能少的丢失数据,那么 RDB 没有 AOF 好。一般来说,RDB 数据快照文件,都是每隔 5 分钟,或者更长时间生成一次,这个时候就得接受一旦 redis 进程宕机,那么会丢失最近 5 分钟的数据。
    • RDB 每次在 fork 子进程来执行 RDB 快照数据文件生成的时候,如果数据文件特别大,可能会导致对客户端提供的服务暂停数毫秒,或者甚至数秒。

    AOF 优缺点

    • AOF 可以更好的保护数据不丢失,一般 AOF 会每隔 1 秒,通过一个后台线程执行一次fsync操作,最多丢失 1 秒钟的数据。
    • AOF 日志文件以 append-only 模式写入,所以没有任何磁盘寻址的开销,写入性能非常高,而且文件不容易破损,即使文件尾部破损,也很容易修复。
    • AOF 日志文件即使过大的时候,出现后台重写操作,也不会影响客户端的读写。因为在 rewrite log 的时候,会对其中的指令进行压缩,创建出一份需要恢复数据的最小日志出来。在创建新日志文件的时候,老的日志文件还是照常写入。当新的 merge 后的日志文件 ready 的时候,再交换新老日志文件即可。
    • AOF 日志文件的命令通过非常可读的方式进行记录,这个特性非常适合做灾难性的误删除的紧急恢复。比如某人不小心用 flushall 命令清空了所有数据,只要这个时候后台 rewrite 还没有发生,那么就可以立即拷贝 AOF 文件,将最后一条 flushall 命令给删了,然后再将该 AOF 文件放回去,就可以通过恢复机制,自动恢复所有数据。
    • 对于同一份数据来说,AOF 日志文件通常比 RDB 数据快照文件更大。
    • AOF 开启后,支持的写 QPS 会比 RDB 支持的写 QPS 低,因为 AOF 一般会配置成每秒 fsync 一次日志文件,当然,每秒一次 fsync,性能也还是很高的。(如果实时写入,那么 QPS 会大降,redis 性能会大大降低)
    • 以前 AOF 发生过 bug,就是通过 AOF 记录的日志,进行数据恢复的时候,没有恢复一模一样的数据出来。所以说,类似 AOF 这种较为复杂的基于命令日志 / merge / 回放的方式,比基于 RDB 每次持久化一份完整的数据快照文件的方式,更加脆弱一些,容易有 bug。不过 AOF 就是为了避免 rewrite 过程导致的 bug,因此每次 rewrite 并不是基于旧的指令日志进行 merge 的,而是基于当时内存中的数据进行指令的重新构建,这样健壮性会好很多。

    RDB 和 AOF 到底该如何选择

    • 不要仅仅使用 RDB,因为那样会导致你丢失很多数据;
    • 也不要仅仅使用 AOF,因为那样有两个问题:第一,你通过 AOF 做冷备,没有 RDB 做冷备来的恢复速度更快;第二,RDB 每次简单粗暴生成数据快照,更加健壮,可以避免 AOF 这种复杂的备份和恢复机制的 bug;
    • redis 支持同时开启开启两种持久化方式,我们可以综合使用 AOF 和 RDB 两种持久化机制,用 AOF 来保证数据不丢失,作为数据恢复的第一选择; 用 RDB 来做不同程度的冷备,在 AOF 文件都丢失或损坏不可用的时候,还可以使用 RDB 来进行快速的数据恢复。

    混合持久化

    redis5.0默认开启

    redis cluster

    client端与redis cluster交互流程

    redis cluster 交互流程,client发送信息到集群中的任意一个节点,如果经过hash(crc16)计算及slot分配,key值确实应该存在这个节点则存储,不是则返回一个重定向的消息(moved)告知客户端应该去访问哪个节点(redis集群中每个节点都会存储slot信息配置元信息,实际过程中client可以通过redis提供的命令获取cluster slot位置分配信息并缓存起来,并且在客户端进行hash(crc16)计算及slot分配,从而减少试错的成本。cluster slot位置分配信息发生变化时,此时访问server端就会返回重定向信息,客户端也就知道cluster slot位置分配信息已经发生变化,从而去刷新本地缓存的slot信息(也可以定期去刷新)。每个redis集群节点都有一个额外的tcp端口用于和其它节点通信

    redis cluster 默认所有从节点的读取都会重定向到对应的主节点(从节点主要是做高可用),可以通过readonly设置当前连接可读(此时可以通过从节点读取数据),通过readwrite取消当前连接的可读状态

    redis主从节点会存在数据不一致的情况

    节点间的内部通信机制

    redis cluster 节点间采用 gossip 协议进行通信。

    集中式是将集群元数据(节点信息、故障等等)集中存储在某个节点上。集中式元数据集中存储的一个典型代表,就是大数据领域的 storm。它是分布式的大数据实时计算引擎,是集中式的元数据存储的结构,底层基于 zookeeper(分布式协调的中间件)对所有元数据进行存储维护。

    redis 维护集群元数据采用另一个方式, gossip 协议,所有节点都持有一份元数据,不同的节点如果出现了元数据的变更,就不断将元数据发送给其它的节点,让其它节点也进行元数据的变更。

    集中式好处在于,元数据的读取和更新,时效性非常好,一旦元数据出现了变更,就立即更新到集中式的存储中,其它节点读取的时候就可以感知到;不好在于,所有的元数据的更新压力全部集中在一个地方,可能会导致元数据的存储有压力。

    gossip 好处在于,元数据的更新比较分散,不是集中在一个地方,更新请求会陆陆续续打到所有节点上去更新,降低了压力;不好在于,元数据的更新有延时,可能导致集群中的一些操作会有一些滞后。

    分布式寻址算法

    一致性 hash 算法

    一致性 hash 算法将整个 hash 值空间组织成一个虚拟的圆环,整个空间按顺时针方向组织,下一步将各个 master 节点(使用服务器的 ip 或主机名)进行 hash。这样就能确定每个节点在其哈希环上的位置。

    来了一个 key,首先计算 hash 值,并确定此数据在环上的位置,从此位置沿环顺时针“行走”,遇到的第一个 master 节点就是 key 所在位置。

    在一致性哈希算法中,如果一个节点挂了,受影响的数据仅仅是此节点到环空间前一个节点(沿着逆时针方向行走遇到的第一个节点)之间的数据,其它不受影响。增加一个节点也同理。

    燃鹅,一致性哈希算法在节点太少时,容易因为节点分布不均匀而造成缓存热点的问题。为了解决这种热点问题,一致性 hash 算法引入了虚拟节点机制,即对每一个节点计算多个 hash,每个计算结果位置都放置一个虚拟节点。这样就实现了数据的均匀分布,负载均衡。

    redis cluster 的 hash slot 算法

    redis cluster 有固定的 16384 个 hash slot,对每个 key 计算 CRC16 值,然后对 16384 取模,可以获取 key 对应的 hash slot。

    redis cluster 中每个 master 都会持有部分 slot,比如有 3 个 master,那么可能每个 master 持有 5000 多个 hash slot。hash slot 让 node 的增加和移除很简单,增加一个 master,就将其他 master 的 hash slot 移动部分过去,减少一个 master,就将它的 hash slot 移动到其他 master 上去。移动 hash slot 的成本是非常低的。客户端的 api,可以对指定的数据,让他们走同一个 hash slot,通过 hash tag 来实现。

    任何一台机器宕机,另外两个节点,不影响的。因为 key 找的是 hash slot,不是机器。

    redis cluster 的高可用与主备切换原理

    redis cluster 的高可用的原理,几乎跟哨兵是类似的。

    判断节点宕机

    如果一个节点认为另外一个节点宕机,那么就是 pfail主观宕机。如果多个节点都认为另外一个节点宕机了,那么就是 fail客观宕机,跟哨兵的原理几乎一样,sdown,odown。

    cluster-node-timeout 内,某个节点一直没有返回 pong,那么就被认为 pfail

    如果一个节点认为某个节点 pfail 了,那么会在 gossip ping 消息中,ping 给其他节点,如果超过半数的节点都认为 pfail 了,那么就会变成 fail

    从节点过滤

    对宕机的 master node,从其所有的 slave node 中,选择一个切换成 master node。

    检查每个 slave node 与 master node 断开连接的时间,如果超过了 cluster-node-timeout * cluster-slave-validity-factor,那么就没有资格切换成 master

    从节点选举

    每个从节点,都根据自己对 master 复制数据的 offset,来设置一个选举时间,offset 越大(复制数据越多)的从节点,选举时间越靠前,优先进行选举。

    所有的 master node 开始 slave 选举投票,给要进行选举的 slave 进行投票,如果大部分 master node(N/2 + 1)都投票给了某个从节点,那么选举通过,那个从节点可以切换成 master。

    从节点执行主备切换,从节点切换为主节点。

    redis 的雪崩、穿透和击穿

    缓存穿透

    缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。

    解决方案:

    1.在服务器端,接收参数时业务接口中过滤不合法的值,null,负值,和空值进行检测和空值。

    2.bloom filter:类似于哈希表的一种算法,用所有可能的查询条件生成一个bitmap,在进行数据库查询之前会使用这个bitmap进行过滤,如果不在其中则直接过滤,从而减轻数据库层面的压力。

    3.空值缓存:一种比较简单的解决办法,在第一次查询完不存在的数据后,将该key与对应的空值也放入缓存中,只不过设定为较短的失效时间,例如几分钟,这样则可以应对短时间的大量的该key攻击,设置为较短的失效时间是因为该值可能业务无关,存在意义不大,且该次的查询也未必是攻击者发起,无过久存储的必要,故可以早点失效。

    缓存雪崩

    因为缓存服务挂掉或者热点缓存失效,所有请求都去查数据库,导致数据库连接不够或者数据库处理不过来,从而导致整个系统不可用。

    解决方案:

    加锁排队、 设置过期标志更新缓存 、 二级缓存(引入一致性问题)、 预热、 缓存与服务降级。

    1.线程互斥:只让一个线程构建缓存,其他线程等待构建缓存的线程执行完,重新从缓存获取数据才可以,每个时刻只有一个线程在执行请求,减轻了db的压力,但缺点也很明显,降低了系统的qps。

    2.交错失效时间:这种方法时间比较简单粗暴,既然在同一时间失效会造成请求过多雪崩,那我们错开不同的失效时间即可从一定长度上避免这种问题,在缓存进行失效时间设置的时候,从某个适当的值域中随机一个时间作为失效时间即可。

    缓存击穿

    缓存击穿实际上是缓存雪崩的一个特例,缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。击穿与雪崩的区别即在于击穿是对于某一特定的热点数据来说,而雪崩是全部数据。

    解决方案:

    若缓存的数据是基本不会发生更新的,则可尝试将该热点数据设置为永不过期。

    若缓存的数据更新不频繁,且缓存刷新的整个流程耗时较少的情况下,则可以采用基于 redis、zookeeper 等分布式中间件的分布式互斥锁,或者本地互斥锁以保证仅少量的请求能请求数据库并重新构建缓存,其余线程则在锁释放后能访问到新缓存。

    若缓存的数据更新频繁或者缓存刷新的流程耗时较长的情况下,可以利用定时线程在缓存过期前主动的重新构建缓存或者延后缓存的过期时间,以保证所有的请求能一直访问到对应的缓存。

    redis监控工具

    redis 监控工具 redislive可视化监控工具

  • 相关阅读:
    【老孙随笔】关羽和吕蒙——天才的失败
    【老孙随笔】项目经理要向唐骏学习
    WebService里奇怪的参数值偏移现象?
    [原创]让您的服务器不再有被挂马的烦恼文件安全卫士
    C#里也可以用上Eval函数了:)
    使用HTTP_X_FORWARDED_FOR获取客户端IP的严重后果
    支持算术运算、逻辑运算、位运算的表达式求值
    在Lambda表达式中进行递归调用
    认识Lambda表达式
    将你的QQ唠叨或QQ签名数据加入到博客上:)
  • 原文地址:https://www.cnblogs.com/hhhshct/p/14653361.html
Copyright © 2011-2022 走看看