排行榜为何同时需要跳表和哈希表
Redis 有序集合既要按成员查分数,也要按分数和名次遍历;较大集合通过两种索引服务这些访问方式。
相关概念
工程问题#
单独使用哈希表无法高效输出有序区间;仅有有序结构又不便于直接按成员找到旧分数。
实际实现#
跳表按分数、成员排序,层级跨度支持排名;字典按成员定位分数,两者共享成员字符串。更新分数时必须维护两份索引。
处理流程#
- 按成员查字典确认是否存在。
- 插入或调整跳表中的有序位置。
- 同步字典中的分数引用。
- 范围查询定位起点后顺序遍历。
源码片段#
固定版本 · 原文摘录src/t_zset.c7.2.0 · L1467–L1487
1467 /* Remove and re-insert when score changes. */1468 if (score != curscore) {1469 znode = zslUpdateScore(zs->zsl,curscore,ele,score);1470 /* Note that we did not removed the original element from1471 * the hash table representing the sorted set, so we just1472 * update the score. */1473 dictSetVal(zs->dict, de, &znode->score); /* Update score ptr. */1474 *out_flags |= ZADD_OUT_UPDATED;1475 }1476 return 1;1477 } else if (!xx) {1478 ele = sdsdup(ele);1479 znode = zslInsert(zs->zsl,score,ele);1480 serverAssert(dictAdd(zs->dict,ele,&znode->score) == DICT_OK);1481 *out_flags |= ZADD_OUT_ADDED;1482 if (newscore) *newscore = score;1483 return 1;1484 } else {1485 *out_flags |= ZADD_OUT_NOP;1486 return 1;1487 }zsetAdd
已有成员改分后,字典保存的新值仍指向跳表节点的 score;新增成员先插入跳表,再把相同成员字符串和该 score 地址加入字典。两种索引因此共享成员存储,但必须同步维护。
只展示 OBJ_ENCODING_SKIPLIST 分支;listpack 条件和转换逻辑在前文。zslUpdateScore 内部可原位更新,并非每次改分都删除重插。
收益与取舍#
工程分析带来的收益
- 兼顾成员点查与有序范围查询。
- 共享字符串减少重复存储。
付出的代价
- 双索引及多层指针消耗额外内存。
- 跳表对数性能是期望性质,并非最坏情况保证。
可比较的选择
listpack小集合紧凑省内存,但查找和插入需要更多线性工作。
版本与适用范围#
限 Redis 7.2.0。小集合可采用 listpack;元素数量、成员长度超过配置阈值,或不满足安全追加条件时,才转换为跳表编码。
参考来源#
源码、官方文档与原始论文相关实现与比较#
4 个相关案例QEMU TCG 用 QHT 保存 TranslationBlock 索引,帮助复用与当前客体执行状态一致的翻译结果。
附源码片段 · 缓存与复用
libvirt 为 Domain 对象维护 UUID 与名称两个哈希索引,两个入口指向同一个对象。
附源码片段 · 索引与查找
把上下文身份编码进块哈希,让重复请求共享已经计算的 KV cache。
附源码片段 · 缓存与复用
Redis 7.2.0 的字典扩缩容保留新旧两张哈希表,逐桶迁移,降低一次性搬迁全部条目的停顿。
附源码片段 · 索引与查找