欢迎大家关注我的微信公众号【老周聊架构】,Java后端主流技术栈的原理、源码分析、架构以及各种互联网高并发、高性能、高可用的解决方案。

一、前言

老周写这篇文章的初衷是这样的,之前项目中有大量使用 Redis 的 ZSet 数据结构来实现各种排行榜的功能。老周以前也写过关于跳表的数据结构,但那是纯数据结构方面来分析的,今天我们就来从跳跃表在 Redis 中的底层实现方向来分析。我们都知道 Redis 有五种常用的数据结构:String、Hash、List、Set 以及 ZSet,其中 ZSet 是 Redis 提供的一个非常特别的数据结构,常用作排行榜等功能,以用户 id 为 value,关注时间或者分数作为 score 进行排序。

ZSet 有两种不同的实现,分别是 ziplist 和 skiplist。具体使用哪种结构进行存储,规则如下:

  • ziplist:满足以下两个条件

    • [value,score] 键值对数量少于 128 个
    • 每个元素的长度小于 64 字节
  • skiplist:不满足以上两个条件时使用跳表、组合了 hash 和 skiplist
    • hash 用来存储 value 到 score 的映射,这样就可以在 O(1) 时间内找到 value 对应的分数
    • skiplist 按照从小到大的顺序存储分数
    • skiplist 每个元素的值都是 [value,score] 对

使用 ziplist 的示意图如下所示:


使用跳表时的示意图:


ziplist 压缩列表本文不是重点讨论范围,我们着重来看下跳跃表 skiplist。

二、什么是跳跃表(skiplist)

跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其它节点的指针,从而达到快速访问节点的目的。和链表、字典等数据结构被广泛地应用在 Redis 内部不同,Redis 只在两个地方用到了跳跃表,一个是实现有序集合健,另一个是在集群节点中用做内部数据结构,除此之外,跳跃表在 Redis 里没有其它用途。

我们来想一下,为啥 Redis 中这两个场景要选择 skiplist?

既然跳跃表是一种有序数据结构,那我们就来考虑在有序序列中查找某个特定元素的情境:

  • 如果该序列用支持随机访问的线性结构(数组)存储,那么我们很容易地用二分查找来做。
  • 但是考虑到增删效率和内存扩展性,很多时候要用不支持随机访问的线性结构(链表)存储,就只能从头遍历、逐个比对。
  • 作为折衷,如果用二叉树结构(BST)存储,就可以不靠随机访问特性进行二分查找了。

我们知道,普通 BST 插入元素越有序效率越低,最坏情况会退化回链表。因此很多大佬提出了自平衡 BST 结构,使其在任何情况下的增删查操作都保持 O(logn) 的时间复杂度。自平衡 BST 的代表就是 AVL 树及其衍生出来的红黑树。如果推广之,不限于二叉树的话,我们耳熟能详的 B 树和 B+ 树也属此类,常用于文件系统和数据库。

自平衡BST显然很香,但是它仍然有一个不那么香的点:树的自平衡过程比较复杂,实现起来麻烦,在高并发的情况下,加锁也会带来可观的overhead。如AVL树需要LL、LR、RL、RR四种旋转操作保持平衡,红黑树则需要左旋、右旋和节点变色三种操作。下面的动图展示的就是AVL树在插入元素时的平衡过程。


那么,有没有简单点的、与自平衡 BST 效率相近的实现方法呢?答案就是跳跃表,并且它简单很多,下面我们就来看一看。

三、如何理解跳跃表

对于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度会很高,是 O(n)。


那怎么来提高查找效率呢?请看我下面画的图,在该链表中,每隔一个节点就有一个附加的指向它在表中前两个位置上的节点的链,正因为如此,在最坏的情形下,最多考察 n/2 + 1 个节点。比如我们要查 90 这个节点,按照之前单链表的查找的话要 8 个节点,现在只需 5 个节点。


我们来将这种想法扩展一下,得到下面的图,这里每隔 4 个节点就有一个链接到该节点前方的下一个第 4 节点的链,只有 n/4 + 1 个节点被考察。


这里我们利用数学的思想,针对通用性做扩展。每隔第 2^i 个节点就有一个链接到这个节点前方下一个第 2^i 个节点链。链的总个数仅仅是加倍,但现在在一次查找中最多只考察 logn 个节点。不难看到一次查找的总时间消耗为 O(logn),这是因为查找由向前到一个新的节点或者在同一节点下降到低一级的链组成。在一次查找期间每一步总的时间消耗最多为 O(logn)。注意,在这种数据结构中的查找基本上是折半查找(Binary Search)。

我只举了两个例子,这里你可以自己想象下大量数据也就是链表长度为 n 的时候,查找的效率更加的凸显出来了。

这种链表加多级索引的的结构,就是跳跃表。接下来我们来定量的分析下,用跳表查询到底有多快。

四、跳跃表的时间复杂度分析

我们知道,在一个单链表中查询某个数据的时间复杂度是 O(n)。那在一个具有多级索引的跳表中,查询某个数据的时间复杂度是多少呢?

我把问题分解一下,先来看这样一个问题,如果链表里有 n 个结点,会有多少级索引呢?

按照我们上面讲的,第一级索引的链节点个数大约就是 n/2 个,第二级索引的链节点个数大约就是 n/4 个,第三级索引的链节点个数大约就是 n/8 个,依次类推,也就是说,第 k 级索引的链节点个数是第 k-1 级索引的链节点个数的 1/2,那第 k 级索引节点的个数就是 n/(2k)。

假设索引有 h 级,最高级的索引有 2 个节点。通过上面的公式,我们可以得到 n/(2h)=2,从而求得 h=log2n-1。如果包含原始链表这一层,整个跳表的高度就是 log2n。我们在跳表中查询某个数据的时候,如果每一层都要遍历 m 个节点,那在跳表中查询一个数据的时间复杂度就是 O(m*logn)。

那这个 m 的值是多少呢?按照前面这种索引结构,我们每一级索引都最多只需要遍历 3 个结点,也就是说 m=3,为什么是 3 呢?我来解释一下。

假设我们要查找的数据是 x,在第 k 级索引中,我们遍历到 y节点之后,发现 x 大于 y,小于后面的节点 z,所以我们通过 y 的 down 指针,从第 k 级索引下降到第 k-1 级索引。在第 k-1 级索引中,y 和 z 之间只有 3 个节点(包含 y 和 z),所以,我们在 k-1 级索引中最多只需要遍历 3 个结点,依次类推,每一级索引都最多只需要遍历 3 个节点。


通过上面的分析,我们得到 m=3,所以在跳跃表中查询任意数据的时间复杂度就是 O(logn)。这个查找的时间复杂度跟二分查找是一样的。换句话说,我们其实是基于单链表实现了二分查找,前提是建立了很多级索引,也就是我们讲过的空间换时间的设计思路。

我们的时间复杂度很优秀,那跳跃表的空间复杂度是多少呢?

实际上,在软件开发中,我们不必太在意索引占用的额外空间。在讲数据结构和算法时,我们习惯性地把要处理的数据看成整数,但是在实际的软件开发中,原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略了。

五、跳跃表的底层结构

Redis 的跳跃表是由 redis.h/zskiplistNode 和 redis.h/zskiplist 两个结构定义,其中 zskiplistNode 用于表示跳跃节点,而 zskiplist 结构则用于保存跳跃表节点的相关信息,比如节点的数量以及指向表头节点和表尾节点的指针等等。


上图最左边的是 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 是节点所保存的成员对象。

我们接下来看下 Redis 是如何实现 skiplist 的。

5.1 结构定义

zskiplist 的结构定义:

zskiplistNode 的结构定义:

5.2 skiplist 创建

这里需要注意的是常量 ZSKIPLIST_MAXLEVEL,它定义了 zskiplist 的最大层数,值为 32,这也是节点最高只到 L32 的原因。

5.3 skiplist 插入节点

其大致执行流程如下:

  • 按照前面讲过的查找流程,找到合适的插入位置。注意 zset 允许分数 score 相同,这时会根据节点数据 obj 的字典序来排序。
  • 调用 zslRandomLevel() 方法,随机出要插入的节点的层数。
  • 调用 zslCreateNode() 方法,根据层数 level、分数 score 和数据 obj 创建出新节点。
  • 每层遍历,修改新节点以及其前后节点的前向指针 forward 和跳跃长度 span,也要更新最底层的后向指针 backward。
  • 其中维护了两个数组 update 和 rank。update 数组用来记录每一层的最后一个分数小于待插入 score 的节点,也就是插入位置。rank 数组用来记录上述插入位置的上一个节点的排名,以便于最后更新 span 值。

5.4 skiplist 删除节点

5.5 skiplist 更新节点

更新的过程和插入的过程都是是使用着 zadd 方法的,先是判断这个 value 是否存在,如果存在就是更新的过程,如果不存在就是插入过程。在更新的过程是,如果找到了 Value,先删除掉,再新增,这样的弊端是会做两次的搜索,在性能上来讲就比较慢了,在 Redis 5.0 版本中,Redis 的作者 Antirez 优化了这个更新的过程,目前的更新过程是如果判断这个 value 是否存在,如果存在的话就直接更新,然后再调整整个跳跃表的 score 排序,这样就不需要两次的搜索过程。

5.6 skiplist 查找节点

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

类型 性能 优点 缺点
Hash 查询、插入平均时间复杂度O(1) 实现简单 1. 依赖哈希函数的选择,需要处理冲突 2. 数据无序 3. 不适宜做范围查找
平衡树 查询、插入时间复杂度O(logn) 系统性能稳定,数据天然有序 实现复杂,并发一般需要加锁
skiplist 查询、插入平均时间复杂度O(logn) 1. 实现简单,数据天然有序 2. skiplist并不是特别耗内存,只需要调整下节点到更高level的概率,就可以做到比平衡树更少的内存消耗。 比平衡树性能稍差

Redis中ZSet的底层数据结构跳跃表skiplist,你真的了解吗?相关推荐

  1. Redis数据结构——跳跃表-skiplist

    跳跃表简介: 跳跃表是一种有序数据结构,通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的. 跳跃表支持评价O(logN).最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量 ...

  2. Redis 为什么这么快? Redis 的有序集合 zset 的底层实现原理是什么? —— 跳跃表 skiplist

    Redis有序集合 zset 的底层实现--跳跃表skiplist Redis简介 Redis是一个开源的内存中的数据结构存储系统,它可以用作:数据库.缓存和消息中间件. 它支持多种类型的数据结构,如 ...

  3. 二、HBase的核心数据结构 跳跃表、LSM树、布隆过滤器

    文章目录 HBase的核心数据结构 跳跃表(SkipList) LSM树 布隆过滤器 布隆过滤器算法示例 HBase与布隆过滤器 HBase的核心数据结构 HBase的一个列簇(Column Fami ...

  4. redis的zset的底层实现_Redis中hash、set、zset有多牛?从底层告诉你数据结构原理...

    前言 今天来说下Redis中hash.set.zset的底层数据结构原理! Redis-哈希对象(hash) hash的底层存储有两种数据结构,一种是ziplist,另外一种是hashtable,这两 ...

  5. Redis内部数据结构-跳跃表

    今天学习了跳跃表,记录一下下~ 一.跳跃表简介 跳跃表是一种随机化数据结构,基于并联的链表,其效率可以比拟平衡二叉树,查找.删除.插入等操作都可以在对数期望时间内完成,对比平衡树,跳跃表的实现要简单直 ...

  6. Redis从入门到精通之底层数据结构快表QuickList详解

    文章目录 0.前言 1. 快表的结构 2. Redis 6.0 快表quicklist 基本结构 2.1 成员变量 2.1 主要操作 2.1 推导结果 3. 快表的操作 3. 快表的优缺点 3.1 优 ...

  7. 图解Redis中的9种数据结构(高级面试,必备)

    如图所示,Redis中提供了9种不同的数据操作类型,他们分别代表了不同的数据存储结构. 图2-17 数据类型 String类型 String类型是Redis用的较多的一个基本类型,也是最简单的一种类型 ...

  8. 【重难点】【Redis 01】为什么使用 Redis、Redis 的线程模型、Redis 的数据类型及其底层数据结构

    [重难点][Redis 01]为什么使用 Redis.Redis 的线程模型.Redis 的数据类型及其底层数据结构 文章目录 [重难点][Redis 01]为什么使用 Redis.Redis 的线程 ...

  9. redis ZSet php,redis中zset怎么使用

    Redis中zset是set的一个升级版本,他在set的基础上增加了一个顺序属性,这一属性在添加修改元素的时候可以指定,每次指定后,zset会自动重新按新的值调整顺序.可以理解了有两列的mysql表, ...

最新文章

  1. 简单Linux C线程池
  2. 二分类神经网络的特征光谱---2-3至2-9
  3. java练气期(3)----java高级(网络编程)
  4. 定位--position属性
  5. 扎心了,程序员2017到2019经历了什么?
  6. python函数多次调用内存溢出_关于循环多次(具体160次左右)获取同一个句柄对象,不同实例,内存溢出的问题。...
  7. Python将csv格式转换为xlsx
  8. 转载-IDEA项目左边栏只能看到文件看不到项目结构
  9. Redis 入门指南(五)管理
  10. 苹果CEO乔布斯脑子里怎么想的
  11. 微信小程序,画布中,根据需要展示的图片比例,获取能截取原图中的最大图片尺寸,并且不变形展示
  12. 客观评价,一起看看iPhone 12是否值得你入手?
  13. Hadoop安装教程_单机/伪分布式配置_Hadoop2.6.0/Ubuntu14.04
  14. 如何利用MES系统实现防错和预警?(详细整理,值得收藏)
  15. 定位线程Segment fault (SIGSEGV)的方法
  16. Simulink 模型导出FMU
  17. RESTful源码学习笔记之RPC和 RESTful 什么区别
  18. python 在指定位置显示图片并调整窗口大小(或图片大小)
  19. 数字IC设计的一些英语术语总结
  20. 监督学习和无监督学习概念

热门文章

  1. jsp70877婚庆策划婚车预订网站 双数据库 mysql版
  2. 饿了么CFO徐舸被架空:阿里派人直接出任 协助王磊
  3. 数学中的鞍点、驻点、拐点、极值点
  4. 能攻心则反侧自消,从古知兵非好战;不审势即宽严皆误,后来治蜀要深思
  5. 实现一个简单的待办事项
  6. android OTA update
  7. android 清理程序,浅谈:Android应用清理内存
  8. 第一阶段笔试面试经历 0 offer 2021年9月21日
  9. ER Win 的官方下载地址
  10. 画论73 龚贤《龚贤论画》