Redis ZSet与SkipList跳表
Redis 的五种数据结构各有特色,用对了才能发挥它的优势。很多人只用到了 String 和 Hash,却不知道 List、Set、ZSet 在特定场景下更合适。本文从应用场景出发,讲什么时候用什么类型。
一、ZSet的编码方式
#
1.1 两种编码
| 编码 |
结构 |
适用条件 |
| ziplist |
压缩列表 |
元素少且值小 |
| skiplist |
跳表+哈希表 |
元素多或值大 |
#
1.2 编码转换条件
zset-max-ziplist-entries 128 # ziplist最多128个entry zset-max-ziplist-value 64 # 每个member最大64字节
|
转换规则:
- 元素数量<=128且每个member<=64字节:ziplist
- 任一条件不满足:skiplist
二、SkipList原理
#
2.1 为什么用SkipList
SkipList是一种概率平衡的数据结构:
| 数据结构 |
查找 |
插入 |
删除 |
范围查询 |
实现复杂度 |
| 数组 |
O(n) |
O(n) |
O(n) |
O(1)开始 |
低 |
| 链表 |
O(n) |
O(1) |
O(1) |
O(n) |
低 |
| 平衡树 |
O(log n) |
O(log n) |
O(log n) |
复杂 |
高 |
| SkipList |
O(log n) |
O(log n) |
O(log n) |
O(log n)开始 |
低 |
SkipList的优势:
- 实现简单(比红黑树简单得多)
- 范围查询友好(比树结构简单)
- 插入/删除不需要旋转平衡
- 性能接近平衡树
#
2.2 SkipList结构
Level 3: head ──────────────────────────> NULL
Level 2: head ──────────────> 30 ──────> NULL
Level 1: head ──────> 20 ──> 30 ──────> NULL
Level 0: head ──> 10 ──> 20 ──> 30 ──> 40 ──> 50 ──> NULL (1, "a") (2, "b") (3, "c") (4, "d") (5, "e") 每个节点有多个level的forward指针
|
#
2.3 Redis SkipList节点
typedef struct zskiplistNode { sds ele; double score; struct zskiplistNode *backward; struct zskiplistLevel { struct zskiplistNode *forward; unsigned long span; } level[]; } zskiplistNode;
typedef struct zskiplist { struct zskiplistNode *header, *tail; unsigned long length; int level; } zskiplist;
|
#
2.4 随机层数生成
int zslRandomLevel(void) { int level = 1; while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF)) level += 1; return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL; }
|
#
2.5 查找操作
查找score=35的节点:
Level 3: head ──────────────────────────> NULL │ 比较30<35,继续 ▼ Level 2: head ──────────────> 30 ───────> NULL │ 比较30<35,继续 ▼ Level 1: head ──────> 20 ──> 30 ────────> NULL │ 下一个NULL,下降 ▼ Level 0: ... ──> 30 ──> 40 ──> 50 ──> NULL │ │ 30<35 40>35 下降 找到位置(在30和40之间)
查找复杂度:O(log n)
|
#
2.6 插入操作
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) { zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x; unsigned int rank[ZSKIPLIST_MAXLEVEL]; int i, level; x = zsl->header; for (i = zsl->level-1; i >= 0; i--) { rank[i] = (i == zsl->level-1) ? 0 : rank[i+1]; while (x->level[i].forward && (x->level[i].forward->score < score || (x->level[i].forward->score == score && sdscmp(x->level[i].forward->ele, ele) < 0))) { rank[i] += x->level[i].span; x = x->level[i].forward; } update[i] = x; } level = zslRandomLevel(); if (level > zsl->level) { for (i = zsl->level; i < level; i++) { rank[i] = 0; update[i] = zsl->header; update[i]->level[i].span = zsl->length; } zsl->level = level; } x = zslCreateNode(level, score, ele); for (i = 0; i < level; i++) { x->level[i].forward = update[i]->level[i].forward; update[i]->level[i].forward = x; x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]); update[i]->level[i].span = (rank[0] - rank[i]) + 1; } for (i = level; i < zsl->level; i++) { update[i]->level[i].span++; } x->backward = (update[0] == zsl->header) ? NULL : update[0]; if (x->level[0].forward) x->level[0].forward->backward = x; else zsl->tail = x; zsl->length++; return x; }
|
#
2.7 span(跨度)的作用
span记录当前节点到forward节点之间跳过了多少个节点:
Level 2: head ──────────────────> 40 span=3 (跳过了3个level0节点)
Level 1: head ──────────> 30 ───> 40 span=2 span=1 Level 0: head ──> 10 ──> 20 ──> 30 ──> 40 span=1 span=1 span=1 span=1
获取排名: ZRANK key 40 = 1+1+1 = 3(从0开始)
|
三、哈希表的作用
#
3.1 为什么需要哈希表
typedef struct zset { dict *dict; zskiplist *zsl; } zset;
|
SkipList按score排序,但查找特定member需要O(n)。哈希表提供O(1)的member查找。
#
3.2 两种结构的配合
操作 使用结构 原因 ───────────────────────────────────────────── ZADD 两者都用 插入到跳表,更新哈希表 ZSCORE 哈希表 O(1)获取score ZRANK 跳表 按score排序 ZRANGE 跳表 范围查询 ZREM 两者都用 从两个结构中删除 ZINCRBY 两者都用 修改score ZCARD 跳表length O(1)
|
四、ZSet命令实现
#
4.1 ZADD
void zaddCommand(client *c) { robj *key = c->argv[1]; robj *zobj = lookupKeyWrite(c->db, key); if (zobj == NULL) { zobj = createZsetObject(); dbAdd(c->db, key, zobj); } int added = 0; for (int j = 2; j < c->argc; j += 2) { double score = strtod(c->argv[j]->ptr, NULL); sds ele = c->argv[j+1]->ptr; int retval = zsetAdd(zobj, score, ele, &added); } addReplyLongLong(c, added); }
|
#
4.2 ZRANK
void zrankCommand(client *c) { robj *zobj = lookupKeyReadOrReply(c, c->argv[1], shared.null[c->resp]); if (zobj == NULL || checkType(c, zobj, OBJ_ZSET)) return; double score; if (zsetScore(zobj, c->argv[2]->ptr, &score) == C_ERR) { addReplyNull(c); return; } long rank = zsetRank(zobj, c->argv[2]->ptr, score, 1); addReplyLongLong(c, rank); }
|
#
4.3 ZRANGE
void zrangeCommand(client *c) { robj *zobj = lookupKeyReadOrReply(c, c->argv[1], shared.emptyarray); if (zobj == NULL || checkType(c, zobj, OBJ_ZSET)) return; long start, end; getLongFromObjectOrReply(c, c->argv[2], &start, NULL); getLongFromObjectOrReply(c, c->argv[3], &end, NULL); long llen = zsetLength(zobj); if (start < 0) start = llen + start; if (end < 0) end = llen + end; zset *zs = zobj->ptr; zskiplistNode *ln = zs->zsl->header->level[0].forward; while (start-- && ln) ln = ln->level[0].forward; addReplyArrayLen(c, rangelen); while (rangelen-- && ln) { addReplyBulkCBuffer(c, ln->ele, sdslen(ln->ele)); if (withscores) addReplyDouble(c, ln->score); ln = ln->level[0].forward; } }
|
五、应用场景
#
5.1 排行榜
@Service public class LeaderboardService { private static final String LEADERBOARD = "leaderboard:weekly"; public void addScore(Long userId, double score) { redis.opsForZSet().incrementScore(LEADERBOARD, String.valueOf(userId), score); } public Set<ZSetOperations.TypedTuple<String>> getTop10() { return redis.opsForZSet().reverseRangeWithScores(LEADERBOARD, 0, 9); } public Long getRank(Long userId) { Long rank = redis.opsForZSet().reverseRank(LEADERBOARD, String.valueOf(userId)); return rank != null ? rank + 1 : null; } public Double getScore(Long userId) { return redis.opsForZSet().score(LEADERBOARD, String.valueOf(userId)); } public Set<ZSetOperations.TypedTuple<String>> getNearbyRank(Long userId) { Long rank = redis.opsForZSet().reverseRank(LEADERBOARD, String.valueOf(userId)); if (rank == null) return Collections.emptySet(); long start = Math.max(0, rank - 5); long end = rank + 5; return redis.opsForZSet().reverseRangeWithScores(LEADERBOARD, start, end); } public void resetLeaderboard() { redis.delete(LEADERBOARD); } }
|
#
5.2 延时队列
@Component public class DelayQueue { @Autowired private StringRedisTemplate redis; public void addDelayTask(String taskId, long delayMs) { long executeTime = System.currentTimeMillis() + delayMs; redis.opsForZSet().add("delay:queue", taskId, executeTime); } @Scheduled(fixedRate = 1000) public void consumeDelayTasks() { long now = System.currentTimeMillis(); Set<String> tasks = redis.opsForZSet() .rangeByScore("delay:queue", 0, now, 0, 100); for (String taskId : tasks) { Long removed = redis.opsForZSet().remove("delay:queue", taskId); if (removed != null && removed > 0) { processTask(taskId); } } } private void processTask(String taskId) { System.out.println("Processing task: " + taskId); } }
|
#
5.3 滑动窗口限流
@Component public class SlidingWindowRateLimiter { public boolean isAllowed(String userId, int maxRequests, long windowMs) { String key = "rate_limit:" + userId; long now = System.currentTimeMillis(); long windowStart = now - windowMs; redis.opsForZSet().removeRangeByScore(key, 0, windowStart); Long count = redis.opsForZSet().zCard(key); if (count != null && count >= maxRequests) { return false; } redis.opsForZSet().add(key, UUID.randomUUID().toString(), now); redis.expire(key, windowMs / 1000 + 1, TimeUnit.SECONDS); return true; } }
|
#
5.4 时间线/Feed流
@Service public class FeedService { public void postContent(Long userId, String contentId) { long timestamp = System.currentTimeMillis(); redis.opsForZSet().add("feed:user:" + userId, contentId, timestamp); redis.opsForZSet().removeRange("feed:user:" + userId, 0, -1001); } public Set<String> getFeed(Long userId, int page, int size) { long start = (long) page * size; long end = start + size - 1; return redis.opsForZSet().reverseRange("feed:user:" + userId, start, end); } public Set<String> getFeedByTimeRange(Long userId, long startTime, long endTime) { return redis.opsForZSet().reverseRangeByScore( "feed:user:" + userId, startTime, endTime); } }
|
六、性能优化
#
6.1 ZSet大小控制
redis.opsForZSet().removeRange("zset", 0, -1001);
redis.opsForZSet().removeRangeByScore("zset", 0, System.currentTimeMillis() - 86400000);
|
#
6.2 批量操作
Set<ZSetOperations.TypedTuple<String>> tuples = new HashSet<>(); for (UserScore us : userScores) { tuples.add(new DefaultTypedTuple<>(us.getUserId().toString(), us.getScore())); } redis.opsForZSet().add("leaderboard", tuples);
|
#
6.3 避免大ZSet
public void addToShardZSet(String key, String member, double score) { int shard = member.hashCode() % 10; redis.opsForZSet().add(key + ":" + shard, member, score); }
|
七、常见问题
#
7.1 ZSet最大元素数
理论限制:约40亿(2^32 - 1) 实际限制:内存大小
建议:单个ZSet不超过10000个元素
|
#
7.2 score相同时的排序
ZADD myzset 1 "b" ZADD myzset 1 "a" ZRANGE myzset 0 -1
|
#
7.3 分数精度
ZADD myzset 9007199254740992 "a" ZADD myzset 9007199254740993 "b"
|
八、总结
| 特性 |
SkipList |
| 查找 |
O(log n) |
| 插入 |
O(log n) |
| 删除 |
O(log n) |
| 范围查询 |
O(log n + m) |
| 实现复杂度 |
低 |
ZSet核心设计:
- SkipList:按score排序,支持范围查询和排名
- Hashtable:按member查找,O(1)获取score
- 双向链表:支持倒序遍历
ZSet使用建议:
- score设计:使用整数避免精度问题
- 控制大小:定期清理,避免无限增长
- 批量操作:减少网络往返
- 范围查询优先:ZRANGE比ZRANK更高效
- 考虑Stream:需要消费者组时用Stream替代延时队列
核心要点
String:简单的键值对,适合缓存、计数器
Hash:存储对象属性,适合用户信息、配置
List:有序列表,适合消息队列、最新列表
Set:无序去重,适合共同好友、抽奖
ZSet:有序集合,适合排行榜、积分系统
总结
选择合适的数据结构是使用 Redis 的关键。在实际项目中,根据业务需求选择合适的类型,可以提升性能和开发效率。