Go 语言版 Redis 有序集合指令复刻探索
# 写在文章开头
在分布式系统和数据处理的广阔领域中,Redis 以其强大的功能和高效性占据着重要的一席之地。其中,有序集合更是展现出了独特的魅力。而今天,我们将踏上一段令人兴奋的探索之旅——Go 语言版 Redis 有序集合指令复刻探索。
当我们深入研究 Go 语言这门简洁而高效的编程语言时,便萌生了用它来复刻 Redis 有序集合指令的想法。这不仅仅是一次技术上的尝试,更是对经典数据结构和操作的致敬与深入理解。我们试图在 Go 语言的世界里,重新构建起那一套有序集合的指令体系,去挖掘其中的奥秘,去发现语言之间的共通与差异,去挑战自我并拓展技术的边界。
通过这个探索过程,我们期望能够更深刻地理解有序集合的工作原理,同时也展示 Go 语言强大的表现力和适应性。让我们一同开启这扇充满无限可能的大门,走进 Go 语言版 Redis 有序集合指令复刻的奇妙世界。

Hi,我是 sharkChili ,是个不断在硬核技术上作死的技术人,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili 。
同时也非常欢迎你star我的开源项目mini-redis:https://github.com/shark-ctrl/mini-redis (opens new window)
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。
# 详解有序集合复刻思路
# 关于原生redis有序集合的实现
在阅读本文笔者实现之前,建议读者阅读一下这篇关于redis有序集合的源码实现,会对后续笔者实现思路的理解有所帮助:
探索数据结构之美——有序集合的内部机制 :https://mp.weixin.qq.com/s/e0N8rztCqMNuU5_XR1iZzw (opens new window)
# zadd添加指令的实现
先来说说zadd的实现,zadd指令内部处理函数为zaddCommand:
func zaddCommand(c *redisClient) {
//传入0,即本次传入的score在元素存在情况下执行覆盖score而非累加score
zaddGenericCommand(c, 0)
}
2
3
4
可以看到其内部是复用了zaddGenericCommand的实现,除了传入客户端信息以外还要求传入参数incr,我们的指令传入的是0,即本次传入的元素即使存在于有序集合,对应的scores也是覆盖而非累加。
例如:我们传入指令zadd test 10 "aa",按照zadd的调用如果aa这个元素存在于有序集合中,那么它的score在本次操作后就会被更新为10,而非+=10:

步入内部细节后的实现的源码如下,例如我们传入zadd test 10 aa 20 bb,大体来说它内部的执行步骤为:
- 校验参数是否为双数,该指令用于在test中插入aa和bb两个元素和对应的score分别是10、20,所以指令必须是双数的情况下才能证明本地传入的元素都有对应的score。
- 从索引2开始每次加上2获取每个元素的score转为浮点数并存入scores数组中,便于后续读取。
- 检查test这个有序集合是否存在于redis中,如果存在且不是有序集合类型则返回错误码出去,如果不存在则初始化一个有序集合存入redis内存中。
- 遍历元素依次拿到元素和和score插入到有序集合中。
- 首先我们遍历到aa发现aa存在于有序集合中,并且原来的score和本次的score值不一样,所以将有序集合底层的字典中aa的value更新为10,然后将aa从跳表中删除再插入以保证最新的结果。
- 遍历到bb发现该元素不存在,分别插入到字典和跳表中即可。

对应的我们给出代码实现,读者可结合上文说法并基于读者注释了解实现细节:
func zaddGenericCommand(c *redisClient, incr int) {
//拿到有序集合的key
key := c.argv[1]
var ele *robj
var zobj *robj
var j uint64
var score float64
//初始化变量记录本次操作添加和更新的元素数
var added int64
var updated int64
//参数非偶数,入参异常直接输出错误后返回
if c.argc%2 != 0 {
addReplyError(c, shared.syntaxerr)
return
}
//减去zadd和key 再除去2 得到本次插入的元素数
elements := (c.argc - 2) / 2
//创建scores记录每个元素对应的score值
scores := make([]float64, elements)
for j = 0; j < elements; j++ {
//对score进行转换,若报错直接返回
if !getDoubleFromObjectOrReply(c, c.argv[2+j*2], &scores[j], nil) {
return
}
}
//若为空则创建一个有序集合,并添加到数据库中
zobj = lookupKeyWrite(c.db, c.argv[1])
if zobj == nil {
zobj = createZsetObject()
dbAdd(c.db, key, zobj)
} else if zobj.robjType != REDIS_ZSET { //若类型不对则返回异常
addReply(c, shared.wrongtypeerr)
return
}
zs := (*zobj.ptr).(*zset)
//基于元素数遍历集合
for j = 0; j < elements; j++ {
//拿到本次元素对应的score
score = scores[j]
//拿到对应的元素
ele = c.argv[3+j*2]
k := (*ele.ptr).(string)
//如果该元素存在于字典中
if zs.dict[k] != nil {
//拿到当前元素对应的score
curScore := zs.dict[k]
//若不一样则更新字典中对应元素的score,并将该元素从跳表中删除再插入
if *curScore != score {
zslDelete(zs.zsl, *curScore, c.argv[3+j*2])
zslInsert(zs.zsl, score, c.argv[3+j*2])
zs.dict[k] = &score
//维护更新数
updated++
}
} else { //若是新增则插入到有序集合对应的跳表和字典中
zslInsert(zs.zsl, score, c.argv[3+j*2])
zs.dict[k] = &score
//维护添加数
added++
}
}
//返回本次插入数
addReplyLongLong(c, added)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# zrem删除指令的实现
删除指令就比较简单了,我们以zrem test aa bb这个指令为例,它要求redis服务端将test这个有序集合下的aa元素进行删除,redis会通过有序集合底层的字典(查询单元素时间复杂度为O(1))查看这个元素是否存在,如果存在则将其从底层的字典和跳表中删除,并累加本次删除了两个元素并返回:

对应的源码如下,实现比较简单,这里就不多做赘述了:
func zremCommand(c *redisClient) {
var deleted int64
//检查有序集合是否存在且类型是否是有序集合类型,如果为空或者类型不一致则返回
o := lookupKeyWriteOrReply(c, c.argv[1], shared.czero)
if o == nil || checkType(c, o, REDIS_ZSET) {
return
}
zs := (*o.ptr).(*zset)
var j uint64
//遍历元素
for j = 2; j < c.argc; j++ {
//拿到元素字符串
ele := (*c.argv[j].ptr).(string)
//如果不为空则将其从底层字典和跳表中删除
if zs.dict[ele] != nil {
//更新删除结果
deleted++
zslDelete(zs.zsl, *zs.dict[ele], c.argv[j])
delete(zs.dict, ele)
//如果发现字典为空,说明有序集合没有元素了,直接将该有序集合从字典中期删除
if len(zs.dict) == 0 {
dbDelete(c.db, c.argv[1])
}
}
}
//返回删除数
addReplyLongLong(c, deleted)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# zrank查看元素排名指令的实现
假设我们在有序集合中删除元素aa bb,那么我们通过zrank test aa返回到的值就是0,即aa位于跳表的第一个元素(除去头节点header),查询逻辑比较简单,通过字典确定是否存在,如果存在则到跳表中查询经过的步数,然后基于这个步数减去1得到元素的实际索引值。
如下图,我们在字典中定位到aa,于是从跳表头节点开始定位aa,发现在2级索引跨1步就定为到aa,所以得到结果1,然后将1-1得到除去头节点后aa节点的索引值0并返回出去,同理该操作得到bb和cc的结果分别是1、2:

对应的我们给出源码实现,读者可参考笔者的注释结合上面的讲解了解一下实现细节:
func zrankGenericCommand(c *redisClient, reverse int) {
//从参数中拿到有序集合的key和本次要查看排名的元素
key := c.argv[1]
ele := c.argv[2]
//查看有序集合是否存在
o := lookupKeyReadOrReply(c, key, nil)
if o == nil || checkType(c, o, REDIS_ZSET) {
return
}
//获取有序集合底层的跳表的长度
zs := (*o.ptr).(*zset)
llen := zs.zsl.length
//查看元素在字典中是否存在
k := (*ele.ptr).(string)
score, exists := zs.dict[k]
//如果存在则查看其在跳表中的排名
if exists {
//zslGetRank返回元素从头节点开始算经过的步数,例如aa是第一个元素,那么header走到它需要跨1步,所以返回1
rank := zslGetRank(zs.zsl, *score, ele)
//如果要返回倒叙结果则基于长度减去rank
if reverse == 1 {
addReplyLongLong(c, llen-rank)
} else {
//将rank减去1得到元素实际的索引值
addReplyLongLong(c, rank-1)
}
} else {//不存在返回空
addReply(c, shared.nullbulk)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# zcard查询有序集合长度的实现
例如我们想知道test有序集合的长度,那么我们就可以直接通过zcard test得到结果,对应实现比较简单,在明确有序集合确实存在的情况下,直接返回底层的跳表的length即可:
func zcardCommand(c *redisClient) {
//限定为有序集合是否存在且类型是否为有序集合
zobj := lookupKeyReadOrReply(c, c.argv[1], shared.czero)
if zobj == nil || checkType(c, zobj, REDIS_ZSET) {
return
}
//拿到其底层的跳表返回元素数
zs := (*zobj.ptr).(*zset)
addReplyLongLong(c, zs.zsl.length)
}
2
3
4
5
6
7
8
9
10
# 小结
自此,笔者将自己用go语言所复刻的redis有序集合操作指令都分析完毕,希望对你有帮助。
我是 sharkchili ,CSDN Java 领域博客专家,mini-redis的作者,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili 。
同时也非常欢迎你star我的开源项目mini-redis:https://github.com/shark-ctrl/mini-redis (opens new window)
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。
# 参考
Redis Zadd 命令:https://www.runoob.com/redis/sorted-sets-zadd.html (opens new window)