Golang实现Redis的list

362 阅读4分钟

list是Redis中重要的数据结构之一,它的应用场景非常多,比如常用应用的关注列表、粉丝列表等都可以用Redis的list结构来实现,list可以按照插入顺序进行排序,同时它的另一个主要应用是消息队列。

它可以为我们提供以下操作:

命令操作
LLEN key获取列表长度
LPUSH key value1 [value2]将一个或多个值插入到列表头部
RPUSH key value1 [value2]在列表中添加一个或多个值到列表尾部
LPOP key移出并获取列表的第一个元素
RPOP key移除列表的最后一个元素,返回值为移除的元素
LINDEX key index通过索引获取列表中的元素
LRANGE key start stop获取列表指定范围内的元素
BLPOP key1 [key2 ] timeout移出并获取列表的第一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止
BRPOPLPUSH source destination timeout从列表中弹出一个值,将弹出的元素插入到另外一个列表中并返回它; 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止
......

例如: lpush在list的左边添加元素,rpush在list的右边添加元素

image.png

最终结果是 e d c b a x y z

可以正向读取,也可以逆向读取,依次,我们可以选择用双向链表进行实现,它的优势在于:

  1. 链表节点的结构拥有pre和next指针,所以获取某个节点的前置节点和后置节点的时间复杂度都是O(1)
  2. list结构提供表头指针head和表尾指针tail,所以获取的表头和表尾节点的时间复杂度都是O(1)
  3. 记录链表节点个数,所以获取链表中的节点数量的时间复杂度是O(1)

以下是我使用golang实现Redis列表的具体代码

首先,定义ListNode和List的结构

pkg\datastruct\list\list.go

// 双向链表的每一个节点
type ListNode struct {
	pre   *ListNode
	next  *ListNode
	value string
}
// 双向链表本身
type List struct {
	head *ListNode
	tail *ListNode
	len  int
}

然后进行list的主要操作的实现

  • Len()
  • RPush(value)
  • LPush(value)
  • LPop()
  • GetByIndex(index)
  • Range(start, stop)

主要说一下RPush的实现,首先创建一个新的链表节点,
如果要插入的链表为空:则头尾指针均指向该节点 如果不为空:则把该节点加入末尾,把尾指针指向该节点 另外,不要忘记把长度++

func (list *List) RPush(value string) {
	node := NewListNode(value)
	if list.len == 0 {
		// 链表为空
		list.head = node
		list.tail = node
	} else {
		// 链表不为空,加入到末尾
		list.tail.next = node
		node.pre = list.tail
		list.tail = node
	}

	list.len++
}

其他方法依此类推,具体实现可以参考slava项目

同时可以编写list_test.go文件进行测试


至此,完成了数据结构的实现,但仍然不能与数据库交互

需要在database文件夹中支持该结构

pkg\database\list.go

对数据库对象进行获取list或者初始化一个list

func (db *DB) getAsList(key string) (*list.List, protocol.ErrorReply) {
	entity, exists := db.GetEntity(key)
	if !exists {
		return nil, nil
	}
	list, ok := entity.Data.(*list.List)
	if !ok {
		return nil, &protocol.WrongTypeErrReply{}
	}
	return list, nil
}

// 首先获取getList,如果list不为空,则返回;如果为空,则初始化一个list
func (db *DB) getOrInitList(key string) (*list.List, bool, protocol.ErrorReply) {
	getList, errReply := db.getAsList(key)
	if errReply != nil {
		return nil, false, errReply
	}
	isNew := false
	if getList == nil {
		getList = list.NewList()
		db.PutEntity(key, &database.DataEntity{
			Data: getList,
		})
	}
	return getList, isNew, nil
}

之后就是对各种操作执行的实现

注意:redis通过AOF写后日志的方式保证数据的持久化,具体可以看我上一篇博客,Redis持久化之AOF ,所以记得在执行操作后调用db.AddAof(...)来进行日志的记录

以execListRPush为例,首先获取参数key,然后获取需要添加的参数

通过db.getOrInitList(key)得到要操作的list,之后针对每一个需要添加的元素执行RPush的操作

最后通过db.AddAof()进行日志的记录

// 执行LPush,并进行aof操作
func execListRPush(db *DB, args [][]byte) slava.Reply {
	key := string(args[0])
	values := args[1:]

	list, _, errReply := db.getOrInitList(key)
	if errReply != nil {
		return errReply
	}

	for _, value := range values {
		list.RPush(string(value))
	}

	db.AddAof(utils.ToCmdLine3("rpush", args...))
	return protocol.MakeIntReply(int64(list.Len()))
}

虽然双向链表有一些优点,但是也存在很多缺点:

- pre 指针 8byte

- next 指针 8byte

- value string  aaaaa

链表对于redis内存存取,消耗很大,当数据量多的时候,容易造成内存碎片

redis的list的另一种实现是通过ziplist,它拥有连续的内存空间,可以根据数据大小和类型进行不同的空间大小分配,可以节省内存

但同时也有些缺点:

扩容问题,如果超过一定量的大小,再添加数据,需要重新开辟一块新的内存空间

那么后续采用了quicklist来实现,后续争取逐步实现