读书笔记:《redis设计与实现》(第一部分)

写在前面

感觉自己弱了,就多读点书,最近感觉对redis的理解有些弱,所以,找了一本书, 黄健宏的 《Redis设计与实现》,写了一些读书笔记与摘抄, 与各位牛马们共勉

底层模型

字符串

SDS模型

  • SDS是redis基于C语言自己实现的一种字符串结构,相比于C语言的字符串做了一些优化
struct sdshdr {
    //记录buf数组中已使用字节的数量
    //等于SDS所保存字符串的长度
    int len;
    //记录buf数组中未使用字节的数量
    int free;
    //字节数组,用于保存字符串
    char buf[];
};
SDS的优势
  • 常数复杂度获取字符串长度
    • 如果遍历获取长度复杂度为O(n),直接记录则为O(n)
  • 杜绝缓冲区溢出
    • 因为记录了未使用的字节数量,在字符串拼接的时候,可以先计算一下空间是否够用,如果不够用扩容之后再做拼接
  • 减少修改字符串时带来的内存重分配次数
    • 字符串增长的操作,底层会先去做一下内存的重新分配,如果不做,容易产生缓冲区溢出
    • 缩短字符串的操作, 底层会在执行操作后, 释放一部分空间, 如果不做,则容易产生内存泄漏
    • SDS实现优化策略
      • 空间预分配策略
        • 当SDS的API对一个SDS进行修改,API会先检查未使用空间是否够用,如果够用则直接使用,无需分配。如果不够则需要对SDS进行空间扩展,这时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间
        • 策略
          • 如果对于SDS进行修改后, SDS长度小于1M, 程序会分配和len属性同样大小的未使用空间
          • 如果对于SDS进行修改后, SDS长度大于等于1M, 程序会分配1M未使用空间
        • 通过空间预分配策略, Redis可以减少连续执行字符串增长操作的内存分配次数
      • 惰性空间释放策略
        • 当SDS的API需要缩短SDS保存的字符串时, 程序并不立即使用内存重分配来回收缩短的字节, 而是使用free属性将这些字节数量记录下来,等待之后使用。
        • 与此同时, 当我们在需要时,真正释放SDS未使用的空间,不用担心惰性空间释放策略会造成内存浪费
  • 二进制安全
    • C字符串存在的问题: C字符串中的字符必须符合某种编码(例如ASCII), 并且除了字符串多末尾不能包含空字符, 否则最先被程序读入的空字符会被误认为是字符串结尾, 所以使用C字符串只能保存文本数据, 不能保存图片、视频、音频、压缩文件等二进制数据
    • SDS 的API是二进制安全的
      • redis用 buf属性来保存二进制数据, 而不是字符
      • SDS不是用空字符来判断字符串的结束,而是用len字段来判断
    • 兼容部分C字符串函数
      • 虽然SDS的API是二进制安全的,但是也遵循C字符串以空字符结尾的惯例
SDS对比C字符串
C字符串SDS
获取字符串长度O(n)获取字符串长度复杂度O(1)
API是不安全的,可能造出缓冲区溢出API是安全的,不会造出缓冲区溢出
修改字符串长度N必然要执行N次内存重分配修改字符串长度N最多要执行N次内存重分配
只能存储文本数据可以保存文本或者二进制数据
可以使用所有<string.h>库中函数可以使用一部分<string.h>库中函数

链表(双端链表)

  • 链表节点
    • adlist.h/listNode
typedef struct listNode {
    // 前置节点
    struct listNode * prev;
    // 后置节点
    struct listNode * next;
    //节点的值
    void * value;
}listNode;
  • 多个listNode可以通过prev和next指针组成双端链表

  • 链表

    • adlist.h/list
    • 虽然多个链表节点可以组成一个链表,但是操作起来复杂,链表结构操作方便
typedef struct list {
    //
    表头节点
    listNode * head;
    //
    表尾节点
    listNode * tail;
    //
    链表所包含的节点数量
    unsigned long len;
    //
    节点值复制函数
    void *(*dup)(void *ptr);
    //
    节点值释放函数
    void (*free)(void *ptr);
    //
    节点值对比函数
    int (*match)(void *ptr,void *key);
} list;
  • dup、free和match成员则是用于实现多态链表所需的类型特定函数

    • dup函数用于复制链表节点所保存的值
    • free函数用于释放链表节点所保存的值
    • match函数则用于对比链表节点所保存的值和另一个输入值是否相等
  • Redis链表的特性

    • 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)
    • 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。
    • 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。
    • 带链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)。
    • 多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。

字典

  • 又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对(key-value pair)的抽象数据结构。
  • Redis的字典使用哈希表作为底层实现

哈希表

  • dict.h/dictht
typedef struct dictht {
    //哈希表数组
    dictEntry **table;
    //哈希表大小
    unsigned long size;
    //哈希表大小掩码,用于计算索引值
    //总是等于size-1
    unsigned long sizemask;
    //该哈希表已有节点的数量
    unsigned long used;
} dictht;
  • table是一个数组, 数组中每个元素都是一个哈希表节点指针dictEntry
  • dictEntry 结构保存一个键值对
  • size 记录哈希表大小(table数组大小)
  • used 记录哈希表已有节点数
  • sizemask值属性总是等于 size-1, 这个属性和哈希值一起决定一个键应该放在table数组中哪个索引上。
哈希表节点
typedef struct dictEntry {
    //键
    void *key;
    //值
    union{
        void *val;
        uint64_tu64;
        int64_ts64;
    } v;
    //指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;
  • key 键值对的键
  • v 键值对的值
    • 可以是 指针
    • 可以是 uint64_t整数
    • 可以是 int64_t整数
  • next 指向另一个哈希表节点,将多个哈希值相同的数据存成链表,用来解决哈希冲突

字典结构

typedef struct dict {
    //类型特定函数
    dictType *type;
    //私有数据
    void *privdata;
    //哈希表
    dictht ht[2];
    // rehash索引
    //当rehash不在进行时,值为-1
    in trehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
  • type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
  • privdata属性则保存了需要传给那些类型特定函数的可选参数。
  • ht属性是一个包含两个项的数组, 一般情况下字典只使用ht[0]哈希表,ht[1]只会在对ht[0]哈希表进行rehash时候使用
typedef struct dictType {
    //计算哈希值的函数
    unsigned int (*hashFunction)(const void *key);
    //复制键的函数
    void *(*keyDup)(void *privdata, const void *key);
    //复制值的函数
    void *(*valDup)(void *privdata, const void *obj);
    //对比键的函数
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);
    //销毁键的函数
    void (*keyDestructor)(void *privdata, void *key);
    //销毁值的函数
    void (*valDestructor)(void *privdata, void *obj);
} dictType;

哈希算法

  • redis 使用 MurmurHash2 算法来计算哈希值
    • MurmurHash的优点在于,即使输入的键很有规律,但是算法仍然能给出一个很好的随机分布性,且算法的速度也非常快
    • MurmurHash目前最新版为 MurmurHash3,可参考MurmurHash

解决hash冲突

  • 链地址法

rehash(重新散列)操作

  • rehash步骤

    • 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也即是ht[0].used属性的值)
      • 双倍扩容: 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2 n(2的n次方幂);
      • 等量扩容: 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2n
    • 将保存在ht[0]中的所有键值对rehash到ht[1]上面,rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上
    • 当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备
  • rehash条件(满足其一就会触发)

    • 服务器目前没有执行BGSAVE 命令或 BGREWRITEAOF命令, 且哈希表的负载因子大于等于1
    • 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5
    • 当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作
  • 根据BGSAVE命令或BGREWRITEAOF命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同,这是因为在执行BGSAVE命令或BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存。

  • 负载因子计算公式

#负载因子=哈希表已保存节点数量/哈希表大小
load_factor = ht[0].used / ht[0].size

渐进式rehash

  • 过程

    • 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
    • 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始
    • 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增一
    • 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成
  • 好处

    • 分而治之,计算工作均摊到对字典的每个添加、删除、查找和更新操作,避免了集中式rehash而带来的庞大计算量
  • rehash期间的hash表做操作

    • 在两个hash表中操作
    • 新添加的键值对一律保存到ht[1]

    跳跃表

    • 跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针
    跳跃表节点
    • redis.h/zskiplistNode
    typedef struct zskiplistNode {
        //层
        struct zskiplistLevel {
            //前进指针
            struct zskiplistNode *forward;
            //跨度
            unsigned int span;
        } level[];
        //后退指针
        struct zskiplistNode *backward;
        //分值
        double score;
        //成员对象
        robj *obj;
    } zskiplistNode;
    
    • level数组, 每一个元素都包涵一个指向其他节点的指针,层的数量越多,访问其他节点的速度就越快
    • 创建一个新的跳跃表节点时,根据幂次定律(power law,越大的数出现的概率越小),随机生成一个1~32的值作为level数组的大小,这个大小就是层的高度
  • 前进指针

    • 每个层都有一个指向表尾方向的前进指针(level[i].forward属性),用于从表头向表尾方向访问节点
  • 跨度

    • 层的跨度(level[i].span属性)用于记录两个节点之间的距离
    • 两个节点之间的跨度越大,它们相距得就越远。
    • 指向NULL的所有前进指针的跨度都为0,因为它们没有连向任何节点
    • 跨度实际上是用来计算排位(rank)
  • 后退指针

    • 节点的后退指针(backward属性)用于从表尾向表头方向访问节点
  • 分值

    • 节点的分值(score属性)是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序
  • 成员对象

    • 节点的成员对象(obj属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。
    • 各个节点保存的成员对象必须是唯一的
  • 跳跃表

typedef struct zskiplist {
    //表头节点和表尾节点
    structz skiplistNode *header, *tail;
    //表中节点的数量
    unsigned long length;
    //表中层数最大的节点的层数
    int level;
} zskiplist;
  • header和tail指针分别指向跳跃表的表头和表尾节点
  • length属性来记录节点的数量
  • evel属性则用于在O(1)复杂度内获取跳跃表中层高最大的那个节点的层数量, 表头节点的层高并不计算在内
跳跃表重点回顾
  • 跳跃表是有序集合的底层实现之一
  • Redis的跳跃表实现由zskiplist和zskiplistNode两个结构组成,其中zskiplist用于保存跳跃表信息(比如表头节点、表尾节点、长度),而zskiplistNode则用于表示跳跃表节点
  • 每个跳跃表节点的层高都是1至32之间的随机数
  • 在同一个跳跃表中,多个节点可以包含相同的分值,但每个节点的成员对象必须是唯一的
  • 跳跃表中的节点按照分值大小进行排序,当分值相同时,节点按照成员对象的大小进行排序

整数集合

  • 当一个集合只包含整数值元素,并且这个集合的元素数量不多时,redis就会使用整数集合作为集合键的底层实现
  • 可以保存类型为int16_t、int32_t或者int64_t的整数值

整数集合实现

typedef struct intset {
    //编码方式
    uint32_t encoding;
    //集合包含的元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
} intset;
  • 整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项

  • contents数组的真正类型取决于encoding属性的值

  • 升级

    • 升级步骤
      • 根据新元素的类型, 扩展整数集合底层数组的空间大小, 并为新元素分配空间
      • 将底层数字现在有的所有元素转换成与新元素相同的类型,并且将转换后的数据元素放在正确的位置上,在放置元素的过程中,需要维持底层数组的有序性质不变
      • 将新元素添加到底层数组里面
        • 如果新元素小于所有元素, 放在索引为0的位置上
        • 如果新元素大于所有元素, 放在索引为len-1的位置
    • 优点
      • 提升了整数集合的灵活性
        • C语言为静态语言, 为了避免类型错误,通常不会将两种不同类型的值放在一个数据结构中
        • 但是,整数集合可以自动升级来适应新元素, 所以可以将不通类型的整数添加到集合中,不必担心出现类型错误
      • 尽可能的节约内存
        • 如果为了保证int16\int32\int64三种类型的值都可以保存,直接使用int64就会实现,但是这样就会造成内存饿的一个浪费,自动升级的目的就是只有在需要使用的时候,才会用到相应的编码,从而尽可能的节省内存
  • 降级

    • 不支持降级,一旦升级编码就会维持升级后的状态
  • 优势

    • 升级操作,为整数集合带来了操作上的灵活,并且尽可能的节约了内存

压缩列表

  • 当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现
  • 当一个哈希键只包含少量键值对,比且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希键的底层实现。
redis> HMSET profile "name" "Jack" "age" 28 "job" "Programmer"
OK
redis> OBJECT ENCODING profile
"ziplist"
  • 压缩列表结构
    • 是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构
    • zlbytes
      • 类型
        • uint32_t
      • 长度
        • 4字节
      • 用途
        • 记录整个压缩列表占用的内存字节数
          • 在对压缩列表进行内存重分配,或者计算zlend的位置时使用
    • zltail
      • 类型
        • uint32_t
      • 长度
        • 4字节
      • 用途
        • 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节
          • 通过这个偏移量,程序无需便利整个压缩列表就可以确定表尾节点的地址
    • zllen
      • 类型
        • uint16_t
      • 长度
        • 2字节
      • 用途
        • 记录压缩列表包含的节点数量
          • 当这个属性的值小于UINT16_MAX(65535)时, 这个属性的值就是压缩列表包含节点的数量;
          • 当这个属性的值等于UINT16_MAX时,节点的真实数量需要遍历整个压缩列表才能计算出来
    • entryX
      • 类型
        • 列表节点
      • 长度
        • 不定
      • 用途
        • 压缩列表包含的各个节点, 节点的长度由节点保存的内容决定
    • zlend
      • 类型
        • uint8_t
      • 长度
        • 1字节
      • 用途
        • 特殊值0xFF(十进制255), 用于标记压缩列表的末端
  • 压缩列表节点的构成
    • 可以保存一个字节数组或者一个整数值
    • 字节数组可以是三种长度
      • 63(2^6-1)
      • 16383(2^14-1)
      • 4294967295(2^32-1)
    • 整数值可以是以下六种长度的一种:
      • 4位长, 介于0~12之间无符号整数
      • 1字节长的有符号整数
      • 3字节长的有符号整数
      • int16_t类型整数
      • int32_t类型整数
      • int64_t类型整数
    • 每个压缩列表节点的构成
      • previous_entry_length
        • 作用
          • 记录了压缩列表中前一个节点的长度
        • 长度可以为1字节或者5字节
          • 如果前一节点的长度小于254字节,长度为1字节
            • 保存前一节点的长度
          • 如果前一节点的长度大于等于254字节,长度为5字节
            • 其中属性的第一字节会被设置为0xFE(十进制值254),而之后的四个字节则用于保存前一节点的长度
      • encoding
        • 记录了节点的content属性所保存数据的类型以及长度
      • content
        • 负责保存节点的值,可以是一个字节数组或者整数
          • 值的类型和长度由节点的encoding属性决定
    • 表结尾节点向表头节点进行便利的完整过程
      • 指向表尾节点可以通过entry4起始地址的指针p1(指向表尾节点通过指向压缩列表起始地址的指针加上zltail属性的值得出)
      • 通过用p1减去entry4节点previous_entry_length属性的值,我们得到一个指向entry4前一节点entry3起始地址的指针p2
      • 通过用p2减去entry3节点previous_entry_length属性的值,我们得到一个指向entry3前一节点entry2起始地址的指针p3;
      • 通过用p3减去entry2节点previous_entry_length属性的值,我们得到一个指向entry2前一节点entry1起始地址的指针p4,entry1为压缩列表的表头节点;
      • 最终完成了从表尾向头节点遍历了整个列表
    • 连锁更新问题
      • 对于previous_entry_lenght 属性记录前一个节点的长度, 小于254 1个字节,大于等于254 用5个字节
      • 情景一:
        • 在一个压缩列表中,多个连续且长度介于250~253的节点, 这时,我们将一个长度大于等于254的节点设置为表头,原来的表头节点e1 的previous_entry_lenght 会从1字节重新分配为5字节,导致e1的长度大于等于254,从而导致e1后面的节点e2也会重新分配previous_entry_lenght,以此类推,程序会不断的对压缩列表进行空间分配,直致eN为止。
      • 情景二:
        • 在一个压缩列表中,从e2~eN多个连续且长度介于250~253的节点,头节点为大于254的节点e1,e2的previous_entry_lenght 为 5字符, e3~eNprevious_entry_lenght 为 1字符,当把e2删除掉时,后面e3~eN也会连续的扩展空间。
      • 这种连续多次空间扩展的操作叫做:连锁更新
      • 虽然连锁更新的复杂较高,但是造成性能问题的几率很低
        • 压缩列表中恰好多个连续且长度介于250~253之间的节点, 连锁更新才会发生,这种情况的概率很低
        • 如果出现连锁更新,只要节点不多,也不会对性能有太大的影响
  • 20
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值