美文网首页Java for us
Redis 的一些理解

Redis 的一些理解

作者: 拿着沙齿的纳兰公子 | 来源:发表于2018-06-06 15:50 被阅读0次

    title: Redis 的一些理解
    date: 2017-04-15 10:07:37


    相信每一个工程师对redis并不陌生,通常可作为缓存,持久化Key-Value数据库,消息队列等解决方案。朕最近对这个也挺感兴趣的,所以就看看了,本文章主要三个部分:数据结构与对象,数据库实现(包括单机,集群,docker集群),使用场景。下面分别介绍。

    第一部分 数据结构与对象

    第一章 数据结构

    地球人都知道redis有五种数据类型:字符串(string),列表(list),哈希(hash),集合(set),有序集合(zset)。我们可以用

    127.0.0.1:30001> type key
    string
    

    来获取key的数据类型,但其实这只是比较高层的数据类型,而非底层数据结构,那我们接下来学习一下redis的底层数据结构,redis主要包括了六大类数据结构字符串,链表,字典,跳跃表,整数集合,压缩列表。

    1、字符串

    大家都知道redis是用C写的程序,我们知道C中字符串其实就是一个char数组以"\0"结束便是一个字符串了,但redis类似于模仿Java Bean一样定义了一个数据结构。这就是redis的字符串神器SDS(一看名字就很高端,其实很简单)。

    struct sdshdr{
      int len;#记录buf数组中已经使用字节的数量
      int free;#记录buf数组中空闲字节的数量
      char buf[];#和c一样保存字符串啦
    };
    

    看出与C的不同了吧,它有什么好处呢?

    1. 获取字符串长度变得so easy了。
    2. 因为空间容量是已知的,修改字符串就不会缓冲区溢出啦。
    3. 减少内存空间重分配的次数(这个策略是:a、初始设置string值是string的长度为len-1,为什么减一就不用解释了吧;b、然后修改字符串并且长度大于当前len需要扩展空间的时候就会进行空间预分配,这时如果len<1m那么新len = len + len + 1byte,如果len>=1m那么新len = len + 1m + 1byte;c、如果修改字符串,当长度不需要那么长时,并不回收空间,而是增大free值。)

    2、链表

    链表很简单,就是我们熟知的链表,先定义一个节点数据结构,再定义数据结构来对链表操作。

    链表节点
    typedef struct listNode{
      struct listNode *prev;#前置节点
      struct listNode *next;#后置节点
      void *value;#节点值
    }listNode;
    
    链表操作结构
    typedef struct listNode{
      listNode *head;#头节点
      listNode *tail;#尾节点
      unsigned long len;#链表节点数
      void *(*dup)(void *ptr)#节点值复制函数
      void (*free)(void *ptr);#节点值释放函数
      int (*match)(void *ptr, void *key);#节点值对比函数
    }listNode;
    

    3、字典

    字典又叫符号表,关联数组,映射表。Redis的字典就是用哈希表实现的。一个哈希表可以有多个哈希表节点,但是每个哈希表节点就保存了字典中的一个键值对。下面看看哈希表的结构定义:

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

    这里需要注意的是size与used的区别,size指的是table的大小,而used表示的是哈希表节点已有的数量(当used/size>=1时,会触发重建哈希),下面看看哈希表节点的数据结构:

    typedef struct dictEntry{
      void *key;#键
      union{
        void *val;
        uint64_t u64;
        int64_t s64;    
      } v;#值
      struct dictEntry *next;#下一个哈希表节点,形成链表
    }
    
    字典
    typedef struct dict{
      dictType *type;#类型特定函数
      void *privdata;#私有数据
      dictht ht[2];#哈希表
      int rehashidx;#rehash索引 没有rehash时值为-1
    }
    

    这里重点是ht属性,有两个哈希表是指当rehash的时候会用第二个哈希表去为第一个重建哈希。而rehashidx记录的重建哈希的进度。

    重建哈希

    为什么要重建哈希的,总的来说就是因为由于哈希节点越来越多,为了减少键冲突,或者键值对越来越少,不能造一个房子大的箱子就为了装一个鸡蛋吧。所以重建哈希。步骤如下:

    1. 为ht[1]分配空间,大小随操作而定
    • 如果执行扩展操作,ht[1]的大小为第一个大于等于ht[0].used2的 2^n(例如:如果ht[0].used = 7,72=14,那么ht[1]的大小=16)
    • 如果执行扩展操作,ht[1]的大小为第一个大于等于ht[0].used的 2^n(例如:如果ht[0].used = 7,那么ht[1]的大小=8)
    1. 然后将ht[0]全部复制到ht[1]上。
    2. 释放ht[0],然后将ht[1]设置为ht[0],并在ht[1]上创建了一个空白哈希表。

    4、跳跃表

    Redis中只有两个地方用到跳跃表,一个是有序集合键,另一个是在集群节点中用作内部数据结构。

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

    5、整数集合

    整数集合是集合键的底层实现之一,当一个集合只有整数元素,并且集合元素数量不多时就会使用整数集合作为底层实现。

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

    contents[]的属性声明int8_t但并不代表他就是保存int8_t类型的数组,它保存的类型由encoding决定,而encoding的值有三种值:INSET_ENC_INT16(类型是:int16_t),INSET_ENC_INT32(类型是:int32_t),INSET_ENC_INT16(类型是:int64_t),

    整数集合的升级

    当我们每添加一个新的元素到整数集合里,并且新元素的类型要比整数集合现有所有元素的类型要长时,整数集合需要先进行升级,然后才能将新元素添加到整数集合里面。
    添加步骤:

    1. 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
    2. 将底层数组现有所有的元素都转换为与新元素相同的类型,并将类型装换后的元素放置到正确的位置上,而且在放置元素的过程中需要维持底层数组的有序性不变。
    3. 将新元素添加到底层数组里面

    注意:整数集合不支持降级,一旦对数组进行了升级,编码便会保持升级后的状态。

    6、压缩列表

    压缩列表是列表键和哈希键的底层实现之一。
    当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来实现列表键。
    当一个哈希键只包含少量键值对,并且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来实现哈希键。

    压缩列表的结构
    zlbytes zltail zllen entry1 entry2 ... entryN zlend
    压缩列表的组成成分详细说明
    属性 类型 长度(字节) 用途
    zlbytes uint32_t 4 压缩列表所占字节数,对压缩列表内存重分配或者计算zend的位置时使用
    zltail uint32_t 4 记录压缩列表尾节点距离起始地址的字节数,即尾节点的位置
    zllen uint16_t 2 节点数(最大值65535),当大于这个数的时候需要遍历压缩列表
    entryX 列表节点 不定 压缩列表节点
    zlend uint8_t 1 特殊值0XFF(255)标记列表末端
    压缩列表的节点结构
    前个字段长度 编码 内容
    previous_entry_length encoding content
    previous_entry_length

    这个字段记录的前一个节点的长度,这个字段占用1个字节或者5个字节。
    当前一个节点的长度小于254个字节,本字段占用1个字节;
    当前一个节点的长度大于或等于254个字节,本字段占用5个字节.
    由于这种特性,修改一个节点数据会导致连锁更新,所有节点都需要重新分配内存空间。
    通过这个字段和压缩列表的zltail属性可以实现压缩列表的从表尾向表头遍历。

    encoding

    这个字段记录了content属性所保存数据的类型以及长度

    content

    节点值,可以是一个字节数组(字符串)或者整数

    第二章 对象

    我们可以通过object encoding key命令查询对象的编码方式。还有大家要知道一个对象在底层是怎么存储的:

    typedef struct redisObject{
      unsigned type:4;#类型
      unsigned encoding:4;#编码
      void *ptr;#指向具体的实现数据
    }robj;
    

    1、字符串对象

    字符串可以有三种编码方式int,embstr,raw
    当一个字符串对象是整数值,并且可以用long表示则用int的编码方式;
    当一个字符串长度小于或等于39字节则用embstr的编码方式;
    其他情况用raw编码方式。
    embstr相对于raw的优点是redisObject和sdshdr结构是连续的,内存一起分配也一起释放,但它是只读的,任何embstr的字符串修改后都变成了raw。

    2、列表对象

    列表对象的编码可以是ziplist或者linkedlist。
    使用ziplist编码的条件是:

    1. 列表保存的所有字符串元素长度小于64字节
    2. 列表对象元素个数小于512个

    3、哈希对象

    哈希对象的编码可以是ziplist和hashtable。
    当用ziplist作为底层实现时,每当有新的键值对加入哈希对象时,程序会先将键推入压缩列表表尾,再把值推入列表表尾。所以键值对总是挨在一起的。
    使用ziplist编码的条件是:

    1. 哈希保存的所有键和值字符串元素长度都小于64字节
    2. 哈希对象元素个数小于512个

    4、集合对象

    集合对象的编码可以是intset和hashtable。
    使用intset编码的条件是:

    1. 集合保存的所有元素都是整数值
    2. 集合对象元素个数小于512个

    当用hashtable保存集合对象时,哈希表的键保存集合元素,value都被设置为null。

    5、有序集合对象

    有序集合对象的编码可以是ziplist和zskiplist。
    当用ziplist来保存有序集合对象时,每每都是第一个节点保存元素成员,第二个保存分数。并且在压缩列表内按照从小到大排序。
    当用zskiplist作为有序集合底层实现时,一个zset包含了一个skiplist和一个dict,如下:

    typedef struct zset{
      skiplist *zsl;
      dict *dict;
    }zset;
    

    字典的作用是:字典的键保存了元素的成员,值保存了分值,这样用O(1)复杂度就可以获取元素的分值。
    使用ziplist编码的条件是:

    1. 有序集合保存的所有元素成员长度都小于64字节
    2. 有序集合对象元素个数小于128个

    6、对象相关

    Redis在初始化服务的时候会创建一万个共享对象:0~9999这些对象不用重新创建
    对象的空转时长(通过lru属性计算)记录了对象最后一次被命令访问的时间,通过下面命令查看空转时长(当前时间减去lru时间计算得出)。

    OBJECT IDLETIME key
    

    第二部分 数据库实现

    第三章 过期时间与数据库持久化

    1、设置过期时间

    在Redis中有四种设置过期时间的方式

    命令 单位 描述
    EXPIRE 设置生存时间(可以活多久)
    PEXPIRE 毫秒 设置生存时间(可以活多久)
    EXPIREAT 过期时间戳
    PEXPIREAT 毫秒 过期时间戳
    TTL KEY 查看剩余时间 单位 秒
    PRESISIT KEY 移除过期时间

    其实事实上虽说有四个命令,但都是通过换算成PEXPIREAT实现的。
    redisDb结构的expires字典保存了数据库中所有键的过期时间,我们称这个为过期字典。

    2、过期键删除策略

    Redis服务器采用的是惰性删除和定期删除。
    惰性删除:指的是程序读取键时,判定键有没有过期,过期则删除。
    定期删除:每隔一段时间,程序对数据库做一次检查,删除过期键。
    这里需要注意的是定期检查并不是全盘扫描,而是从一定数量的数据库中取出一定数量的随机键进行检查,并删除过期键。

    3、生成RDB文件

    在执行SAVE或者BGSAVE命令时候程序会对数据库中的键进行检查,如果过期就不会保存到RDB文件中。

    4、载入RDB文件

    主:载入RDB时,程序会对文件中保存的键进行检查,未过期的载入数据库。
    从:载入时不检查,所有都载入数据库。因为在进行主从同步的时候,从库数据会被清空,所以过期键载入对从库没有影响。

    5、AOF

    生成aof文件时如果数据库某个键已过期但是没有被惰性删除或者定期删除,那么aof文件不会因为这个键已过期而产生任何影响,当被检查要删除的时候,程序会对aof文件追加一条del命令,显示标识已被删除。而aof重写会忽略已过期的键。

    6、复制

    当服务器运行在复制模式下,从服务器的过期键删除动作由主服务器控制:

    1. 主在删除一个过期键的时候会显式的向所有从服务器发送一个DEL命令
    2. 从库执行客户端读命令时即使碰到过期键也不删除,而是继续返回过期键
    3. 从库只有接收到主库的del命令才会删除过期键。

    7、数据库通知

    redis 2.8以后可以让客户端通过订阅数据库中键的变化,以及命令的执行情况:

    127.0.0.1:6379 > SUBSCRIBE __keyspace@0__:message #对0号库message键监听执行哪些命令
    127.0.0.1:6379 > SUBSCRIBE __keyevent@0__:del #对0号库所有执行del操作的键
    

    8、数据库持久化

    数据库持久化分为RDB和AOF两种方式 :

    1. RDB就是通过Redis的ServerCron(间隔100毫秒)判断一次是否满足持久化条件,例如:
      save 900 1
      save 300 10
      save 60 10000
      900秒内数据库修改1次执行BGSAVE
      300秒内数据库修改10次执行BGSAVE
      60秒内数据库修改10000次执行BGSAVE
      redisServer会有dirty和lastsave,dirty记录了上次执行save或者bgsave之后数据库修改的次数,lastsave记录了上次持久化的时间,程序就是检查这两个参数是否达到持久化的条件。

    2. AOF文件的写入与同步

    appendsync选项的值 flushAppendOnlyFile函数的行为
    alway 将aof_buf缓冲区所有内容写入并同步到aof
    everysec 将aof_buf缓冲区所有内容写入到aof,如果上次同步到现在超过1秒,再次对AOF文件进行同步
    no 将aof_buf缓冲区所有内容写入到aof,但不同步,何时同步由操作系统决定

    9、客户端

    对于每个与服务器连接的客户端,服务器都会为这些客户端建立相应的redis.h/redisClient结构(客户端状态),这个结构保存了当前客户端的状态信息,以及执行相关功能要用道德数据结构。
    输入缓冲区:客户端发送的命令请求;
    输出缓冲区:执行客户端命令的回复

    第四章 集群

    1、复制

    Redis的复制包括同步和命令传播:

    1. 同步就是从服务器的数据库状态更新到主服务器的数据库状态。
    2. 命令传播的作用是主服务器数据库状态被修改,导致主从不一致,需要让主从重新一致。

    旧版同步(SYNC)命令过时了不讲,因为存在缺陷:每次执行命令,从服务器都将让主服务器生成RDB文件,并全部读取完成数据库状态,然后再从命令缓冲区读取命令,让主从完成一致。不用说这就像你盖房子,人家让你修改一下你就把房子推了重新盖,极其消耗不必要的资源。
    新版同步(PSYNC)命令,具有完整同步和部分同步两种模式:

    1. 完整同步用于初次同步,和SYNC一样读取全部RDB文件,以及读取命令缓冲区的命令完成主从一致。
    2. 部分同步用于当从服务器断线后重新连接,符合条件(条件后面说)的话,主服务器将主从连接断开期间主服务器执行的写命令发送给从服务器,从服务器执行命令完成主从一致。

    部分同步的条件:

    1. 主从服务器各自的复制偏移量。
    2. 主服务器的复制积压缓冲区。
    3. 服务器的运行id。

    复制偏移量:是指执行复制的双方都会维护一个复制偏移量,主服务器向从服务器传播N个字节的数据时,便将自己的偏移量加N;从服务器每次从主收到N字节的数据时就将自己的偏移量加N。(如果主从一致,复制偏移量是一样的)
    复制积压缓冲区:是指主服务器会有一个固定长度的一个队列,如果从服务器执行PSYNC命令时会将自己的复制偏移量offset发送给主服务器,主服务器根据这个决定执行哪种同步方式:

    1. 如果offset偏移量之后的数据仍在复制缓冲区,那么主服务器对从服务器执行部分同步;
    2. 如果offset偏移量之后的数据不在复制缓冲区,那么主服务器对从服务器执行完整同步。

    服务器运行id:不论主从都有自己的运行id,在服务器启动时自动生成,由40个16进制字符组成,当主从初次同步时,主服务器会将自己的运行id传给从服务器保存起来,从服务器断线重连时,从服务器会将自己保存的运行id传给主服务器,主服务器收到id判断是否和自己的运行id一致,如果一致便可以执行部分同步,反之说明从库现在连接的主库不是之前的主库,需要进行完整同步。

    复制的步骤:
    1. 设置主服务器的地址端口 eg: slaveof 127.0.0.1 6379
    2. 建立套接字连接
    3. 从发送PING命令,主回复PONG命令
    4. 身份验证(需要配置:masterauth选项)
    5. 从向主发送监听端口信息 eg: REPLCONF listening-port 12345
    6. 同步
    7. 命令传播
    心跳检测:

    在命令传播阶段,从服务器每秒一次向主发送命令:
    REPLCONF ACK <replication_offset> ##就是发送复制偏移量。作用有三个:

    1. 检查主从网络连接状况(如果lag大于1秒说明主从连接有故障);
    2. 辅助实现min-slaves配置选项
    min-slaves-to-write 3
    min-slaves-max-lag 10
    #指:当从服务器少于3个,或者3个服务器的延迟(lag值 可以通过‘info replication’命令获取)主服务器将拒绝写命令。
    
    1. 检测命令丢失(如果主传给从的命令在半路丢失,那么重服务器向主发送心态检测携带了复制偏移量,主会发觉从服务器的复制偏移量少于自己的,主就会将复制缓冲区中找到从缺少的数据,然后重新发给从)。

    2、Sentinel

    3、集群

    相关文章

      网友评论

        本文标题:Redis 的一些理解

        本文链接:https://www.haomeiwen.com/subject/esgbsftx.html