实战探讨:为什么 Redis Zset 选择跳表?
在了解了跳表的原理和实现后,一个常见的问题(尤其是在面试中)随之而来:为什么像 Redis 的有序集合 (Zset) 这样的高性能组件会选择使用跳表,而不是大家熟知的平衡树(如红黑树)呢?
对于这个问题,Redis 的作者 Salvatore Sanfilippo (@antirez) 曾给出过解释,主要可以归纳为以下几点:
-
内存效率与灵活性 (Memory Efficiency & Flexibility):
- 跳表并非特别消耗内存。其内存占用可以通过调整节点提升的概率 p 来控制。通过选择合适的 p 值,可以使得跳表的平均内存占用低于某些平衡树。
-
高效的范围查询与缓存局部性 (Efficient Range Queries & Cache Locality):
- Zset 经常需要执行 ZRANGE (按排名范围查询) 或 ZREVRANGE (按排名反向范围查询) 操作,这本质上是在有序结构上进行一段连续元素的遍历。
- 跳表的最底层 (Level 0) 是一个有序链表。执行范围查询时,只需定位到范围的起始点,然后沿着 Level 0 的链表顺序遍历即可。这种顺序访问模式具有良好的缓存局部性 (cache locality),与平衡树的中序遍历相比,至少同样好,甚至可能更好。
-
实现的简单性与易扩展性 (Implementation Simplicity & Extensibility):
- 跳表的实现、调试相比平衡树(尤其是红黑树)要简单得多。平衡树复杂的旋转和重新平衡逻辑很容易出错。
- 跳表的简单性也带来了更好的可扩展性。@antirez 提到,得益于跳表的简洁,他很容易地集成了一个社区贡献的补丁,通过对跳表进行少量修改,就在 O(log N) 时间内实现了 ZRANK (获取成员排名) 的功能。
进一步解读与补充:
-
内存占用对比:
- 平衡树(如红黑树)每个节点通常需要存储 2 个指向子节点的指针,以及可能的父指针和颜色信息。
- 跳表每个节点包含的指针数目是可变的,取决于它提升的层数。平均来说,每个节点包含的指针数量为 1 / (1 - p)(其中 p 是节点提升一级概率)。在 Redis 的实现中,p 通常取 0.25 (1/4),这意味着平均每个节点大约有 1 / (1 - 0.25) = 1.33 个 right 指针(再加上 down 指针和可能的 backward 指针,但核心指向下一节点的指针数平均较少)。这使得跳表在内存使用上具有一定的灵活性和潜在优势。
-
范围查询的易实现性:
- 如 @antirez 所说,跳表执行范围查询非常自然。找到范围起点后,沿着最底层的链表顺序遍历即可,逻辑简单清晰。
- 平衡树执行范围查询,需要先找到范围起点,然后执行中序遍历来依次访问后续节点,直到超出范围终点。虽然中序遍历本身不复杂,但在某些实现中,高效地进行部分中序遍历可能需要额外的辅助结构或递归,相对跳表的直接链表遍历要稍微复杂一些。此外,跳表 Level 0 的节点在内存中可能是物理上更连续的(如果内存分配器配合),有利于缓存;而树的中序遍历则可能在内存地址上跳跃。
-
实现与维护成本:
- 这一点对于像 Redis 这样需要高性能、高稳定性且持续迭代的项目至关重要。更简单的实现意味着更少的 Bug、更快的开发迭代速度和更低的维护成本。平衡树,特别是红黑树,其插入和删除操作涉及多种情况的判断、旋转和重新染色,逻辑复杂,容易出错。
总结来说,Redis Zset 选择跳表是基于其在内存占用、范围查询性能与实现简洁性之间取得的良好平衡。 对于 Zset 这种既需要快速单点查找/更新,又需要高效范围遍历的场景,跳表提供了一个非常实用且工程上更优的解决方案。