Redis 底层使用了 ziplist、skiplist 和 quicklist 三种 list 结构来实现相关对象。顾名思义,ziplist 更节省空间、skiplist 则注重查找效率,quicklist 则对空间和时间进行折中。

在典型的双向链表中,我们有称为节点的结构,它表示列表中的每个值。每个节点都有三个属性:指向列表中的前一个和下一个节点的指针,以及指向节点中字符串的指针。而每个值字符串值实际上存储为三个部分:一个表示长度的整数、一个表示剩余空闲字节数的整数以及字符串本身后跟一个空字符。

可以看到,链表中的每一项都占用独立的一块内存,各项之间用地址指针(或引用)连接起来。这种方式会带来大量的内存碎片,而且地址指针也会占用额外的内存。这就是普通链表的内存浪费问题。

此外,在普通链表中执行随机查找操作时,它的时间复杂度为 O(n),这对于注重效率的 Redis 而言也是不可接受的。这是普通链表的查找效率太低问题。

针对上述两个问题,Redis 设计了ziplist(压缩列表)skiplist(跳跃表)快速链表进行相关优化。

1 ziplist

对于 ziplist,它要解决的就是内存浪费的问题。也就是说,它的设计目标就是是为了节省空间,提高存储效率

基于此,Redis 对其进行了特殊设计,使其成为一个经过特殊编码的双向链表。将表中每一项存放在前后连续的地址空间内,一个 ziplist 整体占用一大块内存。它是一个表(list),但其实不是一个链表(linked list)。

除此之前,ziplist 为了在细节上节省内存,对于值的存储采用了变长的编码方式,大概意思是说,对于大的整数,就多用一些字节来存储,而对于小的整数,就少用一些字节来存储。

也正是为了这种高效率的存储,ziplist 有很多 bit 级别的操作,使得代码变得较为晦涩难懂。不过不要紧,我们本节的目标之一是为了了解 ziplist 对比普通链表,做了哪些优化,可以更好的节省空间

接下来我们来正式认识下压缩列表的结构。

1.1 压缩列表的结构

一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。

图 1-1 展示了压缩列表的各个组成部分:

相关字段说明如下:

  • zlbytes:4 字节,表示 ziplist 占用的字节总数(包括 zlbytes 本身占用的 4 个字节)。
  • zltail:4 字节,表示 ziplist 表中最后一项距离列表的起始地址有多少字节,也就是表尾节点的偏移量。通过此字段,程序可以快速确定表尾节点的地址。
  • zllen:2 字节:表示 ziplist 的节点个数。要注意的是,由于此字段只有 16bit,所以可表达的最大值为 2^16-1。一旦列表节点个数超过这个值,就要遍历整个压缩列表才能获取真实的节点数量。
  • entry:表示 ziplist 的节点。长度不定,由保存的内容决定。要注意的是,列表的节点(entry)也有自己的数据结构,后续会详细说明。
  • zlend:ziplist 的结束标记,值固定为 255,用于标记压缩列表的末端。

图 1-2 展示了一个包含五个节点的压缩列表:

  • 列表 zlbytes 属性的值为 0xd2(十进制 210),表示压缩列表的总长为 210 字节。
  • 列表 zltail 属性的值为 0xb3,(十进制 179),表示尾结点相比列表的起始地址有 179 字节的距离。假如列表的起始地址为 p,那么指针 p + 179 = entry5 的地址。
  • 列表 zllen 属性的值为 0x5(十进制 5),表示压缩列表包含 5 个节点。

1.2 列表节点的结构

节点的结构源码如下(ziplist.c):

typedef struct zlentry {
unsigned int prevrawlensize, prevrawlen;
unsigned int lensize, len;
unsigned int headersize;
unsigned char encoding;
unsigned char *p;
} zlentry;

如图 1-3,展示了压缩列表节点的结构。

  • prevrawlen:表示前一个节点占用的总字节数。此字段是为了让 ziplist 能够从后向前遍历。
  • encoding:此字段记录了节点的 content 属性中所保存的数据类型。
  • lensize:此字段记录了节点所保存的数据长度。
  • headersize:此字段记录了节点 header 的大小。
  • *p:此字段记录了指向节点保存内容的指针。

1.3 压缩列表如何节省了内存

回到我们最开始对普通链表的认识,普通链表中,每个节点包:

  • 一个表示长度的整数
  • 一个表示剩余空闲字节数的整数
  • 字符串本身
  • 结尾空字符。

以图 1-4 为例:

图 1-4 展示了一个普通链表的三个节点,这三个节点中,每个节点实际存储内容只有 1 字节,但是它们除了实际存储内容外,还都要有:

  • 3 个指针 - 占用 3 byte
  • 2 个整数 - 占用 2 byte
  • 内容字符串 - 占用 1 byte
  • 结尾空字符的空间 - 占用 1 byte

这样来看,存储 3 个字节的数据,至少需要 21 字节的开销。可以看到,这样的存储效率是很低的。

另一方面,普通链表通过前后指针来关联节点,地址不连续,多节点时容易产生内存碎片,降低了内存的使用率。

最后,普通链表对存储单位的操作粒度是 byte,这种方式在存储较小整数或字符串时,每个字节实际上会有很大的空间是浪费的。就像上面三个节点中,用来存储剩余空闲字节数的整数,实际存储空间只需要 1 bit,但是有了 1 byte 来表示剩余空间大小,这一个 byte 中,剩余 7 个 bit 就被浪费了。

那么,Redis 是如何使用 ziplist 来改造普通链表的呢?通过以下两方面:

一方面,ziplist 使用一整块连续内存,避免产生内存碎片,提高了内存的使用率

另一方面,ziplist 将存储单位的操作粒度从 byte 降低到 bit,有效的解决了存储较小数据时,单个字节中浪费 bit 的问题。

2 skiplist

skiplist 是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,来达到快速访问节点的目的。

skiplist 本质上是一种查找结构,用于解决算法中的查找问题。即根据指定的值,快速找到其所在的位置。

此外,我们知道,"查找" 问题的解决方法一般分为两大类:平衡树哈希表。有趣的是,skiplist 这种查找结构,因为其特殊性,并不在上述两大类中。但在大部分情况下,它的效率可以喝平衡树想媲美,而且跳跃表的实现要更为简单,所以有不少程序都使用跳跃表来代替平衡树。

本节没有介绍跳跃表的定义及其原理,有兴趣的童鞋可以参考这里

认识了跳跃表是什么,以及做什么的,接下来,我们再来看下在 redis 中,是怎么实现跳跃表的。

server.h 中可以找到跳跃表的源码,如下:

typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist; typedef struct zskiplistNode {
robj *obj;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned int span;
} level[];
} zskiplistNode;

Redis 中的 skiplist 和普通的 skiplist 相比,在结构上并没有太大不同,只是在一些细节上有以下差异:

  • 分数(score)可以重复。就是说 Redis 中的 skiplist 在分值字段上是允许重复的,而普通的 skiplist 则不允许重复。
  • 第一层链表不是单向链表,而是双向链表。这种方式可以用倒序方式获取一个范围内的元素。
  • 比较时,除了比较 score 之外,还比较数据本身。在 Redis 的 skiplist 中,数据本身的内容是这份数据的唯一标识,而不是由 score 字段做唯一标识。此外,当多个元素分数相同时,还需要根据数据内容进行字典排序。

3 quicklist

对于 quicklist,在 quicklist.c 中有以下说明:

A doubly linked list of ziplists

它是一个双向链表,并且是一个由 ziplist 组成的双向链表。

相关源码结构可在 quicklist.h 中查找,如下:

/* quicklistNode is a 32 byte struct describing a ziplist for a quicklist.
* We use bit fields keep the quicklistNode at 32 bytes.
* count: 16 bits, max 65536 (max zl bytes is 65k, so max count actually < 32k).
* encoding: 2 bits, RAW=1, LZF=2.
* container: 2 bits, NONE=1, ZIPLIST=2.
* recompress: 1 bit, bool, true if node is temporarry decompressed for usage.
* attempted_compress: 1 bit, boolean, used for verifying during testing.
* extra: 12 bits, free for future use; pads out the remainder of 32 bits */
typedef struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *zl;
unsigned int sz; /* ziplist size in bytes */
unsigned int count : 16; /* count of items in ziplist */
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
unsigned int recompress : 1; /* was this node previous compressed? */
unsigned int attempted_compress : 1; /* node can't compress; too small */
unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode; /* quicklistLZF is a 4+N byte struct holding 'sz' followed by 'compressed'.
* 'sz' is byte length of 'compressed' field.
* 'compressed' is LZF data with total (compressed) length 'sz'
* NOTE: uncompressed length is stored in quicklistNode->sz.
* When quicklistNode->zl is compressed, node->zl points to a quicklistLZF */
typedef struct quicklistLZF {
unsigned int sz; /* LZF size in bytes*/
char compressed[];
} quicklistLZF; /* quicklist is a 32 byte struct (on 64-bit systems) describing a quicklist.
* 'count' is the number of total entries.
* 'len' is the number of quicklist nodes.
* 'compress' is: -1 if compression disabled, otherwise it's the number
* of quicklistNodes to leave uncompressed at ends of quicklist.
* 'fill' is the user-requested (or default) fill factor. */
typedef struct quicklist {
quicklistNode *head;
quicklistNode *tail;
unsigned long count; /* total count of all entries in all ziplists */
unsigned int len; /* number of quicklistNodes */
int fill : 16; /* fill factor for individual nodes */
unsigned int compress : 16; /* depth of end nodes not to compress;0=off */
} quicklist;

上面介绍链表的时候有说过,链表由多个节点组成。而对于 quicklist 而言,它的每一个节点都是一个 ziplist。quicklist 这样设计,其实就是我们篇头所说的,是一个空间和时间的折中。

ziplist 相比普通链表,主要优化了两个点:降低内存开销减少内存碎片。正所谓,事物总是有两面性。ziplist 通过连续内存解决了普通链表的内存碎片问题,但与此同时,也带来了新的问题:不利于修改操作

由于 ziplist 是一整块连续内存,所以每次数据变动都会引发一次内存的重分配。当在 ziplist 很大的时候,每次重分配都会出现大批量的数据拷贝操作,降低性能。

于是,结合了双向链表和 ziplist 的优点,就有了 quicklist。

quicklist 的基本思想就是,给每一个节点的 ziplist 分配合适的大小,避免出现因数据拷贝,降低性能的问题。这又是一个需要找平衡点的难题。我们先从存储效率上分析:

  • 每个 quicklist 节点上的 ziplist 越短,则内存碎片越多。而内存碎片多了,就很有可能产生很多无法被利用的内存小碎片,降低存储效率。
  • 每个 quicklist 节点上的 ziplist 越长,则为 ziplist 分配大块连续内存的难度就越大。有可能出现,内存里有很多较小块的内存,却找不到一块足够大的空闲空间分配给 ziplist 的情况。这同样会降低存储效率。

可见,一个 quicklist 节点上的 ziplist 需要保持一个合理的长度。这里的合理取决于实际应用场景。基于此,Redis 提供了一个配置参数,让使用者可以根据情况,自己调整:

list-max-ziplist-size -2

这个参数可以取正值,也可以取负值。

当取正值的时候,表示按照数据项个数来限定每个 quicklist 节点上 ziplist 的长度。比如配置为 2 时,就表示 quicklist 的每个节点上的 ziplist 最多包含 2 个数据项。

当取负值的时候,表示按照占用字节数来限定每个 quicklist 节点上 ziplist 的长度。此时,它的取值范围是 [-1, -5],每个值对应不同含义:

  • -1:每个 quicklist 节点上的 ziplist 大小不能超过 4Kb;
  • -2:每个 quicklist 节点上的 ziplist 大小不能超过 8Kb(默认值);
  • -3:每个 quicklist 节点上的 ziplist 大小不能超过 16Kb;
  • -4:每个 quicklist 节点上的 ziplist 大小不能超过 32Kb;
  • -5:每个 quicklist 节点上的 ziplist 大小不能超过 64Kb;

总结

  1. 普通链表存在两个问题:内存利用率低容易产生内存碎片
  2. ziplist 使用连续内存,减少内存碎片,提供内存利用率。
  3. skiplist 可以用相对简单的实现,达到和平衡树相同的查找效率。
  4. quicklist 汲取了普通链表和压缩链表的优点,保证性能的前提下,尽可能的提高内存利用率。

跟着大彬读源码 - Redis 9 - 对象编码之 三种list的更多相关文章

  1. 跟着大彬读源码 - Redis 7 - 对象编码之简单动态字符串

    Redis 没有直接使用 C 语言传统的字符串表示(以空字符串结尾的字符数组),而是构建了一种名为简单动态字符串(simple dynamic string)的抽象类型,并将 SDS 用作 Redis ...

  2. 跟着大彬读源码 - Redis 8 - 对象编码之字典

    目录 1 字典的实现 2 插入算法 3 rehash 与 渐进式 rehash 总结 字典,是一种用于保存键值对的抽象数据结构.由于 C 语言没有内置字典这种数据结构,因此 Redis 构建了自己的字 ...

  3. 跟着大彬读源码 - Redis 10 - 对象编码之整数集合

    [TOC] 整数集合是 Redis 集合键的底层实现之一.当一个集合只包含整数值元素,并且元素数量不多时,Redis 就会使用整数集合作为集合键的底层实现. 1 整数集合的实现 整数集合是 Redis ...

  4. 跟着大彬读源码 - Redis 6 - 对象和数据类型(下)

    继续撸我们的对象和数据类型. 上节我们一起认识了字符串和列表,接下来还有哈希.集合和有序集合. 1 哈希对象 哈希对象的可选编码分别是:ziplist 和 hashtable. 1.1 ziplist ...

  5. 跟着大彬读源码 - Redis 5 - 对象和数据类型(上)

    相信很多人应该都知道 Redis 有五种数据类型:字符串.列表.哈希.集合和有序集合.但这五种数据类型是什么含义?Redis 的数据又是怎样存储的?今天我们一起来认识下 Redis 这五种数据结构的含 ...

  6. 跟着大彬读源码 - Redis 1 - 启动服务,程序都干了什么?

    一直很羡慕那些能读 Redis 源码的童鞋,也一直想自己解读一遍,但迫于 C 大魔王的压力,解读日期遥遥无期. 相信很多小伙伴应该也都对或曾对源码感兴趣,但一来觉得自己不会 C 语言,二来也不知从何入 ...

  7. 跟着大彬读源码 - Redis 2 - 服务器如何响应客户端请求?(上)

    上次我们通过问题"启动服务器,程序都干了什么?",跟着源码,深入了解了 Redis 服务器的启动过程. 既然启动了 Redis 服务器,那我们就要连上 Redis 服务干些事情.这 ...

  8. 跟着大彬读源码 - Redis 3 - 服务器如何响应客户端请求?(下)

    继续我们上一节的讨论.服务器启动了,客户端也发送命令了.接下来,就要到服务器"表演"的时刻了. 1 服务器处理 服务器读取到命令请求后,会进行一系列的处理. 1.1 读取命令请求 ...

  9. 跟着大彬读源码 - Redis 4 - 服务器的事件驱动有什么含义?(上)

    众所周知,Redis 服务器是一个事件驱动程序.那么事件驱动对于 Redis 而言有什么含义?源码中又是如何实现事件驱动的呢?今天,我们一起来认识下 Redis 服务器的事件驱动. 对于 Redis ...

随机推荐

  1. 记一次基于Cloudflare服务的爬虫

    前言 前几天有个朋友托我帮忙爬一个网站,第一次打开那个网站时,我去,它竟然就要验证码,不过当时是想加几个header应该就能解决,不过事实证明我还是错了.接下来将记录下爬虫中遇到的一些坑以及解决办法. ...

  2. smobiler介绍(一)

    如果说基于.net的移动开发平台,不得不自我推荐下smobiler,不用学习另外一套开发模式或者搭建复杂的开发环境,smobiler能够让大家像开发传统windows一样去开发移动应用,那么列举一下平 ...

  3. MySQL性能分析之Explain

    目录 Explain基础 Explain进阶 Explain基础 关于explain命令相信大家并不陌生,具体用法和字段含义可以参考官网explain-output ,这里需要强调rows是核心指标, ...

  4. VS2013日常使用若干技巧+快捷键

    1.注释的方法 1)sqlserver中,单行注释:— —   多行注释:/* 代码  */ 2)C#中,单行注释://    多行注释:/* 代码  */ 3)C#中多行注释的快捷方式:先选中你要注 ...

  5. (ps2019)Photoshop 2019 最新破解版下载

    Photoshop CC 2019新增功能: 下载地址 点我 新功能介绍:https://helpx.adobe.com/cn/photoshop/using/whats-new.html 经过改良设 ...

  6. springboot+druid连接池及监控配置

    1. 问题描述 阿里巴巴的数据库连接池Druid在效率与稳定性都很高,被很多开发团队使用,并且自带的Druid监控也很好用,本章简单介绍下springboot+druid配置连接池及监控. 2. 解决 ...

  7. 原创:Python编写通讯录,支持模糊查询,利用数据库存储

    1.要求 数据库存储通讯录,要求按姓名/电话号码查询,查询条件只有一个输入入口,自动识别输入的是姓名还是号码,允许模糊查询. 2.实现功能 可通过输入指令进行操作. (1)首先输入“add”,可以对通 ...

  8. android_layout_relativelayout(一)

    android的相对布局relativelayout也是一个值得研究的部分.先归纳下最近的几个心得. 相对布局中,xml文件第一个控件总是在屏幕的左上角为它的位置,别管你手机是竖着的还是横着的. 从第 ...

  9. GitHub & Git 的学习之始

    唉,简单地说,感受只有四个字:蓝瘦香菇. 我的GitHub地址为: https://github.com/LinJingYun  (这个,,我不知道具体从哪里找到自己地址啊) 接下来说一下我对git和 ...

  10. C#4.0新增功能04 嵌入的互操作类型

    连载目录    [已更新最新开发文章,点击查看详细] 从 .NET Framework 4 开始,公共语言运行时支持将 COM 类型的类型信息直接嵌入到托管程序集中,而不要求托管程序集从互操作程序集中 ...