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 索引结构对比示意图
下图展示了三种索引结构在数据组织方式上的核心差异,帮助理解各自的适用场景。
2.6 索引选择与缓存一致性治理的关系
索引结构的选择直接影响缓存一致性治理的复杂度:
- 哈希表: 失效策略简单,只需删除或更新对应键即可。但无法高效处理“按条件批量失效”的场景。
- B+ 树: 支持范围失效(如删除某个时间范围内的所有缓存),但树结构的更新需要额外的锁机制保证一致性。
- 跳表: 在并发场景下更容易实现无锁更新,适合高写入的缓存系统。但多层索引的同步失效需要精心设计。
最佳实践:
- 优先使用主键索引(哈希表)作为缓存的核心访问路径,保证 O(1) 的查询性能。
- 仅在需要范围查询或排序时引入二级索引(B+ 树或跳表),并严格控制二级索引的数量。
- 在缓存一致性治理中,为每个二级索引建立“反向映射表”,记录哪些主键受该二级索引影响,以便在数据更新时精准失效。
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+ 树存储索引元数据(如主键列表)。这样可以在不影响主键查询性能的前提下,灵活支持二级索引的失效和重建。