【redis学习系列】数据结构与对象 1

日常的开发中,涉及到缓存的应用场景, 大多数情况会用到redis, 本系列文章将介绍redis的内部构造以及运行机制。

本文主要介绍redis中的三种数据结构: 简单动态字符串、链表和字典。

1. 简单动态字符串

1.1. SDS的定义

redis中并没有直接使用c传统字符串表示, 而是定义了一种名为简单动态字符串(simple dynamic string)的抽象类型, 并将SDS作为redis内部默认字符串表示。 redis中只会将c类型字符串作为字符串字面量应用于无需修改的场景, 如日志记录等。 对于其他场景, 均使用SDS来表示字符串的值。

SDS定义如下:

struct sdshdr {
    //记录buf数组中已经使用的字节数量
    //等于sds保存的字符串长度
    int len;

    //记录buf数组中未使用的字节数量
    int free;

    //字节数组, 用于保存字符串
    char buf[];
}

下图为SDS表示的字符串示例:

可以看到, SDS实际遵循了c字符串的惯例, 最后一个字符是空字符(\0), 同时, 保存空字符的1字节空间是不计入len长度的。这样的好处在于, SDS可以直接使用C字符串函数库中的函数。  

1.2. SDS与C字符串的区别

1.2.1 获取字符串长度

SDS直接读取len,复杂度O(1); C字符串要遍历整个字符串, 复杂度0(n)

1.2.2. 避免缓冲区溢出 

对C字符串进行拼接操作时, 若操作前没有分配足够的存储空间, 那么拼接操作有可能发生缓冲区溢出的情况, 从而影响其他相邻的字符串, 产生不可预知的问题;  而SDS在拼接操作前, 会先通过free空间判断buff中剩余空间大小, 若不足以容纳新字符串, 则会进行扩容操作, 从而避免C字符串遇到的问题。

1.2.3. 减少字符串修改过程中的内存分配次数

由于C字符串并不记录自身长度, 故对于包含了N个字符的C字符串而言, 其底层的字符数组长度总是N+1。因此每次扩展(append)和缩减(trim)均涉及内存空间的分配。 否则, 扩展操作会出现字符串溢出; 缩减操作会造成内存泄漏。

而SDS通过len字段解除了字符串长度和数组长度的关联,buf长度不一定就是字符串长度+1, 其还包含了未使用的字节, 其个数用free来表示,那么buf长度=len+free+1。SDS通过以下两种措施,来提高执行性能:

1). 空间预分配

用于优化字符串扩展操作, 当SDS的API对一个SDS进行扩展修改时, 程序不仅会为SDS分配修改所必须的空间, 还会分配额外的未使用空间供后续扩展使用, 具体算法如下:

a. 对于长度小于1M的字符串, 程序将分配和len属性相同大小的未使用空间, 例如字符串扩展长度为10, 那么会分配10个字节的空闲空间, buf总长为10+10+1=21个字节,如下图:

b. 若SDS长度超过1M, 则每次都会分配1M的未使用空间。

2). 惰性释放空间

而在做字符串缩减类的操作时, SDS也并不急于将不再使用的空间释放, 而是更新free后, 将其作为未使用空间备用。当然SDS的API提供了相关函数来真正释放未使用空间。

1.2.4. 二进制安全

C字符串中只能存储符合编码规范的字符, 除字符串末尾外, 不允许空字符(\0)的存在。 而SDS是可以保存任何字符的, 因此天然就可以支持保存二进制数据。这就使得redis既可以保存字符串数据, 也可以保存任意格式的二进制数据。 

1.2.5. 兼容部分C字符串函数

由于SDS沿用了以空字符结尾的管理,因此在一些场景下, 可以使用C字符串函数库中的一些函数。

2. 链表

redis中的链表应用很广, 我们常用的列表键底层使用的就是链表。 简单来说, redis中使用了双向无环链表, 分别存储了头、尾指针, 可以支持多种数据结构, 定义如下:

链表节点定义: 

typedef struct listNode {
    //前驱节点
    struct listNode *prev;

    //后继节点
    struct listNode *next;

    //节点值
    void *value;
} listNode;

链表结构定义:

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;

下图展示了链表的示例结构

3. 字典

redis自己设计了其使用的字典结构, 并广泛应用。 redis数据项就是基于字典处理的, 若set test_key 'test_value',  test_key即字典的键, test_value为字典的值; 另外, 哈希键的底层实现,也应用了字典结构。

3.1. 字典的实现

redis的字典(dict)通过哈希表(dictht)实现, 一个哈希表里可以有多个哈希节点(dictEntry), 哈希表节点就保存了字典中的一个键值对, 哈希表(dictht)通过数组+链表的形式组织哈希节点。

3.1.1. dictEntry

typedef struct dictEntry {
    //键
    void *key;

    //值
    union {
        void *val;
        unit64_tu64;
        int64_ts64;
    }v;
    struct dictEntry *next;
} dictEntry;

可以看到哈希节点中存储了下一个节点的指针, 其作用是在发生哈希碰撞时, 会将key的hash值相同的节点组成一个单向链表。

3.1.2. dictht

typedef struct dictht {
    //哈希节点数组
    dictEntry **table;

    //哈希表大小
    unsigned long size;

    //哈希表掩码, 用于计算索引值, 等于size-1
    unsigned long sizemask;

    //哈希表中已有节点数量
    unsigned long used;
} dictht;

哈希表的结构示例如下, 其包含了一个size为4的空哈希表:

下图展示了key的hash值相同的两个节点在哈希表中的结构:

3.1.3. dict

typedef struct dict {
    //类型特定函数,其中包含了计算hash值的函数、负值键/值函数等
    dictType *type;

    //私有数据
    void *privdata;

    //哈希表
    dictht ht[2];

    //rehash索引, 当rehash操作不在进行时, 其值为-1
    int trehashidx;
} dict;

typedef struct dictType {
    //计算hash值的函数
    unsigned int (*hashFunction)(counst 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;

下图为字典结构的示例:

ht是长度为2的dictht数组, 一般情况只会用到ht[0], 但是在rehash操作时, ht[1]也会被应用到, 后续会进行详细阐述。 rehashidx字段也是在rehash操作时被用到的。 

3.2. 哈希算法

当一个键值对要放入哈希表时, 会调用dict->type->hashFunction()方法获取hash值, 然后再通过dict->ht[x]->sizemask与前面获取到的hash做按位与操作(&), 从而获取到当前节点要放到dict->ht[x]->table数据组中的具体索引位置。假如要将键值对k0/v0添加到一个空字典中, 过程如下:

1). hash = dict->dictType->hashFunction(k0);计算出k0的hash值,假设是8;

2). index = hash & dict->ht[0].sizemask = 8 & 3  = 0, 结果如下图:

3.3. 键冲突

有两个或以上数量的键被分配到了哈希表的同一个索引上, 被称为键冲突。 redis的哈希表通过链表方式来解决冲突。举个例子, 假设键值对k2/v2要执行放入字典的操作, 而k2通过hash计算出的索引值所在的数组位置上, 已经存在了哈希节点, 即发生了键冲突。 此时, 使用dictEntry的next指针,将k2/v2键值对节点与已存在键值对节点链接起来解决冲入。 由于dictEntry只有next节点, 会生成一个单链表, 因此新加入的节点总是放到链表头。如下图展示了解决键冲突的过程:

3.4. rehash

随着操作不断进行, 哈希表保存的键值对会增多或减少, 当负载因子达到某一个阈值时, 需要对哈希表进行扩容或缩容。这时候就需要进行rehash操作了。 扩容后的哈希表容量为第一个大于等于ht[0].uesed*2的2的n次方的数字; 缩容后容量为大于等于ht[0].uesed的2的n次方的数字。其整个过程简单描述为: 1. 先给ht[1]生成扩容后大小的dictht对象;  2. 循环计算ht[0]中存储的键值对在ht[1]中的索引位置, 并将其放入ht[1]中;  3. 当所有键值对都迁移到了ht[1]后,释放ht[0], 并将ht[0]指向ht[1], 给ht[1]创新一个新的空哈希表。

而redis出于性能考虑, 将上面描述的rehash过程分步执行, 因而引入了渐进式rehash。 这种渐进式rehash应用了分而治之的思想, 将rehash过程分散到了在每次对字典执行添加、删除、查找或更新操作时, 从而保证性能, 其过程如下:

1).  判断dict是否正在rehashing(rehashidx不为-1),只有是,才能继续往下进行,否则已经结束哈希过程,直接返回。
2).  执行完具体的操作后, 将ht[0]表中rehasidx索引上所有键值对rehash到ht[1]上, 完成后ht[0]表中对应的指针设置为空, rehashidx++,
3).  最后判断一下ht[0]是否全部迁移完毕,若是,则回收空间,重置ht[0],重置rehashidx为-1,否则用返回值告诉调用方,dict内仍然有数据未迁移。

下图展示了整个过程:


img1 
img2 
img3 
img4 
img5 
img6

最后,需要说明的一点:  在渐进式rehash执行期间, 查询、更新、删除操作会先试图在ht[0]上进行, 若不存在再操作ht[1];  而插入操作一律会被保存到ht[1];

 

参考:

《Redis设计与实现》

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值