Redis基础数据结构详解

想想这两个组有什么区别?

  • string, list, hash, set, zset
  • SDS, ZipList, QuickList, HashTable, IntSet, SkipList

基础篇

string

Redis的字符串是动态字符串, 可以修改的字符串, 内部类似Java的ArrayList, 采用预分配冗余空间的方式来减少内存的频繁分配.

使用

  • get, set
  • incr, decr
  • mget

底层结构 SDS(Simple Dynamic String)

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

image.png

List

使用

lpush, rpush, lpop, rpop, llen, brpop, blpop

场景

  1. 普通列表存储数据(类似Java的ArrayList)
  2. 做异步队列(lpop vs brpop 区别)

底层结构

在Redis3.2之前,Redis采用的是ZipList(压缩列表)或者LinkedList(链表)。当List中的元素同时满足每个元素的小于64字节和List元素个数小于512个时,存储的方式为ZipList。但凡有一个条件没满足就会转换为LinkedList。

而在3.2之后,其实现变成了QuickList(快速列表)。LinkedList由于是较为基础的东西,此处就不赘述了。

Hash

使用

hset 在hash中设置键值对
hget 获hash中的某个key值
hdel 删除hash中某个键
hlen 统计hash中元素的个数
hmget 批量的获取hash中的键的值
hmset 批量的设置hash中的键和值
hexists 判断hash中某个key是否存在
hkeys 返回hash中的所有键(不包含值)
hvals 返回hash中的所有值(不包含键)
hgetall 获取所有的键值对,包含了键和值

底层结构

hash的底层实现也是有两种,ZipList和HashTable。但具体采用哪一种与Redis的版本无关,而与当前hash中所存的元素有关。首先当我们创建一个hash的时候,采用的ZipList进行存储。随着hash中的元素增多,达到了Redis设定的阈值,就会转换为HashTable。

其设定的阈值如下:

  • 存储的某个键或者值长度大于默认值(64)
  • ZipList中存储的元素数量大于默认值(512)

使用场景

对象化存储: 用户信息, 购物车, 房源分

Set

使用

image.png

底层结构

我们知道Java中的Set有多种实现。在Redis中也是,有IntSet和HashTable两种实现,首先初始化的时候使用的是IntSet,而满足如下的条件时,就会转换成HashTable。

  • 当集合中保存的所有元素都是整数时
  • 集合对象保存的元素数量不超过512

使用场景

案例:在微博中,可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis还为集合提供了求交集、并集、差集等操作,可以非常方便的实现如共同关注、共同喜好、二度好友等功能,对上面的所有集合操作,你还可以使用不同的命令选择将结果返回给客户端还是存集到一个新的集合中。

Sorted Set

其与Set的功能大致类似,只不过在此基础上,可以给每一个元素赋予一个权重。你可以理解为Java的TreeSet。与List、Hash、Set一样,其底层的实现也有两种,分别是ZipList和SkipList(跳表)。

使用

image.png

底层结构

初始化Sorted Set的时候,会采用ZipList作为其实现,其实很好理解,这个时候元素的数量很少,采用ZipList进行紧凑的存储会更加的节省空间。当期达到如下的条件时,就会转换为SkipList:

  • 其保存的元素数量的个数小于128个
  • 其保存的所有元素长度小于64字节

使用场景

处理过期项目

除了能够对其中的元素添加权重之外,使用ZSet还可以实现延迟队列。
另一种常用的项目排序是按照时间排序。我们使用unix时间作为得分即可。
模式如下:

  • 每次有新项目添加到我们的非Redis数据库时,我们把它加入到排序集合中。这时我们用的是时间属性,current_time和time_to_live。
  • 另一项后台任务使用ZRANGE…SCORES查询排序集合,取出最新的10个项目。如果发现unix时间已经过期,则在数据库中删除条目。

排行榜

ZADD leaderboard
你可能用userID来取代username,这取决于你是怎么设计的。
得到前100名高分用户很简单:ZREVRANGE leaderboard 0 99。
用户的全球排名也相似,只需要:ZRANK leaderboard 。

高级篇

String - SDS(Simple Dynamic String,简单动态字符串)

image.png

优点

  • 减少获取字符串长度开销: C语言中获取字符串的长度需要遍历整个字符串,直到遇到结束标志位\0,时间复杂度为O(n),而SDS直接维护了长度的变量,取长度的时间复杂度为O(1)
  • 避免缓冲区溢出: C语言中如果往一个字节数组中塞入超过其容量的字节,那么就会造成缓冲区溢出,而SDS通过维护free变量解决了这个问题。向buf数组中写入数据时,会先判断剩余的空间是否足够塞入新数据,如果不够,SDS就会重新分配缓冲区,加大之前的缓冲区。且加大的长度等于新增的数据的长度
  • 空间预分配&空间惰性释放 C语言中,每次修改字符串都会重新分配内存空间,如果对字符串修改了n次,那么必然会出现n次内存重新分配。而SDS由于冗余了一部分空间,优化了这个问题,将必然重新分配n次变为最多分配n次,而数据从buf中移除的时候,空闲出来的内存也不会马上被回收,防止新写入数据而造成内存重新分配
  • 保证二进制安全: C语言中,字符串遇到\0会被截断,而SDS不会因为数据中出现了\0而截断字符串,换句话说,不会因为一些特殊的字符影响实际的运算结果
  • 减少计算, 少折腾

ZipList

除List外, zset和hash在容器对象较少的时候, 也采用压缩列表(zipList)进行存储

struct ziplist {
    int32 zlbytes; // 整个压缩列表占用字节数
    int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
    int16 zllength; // 元素个数
    T[] entries; // 元素内容列表,挨个挨个紧凑存储
    int8 zlend; // 标志压缩列表的结束,值恒为 0xFF
}

image.png

压缩列表为了支持双向遍历,所以才会有 ztail_offset 这个字段,用来快速定位到最后一 个元素,然后倒着遍历。

entry

struct entry {
    int prevlen; // 前一个 entry 的字节长度
    int encoding; // 元素类型编码
    optional byte[] content; // 元素内容
}

如果 ziplist 占据内存太大,重新分配内存和拷贝内存就会有很大的消耗。所以 ziplist不适合存储大型字符串,存储的元素也不宜过多。

QuickList

Redis 早期版本存储 list 列表数据结构使用的是压缩列表 ziplist 和普通的双向链表linkedlist,也就是元素少时用 ziplist,元素多时用 QuickList。

// 链表的节点
struct listNode {
    listNode* prev;
    listNode* next;
    T value;
}
// 链表
struct list {
    listNode *head;
    listNode *tail;
    long length;
}

考虑到链表的附加空间相对太高,prev 和 next 指针就要占去 16 个字节 (64bit 系统的指针是 8 个字节),另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管理效率。后续版本对列表数据结构进行了改造,使用 quicklist 代替了 ziplist 和 linkedlist。

image.png
struct ziplist {
    ...
}
struct ziplist_compressed {
    int32 size;
    byte[] compressed_data;
}


struct quicklistNode {
    quicklistNode* prev;
    quicklistNode* next;
    ziplist* zl; // 指向压缩列表
    int32 size; // ziplist 的字节总数
    int16 count; // ziplist 中的元素数量
    int2 encoding; // 存储形式 2bit,原生字节数组还是 LZF 压缩存储
    ...
}
struct quicklist {
    quicklistNode* head;
    quicklistNode* tail;
    long count; // 元素总数
    int nodes; // ziplist 节点的个数
    int compressDepth; // LZF 算法压缩深度
    ...
}

ziplist 存多少元素?

quicklist 内部默认单个 ziplist 长度为 8k 字节,超出了这个字节数,就会新起一个ziplist。
ziplist 的长度由配置参数 list-max-ziplist-size 决定。


image.png

compress 是用来表示压缩深度,ziplist 除了内存空间是连续之外,还可以采用特定的 LZF 压缩算法来将节点进行压缩存储,从而更进一步的节省空间,压缩深度可以通过参数 list-compress-depth 控制:

IntSet

当set中添加的元素都是整型且元素数目较少时,set使用intset作为底层数据结构,否则,set使用[dict]作为底层数据结构。

Intset 是集合键的底层实现之一,如果一个集合:

  1. 只保存着整数元素;
  2. 元素的数量不多;

HashTable

在 Redis 中,hashtable 被称为字典(dictionary),它是一个数组+链表的结构。

image.png

从最底层到最高层 dictEntry——dictht——dict——OBJ_ENCODING_HT

dict

typedef struct dict {
  dictType *type; /* 字典类型 */
  void *privdata; /* 私有数据 */
  dictht ht[2]; /* 一个字典有两个哈希表 */
  long rehashidx; /* rehash 索引 */
  unsigned long iterators; /* 当前正在使用的迭代器数量 */
} dict;

dictht

/* This is our hash table structure. Every dictionary has two of this as we
* implement incremental rehashing, for the old to the new table. */
typedef struct dictht {
  dictEntry **table; /* 哈希表数组 */
  unsigned long size; /* 哈希表大小 */
  unsigned long sizemask; /* 掩码大小, 用于计算索引值。 总是等于 size-1 */
  unsigned long used; /* 已有节点数 */
} dictht;

dictEntry

typedef struct dictEntry {
  void *key; /* key 关键字定义 */
  union {
    void *val; uint64_t u64; /* value 定义 */
    int64_t s64; double d;
  } v;
  struct dictEntry *next; /* 指向下一个键值对节点 */
} dictEntry

注意: dictht 后面是 NULL 说明第二个 ht 还没用到。 dictEntry*后面是 NULL 说明没有 hash 到这个地址。 dictEntry 后面是NULL 说明没有发生哈希冲突。

为什么redis的哈希表要用两个dictht数组来存放dictEntry链表呢?

我们首先来回顾一下hashmap的扩容过程,简单点来说就是需要复制一个新的数组,然后把旧数组内的元素进行rehash,然后把元素重新插入到新的数组中。因为resize操作是一次性,集中式完成的,当hashmap的数组大小为初始容量16时,整个resize过程还是可控的,而经过多次扩容后,数组的元素变多,进行resize的时候操作元素也增多,resize所需要的时间就不可控了。而redis作为高性能缓存,从结构设计上就需要避免这种操作时间不可控的场景存在,而采取的策略就是喜闻乐见的用空间换时间了,所以能看到dict中包含了两个dictht的数组。那么redis具体是怎么做的呢?

当读取数据的时候遇到一个节点有多个元素,就需要遍历链表,故链表越长,性能越差。为了保证哈希表的性能,需要在满足以下两个条件中的一个时,对哈希表进行 rehash(重新散列)操作:

负载因子大于等于 1 且 dict_can_resize 为 1 时。
负载因子大于等于安全阈值(dict_force_resize_ratio=5)时。
PS:负载因子 = 哈希表已使用节点数 / 哈希表大小(即:h[0].used/h[0].size)。

hash冲突

Redis 中的 Hash和 Java的 HashMap 更加相似,都是数组+链表的结构.当发生 hash 碰撞时将会把元素追加到链表上
我们先来了解下 hash 的内部结构.第一维是数组,第二维是链表.组成一个 hashtable.
在 Java 中 HashMap 扩容是个很耗时的操作,需要去申请新的数组,扩容的成本并不低,因为需要遍历一个时间复杂度为O(n)的数组,并且为其中的每个enrty进行hash计算。加入到新数组中
为了追求高性能,Redis 采用了渐进式 rehash 策略.这也是 hash 中最重要的部分.
redis在扩容的时候执行 rehash 策略会保留新旧两个 hashtable 结构,查询时也会同时查询两个 hashtable.Redis会将旧 hashtable 中的内容一点一点的迁移到新的 hashtable 中,当迁移完成时,就会用新的 hashtable 取代之前的.当 hashtable 移除了最后一个元素之后,这个数据结构将会被删除.
数据搬迁的操作放在 hash 的后续指令中,也就是来自客户端对 hash 的指令操作.一旦客户端后续没有指令操作这个 hash.Redis就会使用定时任务对数据主动搬迁.
正常情况下,当 hashtable 中元素的个数等于数组的长度时,就会开始扩容,扩容的新数组是原数组大小的 2 倍.如果 Redis 正在做 bgsave(持久化) 时,可能不会去扩容,因为要减少内存页的过多分离(Copy On Write).但是如果 hashtable 已经非常满了,元素的个数达到了数组长度的 5 倍时,Redis 会强制扩容.

当hashtable 中元素逐渐变少时,Redis 会进行缩容来减少空间占用,并且缩容不会受 bgsave 的影响,缩容条件是元素个数少于数组长度的 10%.

rehash 步骤

扩展哈希和收缩哈希都是通过执行 rehash 来完成,这其中就涉及到了空间的分配和释放,主要经过以下五步:

为字典 dict 的 ht[1] 哈希表分配空间,其大小取决于当前哈希表已保存节点数(即:ht[0].used):

如果是扩展操作则 ht[1] 的大小为 2 的n次方中第一个大于等于ht[0].used * 2属性的值(比如used=3,此时ht[0].used * 2=6,故2的3次方为8就是第一个大于used * 2的值(2 的 2 次方 < 6 且 2 的 3 次方 > 6))。
如果是收缩操作则 ht[1] 大小为 2 的 n 次方中第一个大于等于 ht[0].used 的值。
将字典中的属性 rehashix 的值设置为 0,表示正在执行 rehash 操作。

将 ht[0] 中所有的键值对依次重新计算哈希值,并放到 ht[1] 数组对应位置,每完成一个键值对的 rehash之后 rehashix 的值需要自增 1。

当 ht[0] 中所有的键值对都迁移到 ht[1] 之后,释放 ht[0] ,并将 ht[1] 修改为 ht[0],然后再创建一个新的 ht[1] 数组,为下一次 rehash 做准备。

将字典中的属性 rehashix 设置为 -1,表示此次 rehash 操作结束,等待下一次 rehash。

渐进式 rehash

Redis 中的这种重新哈希的操作因为不是一次性全部 rehash,而是分多次来慢慢的将 ht[0] 中的键值对 rehash 到 ht[1],故而这种操作也称之为渐进式 rehash。渐进式 rehash 可以避免集中式 rehash 带来的庞大计算量,是一种分而治之的思想。

在渐进式 rehash 过程中,因为还可能会有新的键值对存进来,此时** Redis 的做法是新添加的键值对统一放入 ht[1] 中,这样就确保了 ht[0] 键值对的数量只会减少**。

当正在执行 rehash操作时,如果服务器收到来自客户端的命令请求操作,则会先查询 ht[0],查找不到结果再到ht[1] 中查询。

image.png

SkipList

跳跃表(skiplist)是一种随机化的数据, 由 William Pugh 在论文《Skip lists: a probabilistic alternative to balanced trees》中提出, 跳跃表以有序的方式在层次化的链表中保存元素, 效率和平衡树媲美 —— 查找、删除、添加等操作都可以在对数期望时间下完成, 并且比起平衡树来说, 跳跃表的实现要简单直观得多。

skiplist本质上也是一种查找结构,用于解决算法中的查找问题(Searching),即根据给定的key,快速查到它所在的位置(或者对应的value)。

image.png

从图中可以看到, 跳跃表主要由以下部分构成:

表头(head):负责维护跳跃表的节点指针。
跳跃表节点:保存着元素值,以及多个层。
层:保存着指向其他元素的指针。高层的指针越过的元素数量大于等于低层的指针,为了提高查找的效率,程序总是从高层先开始访问,然后随着元素值范围的缩小,慢慢降低层次。
表尾:全部由 NULL 组成,表示跳跃表的末尾。

zskiplist

typedef struct zskiplist {

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

    // 节点数量
    unsigned long length;

    // 目前表内节点的最大层数
    int level;

} zskiplist;

zskiplistNode

typedef struct zskiplistNode {

    // member 对象
    robj *obj;

    // 分值
    double score;

    // 后退指针
    struct zskiplistNode *backward;

    // 层
    struct zskiplistLevel {

        // 前进指针
        struct zskiplistNode *forward;

        // 这个层跨越的节点数量
        unsigned int span;

    } level[];

} zskiplistNode;
image.png

skiplist正是受这种多层链表的想法的启发而设计出来的。实际上,按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常类似于一个二分查找,使得查找的时间复杂度可以降低到O(log n)。但是,这种方法在插入数据的时候有很大的问题。新插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的2:1的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点(也包括新插入的节点)重新进行调整,这会让时间复杂度重新蜕化成O(n)。删除数据也有同样的问题。

skiplist为了避免这一问题,它不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机出一个层数(level)。比如,一个节点随机出的层数是3,那么就把它链入到第1层到第3层这三层链表中。为了表达清楚,下图展示了如何通过一步步的插入操作从而形成一个skiplist的过程:

image.png

从上面skiplist的创建和插入过程可以看出,每一个节点的层数(level)是随机出来的,而且新插入一个节点不会影响其它节点的层数。因此,插入操作只需要修改插入节点前后的指针,而不需要对很多节点都进行调整。这就降低了插入操作的复杂度。实际上,这是skiplist的一个很重要的特性,这让它在插入性能上明显优于平衡树的方案。这在后面我们还会提到。

根据上图中的skiplist结构,我们很容易理解这种数据结构的名字的由来。skiplist,翻译成中文,可以翻译成“跳表”或“跳跃表”,指的就是除了最下面第1层链表之外,它会产生若干层稀疏的链表,这些链表里面的指针故意跳过了一些节点(而且越高层的链表跳过的节点越多)。这就使得我们在查找数据的时候能够先在高层的链表中进行查找,然后逐层降低,最终降到第1层链表来精确地确定数据位置。在这个过程中,我们跳过了一些节点,从而也就加快了查找速度。

刚刚创建的这个skiplist总共包含4层链表,现在假设我们在它里面依然查找23,下图给出了查找路径:

image.png

需要注意的是,前面演示的各个节点的插入过程,实际上在插入之前也要先经历一个类似的查找过程,在确定插入位置后,再完成插入操作。

至此,skiplist的查找和插入操作,我们已经很清楚了。而删除操作与插入操作类似,我们也很容易想象出来。这些操作我们也应该能很容易地用代码实现出来。

当然,实际应用中的skiplist每个节点应该包含key和value两部分。前面的描述中我们没有具体区分key和value,但实际上列表中是按照key进行排序的,查找过程也是根据key在比较。

但是,如果你是第一次接触skiplist,那么一定会产生一个疑问:节点插入时随机出一个层数,仅仅依靠这样一个简单的随机数操作而构建出来的多层链表结构,能保证它有一个良好的查找性能吗?为了回答这个疑问,我们需要分析skiplist的统计性能。

在分析之前,我们还需要着重指出的是,执行插入操作时计算随机数的过程,是一个很关键的过程,它对skiplist的统计特性有着很重要的影响。这并不是一个普通的服从均匀分布的随机数,它的计算过程如下:

  • 首先,每个节点肯定都有第1层指针(每个节点都在第1层链表里)。
  • 如果一个节点有第i层(i>=1)指针(即节点已经在第1层到第i层链表中),那么它有第(i+1)层指针的概率为p。
  • 节点最大的层数不允许超过一个最大值,记为MaxLevel。

skiplist与平衡树、哈希表的比较

  • skiplist和各种平衡树(如AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做单个key的查找,不适宜做范围查找。所谓范围查找,指的是查找那些大小在指定的两个值之间的所有节点。
  • 在做范围查找的时候,平衡树比skiplist操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。
  • 平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。
  • 从内存占用上来说,skiplist比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。
  • 查找单个key,skiplist和平衡树的时间复杂度都为O(log n),大体相当;而哈希表在保持较低的哈希值冲突概率的前提下,查找时间复杂度接近O(1),性能更高一些。所以我们平常使用的各种Map或dictionary结构,大都是基于哈希表实现的。
  • 从算法实现难度上来比较,skiplist比平衡树要简单得多。
image.png
image.png
image.png
image.png

总结

image.png

todo: 哈希冲突

你可能感兴趣的:(Redis基础数据结构详解)