Redis常用数据结构以及多并发场景下的使用分析:Sorted List类型
文章目录
- 前言
- redis中的 Sorted List结构
- 元素少: 压缩列表(ziplist)
- 元素多: 跳跃表(skiplist) + 哈希表
- 疑问1: 为什么时间复杂度是O(log n)?
- 疑问2: 时间复杂度为O(log n)的数据结构 很多 什么使用跳表实现?
- 疑问3: 跳表最高层=32 是什么意思?
- 疑问4: 跳表如何维护高层稀疏 (起到索引的功能呢)
- 疑问5: 为什么每次插入一个实际的值(底层),还要根据概率去更新高层的索引?
- 疑问6: 生成 3 层 → 同时在 Level 1、2、3 出现,维护指针。 为什么需要同时 Level 123 都需要新插入 而不是只在level3?
- ZSet 结构总结
- Redis Sorted Set 操作汇总 + 跳表原理分析
- 使用场景
- 基于 Redis Sorted Set 的排行榜系统
- Top-K 点赞排行榜
- redis中Sort List 和hash 类型对比
- 总结:
前言
我们已经分析了四种不同数据类型 在redis中的使用 以及底层的数据结构
Redis常用数据结构以及多并发场景下的使用分析:String类型
Redis常用数据结构以及多并发场景下的使用分析:Hash类型
Redis常用数据结构以及多并发场景下的使用分析:List类型
Redis常用数据结构以及多并发场景下的使用分析:Set类型
接下来 我们要来分析下 Sorted List的基本使用 这一节我会觉得比较难 因为涉及到skiplist 跳表的理解 以及基于跳表的结构 redis支持的操作 跟紧我 上车 !
redis中的 Sorted List结构
底层实现:
元素少: 压缩列表(ziplist)
元素多: 跳跃表(skiplist) + 哈希表
特点: 有序且唯一
每个元素关联score
支持范围查询
首先 分析每一个数据结构的时候 都要抓紧它的特点 有序 唯一 支持范围查询 我们首先来分析元素少的时候的基本实现
元素少: 压缩列表(ziplist)
为了在数据量小、结构简单时节省内存,Redis 在 ZSet 初始阶段使用压缩列表 ziplist 实现,而不是跳跃表 + 哈希表
触发升级的两个配置阈值:
Redis 会根据如下两个条件决定是否使用 ziplist:
zset-max-ziplist-entries 128 # 最大 entry 数
zset-max-ziplist-value 64 # 每个元素最大字节数
如果超过任一阈值,就会自动升级为:
跳跃表(用于排序、范围、分页)+
哈希表(用于快速查找 value → score)
同样为什么采用zipList 很好分析
也是为了 **【连续内存能利用缓存】**的特性 和 我在 list类型中的分析一样
Redis常用数据结构以及多并发场景下的使用分析:List类型
也就是说此时 插入删除 范围查找查找其实都是O(n) 【为什么呢? 顺序遍历所有元素】 但是由于元素很少 所以还是很快
最终zipList的样子是这样的
[score1][member1][score2][member2][score3][member3]...(升序排列)
元素多: 跳跃表(skiplist) + 哈希表
首先你要了解Sort List 维护了两个数据结构 跳跃表(skiplist) + 哈希表
插入一条数据(score,member)
哈希表快速根据 member 查找score O(1)
skiplist按 score 排序,支持范围查找 O(log n)
这种“双结构”保证了:
查找、更新快(通过 哈希表)
排序、范围查询快(通过跳表)
这是一个简单的理解 即为什么需要双结构
【哈希表的查找快 + 跳表的 排序 范围查询】组合 维护 Sorted List的【有序且唯一 支持范围查询 】
首先给出一个简单的插入过程
来了一个数据 (score,member)
dict 记录:member -> score
skiplist 插入:(score, member),按 score 排序,有序链表。
数据结构 | 内容 | 作用 |
---|---|---|
① dict | member → score 哈希表 | 用于 O(1) 快速查找一个 member 的 score |
② skiplist | (score, member) 有序跳表节点 | 用于根据 score 排序、范围查找、Top-N 等操作 |
okokok 到了重头戏 什么是跳表? 首先建议大家去YT看具体的数据结构讲解 然后再来看我的文章。因为图像化的讲解更为直观。
为了 直接让你理解 我用GPT生成了一个数据插入过程:
跳表插入全过程演示(从空跳表开始) 我们设定:
最大层数:32
概率因子 p = 0.5(为了更清晰讲解,实际 Redis 中是 0.25)
要插入的数据:[10, 20, 30, 25, 40]
每次插入我们都用固定“随机层数”,方便你理解结构变化
Step 1:插入 10(生成层数 1)
Level 1: ——> 10
10 是第一个节点,层数是 1,仅出现在最底层。
Step 2:插入 20(生成层数 2)
Level 2: ——> 20
Level 1: ——> 10 ——> 20
20 的层数为 2,出现在第 2 层和第 1 层。
Step 3:插入 30(生成层数 3)
Level 3: ——> 30
Level 2: ——> 20 ——> 30
Level 1: ——> 10 ——> 20 ——> 30
30 出现在 Level 3、2、1
Step 4:插入 25(生成层数 1)
Level 3: ——> 30
Level 2: ——> 20 ——> 30
Level 1: ——> 10 ——> 20 ——> 25 ——> 30
25 插入在 Level 1 中,介于 20 和 30 之间。
查找路径:
Level 3:20 < 25 → 不能走,降到 Level 2;
Level 2:20 < 25 → 不能走,降到 Level 1;
Level 1:20 → 插入在后面。
Step 5:插入 40(生成层数 1)
Level 3: ——> 30
Level 2: ——> 20 ——> 30
Level 1: ——> 10 ——> 20 ——> 25 ——> 30 ——> 40
40 插入在 Level 1 最末尾。
插入过程只更新 Level 1 指针。
最终跳表结构(简化版)
Level 3: ——> 30
Level 2: ——> 20 ——> 30
Level 1: ——> 10 ——> 20 ——> 25 ——> 30 ——> 40
越高层越稀疏 → 快速定位
越底层越完整 → 遍历所有元素
我这里写的不好 建议你去参考别的文章获得视频 去理解跳表
你可以想象为 最底层Level 1 是连续的数据
Level 1: ——> 10 ——> 20 ——> 25 ——> 30 ——> 40
然后上面的层数都是索引 最高层的索引很稀疏 然后每次要查找 \删除元素 都是从最高处的高层索引一路走到底层Level1 去获取数据
所以跳表的时间复杂度是O(log n)
疑问1: 为什么时间复杂度是O(log n)?
以查找为例:
从最顶层往右走,直到当前节点比目标值大(或到底)。
然后往下一层,继续向右找。
直到到达底层 Level 1,找到目标。
由于每层只需要“局部移动”,而总层数为 O(log n),所以总搜索路径也是 O(log n)。
疑问2: 时间复杂度为O(log n)的数据结构 很多 什么使用跳表实现?
特性 | 跳表(SkipList) | AVL / 红黑树等平衡树 |
---|---|---|
插入/删除复杂度 | 平均 O(log n) | 最坏 O(log n)(需旋转) |
插入/删除实现 | 简单,无需平衡调整 | 复杂,需要重平衡/旋转等操作 |
多范围查询效率 | 高效,天然有序支持区间 | 较复杂,需中序遍历或递归 |
并发扩展性 | 好(结构相对独立) | 较差(指针修改涉及大量旋转) |
实现代码 | 简单,基于随机索引 | 复杂,要维持平衡性和颜色等属性 |
Redis 中表现 | 插入/删除/范围查找快 | 不适合 Redis 的高性能需求 |
跳表没有旋转、没有严格的“平衡要求”,但性能几乎不输红黑树,反而更好实现、更稳定。
疑问3: 跳表最高层=32 是什么意思?
高层的索引最多不会超过 32 层(由 Redis 限制)
疑问4: 跳表如何维护高层稀疏 (起到索引的功能呢)
假设我们现在要往跳表中插入一个值,比如 42,那么 Redis 会调用一个函数 randomLevel(),这个函数的作用就是:
用一定的概率(如 0.25)决定这个节点的层数
例如:
随机生成层数 | 含义 |
---|---|
Level 1 | 最底层,必然存在,100% 概率 |
Level 2 | 概率 25%,比 Level 1 少四分之三 |
Level 3 | 概率 6.25%,更少 |
Level 4 | 概率 1.56% |
… | 最多不会超过 32 层(由 Redis 限制) |
也就是说,越高层数出现的概率越低,使得跳表的整体结构近似于“金字塔”,上面稀疏,底下密集。
疑问5: 为什么每次插入一个实际的值(底层),还要根据概率去更新高层的索引?
因为跳表本质上就是**“多层索引结构的链表”**。每插入一个节点,如果只插到底层,它的查找效率就和普通链表一样是
O(n),所以我们通过「概率方式提升索引层」来优化查找性能,使它达到 O(log n)。
随机生成层数(随机索引高度)
当插入一个新节点时,会根据概率(默认 Redis 为 p=0.25)随机生成这个节点在跳表中会出现的层级数。
例如:生成 1 层 → 只参与最底层;生成 3 层 → 同时在 Level 1、2、3 出现,维护指针。插入位置查找从跳表最高层开始查找合适位置;如果该节点出现在第 k 层,就在每一层维护一个“前驱节点”,并更新对应指针。结构更新低层插入的是数据节点;高层插入的是索引指针(shortcut),相当于对底层的加速导航。
疑问6: 生成 3 层 → 同时在 Level 1、2、3 出现,维护指针。 为什么需要同时 Level 123 都需要新插入 而不是只在level3?
因为:1. 跳表的高层依赖低层
跳表查找是从顶层逐层向下“跳”的。如果节点只出现在 Level 3,而不在 Level 2 或 Level 1,那么当你从 Level 2 跳下来时,你根本找不到这个节点!所以为了保持层级间的一致性与可达性,节点必须从 Level 1 到 Level k 连续存在。2. 保持“路径可达性”
跳表类似一个“电梯楼”:
Level 3: ——> X
Level 2: ——> X
Level 1: ——> X
如果只让 X 出现在 Level 3,那你从 Level 2 或 Level 1 根本“看不到”它,就不能访问它。所以它必须同时出现在 Level 1、2、3,形成“可逐层访问”的跳表路径。
ZSet 结构总结
Redis 中的 ZSet 是组合结构
dict<string, score>:快速查元素是否存在
skiplist<score, string>:支持有序范围查找
两者结合,既保证了:
O(1) 的单元素访问(dict)
O(log n) 的有序查询(skiplist)
Redis Sorted Set 操作汇总 + 跳表原理分析
Redis 命令 | 操作类别 | 跳表是否参与 | 跳表的作用 / 结构维护 | 时间复杂度 |
---|---|---|---|---|
ZADD | 添加成员 | 是 | 插入节点到 skiplist,并按 score 确定位置,同时也加入 dict | O(log N) |
ZREM | 删除成员 | 是 | 删除 skiplist 中对应节点,同时从 dict 中移除 | O(log N) |
ZSCORE | 查分数 | 否 | 只查 dict(key→score 映射) | O(1) |
ZRANK | 排名查找 | 是 | 在 skiplist 中累加 span 获取 rank | O(log N) |
ZREVRANK | 反向排名 | 是 | 转换为 ZCARD - 1 - ZRANK | O(log N) |
ZRANGE | 范围查找 | 是 | 按 rank 从 skiplist 中顺序访问节点 | O(log N + M) (M为结果数) |
ZREVRANGE | 反向范围 | 是 | 类似,但跳表只支持正向,所以倒序遍历前要倒排 rank | O(log N + M) |
ZINCRBY | 分数加减 | 是 | 原节点删除后重新按新 score 插入跳表 | O(log N) |
ZCARD | 元素总数 | 否 | 直接读取 dict 长度 | O(1) |
ZCOUNT | 分数范围 | 是 | 从跳表中按 score 定位后遍历范围 | O(log N + M) |
ZRANGEBYSCORE | 分数区间取值 | 是 | 跳表从首个 ≥min 的 score 开始遍历直到 >max | O(log N + M) |
ZREMRANGEBYSCORE | 删除区间 | 是 | 跳表按 score 范围批量删除 | O(log N + M) |
ZREMRANGEBYRANK | 删除排名区间 | 是 | 按 rank 定位并遍历删除 | O(log N + M) |
跳表与 Dict 的协作机制
Dict(哈希表):用于通过成员名快速定位 score(ZSCORE 用它)
SkipList(跳表):用于:
排序操作(如:排名、范围、区间删除)
排名计算(span)
范围遍历
二者同时维护,插入/删除时需同步操作。
使用场景
基于 Redis Sorted Set 的排行榜系统
排行榜结构在 Redis 中的存储
Key: leaderboard:game123
Type: Sorted Set (ZSet)
Data:user_100 → 999user_200 → 879user_300 → 861...内部维护:dict<String userId, double score> 做快速定位skiplist 做范围查询 & 有序排名支持
@Service
public class RedisSortedSetService {@Autowiredprivate RedisTemplate<String, String> redisTemplate;private ZSetOperations<String, String> zSetOps() {return redisTemplate.opsForZSet();}// ===== 场景1: 排行榜系统 =====/*** 更新用户积分 - 支持并发*/public void updateUserScore(String leaderboard, String userId, double score) {String key = "leaderboard:" + leaderboard;// ZINCRBY 原子性增加分数zSetOps().incrementScore(key, userId, score);// 设置过期时间redisTemplate.expire(key, 30, TimeUnit.DAYS);System.out.println("用户 " + userId + " 在 " + leaderboard + " 增加了 " + score + " 分");}/*** 设置用户积分*/public void setUserScore(String leaderboard, String userId, double score) {String key = "leaderboard:" + leaderboard;zSetOps().add(key, userId, score);redisTemplate.expire(key, 30, TimeUnit.DAYS);}/*** 获取排行榜前N名 (从高到低)*/public Set<ZSetOperations.TypedTuple<String>> getTopRanking(String leaderboard, int count) {String key = "leaderboard:" + leaderboard;// ZREVRANGE - 从高分到低分return zSetOps().reverseRangeWithScores(key, 0, count - 1);}/*** 获取用户排名 (1-based)*/public Long getUserRank(String leaderboard, String userId) {String key = "leaderboard:" + leaderboard;// ZREVRANK - 倒序排名(分数从高到低)Long rank = zSetOps().reverseRank(key, userId);return rank != null ? rank + 1 : null; // 转换为1-based排名}/*** 获取用户分数*/public Double getUserScore(String leaderboard, String userId) {String key = "leaderboard:" + leaderboard;return zSetOps().score(key, userId);}/*** 获取指定分数范围的用户*/public Set<String> getUsersByScoreRange(String leaderboard, double min, double max) {String key = "leaderboard:" + leaderboard;return zSetOps().rangeByScore(key, min, max);}/*** 获取用户周围的排名*/public Set<ZSetOperations.TypedTuple<String>> getUserNeighborRanking(String leaderboard, String userId, int range) {String key = "leaderboard:" + leaderboard;Long rank = zSetOps().reverseRank(key, userId);if (rank == null) return Set.of();long start = Math.max(0, rank - range);long end = rank + range;return zSetOps().reverseRangeWithScores(key, start, end);}
}
Top-K 点赞排行榜
ZSet Key | 成员(value) | 分数(score) |
---|---|---|
article:like:zset | 文章 ID / 用户 ID / 视频 ID | 点赞数量(整数) |
获取 Top-K 点赞最多的文章
Set<String> topArticles = redisTemplate.opsForZSet().reverseRange("article:like:zset", 0, K - 1);
对应 Redis 命令:
ZREVRANGE article:like:zset 0 k
redis中Sort List 和hash 类型对比
注意 两个背后都是hashtable 哟
ZSet = HashTable + SkipList,支持“排序 + 范围/排名操作”
Hash = HashTable only,仅支持“键值映射、高效点查”
数据结构差异:
项目 | ZSet(有序集合) | Hash(哈希) |
---|---|---|
底层结构 | 哈希表 + 跳表(skiplist) | 哈希表(dict) |
是否有序 | 按 score 有序 | 无顺序 |
支持排名/TopK | 支持 ZRANK/ZREVRANGE 等 | 不支持 |
范围查找(score) | 支持(ZREMRANGEBYSCORE) | 不支持 |
字段层级 | 一个 key → 多个 member:value:score | 一个 key → 多个 field:value |
常见使用场景对比:
场景 | 推荐数据结构 | 原因 |
---|---|---|
排行榜 / Top-K 查询 | ZSet | 需要按 score 排序、分页、排名 |
点赞统计/积分记录 | ZSet | 用户、文章、积分等可排序统计 |
用户行为日志(按时间排序) | ZSet | 用时间戳作为 score,可分页回查 |
用户点赞行为是否存在(快速判断) | Hash | 查询效率高,结构清晰 |
用户-文章点对点点赞记录 | Hash | 按用户 ID 分桶,value 存文章 ID |
Session、Token 信息存储 | Hash | 类似 Map,用途单一、易更新 |
配置数据、用户属性等KV结构 | Hash | 类似 JSON,对象属性读写方便 |
ZSet = 需要排序/Top-K 查询时用
Hash = 需要高并发读写、字段存储时用
ZSet 用于**“谁的点赞最多?”**
Hash 用于**“这个用户点赞了哪些文章?”**
总结:
业务场景 | 是否推荐用 ZSet | 推荐理由 |
---|---|---|
排行榜 Top-K | 强烈推荐 | 自动排序,分页效率高 |
积分累计 | 推荐 | 支持原子加分,便于统计 |
点赞记录(总数统计) | 推荐 | 用 score 累计点赞次数 |
用户是否点赞 | 不推荐 | 用 Hash / Set 更合适 |
用户点赞详情 | 不推荐 | Hash or Set 更轻量 |
热门内容推荐 | 推荐 | 按权重实时排序取 Top-N |
延时任务队列 | 推荐 | 分数做时间戳,高效到时拉取 |
用户行为日志 | 推荐 | 分数是时间戳,便于范围删查 |
秒杀活动 | 不推荐 | 不适合极高并发下写操作(写是 O(log n)) |