Redis 数据结构

redis适用场景:
    1.取最新N个数据的操作(利用list最新放redis,其他数据从数据库直接读取)
    2.排行榜(利用sorted set,将你要排序的值设置成 sorted set的score,将具体的数据设置成相应的value,只需增加数据,redis自动排序)
    3.需要精准设定过期时间的应用(sorted set的score值设置成过期时间的时间戳,那么就可以简单地通过过期时间排序,定时清除过期数据)
    4.计数器(命令原子性,你可以轻松地利用 incr,decr等命令来构建计数器系统。)
    5.缓存
    6.实时消息系统(发布与订阅)
    7.队列系统(使用 list 可以构建队列系统,使用 sorted set 甚至可以构建有优先级的队列系统)
    8.数据排重(Set)
    9.实时系统

    ...

动态字符串:

Redis没有使用传统的C字符串,构建简单的动态字符串(simple dynamic string,sds),并将其作为redis的默认字符串表示。

sds字符串的内存结构:
/*
 * 保存字符串对象的结构
 */
struct sdshdr {
    
    // buf 中已占用空间的长度
    int len;

    // buf 中剩余可用空间的长度
    int free;

    // 数据空间
    char buf[];
};
示例:

优点:

1. C语言使用长度为N+1的字符数组来表示长度为N的字符串,并且字符数组的最后一个元素总是空字符‘\0’。因为C字符串并不记录自身的长度信息,所以获取长度需要遍历整个字符串数组,时间复杂度为O(N),而Redis的动态字符串则记录了其长度,时间复杂度降为O(1),因为字符串键底层使用SDS来实现,所以对一个字符串反复STRLEN,也不会造成性能影响。

2. C字符串容易造成缓冲区溢出,比如s1 = "reids"; s2 = "mongodb";在内存中s1和s2字符串紧挨着分配,而此时执行了strcat(s1, "good");good字符串数据将溢出到s2所在空间,导致s2保存的内容被意外更改了。而SDS字符串空间分配策略会杜绝发生缓冲区溢出可能性:当SDS的API要对SDS串进行修改时,API会先检查SDS空间是否满足修改需求,如果不满足的话API会自动将SDS空间扩展至执行修改所需的大小,然后才执行修改操作。

/*
 * 将给定字符串 t 追加到 sds 的末尾
 * 
 * 返回值
 *  sds :追加成功返回新 sds ,失败返回 NULL
 *
 * 复杂度
 *  T = O(N)
 */
sds sdscat(sds s, const char *t) {
    return sdscatlen(s, t, strlen(t));
}
sds sdscatlen(sds s, const void *t, size_t len) { 
    struct sdshdr *sh;   
    // 原有字符串长度
    size_t curlen = sdslen(s);

    // 扩展 sds 空间
    // T = O(N)
    s = sdsMakeRoomFor(s,len);

    // 内存不足?直接返回
    if (s == NULL) return NULL;

    // 复制 t 中的内容到字符串后部
    // T = O(N)
    sh = (void*) (s-(sizeof(struct sdshdr)));
    memcpy(s+curlen, t, len);

    // 更新属性
    sh->len = curlen+len;
    sh->free = sh->free-len;

    // 添加新结尾符号
    s[curlen+len] = '\0';

    // 返回新 sds
    return s;
}
3. C字符串长度和底层数组的长度之间存在关联性,每次增长或缩短一个C字符串,程序都要对内存进行重分配操作:
1)如果程序执行的是字符串增长操作,在执行操作之前,需要通过内存重分配扩展底层数组大小,如果忘记操作则会缓冲区溢出。
2)如果程序执行的是字符串缩短操作,在执行操作之前,需要通过内存重分配释放不再使用的内存空间,如果忘记操作则会内存泄露。
而SDS则使用free属性记录未使用空间解除C字符串长度和底层数组的长度之间的关联性。

空间预分配

用于优化字符串的增长操作,当SDS的API对一个SDS进行修改,并且需要对SDS的空间进行扩展的时候,程序不仅会为SDS分配必须的空间,还会为SDS分配额外的空间。

分配策略

+ 如果对SDS进行修改后,SDS的长度将小于1MB,程序分配和len属性同样大小的未使用空间;

+ 如果对SDS进行修改后,SDS的长度将大于等于1MB,程序分配1MB的未使用空间。

惰性空间释放

用于优化字符串的缩短操作,当SDS的API对一个SDS进行缩短时,程序并不立即使用内存重分配来回收多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待下次使用。

4. C字符串的字符必须符合某种编码,出字符串末尾外,字符串里面不能包含空字符,否则最先被读取的空字符被认为是字符串的结尾标识,这些限制使C字符串仅限于文本数据,而不能处理图片,音频,视频,压缩文件等数据。SDS的buf数组是字节数组,redis用它保存二进制安全数据,但该数组末尾也遵循C字符串空字符结尾惯例,从而方便使用C语言的库函数。

链表:
链表提供高效的节点重排,顺序性访问,灵活增删节点特点。
被用于列表键的底层实现,发布订阅功能,慢查询功能,监视器等功能。
链表的内存结构:
/*
 * 双端链表节点
 */
typedef struct listNode {

    // 前置节点
    struct listNode *prev;

    // 后置节点
    struct listNode *next;

    // 节点的值
    void *value;
} listNode;
 /*
 * 双端链表结构
 */
typedef struct list {
    // 表头节点
    listNode *head;

    // 表尾节点
    listNode *tail;

    // 节点值复制函数
    void *(*dup)(void *ptr);

    // 节点值释放函数
    void (*free)(void *ptr);

    // 节点值对比函数
    int (*match)(void *ptr, void *key);

    // 链表所包含的节点数量
    unsigned long len;
} list;
示例:
字典:
字典是一种用于保存键值对的抽象数据结构。
被用于Redis数据库的底层实现,哈希表的底层实现。
字典的内存结构:

/*
 * 哈希表
 *
 * 每个字典都使用两个哈希表,从而实现渐进式 rehash 。
 */
typedef struct dictht {
    // 哈希表数组
    dictEntry **table;

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

    // 该哈希表已有节点的数量
    unsigned long used;
} dictht;
/*
 * 哈希表节点
 */
typedef struct dictEntry {
    // 键
    void *key;

    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;

    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;
/*
 * 字典
 */
typedef struct dict {

    // 类型特定函数
    dictType *type;

    // 私有数据
    void *privdata;

    // 哈希表
    dictht ht[2];

    // rehash 索引
    // 当 rehash 不在进行时,值为 -1
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */

    // 目前正在运行的安全迭代器的数量
    int iterators; /* number of iterators currently running */
} dict;

示例:


哈希算法

当要将一个新的键值添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到指定索引上。redis目前使用Murmurhash2算法来计算哈希值(参考链接Murmurhash算法参考)。

hash = dict->type->hashFunction(key);

index = hash & dict->ht[x].sizemask;

解决键冲突

1)当有两个以上的键被分配到哈希数组同一个索引上时,我们称发生了哈希冲突,redis的哈希表使用链地址法解决键冲突。通过dictEntry结构中的*next属性指针实现。

2)随着操作的不断执行,哈希表保存的键值对会逐渐的增多或减少,为了让哈希表的负载因子维持在一个合理的范围内,当保存的键值对数量太多或太少时,程序需要对哈希表的大小进行相应的扩展和收缩,这个工作通过rehash来实现。

重哈希步骤:

+ 为字典的ht[1]哈希表分配空间,这个哈希表空间的大小取决于要执行的操作,以及ht[0]当前所包含的键值对数量。

    - 如果执行的是扩展操作,那么ht[1] = 第一个大于等于 ht[0].used * 2 的 2^n;

    - 如果执行的是收缩操作,那么ht[1] = 第一个大于等于 ht[0].used 的 2^n;

+ 将保存在ht[0]  上的所有键值对rehash到ht[1]上。

   即重新执行计算哈希值和索引值,将键值对放到ht[1]指定索引位置上。

+ 当ht[0]上的值全部迁移完毕,释放ht[0],将ht[1]设置为ht[0],并在ht[1]上创建空白哈希表,为下一次rehash做准备。

重哈希举例:


字典当前存储如上图,此时进行扩展操作,ht[0].used的当前值为4, 4 * 2 = 8,而8恰好是第一个大于等于4*2的2的n次幂,所以程序会将ht[1]哈希表的大小设置为8。分配后如下图:


将ht[0]包含的键值对重哈希到ht[1]哈希表上后,如下图所示:


释放ht[0],将ht[1]设置为ht[0],然后为ht[1]创建一个空白哈希表,如下图所示。执行完毕后,哈希表的大小也由原来的4变成8了。


哈希表的扩展与收缩:

扩展操作执行条件:

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

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

负载因子load_factor = ht[0].used / ht[0].size;执行BGSAVE或BGREWRITEAOF命令时,服务器存在子进程,需要提高扩展操作所需的负载因子,从而尽量避免在rehash操作,减少不必要的内存写入。

收缩操作执行条件:

哈希表的负载因子小于0.1.

redis的rehash采用渐进式重哈希,避免一次性,集中式的重哈希操作导致的服务器阻塞。通过字典结构中的rehashidx字段实现。

渐进式重哈希步骤:

1) 当ht[1]哈希表分配空间后,将字典中的rehashidx字段设置为0.重哈希操作正式开始,

2) 在重哈希操作执行时,每次对字典进行CRUD操作时,除了执行相应操作外,会顺带将ht[0]上在reashidx索引上的所有键值对迁移到ht[1]上,当本次rehash完成时,程序将字典中的rehashidx属性的值加1,

3) 随着字典操作的不断执行,最终在某个时间点,ht[0]的所有键值对被迁移到ht[1]上,这是程序将字典中的rehashidx属性设为-1.表示rehash操作完成。

跳跃表

跳跃表是一种有序的数据结构,通过在每个节点中维护多个指向其他节点的指针,从而实现快速访问。

被用于有序集合的底层实现。

跳跃表的内存结构

/*
 * 跳跃表节点
 */
typedef struct zskiplistNode {

    // 成员对象
    robj *obj;

    // 分值
    double score;

    // 后退指针
    struct zskiplistNode *backward;

    // 层
    struct zskiplistLevel {

        // 前进指针
        struct zskiplistNode *forward;

        // 跨度
        unsigned int span;

    } level[];

} zskiplistNode;

/*
 * 跳跃表
 */
typedef struct zskiplist {

    // 表头节点和表尾节点
    struct zskiplistNode *header, *tail;

    // 表中节点的数量
    unsigned long length;

    // 表中层数最大的节点的层数
    int level;

} zskiplist;
示例:



在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是各个节点保存的分值却可以是相同的,分值相同的节点将按照成员对象在字典中的大小来进行排序。

整数集合

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

整数集合的内存结构
typedef struct intset {
    
    // 编码方式
    uint32_t encoding;

    // 集合包含的元素数量
    uint32_t length;

    // 保存元素的数组(有序不重复)
    int8_t contents[];

} intset;
虽然将contents属性声明为int8_t类型的数组,但是实际上并不保存int8_t类型的值,真正类型取决于encoding的值。

- 如果encoding属性的值是INTSET_ENC_INT16,那么contents属性就是一个int16_t类型的数组。[-32768 ~ 32767]

- 如果encoding属性的值是INTSET_ENC_INT32,那么contents属性就是一个int32_t类型的数组。[-2^32 ~ 2^32 - 1]

- 如果encoding属性的值是INTSET_ENC_INT64,那么contents属性就是一个int64_t类型的数组。

示例


整数集合升级

当新元素类型比整数集合现有元素的类型都要长时,整数集合先进行升级,然后才将新元素添加 到整数集合里。

1)根据新元素的类型,扩展整数集合底层contents空间大小,并为新元素分配空间;

2)将底层数组现有所有元素都转换成新元素类型,并将转换后的元素放入正确的位置,保证有序性;

3)将新元素添加到contents里。

每次新增元素都有可能引起升级,每次升级都要对底层所有元素进行类型转换,所以向整数集合添加新元素时间复杂度是O(N)。

优点:提升灵活性(避免C类型错误),节约内存

整数集合不支持降级

压缩列表

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

压缩列表是redis为节约内存开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构。

可使用object encoding命令查看底层实现类型。

压缩列表内存结构

/* 
空白 ziplist 示例图

area        |<---- ziplist header ---->|<-- end -->|

size          4 bytes   4 bytes 2 bytes  1 byte
            +---------+--------+-------+-----------+
component   | zlbytes | zltail | zllen | zlend     |
            |         |        |       |           |
value       |  1011   |  1010  |   0   | 1111 1111 |
            +---------+--------+-------+-----------+
                                       ^
                                       |
                               ZIPLIST_ENTRY_HEAD
                                       &
address                        ZIPLIST_ENTRY_TAIL
                                       &
                               ZIPLIST_ENTRY_END

非空 ziplist 示例图

area        |<---- ziplist header ---->|<----------- entries ------------->|<-end->|

size          4 bytes  4 bytes  2 bytes    ?        ?        ?        ?     1 byte
            +---------+--------+-------+--------+--------+--------+--------+-------+
component   | zlbytes | zltail | zllen | entry1 | entry2 |  ...   | entryN | zlend |
            +---------+--------+-------+--------+--------+--------+--------+-------+
                                       ^                          ^        ^
address                                |                          |        |
                                ZIPLIST_ENTRY_HEAD                |   ZIPLIST_ENTRY_END
                                                                  |
                                                        ZIPLIST_ENTRY_TAIL
*/


示例

entry结构:


prev_entry_bytes_length:

表示上个节点所占的字节数,即上个节点的长度,如果需要跳到上个节点,而已知道当前节点的首地址p,上个节点的首地址prev = p-prev_entry_bytes_length

根据编码方式的不同,prev_entry_bytes_length可能占1 bytes或5 bytes:

    1 bytes:如果上个节点的长度小于254,那么就只需要1个字节;

    5 bytes:如果上个节点的长度大于等于254,那么就将第一个字节设为254(1111 1110),然后接下来的4个字节保存实际的长度值;

encoding与length:
ziplist的编码类型分为字符串、整数
encoding的前两个比特位用来判断编码类型是字符串或整数:
            00, 01, 10表示contents中保存着字符串
            11表示contents中保存着整数

连锁更新

添加和删除节点都会造成连锁更新,造成性能影响,但是真正造成性能问题的几率非常低,首先需要恰好有多个连续、长度介于250~253byte之间的结点,才有可能引发,其次即使出现,更新的数量不多时也不会造成性能影响,压缩列表存储空间连续便于载入内存,能减少保存指针对内存的消耗,因此它被用于list,set等的基础实现,例如:

list-max-ziplist-entries 512 #最大的元素个数
list-max-ziplist-value 64 #字符串的最大长度
当满足其中任一条件时,redis才会将本身用ziplist存储的键转换成list结构。即节约内存,也提升性能。

对象

redis使用对象来表示数据库中的键和值,每次我们在数据库中创建一个新的键值对的时候,我们至少要创建两个对象。一个是键对象,一个是值对象。

可使用type命令查看对象类型。

对象的内存结构

typedef struct redisObject {

    // 类型
    unsigned type:4;

    // 编码
    unsigned encoding:4;

    // 对象最后一次被访问的时间
    unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */

    // 引用计数(引用计数实现内存回收机制)
    int refcount;

    // 指向实际值的指针
    void *ptr;

} robj;


raw字符串对象示例


embstr字符串对象示例


embstr编码是专门用于保存短字符串的优化解决方案,用于创建长度小于等于32字节的字符串值,raw编码创建字符串对象时,会调用两次内存分配函数来分别创建redisObject和sdshdr结构,而embstr仅调用一次,分配连续空间来分别创建redisObject和sdshdr结构。内存释放亦是如此,而且embstr更容易载入内存。

ziplist存储的list对象示例
链表存储的list对象示例


#编码转换条件 ziplist -> list
list-max-ziplist-entries 512 #最大的元素个数
list-max-ziplist-value 64 #字符串的最大长度

ziplist存储的哈希对象示例


字典存储的哈希对象示例


#编码转换条件 ziplist -> dict
hash-max-ziplist-entries 512 #最大的元素个数
hash-max-ziplist-value 64 #字符串的最大长度

inset编码的集合对象示例


hashtable编码的集合对象示例

 #编码转换条件 intset-> dict
 intset:集合元素保存的所有元素都是整数,保存的元素数量不超过512个。
 set-max-ziplist-entries 512 #最大的元素个数
ziplist存储的有序集合对象示例


skiplist存储的有序集合对象示例



#编码转换条件 ziplist -> skiplist
zset-max-ziplist-entries 128 #最大的元素个数
zset-max-ziplist-value 64 #字符串的最大长度

/*
 * 有序集合
 */
typedef struct zset {

    // 字典,键为成员,值为分值
    // 用于支持 O(1) 复杂度的按成员取分值操作
    dict *dict;

    // 跳跃表,按分值排序成员
    // 用于支持平均复杂度为 O(log N) 的按分值定位成员操作
    // 以及范围操作
    zskiplist *zsl;

} zset;

redis会共享0~9999的字符串对象,命令object idletime可以查看对象的空转时长。


Murmurhash算法参考

常见的hash算法

Redis数据结构

推荐阅读 >> 《redis设计与实现》 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值