您好,欢迎访问代理记账网站
  • 价格透明
  • 信息保密
  • 进度掌控
  • 售后无忧

Redis数据结构底层原理详细分析

Redis的对象、简单动态字符串、链表、字典、跳跃表、整数集合、压缩列表的存储机制

 

首先内存和硬盘的比较

内存直接由CPU控制,也就是CPU内部集成的内存控制器,所以说内存是直接与CPU对接,享受与CPU通信的最优带宽,然而硬盘则是通过桥接芯片(在主板上)与CPU相连,所以说速度比较慢。两者相比内存比硬盘到底有多快?通常的说法是:内存访问速度是纳秒级(10的-9次方),硬盘的访问速度是微秒级(10的-3次方)。找到一个稍微科学点的测试数据,如下图

1.顺序访问:这种情况下,内存访问速度仅仅是硬盘访问速度的6~7倍(358.2M / 53.2M = 6.7)

2.随机访问:这种情况下,内存访问速度就要比硬盘访问速度快上10万倍以上 (36.7M / 316 = 113,924)

 

Redis选择了高效的内存做为存储,大家有了解过Redis在内存在的结构是怎么样的吗?接下来将为大家解读其中的奥秘

Redis在内存中的结构划分

 

数据:作为数据库,数据是最主要的部分,这部分占用的内存会统计在used_memory中。 Redis使用键值对存储数据,其中的值(对象)包括5种类型,即字符串、哈希、列表、集合、有序集合。 这5种类型是Redis对外提供的,实际上,在Redis内部,每种类型可能有2种或更多的内部编码实现。此外,Redis在存储对象时,并不是直接将数据扔进内存,而是会对对象进行各种包装:如redisObject、SDS等。

 

进程本身运行需要的内存:Redis主进程本身运行肯定需要占用内存,如代码、常量池等等;除了主进程外,Redis创建的子进程运行也会占用内存,如Redis执行AOF、RDB重写时创建的子进程。

 

缓冲内存:缓冲内存包括客户端缓冲区、复制积压缓冲区、AOF缓冲区等。 其中,客户端缓冲存储客户端连接的输入输出缓冲;复制积压缓冲用于部分复制功能;AOF缓冲区用于在进行AOF重写时,保存最近的写入命令。

 

内存碎片:内存碎片是Redis在分配、回收物理内存过程中产生的。 例如,如果对数据的更改频繁,而且数据之间的大小相差很大,可能导致redis释放的空间在物理内存中并没有释放,但redis又无法有效利用,这就形成了内存碎片。

 

生产上我们经常使用 Redis Info 命令查看Redis服务器的信息和统计数值

  • server : 一般 Redis 服务器信息,包含以下域:
    • redis_version : Redis 服务器版本
    • redis_git_sha1 : Git SHA1
    • redis_git_dirty : Git dirty flag
    • os : Redis 服务器的宿主操作系统
    • arch_bits : 架构(32 或 64 位)
    • multiplexing_api : Redis 所使用的事件处理机制
    • gcc_version : 编译 Redis 时所使用的 GCC 版本
    • process_id : 服务器进程的 PID
    • run_id : Redis 服务器的随机标识符(用于 Sentinel 和集群)
    • tcp_port : TCP/IP 监听端口
    • uptime_in_seconds : 自 Redis 服务器启动以来,经过的秒数
    • uptime_in_days : 自 Redis 服务器启动以来,经过的天数
    • lru_clock : 以分钟为单位进行自增的时钟,用于 LRU 管理
  • clients : 已连接客户端信息,包含以下域:
    • connected_clients : 已连接客户端的数量(不包括通过从属服务器连接的客户端)
    • client_longest_output_list : 当前连接的客户端当中,最长的输出列表
    • client_longest_input_buf : 当前连接的客户端当中,最大输入缓存
    • blocked_clients : 正在等待阻塞命令(BLPOP、BRPOP、BRPOPLPUSH)的客户端的数量
  • memory : 内存信息,包含以下域:
    • used_memory : 由 Redis 分配器分配的内存总量,以字节(byte)为单位
    • used_memory_human : 以人类可读的格式返回 Redis 分配的内存总量
    • used_memory_rss : 从操作系统的角度,返回 Redis 已分配的内存总量(俗称常驻集大小)。这个值和 top 、 ps 等命令的输出一致。
    • used_memory_peak : Redis 的内存消耗峰值(以字节为单位)
    • used_memory_peak_human : 以人类可读的格式返回 Redis 的内存消耗峰值
    • used_memory_lua : Lua 引擎所使用的内存大小(以字节为单位)
    • mem_fragmentation_ratio : used_memory_rss 和 used_memory 之间的比率
    • mem_allocator : 在编译时指定的, Redis 所使用的内存分配器。可以是 libc 、 jemalloc 或者 tcmalloc 。

在理想情况下, used_memory_rss 的值应该只比 used_memory 稍微高一点儿。当 rss > used ,且两者的值相差较大时,表示存在(内部或外部的)内存碎片。内存碎片的比率可以通过 mem_fragmentation_ratio 的值看出。当 used > rss 时,表示 Redis 的部分内存被操作系统换出到交换空间了,在这种情况下,操作可能会产生明显的延迟。当 Redis 释放内存时,分配器可能会,也可能不会,将内存返还给操作系统。如果 Redis 释放了内存,却没有将内存返还给操作系统,那么 used_memory 的值可能和操作系统显示的 Redis 内存占用并不一致。查看 used_memory_peak 的值可以验证这种情况是否发生。

mem_fragmentation_ratio < 1 表示Redis内存分配超出了物理内存,操作系统正在进行内存交换,内存交换会引起非常明显的响应延迟;

mem_fragmentation_ratio > 1 是合理的;

mem_fragmentation_ratio > 1.5 说明Redis消耗了实际需要物理内存的150%以上,其中50%是内存碎片率,可能是操作系统或Redis实例中内存管理变差的表现

 

  • persistence : RDB 和 AOF 的相关信息
  • stats : 一般统计信息
  • replication : 主/从复制信息
  • cpu : CPU 计算量统计信息
  • commandstats : Redis 命令统计信息
  • cluster : Redis 集群信息
  • keyspace : 数据库相关的统计信息

 

 

 

了解了一些Redis基本常识以后,让我们真正的来看一下Redis的数据储存结构是怎么样的,Redis的数据存储中我们会涉及到内存分配器、简单的动态字符串(SDS)、5种数据类型及内部编码、redisObject等知识梳理。我们首先看下面一张图我们在存储数据时数据基本的数据模型。

dictEntry: 我们都知道Redis是Key-value数据库,因此每个键值都会有对应的一个dictEntry,里面存储着指向key和value的指针及next指向下一个dictEntry。

 

Key: Key并不是直接已字符串形式存储,而是存储在SDS结构中。

 

redisObject:Value既不是直接以字符串存储,也不是像Key一样直接存储在SDS中,而是存储在redisObject中。 实际上,不论Value是5种类型的哪一种,都是通过redisObject来存储的;而redisObject中的type字段指明了Value对象的类型,ptr字段则指向对象所在的地址。 不过可以看出,字符串对象虽然经过了redisObject的包装,但仍然需要通过SDS存储。 实际上,redisObject除了type和ptr字段以外,还有其他字段图中没有给出,如用于指定对象内部编码的字段。redisObject对象非常重要,Redis对象的类型、内部编码、内存回收、共享对象等功能,都需要redisObject支持。redisObject结构如下:

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;

type: type字段表示对象的类型,占4个比特;目前包括REDIS_STRING(字符串)、REDIS_LIST (列表)、REDIS_HASH(哈希)、REDIS_SET(集合)、REDIS_ZSET(有序集合)

encoding: encoding表示对象的内部编码,占4个比特。 对于Redis支持的每种类型,都有至少两种内部编码,例如对于字符串,有int、embstr、raw三种编码。 通过encoding属性,Redis可以根据不同的使用场景来为对象设置不同的编码,大大提高了Redis的灵活性和效率。 以列表对象为例,有压缩列表和双端链表两种编码方式。 如果列表中的元素较少,Redis倾向于使用压缩列表进行存储,因为压缩列表占用内存更少,而且比双端链表可以更快载入;当列表对象元素较多时,压缩列表就会转化为更适合存储大量元素的双端链表。

lru: lru记录的是对象最后一次被命令程序访问的时间

refcount: refcount与共享对象 refcount记录的是该对象被引用的次数,类型为整型

ptr: ptr指针指向具体数据的SDS、列表、hash、set、zset

 

SDS: SDS是简单动态字符串(Simple Dynamic String)的缩写,sds的结构如下:

struct sdshdr {     int len;     int free;     char buf[]; };

buf: 字节数组

len:buf已经使用的长度

free: 表示buf未使用的长度

 

以图说话

通过上图SDS的结构可以看出,buf数组的长度=free+len+1(其中1表示字符串结尾的空字符);所以,一个SDS结构占据的空间为:free所占长度+len所占长度+ buf数组的长度=4+4+free+len+1=free+len+9。那么SDS在C字符串的基础上加入了free和len字段,带来了很多好处:

  1. 获取字符串长度:SDS是O(1),C字符串是O(n)
  2. 缓冲区溢出:使用C字符串的API时,如果字符串长度增加(如strcat操作)而忘记重新分配内存,很容易造成缓冲区的溢出;而SDS由于记录了长度,相应的API在可能造成缓冲区溢出时会自动重新分配内存,杜绝了缓冲区溢出。
  3. 修改字符串时内存的重分配:对于C字符串,如果要修改字符串,必须要重新分配内存(先释放再申请)。因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。而对于SDS,由于可以记录len和free,因此解除了字符串长度和空间数组长度之间的关联。可以在此基础上进行优化:空间预分配策略(即分配内存时比实际需要的多)使得字符串长度增大时重新分配内存的概率大大减小;惰性空间释放策略使得字符串长度减小时重新分配内存的概率大大减小。
  4. 存取二进制数据:SDS可以,C字符串不可以。因为C字符串以空字符作为字符串结束的标识,而对于一些二进制文件(如图片等),内容可能包括空字符串,因此C字符串无法正确存取;而SDS以字符串长度len来作为字符串结束标识,因此没有这个问题。

此外,由于SDS中的buf仍然使用了C字符串(即以’\0’结尾),因此SDS可以使用C字符串库中的部分函数;但是需要注意的是,只有当SDS用来存储文本数据时才可以这样使用,在存储二进制数据时则不行(’\0’不一定是结尾)。

 

SDS的空间预分配:为减少修改字符串带来的内存重分配次数,sds采用了“一次管够”的策略:

  • 如果对SDS进行修改之后,SDS的长度(也即是len属性的值)将小于1MB,那么程序分配和len属性同样大小的未使用空间,这时SDS len属性的值将和free属性的值相同。
  • 如果对SDS进行修改之后,SDS的长度(也即是len属性的值)将大于等于1MB,那么程序会分配1MB的未使用空间。

注:所以稍微细心一点的小伙伴们有没有发现我上面的图中len为4,free为0是错误的。

 

SDS惰性删除机制:所谓惰性删除,即调整删除SDS中部分数据时,不会立刻执行内存重分配,而是会保留空出来内存,并更新内部free属性。以备将来有字符扩展需求,可以直接使用。是不是有点抽象我们用图表述一下

当我们删除XX后变为

 

双向链表(list)

Redis 的双向链表实现具有以下特性:

  • 双向、无环、带有表头和表尾指针。
  • 一个链表包含多个项,每个项都是一个字符串对象,换句话来说,一个列表对象可以包含多个字符串对象。
  • 可以从表头或者表尾遍历整个链表,复杂度为 O(N) 。
  • 定位特定索引上的项,复杂度为 O(N) 。
  • 链表带有长度记录属性,获取链表的当前长度的复杂度为 O(1) 。

 

双向列表的代码结构每个节点都是一个listNode,拥有前驱节点,后继节点和值

typedef struct listNode { struct listNode *prev; //前驱节点,如果是list的头结点,则prev指向NULL struct listNode *next;//后继节点,如果是list尾部结点,则next指向NULL void *value; //万能指针,能够存放任何信息 } listNode;

 

只要有多个节点就可以组成一个链表了,但是redis再在外面封装了一层,也就是使用adlist.h/list来实现,这样操作起来更加方便。

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;

那上面数据的值为

 

 

压缩列表(ziplist)

参数说明:

zlbytes:记录整个压缩列表占用的内存字节数。

zltail:记录压缩列表表尾节点距离压缩列表起始地址有多少字节。

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

entryN:压缩列表的节点,节点长度由节点保存的内容决定。

zlend:特殊值0xFF(十进制255),用于标记压缩列表的末端。

 

哈哈!上面的结构是不是有点类似数组,通过一段连续的内存空间来存储数据,但是它和数组还是不一样的。听到“压缩”两个字,直观的反应就是节省内存。之所以说这种存储结构节省内存,是相较于数组的存储思路而言的。我们知道,数组要求每个元素的大小相同,如果我们要存储不同长度的字符串,那我们就需要用最大长度的字符串大小作为元素的大小(假设是20个字节)。存储小于 20 个字节长度的字符串的时候,便会浪费部分存储空间

数组的优势占用一片连续的空间可以很好的利用CPU缓存访问数据。如果我们想要保留这种优势,又想节省存储空间我们可以对数组进行压缩

但是这样有一个问题,我们在遍历它的时候由于不知道每个元素的大小是多少,因此也就无法计算出下一个节点的具体位置。这个时候我们可以给每个节点增加一个lenght的属性。

 

压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结枃。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值,如下图

zltail示例

entry结构示例

各值的具体含义

节点的 previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。 previous_entry_length属性的长度可以是1字节或者5字节。

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

    节点的encoding属性记录了节点的content属性所保存数据的类型以及长度。

  • 一字节或者五字节长,值的最高位为00或者10的是字节数组编码这种编码表示节点的 content属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录。
  • 一字节长,值的最高位以11开头的是整数编码:这种编码表示节点的content属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录。

    节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。

上面的例子中的具体解释

  • 编码的最高两位00表示节点保存的是一个字节数组。
  • 编码的后六位001011记录了字节数组的长度11。
  • content属性保存着节点的值"hello world"。
  • 编码11000000表示节点保存的是一个int16_t类型的整数值;
  • content属性保存着节点的值10086

 

整数集合(intset)

整数集合具有以下特点:

  • 集合元素只能是整数(最大 为 64 位),并且集合中不会出 现重复的元素。
  • 集合的底层使用有序的整数数组来表示。
  • 数组的类型会随着新添加元素的 类型而改变:举个例子,如果集合中位长度最大的元素可以使用16 位整数来保存,那么数 组的类型就是

int16_t ,而如果集合中位长度最大的元素可以使用 32 位整数来保存的话,那么数组的类型就是 int32_t ,诸如此类。

  • 数组的类型只会自动增大,但不会减小。

科普一下嘻嘻Int8,Int16,Int32,int64,后面的数字就代表这个数据类型占据的空间。

       Int8, 等于Byte, 占1个字节.

    Int16, 等于short, 占2个字节. -32768 32767

    Int32, 等于int, 占4个字节. -2147483648 2147483647

    Int64, 等于long, 占8个字节. -9223372036854775808 9223372036854775807

    这样, 看起来比short,int,long更加直观些!

C语言中写法为int8_t、int16_t、int32_t、int64_t

 

 如上图,为一int16_t类型的整数集合,我们可以看到数组中存储了5个int16_t类型的整数,它们按照从小到大的顺序依次排列。这个时候我们思考一个问题。如果这个时候存入一个int32_t类型的整数会怎么样?内存溢出?

 

 

正如上面所提到的问题,每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级,然后才能将新元素添加到整数集合里面。升级整数集合并添加新元素主要分三步来进行。

  1. 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
  2. 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。
  3. 将新元素添加到底层数组里面。

有升级是是否存在降级的动作?

整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。也就是说一旦我们向一个int16_t的整数集合内添加了一个int32_t的元素后,整数集合将升级到int32_t类型。即使后续的操作中我们删除了这个元素,整数集合还是会保持int32_t类型的状态。

 

整数集合升级的优点

  1. 提升灵活性

    因为C语言是静态类型语言,为了避免类型错误,我们通常不会将两种不同类型的值放在同一个数据结构里面。

    例如,我们一般只使用int16_t类型的数组来保存int16_t类型的值,只使用int32_t类型的数组来保存int32_t类型的值,诸如此类。但是,因为整数集合可以通过自动升级底层数组来适应新元素,所以我们可以随意地将int16_t、int32_t或者int64_t类型的整数添加到集合中,而不必担心出现类型错误,这种做法非常灵活。

  1. 节约内存

    要让一个数组可以同时保存int16_t、int32_t、int64_t三种类型的值,最简单的做法就是直接使用int64t类型的数组作为整数集合的底层实现。不过这样一来,即使添加到整数集合里面的都是int16_t类型或者int32_t类型的值,数组都需要使用int64_t类型的空间去保存它们,从而出现浪费内存的情况。

而整数集合现在的做法既可以让集合能同时保存三种不同类型的值,又可以确保升级操作只会在有需要的时候进行,这可以尽量节省内存。如果我们一直只向整数集合添加int16_t类型的值,那么整数集合的底层实现就会一直是int16_t类型的数组,只有在我们要将int32_t类型或者int64_t类型的值添加到集合时,程序才会对数组进行升级。

 

字典

字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对的抽象数据结构。 在字典中,一个键(key)可以和一个值(value)进行关联,字典中的每个键都是独一无二的。在C语言中,并没有这种数据结构,但是Redis 中构建了自己的字典实现。

举个简单的例子:

redis > SET msg "OO XX" OK

创建这样的键值对(“msg”,“OO XX”)在数据库中就是以字典的形式存储。

字典的定义

 

字典的结果定义

typedef struct dict { dictType *type; // 类型特定函数 void *privedata; // 私有数据 dictht ht[2];// 哈希表 // rehash 索引 in trehashidx; }

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

ht 属性是一个包含两个项(两个哈希表)的数组

 

Redis 字典所使用的哈希表由 dict.h/dictht 结构定义:

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

一个空的字典表如下:

我们可以看到,在结构中存有指向dictEntry 数组的指针,而我们用来存储数据的空间既是dictEntry

哈希表节点dictEntry结构定义

typeof struct dictEntry{ //键 void *key; //值 union{ void *val; uint64_tu64; int64_ts64; } struct dictEntry *next; }

在数据结构中,我们清楚key 是唯一的,但是我们存入里面的key 并不是直接的字符串,而是一个hash 值,通过hash 算法,将字符串转换成对应的hash 值,然后在dictEntry 中找到对应的位置。 这时候我们会发现一个问题,如果出现hash 值相同的情况怎么办?Redis 采用了链地址法:当k1 和k0 的hash 值相同时,将k1中的next 指向k0 形成一个链表。

在插入后我们可以看到,dictEntry指向了k1,k1的next 指向了k0,从而完成了一次插入操作(这里选择表头插入是因为哈希表节点中没有记录链表尾节点位置)

 

什么是rehash? 其实有点类似HashMap的扩容

哈希表空间分配规则:

如果执行的是拓展操作,那么ht[1] 的大小为第一个大于等于ht[0] 的2的n次幂

如果执行的是收缩操作,那么ht[1] 的大小为第一个大于等于ht[0] 的2的n次幂

以图展示效果,图中每个节点都已经使用到了,这时候我们就需要对哈希表进行扩展。

按照上面的分配规则,此时ht[1]分配的空间为8,变为下图所示

 

将ht[0]中的数据转移到ht[1]中,在转移的过程中,需要对哈希表节点的数据重新进行哈希值计算,数据转移后的结果如下图

将ht[0]释放,然后将ht[1]设置成ht[0],最后为ht[1]分配一个空白哈希表,如下表所示

渐进式 rehash

上面我们说到,在进行拓展或者压缩的时候,可以直接将所有的键值对rehash 到ht[1]中,这是因为数据量比较小。在实际开发过程中,这个rehash 操作并不是一次性、集中式完成的,而是分多次、渐进式地完成的。采用渐进式rehash 的好处在于它采取分而治之的方式,避免了集中式rehash 带来的庞大计算量。

渐进式rehash 的详细步骤:

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

 2、在维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash 开始

 3、在rehash 进行期间,每次对字典执行CRUD操作时,程序除了执行指定的操作以外,还会将ht[0]中的数据rehash 到ht[1]表中,并且将rehashidx加一

 4、当ht[0]中所有数据转移到ht[1]中时,将rehashidx 设置成-1,表示rehash 结束

 

跳跃表

何为跳跃表?

  • Redis使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员是比较长的字符串时, Redis就会使用跳跃表来作为有序集合健的底层实现。这里我们需要思考一个问题——为什么元素数量比较多或者成员是比较长的字符串的时候Redis要使用跳跃表来实现?
  • Redis跳表是由zskiplist和zskiplistnode两个结构组成的,其中zskiplist用于保存跳表信息,比如表头、节点、表尾点及长度
  • 每个跳表的节点高度都是1到32之间的随机数
  • 跳表中的节点按照分值的大小排序,当分值相同的时候,节点按照成员对象的大小进行排序。

如下图所示一个简单的跳表结构

上图展示了一个跳跃表示例, 位于图片最左边的是 zskiplist 结构, 该结构包含以下属性:

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

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

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

注意表头节点和其他节点的构造是一样的: 表头节点也有后退指针、分值和成员对象, 不过表头节点的这些属性都不会被用到, 所以图中省略了这些部分, 只显示了表头节点的各个层

 

我们从简单跳表到多层的跳表查找数据实现

对于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度会很高,是 O(n)。如果我们想要提高其查找效率,可以考虑在链表上建索引的方式。每两个结点提取一个结点到上一级,我们把抽出来的那一级叫作索引。

这个时候,我们假设要查找节点8,我们可以先在索引层遍历,当遍历到索引层中值为 7 的结点时,发现下一个节点是9,那么要查找的节点8肯定就在这两个节点之间。我们下降到链表层继续遍历就找到了8这个节点。原先我们在单链表中找到8这个节点要遍历8个节点,而现在有了一级索引后只需要遍历五个节点。

从这个例子里,我们看出,加来一层索引之后,查找一个结点需要遍的结点个数减少了,也就是说查找效率提高了,同理再加一级索引

从图中我们可以看出,查找效率又有提升。在例子中我们的数据很少,当有大量的数据时,我们可以增加多级索引,其查找效率可以得到明显提升。

回答刚刚提出的那个问题:从上面我们可以知道,跳跃表在链表的基础上增加了多级索引以提升查找的效率,但其是一个空间换时间的方案,必然会带来一个问题——索引是占内存的。原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值值和几个指针,并不需要存储对象,因此当节点本身比较大或者元素数量比较多的时候,其优势必然会被放大,而缺点则可以忽略。

 

  • 跳跃表节点的 level 数组可以包含多个元素, 每个元素都包含一个指向其他节点的指针, 程序可以通过这些层来加快访问其他节点的速度, 一般来说, 层的数量越多, 访问其他节点的速度就越快。
  • 每次创建一个新跳跃表节点的时候, 程序都根据幂次定律 (power law,越大的数出现的概率越小) 随机生成一个介于 1 和 32 之间的值作为 level 数组的大小, 这个大小就是层的“高度”。下图分别展示了三个高度为 1 层、 3 层和 5 层的节点, 因为 C 语言的数组索引总是从 0 开始的, 所以节点的第一层是 level[0] , 而第二层是 level[1] , 以此类推。

前进指针:每个层都有一个指向表尾方向的前进指针(level[i].forward 属性), 用于从表头向表尾方向访问节点

 

跨度:层的跨度(level[i].span 属性)用于记录两个节点之间的距离;指向 NULL 的所有前进指针的跨度都为 0 , 因为它们没有连向任何节点; 跨度实际上是用来计算排位(rank)的: 在查找某个节点的过程中, 将沿途访问过的所有层的跨度累计起来, 得到的结果就是目标节点在跳跃表中的排位

 

后退指针:节点的后退指针(backward 属性)用于从表尾向表头方向访问节点: 跟可以一次跳过多个节点的前进指针不同, 因为每个节点只有一个后退指针, 所以每次只能后退至前一个节点

 

Redis中为什么使用跳表不使用红黑树?

理论上来讲,查找、插入、删除以及迭代输出有序序列这几个操作,红黑树也可以完成,时间复杂度和跳表是一样的。

redis使用跳表而不是红黑树的原因:

  • 按照区间查找数据这个操作,红黑树的效率没有跳表高。跳表可以在 O(logn)时间复杂度定位区间的起点,然后在原始链表中顺序向后查询就可以了。
  • 相比于红黑树,跳表还具有代码更容易实现、可读性好、不容易出错、更加灵活等优点。
  • 插入、删除时跳表只需要调整少数几个节点,红黑树需要颜色重涂和旋转,开销较大。

 

Redis支持的数据结构

Redis所有的数据结构都是以唯一的key字符串作为名称,然后通过这个唯一的key值来获取相应的value数据。通过上面的讲解不同类型的数据结构的差异在于value的结构是不一样的。Redis可以存储键与5种不同数据结构类型之间的映射,这5种数据结构类型分别为:STRING、LIST、SET、HASH和ZSET(有序集合)

Set可以用intset或者字典实现

只有当数据全是整数值,而且数量少于512个时,才使用intset,intset是一个由整数组成的有序集合,可以进行二分查找。

不满足intset使用条件的情况下都使用字典(拉链法),使用字典时把value设置为null。

 

Zset

zset中的每个元素包含数据本身和一个对应的分数(score)。

经典例子:一个zset的key是"math",代表数学课的成绩,然后可以往这个key里插入很多数据。输入数据的时候,每次需要输入一个姓名和一个对应的成绩。那么这个姓名就是数据本身,成绩就是它的score。

zset的数据本身不允许重复,但是score允许重复。

zset底层实现原理:

  1. 数据少时,使用ziplist:ziplist占用连续内存,每项元素都是(数据+score)的方式连续存储,按照score从小到大排序。ziplist为了节省内存,每个元素占用的空间可以不同,对于大的数据(long),就多用一些字节来存储,而对于小的数据(short),就少用一些字节来存储。因此查找的时候需要按顺序遍历。ziplist省内存但是查找效率低。
  2. 数据多时,使用字典+跳表:字典用来根据数据查score,跳表用来根据score查找数据(查找效率高)

 

 

数据库示例

在 Redis 里面,每个数据库都是一个字典,该字典的键和值都是我们之前提到的对象,其中:字典的键总是一个字符串对象,它储存了用户为键设置的键名;字典的值则可以是字符串对象、列表对象、散列对象、集合对象或者有序集合对象的其中一个;因为数据库就是字典,所以针对数据库的操作都是基于字典操作来 实现的。为了记录数据库键的过期时间,Redis 为每个数据库创建了另一个字典,专门使用这个字典来记录键的 过期时间,其中字典的键指向数据库键对象,也即是带有过期时间的那个键(数据库字典和储存过期时间的字典通过指针使用同一个键对象,不会㐀成任何资源浪费);键的值则是一个毫秒格式的 UNIX 时间戳,记录了键到期的时间。

详细结构

 

总结

在Redis的五大数据对象中,string对象是唯一个可以被其他四种数据对象作为内嵌对象的;

列表(list)、哈希(hash)、集合(set)、有序集合(zset)底层实现都用到了压缩列表结构,并且使用压缩列表结构的条件都是在元素个数比较少、字节长度较短的情况下;

四种数据对象使用压缩列表的优点:

(1)节约内存,减少内存开销,Redis是内存型数据库,所以一定情况下减少内存开销是非常有必要的。

(2)减少内存碎片,压缩列表的内存块是连续的,并分配内存的次数一次即可。

(3)压缩列表的新增、删除、查找操作的平均时间复杂度是O(N),在N再一定的范围内,这个时间几乎是可以忽略的,并且N的上限值是可以配置的。

(4)四种数据对象都有两种编码结构,灵活性增加。

 

五种类型的应用场景

  • String,redis对于KV的操作效率很高,可以直接用作计数器。例如,统计在线人数等等,另外string类型是二进制存储安全的,所以也可以使用它来存储图片,甚至是视频等。
  • hash,存放键值对,一般可以用来存某个对象的基本属性信息,例如,用户信息,商品信息等,另外,由于hash的大小在小于配置的大小的时候使用的是ziplist结构,比较节约内存,所以针对大量的数据存储可以考虑使用hash来分段存储来达到压缩数据量,节约内存的目的,例如,对于大批量的商品对应的图片地址名称。比如:商品编码固定是10位,可以选取前7位做为hash的key,后三位作为field,图片地址作为value。这样每个hash表都不超过999个,只要把redis.conf中的hash-max-ziplist-entries改为1024,即可。
  • list,列表类型,可以用于实现消息队列,也可以使用它提供的range命令,做分页查询功能。
  • set,集合,整数的有序列表可以直接使用set。可以用作某些去重功能,例如用户名不能重复等,另外,还可以对集合进行交集,并集操作,来查找某些元素的共同点
  • zset,有序集合,可以使用范围查找,排行榜功能或者topN功能。

 


分享:

低价透明

统一报价,无隐形消费

金牌服务

一对一专属顾问7*24小时金牌服务

信息保密

个人信息安全有保障

售后无忧

服务出问题客服经理全程跟进