通过阅读《Redis设计与实现》黄健宏(机械工业出版社 ISBN9787111464747)一书,对Redis的设计有了更深刻的认识,在此整理下关键的知识点。

一、基本数据结构

1.简单动态字符串(SDS:simple dynamic string)

//数据结构
struct sdshdr {  
    //sds保存的字符串长度
    int len;
    //记录buf数组中未使用字节的数量:重要作用就是冗余部分字节,避免扩容时频繁申请内存带来的开销
    int free;
    //字节数组,保存真正的字符串
    char buf[];
}
  • SDS遵循C字符串以空字符结尾的惯例,保存空字符的最后1字节的空间不计入SDS的len属性
  • 用len属性保存字符串长度,常数复杂度
  • 杜绝C中的缓冲区溢出的问题(申请的空间不够,导致写操作溢出到未分配的空间)
  • 空间预分配。对SDS进行修改时,会先判断free的空间是否够用,如果不够,对SDS进行扩容,扩容时额外分配的free空间数量遵循以下原则:如果修改后的len小于1MB,则额外分配len大小的free空间;如果修改后的len大于等于1MB,则额外分配1MB的free空间
  • 惰性空间释放。当需要缩短SDS保存的字符串时,不会立即回收多余的字节,而是冗余在free属性中,以备将来使用
  • 二进制安全。使用了len属性,确保读取字符串时以长度为边界,而非以C语言的空字符'\0'为边界,这使得SDS能保存二进制数据(如图片、音视频等二进制数据)
  • 使用C中的空字符结尾,便于兼容部分C字符串函数
  • 2.Redis的链表实现

    //节点:双向链表
    typedef struct listNode {  
        //前置节点
        struct listNode * prev;
        //后置节点
        struct listNode * next;
        //节点的值
        void * value;
    }listNode;
    
    //list结构:记录头、尾指针,以及长度计数器len,便于操作链表
    typedef struct list {  
        //表头节点
        listNode * head;
        //表尾节点
        listNode * tail;
        //链表节点数量
        unsigned long len;
        //节点值复制函数
        void *(*dup)(void *ptr); //使用函数指针便于扩展,不同的业务指定自己的实现
        //节点值释放函数
        void (*free)(void *ptr);
        //节点值对比函数
        void (*match)(void *ptr, void *key);
    } list;
    
  • 双端:带有prev和next指针,获取某个节点的前后节点复杂度都是O(1)
  • 无环:表头的prev和表尾的next指针都指向NULL,对链表的访问以NULL为终点
  • 带表头和表尾指针:通过list结构的head指针和tail指针,快速获取表头节点和表尾节点,O(1)
  • 带链表长度计算器:list结构的len属性记录了链表节点数,O(1)
  • 多态:listNode中使用void * 指针保存节点值,便于保存不同类型的值
  • 3.Redis的字典实现
    Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对

    //哈希表结构
    typedef struct dictht {  
        //哈希表数组
        dictEntry **table;
        //哈希表大小
        unsigned long size;
        //哈希表大小掩码,用于计算索引值,总是等于size-1。对数据的哈希值和sizemask作运算,确定数据应该存放在table的哪个索引上
        unsigned long sizemask;
        //该哈希表已有节点的数量
        unsigned long used;
    } dictht;
    
    //哈希表节点
    typedef struct dictEntry {  
        //键
        void *key;
        //值
        union{
            void *val;
            uint64_tu64;
            int64_ts64;
        } v;
        //指向下个哈希表节点,形成链表,解决哈希冲突问题
        struct dictEntry *next;
    } dictEntry;
    

    //dict结构
    typedef struct dict {  
        //类型特定函数
        dictType *type;
        //私有数据
        void *privdata;
        //哈希表
        dictht ht[2];//包含2项的数组,每个都是哈希表。字典使用ht[0],而ht[1]只在rehash的时候使用
        //rehash索引,不在rehash状态时为-1
        int rehashindex;
    } dict;
    
    typedef struct dictType {  
        //计算哈希值的函数
        unsigned int (*hashFunction)(const void *key);
        //复制键的函数
        void *(*keyDup)(void *privdata, const void *key);
        //复制值的函数
        void *(*valDup)(void *privdata, const void *obj);
        //对比键的函数
        void (*keyCompare)(void *privdata, const void *key1, const void *key2);
        //销毁键的函数
        void (*keyDestructor)(void *privdata, void *key);
        //销毁值的函数
        void (*valDestructor)(void *privdata, void *obj);
    } dictType;
    
  • 字典中新添加键值对时,先根据键应用哈希算法计算哈希值和索引值,再将包含新键值对的哈希表节点放到哈希表数组的指定索引上。如果存在哈希冲突,则新节点放在对应索引链表的表头
  • 为了让哈希表的复杂因子(load factor)维持在合理的范围内,当哈希表保存的键值对数量太多或太少时,需要进行rehash对哈希表进行扩展或者收缩
  • 如果进行扩展操作,ht[1]的大小为第一个大于等于ht[0].used*2的 2^n(2的n次方);如果进行收缩操作,ht[1]的大小为第一个大于等于ht[0].used的2^n(2的n次方)
  • rehash的过程:分配ht[1]空间后,把ht[0]的数据迁移到ht[1]中;迁移后释放ht[0],将ht[1]设置为ht[0];新创建ht[1]的空白哈希表以备下次使用
  • 对哈希表自动扩展的条件(负载因子=已保存节点数/哈希表大小):

    1)服务器当前没执行BGSAVE或者BGREWRITEAOF命令,且哈希表负载因子大于等于1

    2)服务器在执行BGSAVE或BGREWRITEAOF命令,且哈希表负载因子大于等于5

  • 当负载因子小于0.1时自动进行哈希表收缩操作
  • 渐进性rehash,将rehash的工作均摊到对字典的每个添加、删除、查找和更新上
  • 渐进式rehash过程中,新添加的字典的键值对都会在ht[1]中。字典的删除、查找、更新都会在两表进行,先查ht[0]再查ht[1]
  • 4.跳跃表skiplist(用于实现有序集合和集群节点中的内部数据结构)

    //跳跃表节点
    typedef struct zskiplistNode {  
        //层,每个节点保存了该节点所分布的层次,以及在该层对应的前序指针,和层级之间的跨度
        struct zskiplistLevel {
            //前进指针,指向表尾方向,节点在该层的前进指针
            struct zskiplistNode *forward;
            //跨度,与下一层级的跨度
            unsigned int span;
        } level[];
        //后退指针
        struct zskiplistNode *backward;
        //分值
        double score;
        //成员对象
        robj *obj;
    } zskiplistNode;
    
    //zskiplist结构
    typedef struct zskiplist {  
        //表头节点和表尾节点
        struct zskiplistNode *header, *tail;
        //表中节点的数量
        unsigned long length;
        //表中层数最大的节点的层数
        int level;
    } zskiplist;
    
  • 每个跳跃表节点的层高都是1至32之间的随机数
  • 跳跃表中的节点按照分值大小排序,分值相同时按照成员对象的大小排序(字母表顺序)
  • 一个五层带zskiplist的跳跃表

    5.整数集合

    //intset数据结构
    typedef struct intset {  
        //编码方式
        uint32_t encoding;//取值范围:INTSET_ENC_INT16,INTSET_ENC_INT32,INTSET_ENC_INT64
        //集合包含的元素数量
        uint32_t length;
        //保存元素的数组
        int8_t contents[];//实际类型以encoding的属性为准
    } intset;
    
  • contents数组所有元素的类型一致
  • 当新元素类型超出既有范围时,需要先升级数组的结构(分配更大的存储空间),然后进行旧元素的类型升级,继而更新数组各元素的位置,最终再更新encoding的编码类型
  • 支持自动升级,不支持降级
  • 6.压缩列表ziplist
    当一个[列表的列表项|哈希类型的键值对]很少,并且值要么都是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表作为[列表类型|哈希类型]的底层实现,节约内存

    压缩列表整体结构
    zlbytes:4字节空间,记录整个压缩列表的内存字节数  
    zltail:4字节空间,离起始地址的偏移量,无需遍历列表即可定位表尾地址  
    zllen:2字节空间,记录节点数,小于65535时数据准确,等于65535时存的已经不准确,需要遍历节点计算得出  
    entryX:列表节点,长度由内容决定  
    zlend:1字节,特殊值OxFF(255),标记列表末端
    
    单个节点的结构
    previous_entry_length:记录前一节点的长度。如果前一节点长度小于254字节,则此属性长度为1字节,记录了前一节点的长度;如果前一节点长度大于等于254字节,则该属性长度为5字节,其中第一字节为OxFE(254,为啥不是OxFF?因为这是结尾的特殊值),后续4字节保存具体的前一字节的长度  
    encoding:记录了content属性保存的数据类型和长度  
    content:节点的值  
    
  • 为节约内存而开发的顺序型数据结构

  • 添加新节点或者删除节点,都可能导致连锁更新(连续空间依次被重新分配)

  • 二、五种对象

    以上的数据结构是Redis对象的具体底层实现,而Redis的五种对象类型(字符串、列表、哈希、集合、有序集合)是由一种或多种底层实现组合而成。

    //对象的主要属性
    typedef struct redisObject{  
        //类型
        unsigned typpe:4;//REDIS_STRING,REDIS_LIST,REDIS_HASH,REDIS_SET,REDIS_ZSET
        //编码
        unsigned encoding:4;
        //指向底层实现数据结构的指针
        void *ptr;
        //...其他
    } robj;
    

    encoding记录了对象使用的编码
    每种对象可能有多种不同的编码(节约内存)

  • 通过encoding属性设置对象使用的编码,提升了Redis的灵活性和效率,优化对象在不同场景下的效率

  • 当同一个对象因为属性变化导致类型会发生变化时,Redis会自动更新对象的编码类型

  • 1.字符串对象
    可以是int、embstr或者raw类型编码

  • 如果保存的是整数值,并且可以用long类型表示。对象使用int编码
  • 如果保存的是小于等于32字节的字符串,使用embstr编码保存字符串
  • 如果保存大于32字节的字符串,编码类型为raw,且字符串值用SDS保存
  • embstr相比raw编码,会申请一个连续内存用于保存redisObject和sdshdr,减少内存分配并且充分利用了内存缓存
  • int和embstr类型编码的对象,在一定条件下会被转换成raw编码的对象(当保存的值超出原有范围的时候)
  • 2.列表对象
    可以是ziplist或者linkedlist编码 其中,满足以下两个条件的场景下会用ziplist编码:

  • 列表对象保存的所有字符串长度都小于list-max-ziplist-value配置的值(默认64字节)
  • 列表对象保存的元素数小于list-max-ziplist-entries值(默认512)
  • 其他场合下用linkedlist编码

    另:使用ziplist编码的对象,当以上两条件不再满足时,会自动编码转换成linkedlist

    3.哈希对象
    可以是ziplist或者hashtable

  • 使用ziplist编码时,新的键值对要加入时,先把键的节点压入队尾,再把值的节点紧挨着压入队尾
  • 使用hashtable编码时,底层每个键值对都是一个字典值对保存
  • 编码转换条件同列表对象(超长或超个数都会使用hashtable编码,否则用ziplist编码)
  • 4.集合对象
    可以是intset或者hashtable

  • intset编码的集合对象,数据都保存在整数集合中
  • hashtable编码的集合对象,数据都保存在字典中,其中字典的键就是字符串对象,代表一个集合元素,而字典的值为NULL。
  • 当集合所有元素都是整数值并且元素数量不超过set-max-intset-entries(默认512)时,使用intset编码,否则使用hashtable编码,编码条件满足时也会类型转换
  • 5.有序集合对象
    可以是ziplist或者skiplist

  • ziplist保存时,每个集合元素使用两个相邻的压缩列表节点来保存,第一个节点保存元素的成员member,第二个节点保存元素的分值score。
  • 压缩列表节点从小到大排序,表头小,表尾大。
  • skiplist编码的有序集合对象,使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表:
  • typedef struct zset {  
        zskiplist *zsl;
        dict *dict;
    } zset;
    
  • zsl跳跃表按分值从小到大保存集合的所有元素,通过跳跃表可以对有序集合进行范围操作
  • zset中的dict保存了成员到分值的映射,保证O(1)复杂度内查找指定成员的分值
  • 当有序集合元素数小于zset-max-ziplist-entries(默认128)且每个元素成员的长度都小于zset-max-ziplist-value(默认64)时用ziplist编码,否则用skiplist并且支持编码自动转换
  • 6.其他

  • Redis对象带有引用计数,用来实现内存回收和对象共享(多个元素指向同一个底层对象)
  • 对象会记录最后一次被访问的时间,用来计算对象的空转时间
  • 下一篇Redis学习笔记——数据库和部署