2. 缓存索引基础:索引在缓存中的作用、主键索引与二级索引、索引的存储结构

2.1 索引在缓存中的核心作用

在缓存系统中,索引是加速数据检索的核心数据结构。与数据库索引类似,缓存索引通过建立键(Key)到值(Value)或键到元数据的映射关系,将原本需要遍历全量数据的 O(n) 操作降为 O(1) 或 O(log n)。

缓存索引的三大核心作用:
  • 快速定位: 通过哈希或树形结构,在毫秒级内定位目标数据,避免全量扫描。
  • 范围查询支持: 有序索引(如 B+ 树、跳表)支持高效的范围扫描和排序操作。
  • 元数据管理: 记录数据的过期时间、版本号、访问频率等辅助信息,支撑失效策略和淘汰算法。

缓存索引的设计直接影响系统的吞吐量和延迟。一个设计不当的索引可能导致缓存击穿、雪崩或内存浪费。

2.2 主键索引与二级索引

缓存索引按功能可分为主键索引和二级索引,它们在数据组织和查询方式上存在显著差异。

对比维度 主键索引 二级索引
定义 基于缓存主键(Primary Key)建立的唯一索引 基于非主键字段建立的辅助索引
唯一性 唯一,一个缓存条目只有一个主键 非唯一,多个条目可共享相同二级索引值
查询方式 直接定位到数据行,一次索引查找即可 先找到主键列表,再通过主键索引回查数据
存储开销 较低,通常与数据一起存储 较高,需要额外维护索引结构
典型实现 Redis 的 Key-Value 映射、Memcached 的哈希表 Redis 的 ZSET 跳表索引、MySQL InnoDB 的二级索引
失效影响 直接导致缓存条目不可访问 仅影响基于该字段的查询路径
性能调优提示: 在缓存一致性治理中,二级索引的失效策略需要特别关注。当主键索引对应的数据更新时,所有关联的二级索引必须同步失效或更新,否则会导致脏读。建议采用“主键驱动”模式,即所有查询先经过主键索引,二级索引仅作为辅助路由。

2.3 索引的存储结构

不同的存储结构决定了缓存的查询性能、内存占用和并发能力。以下是三种最常用的索引存储结构。

2.3.1 哈希表(Hash Table)

哈希表是缓存中最基础的索引结构,通过哈希函数将键映射到桶(Bucket)中,实现 O(1) 的平均查找时间复杂度。

  • 优点: 读写速度极快,适合等值查询;实现简单,内存开销可控。
  • 缺点: 不支持范围查询;哈希冲突时性能退化;扩容时需 rehash,可能引起短暂阻塞。
  • 典型应用: Redis 的字典(dict)、Memcached 的哈希表、Java HashMap。

2.3.2 B+ 树(B+ Tree)

B+ 树是一种多路平衡查找树,所有数据都存储在叶子节点,内部节点仅存储键值用于路由。它支持高效的等值查询和范围查询。

  • 优点: 范围查询性能优异;树高较低(通常 3~4 层),磁盘 I/O 次数少;数据有序存储。
  • 缺点: 写操作需要维护树平衡,开销较大;内存占用高于哈希表。
  • 典型应用: MySQL InnoDB 的聚簇索引和二级索引、MongoDB 的 WiredTiger 存储引擎。

2.3.3 跳表(Skip List)

跳表是一种基于链表扩展的概率性数据结构,通过多层索引实现 O(log n) 的查找效率。它结合了链表和二分查找的思想。

  • 优点: 实现简单,无需复杂的平衡操作;支持范围查询;并发性能好,适合无锁编程。
  • 缺点: 空间开销较大(多层索引指针);查找性能不如 B+ 树稳定(概率性)。
  • 典型应用: Redis 的 ZSET(有序集合)底层实现、LevelDB 的 MemTable。
选型避坑: 不要盲目追求 O(1) 的哈希表。如果你的缓存场景需要频繁进行范围查询(如排行榜、时间窗口数据),哈希表会导致全量扫描,此时 B+ 树或跳表才是正确选择。反之,如果只有等值查询,哈希表的内存效率和速度都优于树形结构。

2.4 三种索引结构的对比总结

特性 哈希表 B+ 树 跳表
等值查询 O(1) O(log n) O(log n)
范围查询 不支持 O(log n + k) O(log n + k)
写操作 O(1) 平均 O(log n) O(log n) 概率
内存开销 低 中 高
并发友好 中(需锁桶) 低(需锁树) 高(无锁实现)
实现复杂度 低 高 中

2.5 索引结构对比示意图

下图展示了三种索引结构在数据组织方式上的核心差异,帮助理解各自的适用场景。

三种缓存索引存储结构对比 哈希表 O(1) 等值查询 Key1 → Hash Key2 → Hash 桶 0: Val1 桶 1: Val2 B+ 树 O(log n) 范围查询 50 90 10 30 70 100 跳表 O(log n) 概率平衡 L2 L2 L2 L1 L1 L1 L0 L0 L0 说明:哈希表适合等值查询,B+ 树和跳表支持范围查询;跳表实现简单且并发友好,B+ 树磁盘 I/O 更优。

2.6 索引选择与缓存一致性治理的关系

索引结构的选择直接影响缓存一致性治理的复杂度:

  • 哈希表: 失效策略简单,只需删除或更新对应键即可。但无法高效处理“按条件批量失效”的场景。
  • B+ 树: 支持范围失效(如删除某个时间范围内的所有缓存),但树结构的更新需要额外的锁机制保证一致性。
  • 跳表: 在并发场景下更容易实现无锁更新,适合高写入的缓存系统。但多层索引的同步失效需要精心设计。
最佳实践:
  1. 优先使用主键索引(哈希表)作为缓存的核心访问路径,保证 O(1) 的查询性能。
  2. 仅在需要范围查询或排序时引入二级索引(B+ 树或跳表),并严格控制二级索引的数量。
  3. 在缓存一致性治理中,为每个二级索引建立“反向映射表”,记录哪些主键受该二级索引影响,以便在数据更新时精准失效。

2.7 代码示例:Redis 跳表索引实现(简化)

以下代码展示了 Redis 中 ZSET 的跳表索引核心逻辑,用于理解跳表在缓存中的实际应用。

// 跳表节点结构
typedef struct zskiplistNode {
    sds ele;                    // 存储的元素值
    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;

// 插入操作(简化)
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL];
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    // 1. 从顶层开始查找插入位置
    // 2. 记录每层需要更新的节点
    // 3. 随机生成新节点的层数
    // 4. 更新各层指针和跨度
    // 5. 更新后退指针
    return newNode;
}
性能调优提示: 在实现自定义缓存索引时,建议将索引结构与数据存储分离。例如,使用哈希表存储实际数据,使用跳表或 B+ 树存储索引元数据(如主键列表)。这样可以在不影响主键查询性能的前提下,灵活支持二级索引的失效和重建。