当前位置:首页 > 问答 > 正文

Redis跳跃这块源码真有意思,深入看才能发现它的妙处和设计巧思

主要基于对Redis源码文件 t_skiplist.c 的阅读和理解)

第一次看到Redis里跳跃列表的源码时,感觉有点懵,不就是个链表吗,干嘛弄得这么复杂?但耐着性子往下看,才发现里面藏着不少让人拍大腿的巧妙设计,它不是那种死板的数据结构,而是透着一股“活”劲儿,为了效率和节约内存,真是花了不少心思。

Redis跳跃这块源码真有意思,深入看才能发现它的妙处和设计巧思

最核心的巧妙之处,就在于那个“随机层高”的机制,我们都知道,跳跃列表就是通过建立多级索引来加速查找的,理想情况下,上一层的节点数量应该是下一层的一半,这样查找起来就是对数级别的时间复杂度,能和平衡树媲美,但问题来了,如果严格按照这个规则在插入节点时调整所有索引,那插入操作就会变得非常耗时,失去了简单链表的优势。

Redis的解决办法非常聪明,它把决定权交给了“概率”,每个新节点在创建时,它的层高不是计算出来的,而是“掷骰子”掷出来的,源码里有一个函数(比如叫 zslRandomLevel),它的逻辑很有意思:它规定一个节点至少有1层(也就是最基础的数据链),然后就像抛硬币一样,连续地“抛”,只要是正面(比如用一个随机数小于某个概率值,通常是0.25),就让层数加一,直到抛出反面为止,这样一来,天生就有大约一半的节点只有1层,四分之一的节点有2层,八分之一的节点有3层……这恰好就自动形成了我们理想中那种“上层节点数是下层一半”的近似结构,这个设计妙啊,用一次简单的随机操作,避免了插入时复杂的再平衡,让插入操作始终是常数时间复杂度,而查询效率在统计意义上依然很高。

Redis跳跃这块源码真有意思,深入看才能发现它的妙处和设计巧思

另一个让我觉得精妙的设计是“双向”和“跨度”的结合,我们常见的跳跃列表介绍里,节点只保存指向下一个节点的指针,但Redis的跳跃列表节点(结构体叫 zskiplistNode)里,除了有指向下一个节点的指针数组(forward),还有一个指向前一个节点的指针(backward),这让它可以进行反向的遍历,增加了灵活性。

但更绝的是“跨度”这个属性,每个节点的每一层指针,都记录了一个span值,这个span表示的是,沿着这个指针跳到下一个节点,中间会“跨越”多少个底层节点,在第三层索引上,一个节点A的指针指向节点D,跨度是3,那就意味着从A到D,需要跨越A、B、C(假设B、C是底层节点)这三个节点,这个设计简直是神来之笔,跳跃列表的一个常见用途就是实现有序集合(ZSet),而有序集合经常需要计算排名(rank),如果没有跨度信息,你要查一个成员的排名,只能从头开始遍历底层链表一个个数过去,效率是O(N),但有了跨度,在查找某个节点的过程中,你就可以把沿途经过的所有“跨度”值累加起来,当你找到目标节点时,它的排名也就瞬间计算出来了!查找和排名计算在一次遍历中同时完成,效率和对数级别的查找一样,这是一种典型的用空间(多存一个整数)换时间的高效策略,而且这个空间换得非常值。

还有在内存管理上的细节也体现了巧思,节点层高的随机数是有限制的,Redis源码里定义了一个最大层数(比如32层),这避免了在极端情况下出现一个层高特别离谱的节点,保证了结构的稳定性,节点创建时,会根据随机出来的层数,动态申请刚好大小的内存来存放指针数组,如果层数是3,就申请存放3个指针的空间,一点不浪费,这比直接为每个节点分配最大层数的数组要节约大量内存,尤其是在节点数量庞大时。

整个跳跃列表的结构体(zskiplist)本身也设计得很周到,它不仅仅是一个头指针,还清晰地记录了尾节点(方便反向操作)、节点数量(方便快速获取集合大小)、以及当前的最大层数(方便进行遍历),这些信息都让外部的操作变得简单和高效。

看Redis的跳跃列表源码,就像在看一个精密的机械表,表面上看指针在走动,但内部是各种精巧的齿轮(随机层高、跨度指针)在协同工作,它没有追求理论上的绝对完美,而是通过一些概率性和实用性的设计,在性能、内存和实现复杂度之间找到了一个极其漂亮的平衡点,它可能不如红黑树那样“标准”,但正是这种为解决实际问题而生的、带着点“工程智慧”的设计,才显得格外有意思和巧妙。

Redis跳跃这块源码真有意思,深入看才能发现它的妙处和设计巧思