Redis ZSet 的底层实现:从 Ziplist 到 Listpack 的演变

414 阅读6分钟

ZSet 为什么是有序的?

在 Redis 数据库中,ZSet(有序集合)是一种非常重要的数据结构,它不仅与普通的集合(Set)类似,但与普通集合不同的是,ZSet 中的元素是按照权重(通常是分数)来排序的。每个元素都会关联一个分数,Redis 会根据这些分数对 ZSet 中的元素进行排序。

那么,为什么 ZSet 会是有序的?

ZSet 的内部实现

Redis的ZSet底层实现方式在不同版本中有所变化。Redis 7.0之前,ZSet使用压缩列表(Ziplist)存储小量数据,而数据量较大时使用跳跃表(Skiplist)。在Redis 7.0及其之后的版本中,Ziplist被紧凑列表(Listpack)所取代,而跳跃表仍然是ZSet的主要存储方式。

通过这两种结构的结合,Redis 的 ZSet 可以实现以下特性:

  • 有序性:元素按照分数从小到大排序。
  • 高效性:查找、插入、删除等操作都能够在 O(logN) 或 O(1) 时间复杂度内完成。

1. 跳跃表(Skiplist)

跳跃表是一种多级索引的数据结构,它通过多层索引链表来加速对元素的查找。Redis 在 ZSet 中使用跳跃表来存储元素,保证了插入和查找操作的高效性,时间复杂度为 O(logN)

  • 层级结构:跳跃表由多级链表组成,每一层索引都包含部分元素。查找操作可以从上层的索引跳跃到下层,从而加速查询过程。
  • 插入与删除:跳跃表支持 O(logN) 的插入和删除操作,插入时会根据元素的分数决定插入位置,并在多级索引中更新相应的指针。

2. 压缩列表(Ziplist)与紧凑列表(Listpack)

在 Redis 7.0 之前,ZSet 会使用 压缩列表(Ziplist) 存储元素。压缩列表是一种非常节省内存的存储结构,适用于小规模的数据存储。它将元素和分数压缩在一起,以节省内存空间。当 ZSet 中的元素较少时,压缩列表能够提供更好的内存利用率。

但是,当 ZSet 中的元素数量增大时,压缩列表会变得效率低下,因为它的插入和删除操作的时间复杂度是 O(N),并且无法有效支持快速查找。因此,当数据量较大时,Redis 会切换到跳跃表来处理 ZSet。

Redis 7.0 及之后的版本,压缩列表被 紧凑列表(Listpack) 所取代。紧凑列表是 Redis 7.0 新引入的一种数据结构,它与压缩列表类似,但性能更高,特别是在处理大量数据时。与压缩列表相比,紧凑列表在存储和访问效率上都有较大的提升。

紧凑列表具有以下特点:

  • 内存效率:紧凑列表比压缩列表使用更少的内存,同时保持较高的性能。
  • 性能优化:对于大规模数据集,紧凑列表能够提供更好的性能,支持 O(1) 的插入和删除操作。

ZSet 的存储方式演变

  1. Redis 7.0 之前

    • 小数据量时:使用 压缩列表(Ziplist)  存储 ZSet 的元素。
    • 大数据量时:使用 跳跃表(Skiplist)  存储 ZSet 的元素。
  2. Redis 7.0 及之后

    • 小数据量时:使用 紧凑列表(Listpack)  存储 ZSet 的元素。
    • 大数据量时:仍然使用 跳跃表(Skiplist)  存储 ZSet 的元素。

ZSet 的主要操作

在 Redis 中,ZSet 提供了多种操作,它们的高效性得益于跳跃表和紧凑列表的结合。

  1. 添加元素(add) :向 ZSet 中添加一个元素时,需要指定元素的值和分数。Redis 会将元素插入到跳跃表或紧凑列表中,并确保按分数排序。
  2. 删除元素(remove) :当从 ZSet 中删除元素时,Redis 会在跳跃表和紧凑列表中删除对应的节点。
  3. 获取排名(rank) :获取某个元素的排名。在 Redis 中,ZSet 中的元素是根据分数升序排序的。通过跳跃表或紧凑列表,可以高效地获取某个元素的排名。
  4. 范围查询(range) :根据分数范围返回一组元素。Redis 可以在 O(logN) 或 O(1) 的时间复杂度内完成这个操作,具体时间复杂度取决于使用的数据结构(跳跃表或紧凑列表)。

我们可以看出 ZSet 主要通过跳表来保持元素的有序性。跳表使得我们能够高效地进行元素的插入、查找和删除操作。同时,紧凑列表则提供了 O(1) 时间复杂度来快速定位元素的分数。在 Redis 中,ZSet 的有序性和高效性正是依赖于这两种数据结构的结合。

Java 实现 ZSet 的算法过程

下面我们通过 Java 代码来模拟实现一个简单的 ZSet 数据结构。这个实现将使用跳表和哈希表来完成 ZSet 的基本操作。

1. 定义跳表的节点(SkipListNode)

public class SkipListNode {
    String value;   // ZSet 元素
    double score;   // 分数
    SkipListNode[] forward; // 指向不同层的指针
    
    public SkipListNode(String value, double score, int level) {
        this.value = value;
        this.score = score;
        this.forward = new SkipListNode[level + 1];
    }
}

2. 定义跳表(SkipList)

public class SkipList {
    private static final int MAX_LEVEL = 16;
    private SkipListNode header; // 跳表的头节点
    private int level;  // 当前跳表的最大层数
    
    public SkipList() {
        this.header = new SkipListNode(null, Double.NEGATIVE_INFINITY, MAX_LEVEL);
        this.level = 0;
    }

    // 创建随机层数
    private int randomLevel() {
        int lvl = 0;
        while (Math.random() < 0.5 && lvl < MAX_LEVEL) {
            lvl++;
        }
        return lvl;
    }

    // 向跳表插入元素
    public void insert(String value, double score) {
        SkipListNode[] update = new SkipListNode[MAX_LEVEL + 1];
        SkipListNode current = header;
        
        // 从高层到低层查找插入位置
        for (int i = level; i >= 0; i--) {
            while (current.forward[i] != null && current.forward[i].score < score) {
                current = current.forward[i];
            }
            update[i] = current;
        }
        
        current = current.forward[0];
        
        // 如果元素已经存在,更新分数
        if (current != null && current.score == score) {
            current.value = value;
        } else {
            int newLevel = randomLevel();
            if (newLevel > level) {
                for (int i = level + 1; i <= newLevel; i++) {
                    update[i] = header;
                }
                level = newLevel;
            }
            SkipListNode newNode = new SkipListNode(value, score, newLevel);
            for (int i = 0; i <= newLevel; i++) {
                newNode.forward[i] = update[i].forward[i];
                update[i].forward[i] = newNode;
            }
        }
    }

    // 查找元素
    public SkipListNode find(double score) {
        SkipListNode current = header;
        for (int i = level; i >= 0; i--) {
            while (current.forward[i] != null && current.forward[i].score < score) {
                current = current.forward[i];
            }
        }
        current = current.forward[0];
        return (current != null && current.score == score) ? current : null;
    }
}

3. 定义 ZSet(使用跳表和哈希表)

import java.util.HashMap;
import java.util.Map;

public class ZSet {
    private SkipList skipList;  // 跳表
    private Map<String, Double> scoreMap;  // 哈希表存储元素与分数

    public ZSet() {
        this.skipList = new SkipList();
        this.scoreMap = new HashMap<>();
    }

    // 添加元素
    public void add(String value, double score) {
        if (!scoreMap.containsKey(value)) {
            scoreMap.put(value, score);
            skipList.insert(value, score);
        } else {
            // 如果已经存在,更新分数
            scoreMap.put(value, score);
            skipList.insert(value, score);
        }
    }

    // 获取元素的分数
    public Double getScore(String value) {
        return scoreMap.get(value);
    }

    // 查找某个元素
    public String find(double score) {
        SkipListNode node = skipList.find(score);
        return (node != null) ? node.value : null;
    }
}

总结

Redis 的 ZSet 在不同版本中采取了不同的存储方式,以提高内存利用率和性能。Redis 7.0 之前,ZSet 使用压缩列表存储小量数据,而使用跳跃表存储大数据。而在 Redis 7.0 之后,压缩列表被更高效的紧凑列表取代,同时跳跃表仍然是 ZSet 的主要存储方式。这些变革使得 Redis 在处理有序集合时更加高效和灵活。