高效的Redis底层都使用了哪些数据结构?

Redis的数据类型总结

Redis数据库里面的每一个键值对(key-value pair)都是对象(object)组成的,其中:

数据库键总是一个对象(string object);

而数据库的值则可以是字符串对象、列表对象(list object)、哈希对象(hash object)、集合对象(set object)、有序集合对象(sorted set object)这五种对象其中的一种。

Redis中的数据类型所使用的底层数据结构

StringListHashSetSorted Set
简单动态字符串(SDS)双向链表、压缩列表压缩列表、哈希表哈希表、整数集合压缩列表、跳表

简单动态字符串(SDS)

c语言定义方法:

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

SDS遵循C字符串以空字符串结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里面,并且为空字符分配额外的1字节空间(如Redis字符串保存在数组中是’R’,‘e’,‘d’,‘i’,‘s’,’\0’)。添加空字符到字符串末尾是完全透明的。遵循空字符串结尾这一惯例的好处是:SDS可以重用c字符串库里面的函数

比起C字符串,SDS具有的特点:

  1. O(1)时间复杂度获取字符串长度,程序只需要访问len属性,就可以立即获取到字符串长度。
  2. 拒绝缓冲区溢出(buffer overflow), 当SDS API需要对SDS进行修改时,API会先检查SDS空间
  3. 减少修改字符串时带来的内存重分配次数
  4. 二进制安全:Redis使用buf属性保存的是一系列二进制数据。
  5. 兼容部分C字符串函数。

链表

链表节点listNode的表示方法:

typedef struct listNode {
    //表头节点
    struct listNode *prev;
    struct listNode *next;
    viod *value;
}listNode;

链表的实现方法:

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

链表中需要了解的:

  1. 链表被广泛用于列表键、发布与订阅、慢查询、监视器等。
  2. 每个链表节点由一个listNode结构来表示,每个节点都有一个前驱和后继,因此Redis的链表实现是双端队列。
  3. 每个链表使用一个list结构来表示,这个结构带有表头指针和表尾指针以及链表长度等信息。

哈希表(字典)

哈希表的定义如下:

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

哈希表节点的定义如下:

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        unit64_t u64;
        int64_t s64;
    } v;
    // 指向下一个节点,形成链表
    struct dictEntry *next;
} dictEntry;

问1:为什么会有next属性?

答:next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一次,从而解决键冲突(hash collision)的问题。

问2:那讲讲解决哈希冲突的常用办法?

答:解决哈希冲突的方法有两大类。

一是上文提到的链表法:当插入的我们通过哈希函数(也称散列函数)计算出对应的哈希值相同时,只需要将其插入到链表的末尾。

二是使用开放寻址法:如果出现了哈希冲突,我们就重新探测一个空闲的位置,探测的方法也五花八门,有线性探测(从当前位置继续向后找,直到找到一个空闲的位置)。还有多此哈希等等。

关于哈希表中的哈希算法,Redis使用的是MurmurHash2算法来计算键的哈希值。

随着哈希表中写入更多的数据时,哈希冲突会越来越多,进而导致从链中查找元素的查找时间耗时长,效率降低。显然高性能的Redis是不能忍受这样的事件复杂度的退化的。为了让哈希表的负载因子(load factor)在一个合理的范围内,Redis会对哈希表做rehash操作。Rehash操作就是同时使用两个全局哈希表。这里分别称其为哈希表1和哈希表2。一开始插入数据时,默认使用哈希表1,此时的哈希表2并没有被分配空间。随着数据的增多,Redis开始执行Rehash操作。

  1. 给哈希表2分配一个更大的空间,例如是当前哈希表1的两倍大小。
  2. 在哈希表中维持一个索引计数器变量rehashidx , 并将其值设置为0,表示rehash工作正式开始。
  3. 在rehash的过程中,每次处理一个客户端请求时,就从rehashidx上面所有的键值对移动到哈希表2,当本次rehash工作完成后,程序将rehashidx属性的值增加1。
  4. 随着不断的执行,在最终哈希表1中的所有键值对都被rehash到哈希表2后,程序会将rehashidx属性的值设置为-1。

渐近式rehash的好处在于巧妙地把一次性大量拷贝的开销,分摊到了多次请求处理的过程中,避免了耗时操作,保证了数据的快速访问。

跳表(skiplist)

跳跃表是有序集合的实现之一,也是集群节点中用作内部数据结构,它通过在每一个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。

网上对跳表的理论讲解和各类代码实现都比较多,这里引用简书上面的一篇文章https://www.jianshu.com/p/dc252b5efca6。其基本原理为添加多级索引来加快查找速度实现O(logN)的时间复杂度, 通过随机函数确定节点插入到几级索引中来防止跳表退化为单链表。

整数集合(intset)

整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。

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

其中contents数组是整数集合的底层实现,整数集合的每一个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到达有序排列,不包括任何重复项。其中encoding属性决定了contents数组中的类型。当添加一个新的元素到整数集合中,并且新元素的类型比整数集合现有所有的类型都要长时,整数数组会先进行升级(upgrade),才能将新元素添加到整数集合里面去。但是整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。

知识点整理:

  1. 整数集合是集合键的底层实现之一。
  2. 整数集合的底层实现是数组,这个数组以有序、无重复的方式保存了集合元素,在有需要时,程序会根据新添加元素的类型,改变这个数组的类型。
  3. 升级操作作为整数集合带来了操作的灵活性,并且尽可能地节约了内存。
  4. 整数集合只支持升级操作,不支持降级。

压缩列表(ziplist)

压缩列表是Redis为了节约内存而开发的,

  1. 当每一个列表项或当一个哈希键只包含少量的键值对,且每个列表项要么是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表做列表键的底层实现。
redis> RPUSH lst 1 3 4 1032 "hello" "redis"
(integer)6

redis> OBJECT ENCODING lst
"ziplist"

redis> HMSET profile "name" "Jake" "age" 18 "job" "student"
OK
redis> OBJECT ENCODING profile
"ziplist"

压缩列表是由一系列连续内存块组成的顺序型(sequential)数据结构。

zlbyteszltailzllenentry1entry2entryNzlend
记录整个列表占用的字节数记录ziplist表尾节点距离起始节点有多少字节压缩列表包含的节点数量列表节点1列表节点2列表节点N标记压缩列表的末端。OxFF

压缩列表总结:

  1. 压缩列表是为了节约内存而开发的顺序型数据结构
  2. 压缩列表被用作列表键和哈希键的底层实现之一
  3. 添加新的节点到压缩列表,或者从压缩列表中删除某个节点,可能会引发连锁更新操作,但这种操作出现的机率不高。

最后思考

整数数组和压缩列表在查找时间复杂度方面并没有太大优势,为什么还会将它们作为底层数据结构呢?

答:两方面原因:

1、内存利用率,数组和压缩列表都是非常紧凑的数据结构,它比链表占用的内存要更少。Redis是内存数据库,大量数据存到内存中,此时需要做尽可能的优化,提高内存的利用率。

2、数组对CPU高速缓存支持更友好,所以Redis在设计时,集合数据元素较少情况下,默认采用内存紧凑排列的方式存储,同时利用CPU高速缓存不会降低访问速度。当数据元素超过设定阈值后,避免查询时间复杂度太高,转为哈希和跳表数据结构存储,保证查询效率。

参考文章:

Redis的设计与实现

极客时间专栏 https://time.geekbang.org/column/article/268253

极客时间专栏评论区@Kaito https://time.geekbang.org/discuss/detail/239388

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值