Golang实现自己的Redis(有序集合跳表)实例探究

 更新时间:2024年01月24日 09:27:18   作者:绍纳 nullbody笔记  
这篇文章主要为大家介绍了Golang实现自己的Redis(有序集合跳表)实例探究,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪

引言

用11篇文章实现一个可用的Redis服务,姑且叫EasyRedis吧,希望通过文章将Redis掰开撕碎了呈现给大家,而不是仅仅停留在八股文的层面,并且有非常爽的感觉,欢迎持续关注学习。

[x] easyredis之TCP服务

[x] easyredis之网络请求序列化协议(RESP)

[x] easyredis之内存数据库

[x] easyredis之过期时间 (时间轮实现)

[x] easyredis之持久化 (AOF实现)

[x] easyredis之发布订阅功能

[x] easyredis之有序集合(跳表实现)

[ ] easyredis之 pipeline 客户端实现

[ ] easyredis之事务(原子性/回滚)

[ ] easyredis之连接池

[ ] easyredis之分布式集群存储

EasyRedis之有序集合(跳表实现)

有序集合需求分析:

  • 集合:同一个member只能出现一次(去重)
  • 有序:可以正序or倒序遍历

Golang中去重我们可以使用map去重,有序则使用链表。所以有序集合的数据结构定义如下:

// score可以相同,member不能重复
type SortedSet struct {
	dict map[string]*Pair // 利用map基于member去重
	skl  *skiplist        // 利用skl进行排序
}
// 要存储的值
type Pair struct {
	Member string
	Score  float64
}

那应该怎么利用链表实现有序的?很简单,插入/修改/删除都保证链表的有序性,那整个链表最后肯定就是一直有序的

例如:要插入节点9,为了保证链表的有序行,需要找到前驱节点。

前驱节点:即比9小的节点中的【最大的一个】。1/2/3/4/5都比9小,但是这些小的当中最大的一个是5。查找的方式简单粗暴,就是遍历链表,从头开始遍历。

伪代码:

type  node struct{
	val int64
}
var curNode *node = &node{val:9}
if pre.next != nil && pre.next.val < curNode.val
{
	pre = pre.next
}
// 插入
pre.next = curNode

可以看出,每次我要插入一个新节点,都是要遍历链表,定位出前驱节点,随着链表长度越来越长,是不是整个链表的插入效率就越来越差。那有没有办法快速定位到链表中的前驱节点。

这里就引申出了跳表的实现。 跳表的本质就是链表,只是链表节点个头长高了。普通的链表只有一层,跳表是包含多层的链表,

当我们要查找前驱节点5的时候, 伪代码如下:

type Level struct {
    forward *node// 每层的指向
    span int64// 每层的跨度
}
type node struct {
    val int64
    level []*Level //每个节点的层
}
var maxlevel = 5
// 被插入的节点9 
var curNode *node = &node{val:9}
// dummy 节点
var dummy *node = &node{val:0}
// pre一开始指向dummy节点
var pre *node = dummy
// 从最高层开始,这里的i表示层高索引
for i := maxlevel-1;i >= 0;i-- {
    for pre.level[i].forward != nil && pre.level[i].forward.val < curNode.val {
        pre = pre.level[i].forward
    }
}
  • 从最高层开始进行遍历,这里的最高层是 pre.level[4]

  • 当 pre.level[4].forward != nil && pre.level[4].forward.val < 9,更新pre的指向节点4。继续重复for循环过程,因为pre指向了节点4,所以 pre.level[4].forward == nil,(结合下图理解)跳出内部的for循环,外部的for循环会将层高i-1。即:降层

  • 等到所有的层级全部遍历完成以后,此时pre的指向就是最终的节点5

通过这种给节点增加层高的概念,可以看到找到节点5,我们只需要跳跃两次从dummy -> 节点4 -> 节点5,比遍历单层的链表效率高了很多。

有序集合代码讲解

有了上面的基础知识,就以zadd key score member向有序集合中插入为例讲解下代码。 入口函数位于engine/sortedset.go

// zadd key score member [score member...]
func cmdZAdd(db *DB, args [][]byte) protocol.Reply {
	//... 省略...
    // 获取有序集合对象
	sortedSet, _, reply := db.getOrInitSortedSetObject(key)
	if reply != nil {
		return reply
	}
	// 将pairs保存到有序集合中
	i := int64(0)
	for _, pair := range pairs {
		if sortedSet.Add(pair.Member, pair.Score) {
			i++ // 新增的个数
		}
	}
	//... 省略...
}

其实就是调用sortedSet.Add(pair.Member, pair.Score)函数. 在有序集合中,通过map保证member的唯一性(去重);如果有重复的,跳表中会先删除;通过 s.skl.insert(member, score)将数据插入到跳表中,并保证有序行;

// bool 为true表示新增, false表示修改
func (s *SortedSet) Add(member string, score float64) bool {
	pair, ok := s.dict[member]
	s.dict[member] = &Pair{
		Member: member,
		Score:  score,
	}
	// 说明是重复添加
	if ok {
		// 分值不同
		if score != pair.Score {
			// 将原来的从跳表中删除
			s.skl.remove(pair.Member, pair.Score)
			// 插入新值
			s.skl.insert(member, score)
		}
		// 分值相同,do nothing...
		returnfalse
	}
	// 新增
	s.skl.insert(member, score)
	returntrue
}

插入的逻辑细节很多,但是思想不复杂;因为跳表是多层的,所以插入一个新节点的时候,【每层的前驱节点】需要记录下来beforeNode。如下图:

如果我向插入一个节点3

  • level[4]第4层的前驱节点是 dummy
  • level[3]第3层的前驱节点是 1
  • level[2]第2层的前驱节点是 2
  • level[1]第1层的前驱节点是 2
  • level[0]第0层的前驱节点是 2

可以看到每一层的前驱节点是不一定相同的,所以每一层都记录下来,当节点3要插入的时候,需要每一层进行相连接。

完整代码如下:

// 增
func (s *skiplist) insert(member string, score float64) *node {
	beforeNode := make([]*node, defaultMaxLevel)      // 每一层的前驱节点
	beforeNodeOrder := make([]int64, defaultMaxLevel) // 每一层的前驱节点排序编号
	node := s.header
	// i从最高层遍历,通过遍历,将member和score在每一层的前驱节点全部保存在 beforeNode中,以及节点的排序保存在beforeNodeOrder
	for i := s.maxLevel - 1; i >= 0; i-- {
		// 节点node的排序
		if i == s.maxLevel-1 {
			beforeNodeOrder[i] = 0
		} else {
			beforeNodeOrder[i] = beforeNodeOrder[i+1]
		}
		// 节点node在当前i层的forward不为空
		for node.levels[i].forward != nil &&
			// node在层levels[i]的forward节点,分值 < score 或者 分值相同但是成员 < member,说明forward指向的节点,作为下一个前驱节点
			(node.levels[i].forward.Score < score || (node.levels[i].forward.Score == score && node.levels[i].forward.Member < member)) {
			beforeNodeOrder[i] += int64(node.levels[i].span) // 更新节点node的编号
			node = node.levels[i].forward                    // 更新当前节点node
		}
		beforeNode[i] = node
	}
	// 新节点层高
	newLevel := randomLevel()
	// 如果新层高比当前已经存在的层高s.maxLevel都要高,说明还缺少了 newLevel - s.maxLevel范围的前驱节点
	if newLevel > s.maxLevel {
		for i := s.maxLevel; i < newLevel; i++ {
			// beforeNode[i] 表示在i层的前驱节点
			beforeNode[i] = s.header
			beforeNodeOrder[i] = 0
			beforeNode[i].levels[i].forward = nil
			beforeNode[i].levels[i].span = s.length
		}
	}
	node = newNode(newLevel, member, score)
	// 将节点插入到多层链表中,仅仅对[0,newLevel)范围进行节点拼接
	for i := int16(0); i < newLevel; i++ {
		//也就是在每一层插入节点
		node.levels[i].forward = beforeNode[i].levels[i].forward
		beforeNode[i].levels[i].forward = node
		// 更新本层节点跨度
		node.levels[i].span = beforeNode[i].levels[i].span - (beforeNodeOrder[0] - beforeNodeOrder[i])
		beforeNode[i].levels[i].span = beforeNodeOrder[0] - beforeNodeOrder[i] + 1
	}
	// 如果新节点的高度很低,比最高低很多
	for i := newLevel; i < s.maxLevel; i++ {
		beforeNode[i].levels[i].span++ // 超过的节点的跨度默认+1
	}
	// 修改第0层的 backward指向
	if beforeNode[0] == s.header {
		node.backward = nil
	} else {
		node.backward = beforeNode[0]
	}
	if node.levels[0].forward != nil {
		node.levels[0].forward.backward = node
	} else { // 说明node是最后一个节点
		s.tailer = node
	}
	// 因为新增,数量+1
	s.length++
	return node
}

代码中还有一个细节:节点3的高度因为是随机的,可能很高,也可能很矮

当很高的时候,超过当前最高的部分的前驱节点就是s.header 代码细节:

// 如果新层高比当前已经存在的层高s.maxLevel都要高,说明还缺少了 newLevel - s.maxLevel范围的前驱节点
	if newLevel > s.maxLevel {
		for i := s.maxLevel; i < newLevel; i++ {
			// beforeNode[i] 表示在i层的前驱节点
			beforeNode[i] = s.header
			beforeNodeOrder[i] = 0
			beforeNode[i].levels[i].forward = nil
			beforeNode[i].levels[i].span = s.length
		}
	}

当很矮的时候,比最高部分矮的部分前驱节点,需要span+1 代码细节:

// 如果新节点的高度很低,比最高低很多
	for i := newLevel; i < s.maxLevel; i++ {
		beforeNode[i].levels[i].span++ // 超过的节点的跨度默认+1
	}

效果演示

项目代码地址: https://github.com/gofish2020/easyredis 

以上就是Golang实现自己的Redis(有序集合跳表)实例探究的详细内容,更多关于Golang Redis有序集合的资料请关注脚本之家其它相关文章!

相关文章

  • 关于golang利用channel和goroutine完成统计素数的思路

    关于golang利用channel和goroutine完成统计素数的思路

    这篇文章主要介绍了golang利用channel和goroutine完成统计素数的思路详解,通过思路图分析及实例代码相结合给大家介绍的非常详细,需要的朋友可以参考下
    2021-08-08
  • Go语言--切片(Slice)详解

    Go语言--切片(Slice)详解

    这篇文章主要介绍了Go语言--切片(Slice),Go 语言切片是对数组的抽象,下面文章小编将为大家详细介绍该内容,需要的朋友可以参考下,希望对你有所帮助
    2021-10-10
  • go语言环境搭建简述

    go语言环境搭建简述

    本文简单记录了下go语言环境的搭建流程,给小伙伴们一个参考,希望大家能够喜欢。
    2015-01-01
  • Golang sync.Map底层实现场景示例详解

    Golang sync.Map底层实现场景示例详解

    这篇文章主要为大家介绍了Golang sync.Map底层实现及使用场景示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-09-09
  • Golang中HTTP服务的分析与设计详解

    Golang中HTTP服务的分析与设计详解

    这篇文章主要介绍了Golang中HTTP服务的分析与设计,HTTP服务是实现Web应用程序的重要组成部分,为了实现高效可扩展的Web应用程序,需要对HTTP服务进行分析与设计,需要的朋友可以参考下
    2023-05-05
  • go语言编程实现递归函数示例详解

    go语言编程实现递归函数示例详解

    这篇文章主要为大家介绍了go语言编程实现递归函数示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-09-09
  • 利用rpm打包上线部署golang代码的方法教程

    利用rpm打包上线部署golang代码的方法教程

    RPM是RPM Package Manager(RPM软件包管理器)的缩写,这篇文章主要给大家介绍了关于利用rpm打包上线部署golang代码的相关资料,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧。
    2018-01-01
  • GoFrame框架gset使用对比PHP Java Redis优势

    GoFrame框架gset使用对比PHP Java Redis优势

    这篇文章主要为大家介绍了GoFrame框架gset对比PHP Java Redis的使用优势详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-06-06
  • Go语言死锁与goroutine泄露问题的解决

    Go语言死锁与goroutine泄露问题的解决

    最近在工作中使用golang编程,今天的文章给大家分享一下Go语言死锁与goroutine泄露问题,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2021-07-07
  • Golang Gorm 更新字段save、update、updates

    Golang Gorm 更新字段save、update、updates

    在gorm中,批量更新操作可以通过使用Update方法来实现,本文主要介绍了Golang Gorm 更新字段save、update、updates,具有一定的参考价值,感兴趣的可以了解一下
    2023-12-12

最新评论