Redis系列(二):跳跃表详解(redis跳跃表原理和实现)
mhr18 2024-11-03 13:46 36 浏览 0 评论
本文中,我们围绕一个非常重要的Redis的内部数据结构 -- skiplist(跳跃表)展开讨论。Redis中使用跳跃表(skiplist)是为了实现sorted set(有序集合)这种对外的数据结构。sorted set提供的操作非常丰富,可以满足非常多的应用场景。搞清楚其底层实现是非常重要的,有利于我们在复杂场景中的使用。
skiplist简介
skiplist(跳跃表)是一种随机化的数据结构,是由William Pugh在论文《Skip lists:a probabilistic alternative to balanced trees》中提出,是一种层次化链表结构:查找、删除、添加等操作的时间复杂度O(logn)。
- 本质是解决查找问题
skiplist本质上是一种查询结构,用于解决算法中的查找问题(Searching),即:根据给定的key,快速查找到key所在的位置或者对应的value值。
先看一个普通的有序链表,其中最左侧的灰色结点表示一个空的头结点:
在这样一个有序链表中,由于链表是没法进行位置定位的,所以如果我们要查找某个数据,需要从头开始逐个进行比较,直至找到包含数据的那个结点或者第一个比给定数据大的结点(没有要找的数据),时间复杂度O(n)。同样的,当我们要插入新数据时,也需要同样的查找过程,从而确定插入位置,时间复杂度同样是O(n)。
如果每相邻的两个结点增加一个指针,让指针指向下一个结点,结果会如何呢?
这样新增的指针连城了一个新的链表,包含的结点个数只有原来的一半。现在,我们查找数据时,可以沿着新的链表进行查找,当遇到比待查数据大的结点时,再回到原来的链表中进行查找。例如,我们需要查找数据23,查找的路径是沿着下图中红色标记的指针方向进行的:
- 首先和7进行比较,比7大,沿当前链表继续向后比较;
- 沿着当前链表比较,26比23大,回到原链表,与22进行比较;
- 22比23小,又比下一个比较数据26小,说明待查数据23在原链表中不存在,而且它的待插入位置在22和26之间。
由此,利用同样的方式,我们可以在上层在产生新的链表,继续为每相邻的两个结点增加指针,产生第三层链表,如下图:
基于这种多层链表设计,skiplist应运而生。但是上述产生链表的方式存在一个问题:上面每一层链表的结点个数是下一层结点个数的一半,查找过程类似二分查找,查找时间复杂度可以降到O(logn)。但是插入数据时,会打乱原先上下层结点个数严格的1:2的对应关系。如果要维持这种对应关系,需要将新插入的结点以及后面所有的结点进行调整,这会使得时间复杂度退化到O(n),删除存在同样的问题。有没有什么办法解决呢?skiplist是如何设计的呢?
skiplist不要求上下相邻两层链表之间的结点个数有严格的对应关系,而是为每个结点随机生成一个层数(level)。比如,一个结点随机生成的层数是5,那么就将它链入到第1层到第5层链表中。为了更好的理解,下图展示一个skiplist生成的过程:
从上面skiplist的创建和插入过程可以看出,每一个节点的层数(level)是随机出来的,而且新插入一个节点不会影响其它节点的层数。因此,插入操作只需要修改插入节点前后的指针,而不需要对很多节点都进行调整。这就降低了插入操作的复杂度。
- 为什么要使用跳跃表
首先,由于sorted set要支持随机的插入和删除,所以它不适合使用数组这种数据结构来实现。另外,关于排序问题,我们很容易想到红黑树/平衡树这样的树型结构,为什么Redis不使用这样的数据结构呢?
- 性能考虑:树型结构需要进行一些类似于rebalance操作,这样的操作可能涉及到整棵树的结构调整,相对的skiplist的变化只涉及局部;
- 实现考虑:在复杂度相同的情况下,skiplist实现更简单;
Redis中的skiplist实现
了解了skiplist数据结构的设计过程,我们来看一下Redis中skiplist是如何实现的?
Redis中skiplist是由 zskiplistNode 和 zskiplist 两个结构定义。源码如下:
#define ZSKIPLIST_MAXLEVEL 32
#define ZSKIPLIST_P 0.25
typedef struct zskiplistNode {
robj *obj;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
// 跳跃表头指针
struct zskiplistNode *header, *tail;
// 表中结点的数量
unsigned long length;
// 表中层数最大的结点的层数
int level;
} zskiplist;
- 随机层数
对于新插入的结点,都需要调用一个随机算法分配一个合理的层数,源码zslRandomLevel中定义:
int zslRandomLevel(void) {
int level = 1;
while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF)) {
level += 1;
}
return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
从上述源码中,可以直观看出:期望目标是50%的概率分配到level1,25%的概率分配到level2,以此类推,有1/2^63概率分配到最顶层,因为每一层的晋升概率都是50%。
【注意】:Redis中skiplist默认允许的最大层数是32.
- skiplist创建
skiplist创建的过程比较简单,源码zslCreate中定义:
zskiplist *zslCreate(void) {
int j;
zskiplist *zsl;
// 申请内存空间
zsl = zmalloc(sizeof(*zsl));
// 初始化层数为 1
zsl->level = 1;
// 初始化长度为 0
zsl->length = 0;
// 创建一个层数为 32,分数为 0,没有 value 值的跳跃表头节点
zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
// 跳跃表头节点初始化
for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
// 将跳跃表头节点的所有前进指针 forward 设置为 NULL
zsl->header->level[j].forward = NULL;
// 将跳跃表头节点的所有跨度 span 设置为 0
zsl->header->level[j].span = 0;
}
// 跳跃表头节点的后退指针 backward 置为 NULL
zsl->header->backward = NULL;
// 表头指向跳跃表尾节点的指针置为 NULL
zsl->tail = NULL;
return zsl;
}
- 插入操作实现
- 找到当前待插入的位置
- 创建新结点,调整前后指针指向,完成插入
Redis源码实现具体过程:
- 声明存储变量
// 存储搜索路径
zskiplistNode *update[ZSKIPLIST_MAXLEVEL],
*x;
// 存储路径的结点跨度
unsigned int rank[ZSKIPLIST_MAXLEVEL];
int i;
int level;
- 搜索当前结点插入位置
serverAssert(!isnan(score));
x = zsl->header;
// 逐步降级寻找目标节点,得到 "搜索路径"
for (i = zsl->level-1; i >= 0; i--) {
/* store rank that is crossed to reach the insert position */
rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
// 如果 score 相等,还需要比较 value 值 【重要!】
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
sdscmp(x->level[i].forward->ele,ele) < 0))) {
rank[i] += x->level[i].span; x = x->level[i].forward;
}
// 记录 "搜索路径"
update[i] = x;
}
- 生成插入结点
level = zslRandomLevel();
// 如果随机生成的 level 超过了当前最大 level 需要更新跳跃表的信息
if (level > zsl->level) {
for (i = zsl->level; i < level; i++) {
rank[i] = 0;
update[i] = zsl->header;
update[i]->level[i].span = zsl->length;
}
zsl->level = level;
}
// 创建新节点
x = zslCreateNode(level,score,ele);
- 重排前向指针
for (i = 0; i < level; i++) {
x->level[i].forward = update[i]->level[i].forward;
update[i]->level[i].forward = x;
/* update span covered by update[i] as x is inserted here */
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
update[i]->level[i].span = (rank[0] - rank[i]) + 1;
}
/* increment span for untouched levels */
for (i = level; i < zsl->level; i++) {
update[i]->level[i].span++;
}
- 重排后向指针并返回
x->backward = (update[0] == zsl->header) ? NULL : update[0];
if (x->level[0].forward) {
x->level[0].forward->backward = x;
} else {
zsl->tail = x;
}
zsl->length++;
return x;
- 删除实现
删除过程和插入过程实现类似,都需要先找出“搜索路径”,然后对于每个层的相关结点重排前向和后向指针,同时还要注意更新最高层数maxLevel属性。源码实现:
/* Internal function used by zslDelete, zslDeleteByScore and zslDeleteByRank */
void zslDeleteNode(zskiplist *zsl, zskiplistNode *x, zskiplistNode **update) {
int i;
for (i = 0; i < zsl->level; i++) {
if (update[i]->level[i].forward == x) {
update[i]->level[i].span += x->level[i].span - 1;
update[i]->level[i].forward = x->level[i].forward;
} else {
update[i]->level[i].span -= 1;
}
}
if (x->level[0].forward) {
x->level[0].forward->backward = x->backward;
} else {
zsl->tail = x->backward;
}
while(zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL) {
zsl->level--;
}
zsl->length--;
}
int zslDelete(zskiplist *zsl, double score, sds ele, zskiplistNode **node) {
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
int i;
x = zsl->header;
for (i = zsl->level-1; i >= 0; i--) {
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
sdscmp(x->level[i].forward->ele,ele) < 0))) {
x = x->level[i].forward;
}
update[i] = x;
}
x = x->level[0].forward;
if (x && score == x->score && sdscmp(x->ele,ele) == 0) {
zslDeleteNode(zsl, x, update);
if (!node) {
zslFreeNode(x);
} else {
*node = x;
}
return 1;
}
return 0; /* not found */
}
- 更新实现
我们使用sorted set结构时,调用zadd方法,如果对应的value不存在,那就是插入过程;如果value已经存在,只是调整一下score值,此时需要执行更新过程。
在更新过程中,如果这个新的score值没有带来排序上的变化,就不需要调整位置,直接修改元素的score值即可;如果排序位置改变了,就需要调整位置了。Redis是如何进行调整的呢?源码见晓:把这个元素删除再插入!!!
/* Remove and re-insert when score changed. */
if (score != curscore) {
zobj->ptr = zzlDelete(zobj->ptr,eptr);
zobj->ptr = zzlInsert(zobj->ptr,ele,score);
*flags |= ZADD_UPDATED;
}
往期精彩文章
相关推荐
- Spring Boot3 连接 Redis 竟有这么多实用方式
-
各位互联网大厂的后端开发精英们,在日常开发中,想必大家都面临过系统性能优化的挑战。当系统数据量逐渐增大、并发请求不断增多时,如何提升系统的响应速度和稳定性,成为了我们必须攻克的难题。而Redis,这...
- 隧道 ssh -L 命令总结 和 windows端口转发配置
-
摘要:隧道ssh-L命令总结和windows端口转发配置关键词:隧道、ssh-L、端口转发、网络映射整体说明最近在项目中,因为内网的安全密级比较高,只能有一台机器连接内网数据库,推送...
- 火爆BOOS直聘的13个大厂Java社招面经(5年经验)助你狂拿offer
-
火爆BOOS直聘的13个大厂Java社招面经(5年经验)助你狂拿offer综上所述,面试遇到的所有问题,整理成了一份文档,希望大家能够喜欢!!Java面试题分享(Java中高级核心知识全面解析)一、J...
- 「第五期」游服务器一二三面 秋招 米哈游
-
一面下午2点,35分钟golang内存模型golang并发模型golanggc原理过程channel用途,原理redis数据结构,底层实现跳跃表查询插入复杂度进程,线程,协程kill原理除了kil...
- RMQ——支持合并和优先级的消息队列
-
业务背景在一个项目中需要实现一个功能,商品价格发生变化时将商品价格打印在商品主图上面,那么需要在价格发生变动的时候触发合成一张带价格的图片,每一次触发合图时计算价格都是获取当前最新的价格。上游价格变化...
- Redis 中的 zset 为什么要用跳跃表,而不是B+ Tree 呢?
-
Redis中的有序集合使用的是一种叫做跳跃表(SkipList)的数据结构来实现,而不是使用B+Tree。本文将介绍为什么Redis中使用跳跃表来实现有序集合,而不是B+Tree,并且探讨跳跃表...
- 一文让你彻底搞懂 WebSocket 的原理
-
作者:木木匠转发链接:https://juejin.im/post/5c693a4f51882561fb1db0ff一、概述上一篇文章《图文深入http三次握手核心问题【思维导图】》我们分析了简单的一...
- Redis与Java整合的最佳实践
-
Redis与Java整合的最佳实践在这个数字化时代,数据处理速度决定了企业的竞争力。Redis作为一款高性能的内存数据库,以其卓越的速度和丰富的数据结构,成为Java开发者的重要伙伴。本文将带你深入了...
- Docker与Redis:轻松部署和管理你的Redis实例
-
在高速发展的云计算时代,应用程序的部署和管理变得越来越复杂。面对各种操作系统、依赖库和环境差异,开发者常常陷入“在我机器上能跑”的泥潭。然而,容器化技术的兴起,尤其是Docker的普及,彻底改变了这一...
- Java开发中的缓存策略:让程序飞得更快
-
Java开发中的缓存策略:让程序飞得更快缓存是什么?首先,让我们来聊聊什么是缓存。简单来说,缓存是一种存储机制,它将数据保存在更快速的存储介质中,以便后续使用时能够更快地访问。比如,当你打开一个网页时...
- 国庆临近,字节后端开发3+4面,终于拿到秋招第一个offer
-
字节跳动,先面了data部门,3面技术面之后hr说需要实习转正,拒绝,之后另一个部门捞起,四面技术面,已oc分享面经,希望对大家有所帮助,秋招顺利在文末分享了我为金九银十准备的备战资源库,包含了源码笔...
- “快”就一个字!Redis凭什么能让你的APP快到飞起?
-
咱们今天就来聊一个字——“快”!在这个信息爆炸、耐心越来越稀缺的时代,谁不希望自己手机里的APP点一下“嗖”就打开,刷一下“唰”就更新?谁要是敢让咱用户盯着个小圈圈干等,那简直就是在“劝退”!而说到让...
- 双十一秒杀,为何总能抢到?Redis功不可没!
-
一年一度的双十一“剁手节”,那场面,简直比春运抢票还刺激!零点的钟声一敲响,亿万个手指头在屏幕上疯狂戳戳戳,眼睛瞪得像铜铃,就为了抢到那个心心念念的半价商品、限量版宝贝。你有没有发现一个奇怪的现象?明...
- 后端开发必看!为什么说Redis是天然的幂等性?
-
你在做后端开发的时候,有没有遇到过这样的困扰:高并发场景下,同一个操作重复执行多次,导致数据混乱、业务逻辑出错?别担心,很多同行都踩过这个坑。某电商平台就曾因订单创建接口在高并发时不具备幂等性,用户多...
- 开发一个app需要哪些技术和工具
-
APP开发需要一系列技术和工具的支持,以下是对这些技术的清晰归纳和分点表示:一、前端开发技术HTML用于构建页面结构。CSS用于样式设计和布局。JavaScript用于页面交互和逻辑处理。React...
你 发表评论:
欢迎- 一周热门
- 最近发表
- 标签列表
-
- oracle位图索引 (63)
- oracle批量插入数据 (62)
- oracle事务隔离级别 (53)
- oracle 空为0 (50)
- oracle主从同步 (55)
- oracle 乐观锁 (51)
- redis 命令 (78)
- php redis (88)
- redis 存储 (66)
- redis 锁 (69)
- 启动 redis (66)
- redis 时间 (56)
- redis 删除 (67)
- redis内存 (57)
- redis并发 (52)
- redis 主从 (69)
- redis 订阅 (51)
- redis 登录 (54)
- redis 面试 (58)
- 阿里 redis (59)
- redis 搭建 (53)
- redis的缓存 (55)
- lua redis (58)
- redis 连接池 (61)
- redis 限流 (51)