读《Redis设计与实现》—Redis入门与数据结构

本文详细介绍了Redis的基础数据类型,包括简单动态字符串(SDS)、链表、字典、跳跃表、整数集合和压缩表。文章阐述了这些数据结构的内部实现原理,如SDS的缓冲区设计、链表的双端特性、字典的哈希表和rehash策略,以及跳跃表的高效查找。此外,还讨论了Redis如何利用这些数据结构实现丰富的功能,如缓存、分布式锁和排序。
摘要由CSDN通过智能技术生成

前言

在互联网和大数据时代,特别是分布式系统大行其道的今天,Redis是一个广泛被应用在我们系统之中,不论是传统的ERP、CRM还是互联网高并发的系统。Redis是一款开源、高性能、基于C语言开发的键值对存储的NoSql数据库。所以我们应该需要对Redis有更加深入的了解,下面很多都是Copy《Redis设计与实现(第二部)》中的内容,因为书中肯定总结的会更加好。

概述

优势

Redis的优势主要包括以下几点:

1、内存数据库,对数据的读写极快,官方可以达到10W次读写/s,当然实际情况下需要多个客户端连接才能测出Redis性能极限

2、数据类型丰富,Redis中有多种不同的数据类型,比如:String、List、Set、ZSet、Hash等。

3、支持数据备份,支持集群(高可用)

4、不仅单个操作是原子操作(单线程),并且支持事务(多个命令)

5、其他特性丰富,比如支持publish/subscribe、通知、key过期等等

应用场景

对于Redis而言主要的用途包括:

1、数据缓存,减轻数据库压力

2、分布式锁,利用SetNX实现

3、排序问题,比如点击量排序,可以利用ZSet实现

4、计数器/限速器,利用INCRBY等命令实现

5、利用位操作实现用户签到、用户在线人数统计等功能

6、也可以利用redis实现单点登录等功能

Redis基础数据类型

在redis中最基础的数据类型有:String、Hash、List、Set、ZSet(sorted set)五种数据类型。下面就开门见山的介绍这五种数据类型的使用以及这五种数据类型的内部实现的原理。

数据结构

Redis中的五种数据类型都是由最基础的数据结构组成的。

简单动态字符串

Redis没有直接使用C语言传统的字符串表示(以空字符‘\0’结尾的字符数组),而是自己构建了一种名为简单动态字符串(Simple Dynamic String, SDS)。在Redis中包含字符值的键值对在底层都是由SDS实现的,除了字符串值外,SDS还将被用于缓冲区:AOF模块中的AOF缓冲区,以及客户端状态中的输入缓冲区等。

下面是SDS数据结构(图2-2图例):

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

len是已使用的字节长度,这样的设计可以直接读取字符串长度,不用遍历统计字符串长度;

free是未使用的字节长度,这样的设计说白了就是充当缓冲区使用,可以在尽量减少字符串扩容/缩小情况下(因为扩容会进行内存重新分配),完成字符串拼接/截取操作。下面是字符串空间分配情况(空间预分配):

    1)如果SDS的len长度小于1MB,那SDS分配的空余大小和len一样:举个例子,如果当前SDS的len=10,那么free=len=10,实际SDS大小为10byte+10byte+1byte;

    2)入宫SDS的len长度大小等于1MB,那么Redis分配给SDS的空余大小为固定1MB:举个例子,如果当前SDS的len=10MB,那么free=1MB,实际SDS大小为10MB+1MB+1byte;

上面说的是SDS扩容的情况,如果是我们截取某些字符串的话,Redis这边使用的是惰性空间释放的方式。通俗的说就是Rredis不会在字符串缩短/截取后直接释放空余内容空间,而是用这些空间,来预发下一次SDS的扩容,除非调用SDS API进行手动释放。举例说明就是:

    1)当前SDS.len=10;SDS.free=10;

    2)如果我现在删除后面五个字节,那结果就是SDS.len=5;SDS.free=15;

    3)如果我现在又拼接了一个长度为8字节字符串,那这次拼接可用的空间是足够的,不需要扩容,结果是:SDS.len=13;SDS.free=7

同时Redis的SDS是二进制安全的,也就是说你放进去是什么数据,拿出来也是什么数据,Redis不会对一些特殊字符进行任何处理。下面是Redis的SDS和C语言的字符串区别总结(表2-1):

链表

Redis中链表提供了高效的节点重排能力以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度。链表在redis中的应用非常广泛,比如列表键底层实现之一、发布订阅模式、慢查询、监视器等等功能,同时redis服务器还可以使用链表保存多个客户端信息等等。

下面是链表的数据结构(图3-1图例):

typedef struct listNode {
    // 前置节点
    struct listNode *prev;
    // 后置节点
    struct listNode *next;
    // 节点值
    void *value;
}

虽然多个listNode节点就可以构成链表,但是在对链表管理这块还需要引入另外一个结构叫list,这个结构的主要作用就是记录链表信息和实现一些常用方法,下面是链表的数据结构(图3-2图例):

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

Redis链表实现的特性可以总结如下:

1)双端:redis中的链表是一个双向链表,带有next(指向后置节点)和prev(指向前置节点)指针。

2)无环:表头节点的prev指针和表尾的next指针都指向null,对链表的访问以null为终点。

3)List结构带表头指针和表尾指针,有方便链表表头和表尾的访问。

4)List结构带有链表长度计算器len,这样获取链表长度的时间复杂度就是O(1)。

5)多态:链表节点使用void* (我们可以把void*当成java中的Object,void*指的是空类型指针)指针来保持节点的值,并且可以通过list结构的dup、free、match三个属性为节点设置类型特定函数,所以链表可以用于保存各种不同类型的值。

字典

字典是一种用于保存键值对(key-value pair)的抽象数据结构,字典中的键是独一无二的,程序可以通过键查找、修改、删除值。在Redis中所使用的C语言并没有内置这种数据结构,因此Redis构建了自己的字典实现。

字典在Redis中的使用非常广泛,比如:Redis数据库实现、Hash(哈希)实现等等,如下所示:

下面是字典的数据结构(图4-1 事例):

typedef struct dictht {
    // 哈希表数组
    dictEntry **table;
    // 哈希表大小
    unsigned long size;
    // 哈希表大小掩码,用于计算索引值(size-1)
    unsigned long sizemask;
    // 该哈希表已有的节点数量
    unsigned long used;
} dictht;

table属性是一个数组,数组中的每个元素都指向一个指向dict.h/dictEntry结构的指针,每一个dictEntry结构保存着一个键值对。

size属性记录了哈希表的大小,就是table数组的大小。sizemark属性的值等于size-1,这个属性和哈希值一起决定一个键应该被放到tablle数组的哪个索引上。

used属性记录了目前哈希表已有节点数量。

哈希表节点使用dictEntry结构表示,每一个dictEntry结构都保存着一个键值对:

typedef struct dictEntry {
    // 键
    void *key;
    // 值
    union {
        void *key;
        uint64_tu64;
        int64_ts64;
    } v;
    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

通过上面伪代码可以看到,hash冲突是通过链地址法来解决的,如下图所示(图4-2):

实现字典的话,还需要一个字典结构(dict)来解决例如rehash等情况,字典的结构如下所示:

typedef struct dict {
    // 类型特定函数
    dictType *type;
    // 私有数据
    void *privdata;
    // 哈希表
    dictht ht[2];
    // rehash索引,当不在rehash是值为-1
    int trehashidx;
}

type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的;

而ht属性是一个包含两个项的数组,每一个项都是一个dictht哈希表,一般情况下,字典只用ht[0],ht[1]只会在对ht[0]进行rehash时使用。除此之外,rehashidx记录了rehash目前的进度,如果没有在进行rehash,那么它的值为-1。如下图所示(4-3)

随着操作的不断执行,哈希表保存的键值对会增加或者减少,为了让哈希表的负载因子在合理的范围内,程序需要对哈希表进行扩容或者收缩。扩容或者收缩需要哈希表进行rehash操作,操作的步骤大致如下所示:

1)为字段ht[1]哈希表分配空间;

2)将保存在ht[0]中的所有键值对rehash到ht[1]上面;

3)当ht[0]包含的所有键值对都迁移到ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新建一个空白哈希表,为下一个rehash做准备。

下面是程序进行rehash的条件:

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

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

其中哈希表的负载因子可以通过公式:

# 负载因子 = 哈希表已保存节点数量 / 哈希表大小

load_factor = ht[0].used / ht[0].size

3)当哈希表在负载因子小于0.1时,程序自动开始对哈希表执行收缩操作;

当然,Redis中rehash(ht[0]->ht[1])操作不是一次性、集中式地完成的,而是分多次、渐进式的完成的(这样做的主要目的是要降低对服务器影响)。

以下是哈希表渐进式 rehash 的详细步骤:

1)为ht[1]分配空间, 让字典同时持有ht[0]和ht[1]两个哈希表;

2)在字典中维持一个索引计数器变量rehashidx, 并将它的值设置为0,表示rehash工作正式开始;

3)在rehash进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增一;

4)随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成;

渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作均滩到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的庞大计算量。

跳跃表

跳跃表是一种有序的数据结构,它通过在每一个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。与平衡树相比,跳跃表实现起来比较简单,并且在大多数情况下效率和平衡树相当。在Redis中ZSet(Sorted Set)底层实现之一就是跳跃表。

如下图所示(5-1)是Redis中跳跃表的结构图(跳跃表实现可以参考这篇博客:https://blog.csdn.net/qpzkobe/article/details/80056807

其中最开始位置的是zskiplist结构,该结构包含以下属性:

  • header :指向跳跃表的表头节点。
  • tail :指向跳跃表的表尾节点。
  • level :记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)。2^5=32
  • length :记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)。

位于 zskiplist 结构右方的是四个 zskiplistNode 结构, 该结构包含以下属性:

  • 层(level):节点中用 L1 、 L2 、 L3 等字样标记节点的各个层, L1 代表第一层, L2 代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。
  • 后退(backward)指针:节点中用 BW 字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。
  • 分值(score):各个节点中的 1.0 、 2.0 和 3.0 是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。
  • 成员对象(obj):各个节点中的 o1 、 o2 和 o3 是节点所保存的成员对象。

下面是跳跃表节点的实现伪代码:

typedef struct zskiplistNode {
    // 层
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
        // 跨度
        unsigned int span;
    } level[];
    // 后退指针
    struct zskiplistNode *backward;
    // 分值
    double score;
    // 成员对象
    robj *obj;
} zskiplistNode;

注意:每一个节点的层高都是根据幂次定律随机生成1~32之间的值作为Level数组的大小。在跳跃表中保存的成员对象是唯一的,但是分数可以相同,并且会根据分数大小进行排序。

整数集合

整数集合是集合键的底层实现之一,当一个集合只包含整数值元素时,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。下面是整数集合的数据结构,整数集合保存类型为int16_t、int32_t、int64_t的整数值。

typedef struct intset {
    // 编码方式
    uint32_t encoding;
    // 集合包含的元素数量
    uint32_t lenght;
    // 保存元素的数组
    int8_t contents[];
} intset;
  • encoding:编码格式,一共有三种,分别是:int16_t、int32_t、int64_t,三种不同的类型有不同的整数范围,分别是:-32768~32767、-2147483648~2147483647、-9223372036854775808~9223372036854775807;
  • lenght:数组长度
  • contents数组:按值的大小从小到大排序,并且数组不包含重复项;

如下图所示是一个int16_t类型整数值的整数集合:

当然整数元素既然有不同的类型,那么必然会存在类型的升级问题,比如:有一个元素大小超过了int16_t的范围,但在int32_t内,而其他的仍然在int16_t范围内,那么整数集合所有元素int16_t必然也会升级int32_t,从整数集合的结构页能看出来一个整数集合只有一个统一设置编码的地方,单个元素只有值,不存在设置编码的地方。关于整数集合升级详细步骤可以看书或者百度即可。

redis中使用多种整数编码并支持升级操作是为了降低内存消耗和增加灵活性。注意:redis并不支持编码降级的操作。 

压缩表

压缩表(ziplist)是列表键和哈希键的底层实之一(元素数量比较少或者元素长度短时)。压缩表,顾名思义是为节约内存而开发的,是一系列特殊编码的连续内存地址快组成的顺序型数据结构。下面是压缩表的组成部分及其说明:

zlbytes:用于记录压缩列表占用内存字节数

zltail:记录压缩列表表尾节点距离压缩列表起始地址的字节数,用于快速访问压缩列表表尾

zllen:记录压缩列表包含的节点数量

entryX:压缩列表包含的各个节点

zlend:特殊值0xFF,用于标记压缩列表的末端

压缩列表节点的组成如下所示,每一个压缩列表可以保存一个字节数组或者一个整数值:

具体的三个参数介绍可以看书或者百度,下面主要介绍一下压缩表在更新下出现的连锁更新情况。通过对压缩表节点的了解,我们应该知道每一个previous_entry_length属性都记录了前一个节点长度(previous_entry_length的长度可以是1或者5字节):

  • 如果前一节点的长度小于 254 字节, previous_entry_length 的长度为 1 字节。
  • 如果前一节点的长度大于等于 254 字节, previous_entry_length 的长度为 5 字节: 其中第一字节会被设置为 0xFE (十进制值 254), 而之后的四个字节则用于保存前一节点的长度。

那么如果,在一个压缩表中e1~eN节点的长度都介于250~253字节之间,如果这时插入一个长度大于254字节的元素,那么e1.previous_entry_length属性就需要5字节记录前值,然后自己的长度也会大于254字节,然后e2.previous_entry_length属性就需要5字节记录前值,然后自己的长度也会大于254字节......以此类推,如下如所示:

所以在最坏的情况下需要对压缩表执行N次空间重分配操作,而每次重分配最坏的复杂度为O(N),所以连锁更新的最坏复杂度为O(N^2)。

对象(五种数据类型实现)

在Redis中五种基本数据类型就是基于之前将的基本数据(简单动态字符串、链表、字典、跳跃表、压缩列表、整数集合等)结构实现的。

首先Redis的对象结构如下面的伪代码所示:

typedef struct redisObject {
    // 类型
    unsigned type;
    // 编码
    unsigned encoding;
    // 指向底层实现的数据结构指针
    void *ptr;
    // ...
}

其中redisObject的type属性记录了 对象的类型、encoding属性记录了底层实现的数据结构。如下所示,对象类型就是我们常见的五种Redis对象:

下面是redisObject的编码格式(redis中一种对象类型可能有多种不同的底层实现)

下面是五种常见数据类型和八种编码格式对应关系

一种数据类型对应多种编码格式(底层实现)主要能提升Redis灵活和效率,为不同的场景做不同的优化。下面的话主要讲一下每一个数据类型选择的编码格式具体情况。

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值