作者:没有四次元口袋的蓝胖
日期:2026-10-13
标签:Redis, List, Set, ZSet, 排行榜
Redis知识梳理(3)
写在前面
上一篇我们搞定了 String 和 Hash 的命令。本篇继续:把剩下的三种类型------List、Set、ZSet 过一遍。
这三种类型各有特色:
- List:有序、可重复、支持两端操作,天然适合队列
- Set:无序、不重复,天然去重,能做集合运算
- ZSet :有序集合,每个元素带分数,排行榜的绝对王者
面试中,ZSet 的排行榜场景 和底层跳表结构是高频考点,必须讲透。
一、List 类型详解
1.1 List 是什么
List(列表)是一个有序的字符串列表 ,可以包含重复元素,支持从两端插入和弹出。
类比:
- Java 的
LinkedList - 排队购物:队头、队尾都能加人
- 微信消息列表:最新的在顶部
1.2 基本命令
bash
# 从左边(头部)插入
LPUSH list:a b c # 按顺序插入 c, b, a(最左是a)
# 列表变成 [a, b, c]
# 从右边(尾部)插入
RPUSH list:a b c # 按顺序插入 a, b, c(最右是c)
# 列表变成 [a, b, c]
# 从左边(头部)弹出
LPOP list # 返回 "a",并从列表移除
# 列表变成 [b, c]
# 从右边(尾部)弹出
RPOP list # 返回 "c"
# 列表变成 [b]
# 按范围查看(LRANGE)
LRANGE list 0 -1 # 查看所有元素,-1 表示最后一个
# 按索引获取单个元素
LINDEX list 0 # 获取第0个元素
# 按索引范围截取
LRANGE list 0 2 # 取第0到第2个
# 查看长度
LLEN list
# 按值删除元素(count>0从左边删,<0从右边删,0删所有)
LREM list 2 "a" # 从左边删除 2 个 "a"
# 设置指定索引的值
LSET list 0 "new_value"
# 截取列表,只保留指定范围
LTRIM list 0 9 # 只保留第0到第9个元素
1.3 两种队列用法
FIFO 队列(先进先出):
bash
RPUSH queue task1 task2 # 任务从右边进
LPOP queue # 任务从左边出
LIFO 栈(后进先出):
bash
LPUSH stack a b c # 从左边压栈
LPOP stack # 从左边弹栈
RPOPLPUSH:从一个列表尾部弹,压到另一个列表头部(原子操作)
bash
# 把 source 尾部元素移到 dest 头部(原子操作,常用于消息队列)
RPOPLPUSH source dest
1.4 阻塞操作:BLPOP / BRPOP
bash
# 如果列表为空,会一直阻塞等待(最多等30秒)
BLPOP list 30
BRPOP list 30
# 阻塞并移动
BRPOPLPUSH source dest 30
这是实现"消息队列"的关键命令------消费者可以"挂起"等待新消息,有新消息就立刻处理,不会空轮询。
1.5 List 底层:quicklist
List → quicklist = 多个 ziplist + 双向链表
┌─────────┐ ┌─────────┐ ┌─────────┐
│ziplist │──▶│ziplist │──▶│ziplist │
│ [a,b,c] │◀──│ [d,e,f] │◀──│ [g,h,i] │
└─────────┘ └─────────┘ └─────────┘
每个 ziplist 装几个元素(默认8192字节或64个),双向链表串联它们。这样:
- 两端操作高效
- 内存相对紧凑
- 中间访问也能索引(虽然不如数组快)
1.6 List 典型场景
| 场景 | 实现 |
|---|---|
| 消息队列 | LPUSH + BRPOP / RPOPLPUSH |
| 文章/消息最新列表 | LPUSH + LTRIM 截取 |
| 时间线/Timeline | LPUSH 最新在最前 |
| 弹幕系统 | LPUSH + LRANGE 获取最近N条 |
💡 Redis 5.0 之后引入了 Stream 类型做更专业的消息队列,但 List 依然是经典实现。
二、Set 类型详解
2.1 Set 是什么
Set(集合)是一个无序、不重复的字符串集合。天然去重。
类比:
- Java 的
HashSet - 一群互不相同的苹果
- "共同好友"、"抽奖候选人"
2.2 基本命令
bash
# 添加一个或多个成员
SADD set1 a b c
# 移除
SREM set1 b
# 判断成员是否存在(O(1))
SISMEMBER set1 a # 1存在 0不存在
# 返回所有成员(无序)
SMEMBERS set1 # ["a", "c"]
# 随机返回一个成员(不删除)
SRANDMEMBER set1
# 随机返回并删除一个成员(抽奖神器)
SPOP set1
# 查看集合大小
SCARD set1 # 2
2.3 集合运算(Set 的杀手锏)
Set 支持两个集合之间的交集、并集、差集运算:
bash
# 两个集合
SADD s1 a b c d
SADD s2 c d e f
# 交集(共同的元素)
SINTER s1 s2 # ["c", "d"]
# 并集(所有元素的合集,去重)
SUNION s1 s2 # ["a", "b", "c", "d", "e", "f"]
# 差集(s1有但s2没有的)
SDIFF s1 s2 # ["a", "b"]
SDIFF s2 s1 # ["e", "f"]
# 交集存入新集合
SINTERSTORE result s1 s2
# 并集存入新集合
SUNIONSTORE result s1 s2
# 差集存入新集合
SDIFFSTORE result s1 s2
2.4 Set 典型场景
| 场景 | 实现 |
|---|---|
| 去重 | SADD(重复的加不进去) |
| 抽奖 | SPOP(随机弹出一个) |
| 共同好友 | SINTER(两用户好友集合取交集) |
| 点赞/收藏集合 | SADD 用户ID,SISMEMBER 判断是否点过 |
| 标签系统 | SADD 标签,SINTER 找有共同标签的 |
| 随机推荐 | SRANDMEMBER |
| 黑白名单 | SADD 白名单,SISMEMBER 判断是否允许 |
共同好友案例:
bash
# 用户1001的好友
SADD user:1001:friends 张三 李四 王五
# 用户1002的好友
SADD user:1002:friends 李四 王五 赵六
# 计算共同好友
SINTER user:1001:friends user:1002:friends # ["李四", "王五"]
2.5 Set 底层结构
全整数且元素少 (默认512以内) → intset(整数集合) ,内存紧凑
否则 → hashtable(value 为 null,靠 key 去重)
2.6 常见坑点
坑:SMEMBERS 遍历巨大集合
当集合有百万级成员时,SMEMBERS 会一次性返回所有,阻塞 Redis。
用 SSCAN 游标分批扫描更安全。
三、ZSet 类型详解(重点!)
3.1 ZSet 是什么
ZSet(Sorted Set,有序集合)是 Set 的升级版------每个元素关联一个分数(score) ,Redis 按分数从小到大排序。
类比:
- Java 的
TreeSet+ 分数 - 一份成绩单:张三95分、李四88分、王五90分 → 按分数排序
- 一份"排行榜"
ZSet 的核心优势:能高效地按范围/分数查询,同时天然去重。
3.2 基本命令
bash
# 添加元素(带分数)
ZADD zset1 100 a 200 b 50 c
# 添加三个元素:a(100分) b(200分) c(50分)
# 按分数升序查看
ZRANGE zset1 0 -1 # [c(50), a(100), b(200)]
# 按分数降序查看
ZREVRANGE zset1 0 -1 # [b(200), a(100), c(50)]
# 带分数返回
ZRANGE zset1 0 -1 WITHSCORES
# 返回 [c 50, a 100, b 200]
# 按分数范围查询(升序)
ZRANGEBYSCORE zset1 0 150 # [c(50), a(100)]
# 按分数范围查询(降序)
ZREVRANGEBYSCORE zset1 150 0 # [a(100), c(50)]
# 按排名查询(从0开始,升序)
ZRANGE zset1 0 2 # 排名0到2
# 按排名查询(降序)
ZREVRANGE zset1 0 2 # 最高分前3名
# 修改分数(累加)
ZINCRBY zset1 50 c # c 的分数 50→100
# 获取元素的分数
ZSCORE zset1 a # 100
# 获取元素的排名(从0开始,升序)
ZRANK zset1 a # 1
# 获取排名(降序)
ZREVRANK zset1 a # 1
# 统计分数范围内的元素数量
ZCOUNT zset1 0 150 # 2
# 查看元素数量
ZCARD zset1 # 3
# 删除元素
ZREM zset1 c
# 按排名范围删除
ZREMRANGEBYRANK zset1 0 0 # 删除排名最低的
# 按分数范围删除
ZREMRANGEBYSCORE zset1 0 100 # 删除分数0~100的
3.3 ZSet 排行榜实战(面试核心)
场景:实现一个游戏实时排行榜
bash
# 玩家注册/登录,加入排行榜
ZADD game:rank 0 "player:1001"
ZADD game:rank 0 "player:1002"
ZADD game:rank 0 "player:1003"
# 玩家获得分数(累加)
ZINCRBY game:rank 150 "player:1001" # 1001 加 150 分
ZINCRBY game:rank 200 "player:1002"
ZINCRBY game:rank 100 "player:1003"
# 查看全球前10名(降序)
ZREVRANGE game:rank 0 9 WITHSCORES
# 查看某个玩家的分数
ZSCORE game:rank "player:1001"
# 查看某个玩家的排名(降序,第0名是第一名)
ZREVRANK game:rank "player:1001"
# 查看分数区间内的玩家
ZRANGEBYSCORE game:rank 100 300 WITHSCORES
# 清理低分玩家(比如删除分数<50的)
ZREMRANGEBYSCORE game:rank -inf 50
Java 代码示例:
java
// 玩家加分
public void addScore(long userId, long score) {
String key = "game:rank";
redisTemplate.opsForZSet().incrementScore(key, "player:" + userId, score);
}
// 获取前N名
public Set<ZSetOperations.TypedTuple<String>> getTopN(long n) {
return redisTemplate.opsForZSet().reverseRangeWithScores("game:rank", 0, n - 1);
}
// 获取某玩家排名
public Long getRank(long userId) {
Long rank = redisTemplate.opsForZSet().reverseRank("game:rank", "player:" + userId);
return rank == null ? null : rank + 1; // +1 让排名从1开始
}
3.4 同分排序问题
坑: 两个玩家同分,怎么按时间排序?
方案: 把分数拼成一个复合分数:
实际分数 = 原始分数 * 10^13 + (时间戳倒数)
比如:
- 玩家1: 100分,在时间戳 1720000000000 → 100*10^13 + (9999999999999 - 1720000000000) = 1000000000000000 + 8279999999999
- 玩家2: 100分,在时间戳 1720000001000 → 100*10^13 + (9999999999999 - 1720000100000)
这样玩家1分数更大,排前面。
3.5 ZSet 底层结构(面试必问)
小数据(默认<128 元素,且值<64 字节):
- 用 ziplist(压缩列表)
- 元素按分数从小到大排列
大数据:
- 用 skiplist(跳表)+ hashtable
- 跳表负责有序遍历和范围查询
- hashtable 负责 O(1) 查分数
3.5.1 跳表(Skip List)详解
跳表是 ZSet 的核心数据结构,面试必问。
跳表结构示意:
Level 3: 1 ──────────────────────────────────────▶ 9
Level 2: 1 ────────▶ 3 ────────▶ 5 ────────▶ 9
Level 1: 1 ─▶ 2 ─▶ 3 ─▶ 4 ─▶ 5 ─▶ 6 ─▶ 7 ─▶ 8 ─▶ 9
跳表的核心思想:
- 底层(Level 1)是一个完整有序链表
- 往上每一层随机"抽取"一部分节点作为"快速通道"
- 查询时从最高层开始,遇到比目标大的就降一层继续往前找
为什么不用平衡树(如红黑树)?
| 对比 | 跳表 | 平衡树(红黑树) |
|---|---|---|
| 实现复杂度 | 简单 | 复杂(旋转、平衡维护) |
| 范围查询 | 快(直接遍历链表) | 较慢 |
| 并发友好 | 容易实现无锁 | 难 |
| 内存开销 | 每个节点维护多层指针,略高 | 相对较低 |
| 查找效率 | O(log n) | O(log n) |
为什么 Redis 选跳表?
- 实现简单,可读性强
- 范围查询效率高(找到起点后直接遍历底层链表)
- 更容易调参(通过修改层数概率调整性能)
🎯 面试高频:ZSet 为什么用跳表而不是红黑树?
核心三点:①实现简单;②范围查询高效;③内存友好(跳表每层节点随机保留)。
3.6 ZSet 性能分析
| 操作 | 时间复杂度 |
|---|---|
| ZADD | O(log N) |
| ZSCORE | O(1)(hashtable) |
| ZRANK | O(log N) |
| ZRANGEBYSCORE | O(log N + M)(M 是结果数) |
| ZINCRBY | O(log N) |
| ZCARD | O(1) |
| ZCOUNT | O(log N) |
四、List/Set/ZSet 对比总结
| 特性 | List | Set | ZSet |
|---|---|---|---|
| 有序性 | 有序(按插入) | 无序 | 有序(按分数) |
| 唯一性 | 可重复 | 不重复 | 不重复 |
| 主要操作 | 两端插入/弹出 | 集合运算 | 按分数范围查询 |
| 底层结构 | quicklist | intset/hashtable | ziplist/skiplist+hashtable |
| 典型场景 | 队列/最新消息 | 去重/共同好友 | 排行榜/延时队列 |
| 计数能力 | 无 | 无 | 分数可累加 |
| 复杂度 | 两端 O(1) | 添加/查询 O(1) | O(log N) |
思维导图速览
List/Set/ZSet 命令 + 排行榜实战
│
├── List(有序、可重复、两端操作)
│ ├── LPUSH/RPUSH/LPOP/RPOP/LRANGE
│ ├── LLEN/LINDEX/LREM/LSET/LTRIM
│ ├── BLPOP/BRPOP(阻塞)
│ ├── RPOPLPUSH(原子移动)
│ ├── 底层:quicklist
│ └── 场景:队列/最新消息/Timeline
│
├── Set(无序、不重复、集合运算)
│ ├── SADD/SREM/SISMEMBER/SMEMBERS
│ ├── SPOP/SRANDMEMBER
│ ├── SINTER/SUNION/SDIFF(交集并集差集)
│ ├── SSCAN(游标扫描)
│ ├── 底层:intset/hashtable
│ └── 场景:去重/抽奖/共同好友/点赞
│
├── ZSet(有序集合,按分数排序)⭐
│ ├── ZADD/ZRANGE/ZREVRANGE
│ ├── ZRANGEBYSCORE/ZINCRBY/ZSCORE
│ ├── ZRANK/ZREVRANK/ZCOUNT/ZCARD
│ ├── ZREM/ZREMRANGEBYSCORE
│ └── 底层:ziplist / skiplist+hashtable
│ ├── 跳表实现 O(log N) 查询
│ └── 范围查询高效
│
└── ZSet 排行榜实战
├── ZADD 初始化
├── ZINCRBY 加分
├── ZREVRANGE 取前N名
├── ZREVRANK 看玩家排名
└── 同分排序:复合分数方案
写在最后
三种类型里,ZSet 是面试的宠儿------不仅考命令,还考底层跳表和排行榜设计。
三个记忆锚点:
- List → 两端操作 + 队列
- Set → 去重 + 集合运算
- ZSet → 分数排序 + 排行榜 + 跳表
跳表是加分项,能把"跳表 vs 红黑树"讲清楚,面试直接加分。