Golang从0到1实现简易版expired LRU cache带图解

1、支持Put、Get的LRU实现

想要实现一个带过期时间的LRU,从易到难,我们需要先学会如何实现一个普通的LRU,做到O(1)的Get、Put。

想要做到O(1)的Get,我们很容易想到使用哈希表来存储每个key对应的value ;要想实现O(1)的Put,并且能当容量满了的时候自动弹出最久未使用的元素,单纯使用哈希表是比较难实现的,因此我们可以使用一个双向链表头部存放最新被使用的节点尾部存放最久未使用的节点 。那么哈希表只需要记录key到node的映射,就能让我们快速的追踪到节点在双向链表中的位置。

为了使得双向链表易于维护,我们可以增加两个哨兵节点 ,分别代表头部和尾部

到这里,我们就能确定实现一个简单操作的LRU需要涉及的数据结构:双向链表、哈希表

1.1、数据结构定义

现在,我们将基本的数据结构 定义出来,并且定义个构造器函数

go 复制代码
type Node struct {
	Next, Pre *Node
	key, val  int
}

type DoubleLinkList struct {
	Head *Node
	Tail *Node
}

type LRUcache struct {
	doubleList DoubleLinkList
	KeyToNode  map[int]*Node
	cap, cnt   int
}

func Constructor(cap int) *LRUcache {
	head := &Node{}
	tail := &Node{}
	head.Next = tail
	tail.Pre = head
	lru := &LRUcache{
		doubleList: DoubleLinkList{
			Head: head,
			Tail: tail,
		},
		cap:       cap,
		KeyToNode: make(map[int]*Node, cap),
	}
	return lru
}

1.2、方法分析

我们可以先来考虑Put方法。当Put一个(key,value)对的时候,需要先检查是否存在对应的key,若存在,我们就只需要更新这个节点的值并且将节点移动至头部就可以了 ;若不存在,就需要创建一个节点,添加到头部,若LRUcache满了,就需要移除最久没使用的尾部节点

再来考虑Get方法,假若我们能获取到key对应的节点,那么就需要将这个节点更新至链表的头部,然后返回值即可;否则,直接返回-1.

从上面的分析我们不难发现,实现Get和Put方法,需要实现三个基本操作:移除一个节点、移除尾部节点、添加节点至头部

上面的方法,涉及到哈希表的值改变的,也需要一一处理。

接下来我们一一实现。

1.3、添加节点至头部

go 复制代码
func (c *LRUcache) AddNode(node *Node) {
	//哈希表注册这个节点
	c.KeyToNode[node.key] = node
	//添加到链表中,头节点之后
	node.Next = c.doubleList.Head.Next
	node.Pre = c.doubleList.Head
	c.doubleList.Head.Next.Pre = node
	c.doubleList.Head.Next = node
	//更新表记录节点数
	c.cnt++
}

执行过程:

  • 注册该节点到哈希表中
  • 将该节点的前后指针正确设置
  • 将原本的第一个节点的Pre指针设置为node
  • 将头节点的Next设置为node
  • 更新cnt计数器

1.4、移除节点

go 复制代码
func (c *LRUcache) RemoveNode(node *Node) {
	//哈希表删除节点记录
	delete(c.KeyToNode, node.key)
	//更新链表
	node.Next.Pre = node.Pre
	node.Pre.Next = node.Next
	//更新计数器
	c.cnt--
}

1.5、移除尾部节点

go 复制代码
func (c *LRUcache) RemoveTail() {
	//获取尾部节点
	node := c.doubleList.Tail.Pre
	//移除
	c.RemoveNode(node)
}

1.6、Get()

接下来,就可以实现Get和Put方法了。先来实现一下Get

go 复制代码
func (c *LRUcache) Get(key int) int{
	//查询节点是否存在
	if node, ok := c.KeyToNode[key]; ok{
		//存在:从链表移除、添加到链表头部
		c.RemoveNode(node)
		c.AddNode(node)
		return node.val
	}else{
		//不存在,返回-1
		return -1
	}
}

1.7、Put()

Put的执行流程也比较简单:

go 复制代码
func (c *LRUcache) Put(key int, val int) {
	//检查节点是否存在
	if node, ok := c.KeyToNode[key]; ok {
		//存在:更新节点的值、移除节点、添加节点到头部
		node.val = val
		c.RemoveNode(node)
		c.AddNode(node)
	} else {
		//不存在,先检查容量是否上限
		if c.cnt == c.cap {
			c.RemoveTail() //移除尾部节点
		}
		//容量足够,添加节点
		newNode := &Node{key: key, val: val}
		c.AddNode(newNode)
	}
}

至此,一个简单的LRU就实现了。

2、优雅实现带过期时间的LRU

现在,我们来考虑如何引入过期时间。

首先,我们当然要为每个node添加一个过期时间字段,来记录它什么时候会过期。

对于用户来说,为了保证数据是有效的,每次Get一个值,是不允许用户获取到一个过期对象的 。这里可以采用一个懒更新 的策略,当调用Get获取到一个节点的时候,应该检查是否已经过期,过期了就应该去除掉这个节点,给用户返回-1。

这样子,我们就已经对用户的值获取有了个最基本的保障,至少不会获取到已经过期的数据。接下来我们就要考虑,如何去实现节点的自动过期删除呢

要快速的获取到最早要过期的节点 ,我们可以引入一个过期时间最小堆 ,位于堆顶的是最早将要过期的节点;然后为了实现**"自动管理"** ,我们可以引入一个协程去打理这个堆 ,每次发现节点过期了,就让这个协程自己去把节点清理掉就好了。这样子,可以做到当有节点过期了,只需要O(logn)的时间去清理掉节点,Put/Get主流程仍然只需要O(1)的时间复杂度,做到了"优雅高效"。

以为我们引入了协程,这就会有线程安全的问题,所以需要对LRU添加一把锁,来实现对操作的安全访问。

接着,我们希望当LRU存在节点的时候,再进行检查是否过期,为了防止协程长期自旋检查 ,我们可以为LRUcache添加一个sycn.Cond做到当没有节点的时候,就可以沉睡等待被唤醒。(一个优化的点)

接下来,我们一一修改时间。

2.1、数据结构修改

原本的node需要添加过期时间的记录,以及为了实现最小堆,需要添加index下标,记录在堆的位置。

go 复制代码
type Node struct {
	Next, Pre *Node
	key, val  int
	index int
	expire time.Time
}

接着是LRUcache,添加一个最小堆结构,然后还需要添加锁,以及sync.Cond。

附带实现这个最小堆(使用container/heap)

go 复制代码
// 最小堆实现
type MinHeap []*Node

func (h MinHeap) Less(i, j int) bool { return h[i].expire.Before(h[j].expire) }
func (h MinHeap) Len() int           { return len(h) }
func (h MinHeap) Swap(i, j int) {
	h[i], h[j] = h[j], h[i]
	h[i].index, h[j].index = i, j
}
func (h *MinHeap) Push(x interface{}) {
	n := h.Len()
	node := x.(*Node)
	node.index = n
	*h = append(*h, node)
}
func (h *MinHeap) Pop() interface{} {
	old := *h
	n := old.Len()
	node := old[n-1]
	node.index = -1
	*h = old[:n-1]
	return node
}

然后是结构的修改,和构造器修改

go 复制代码
type LRUcache struct {
	doubleList DoubleLinkList
	KeyToNode  map[int]*Node
	cap, cnt   int

	expHeap MinHeap
	mu      sync.Mutex
	expCond sync.Cond
}
go 复制代码
func Constructor(cap int) *LRUcache {
	head := &Node{}
	tail := &Node{}
	head.Next = tail
	tail.Pre = head
	lru := &LRUcache{
		doubleList: DoubleLinkList{
			Head: head,
			Tail: tail,
		},
		cap:       cap,
		KeyToNode: make(map[int]*Node, cap),
	}
	heap.Init(&lru.expHeap)//初始化堆
	lru.expCond = *sync.NewCond(&lru.mu)//创建cond
	go lru.expirer()
	return lru
}

2.2、清理协程实现

go 复制代码
func (c *LRUcache) expirer() {
	for {
		//获取锁
		c.mu.Lock()
		//若列表没有节点,就沉睡等到被put唤醒
		for c.expHeap.Len() == 0 {
			c.expCond.Wait() //会自动释放锁,等待被唤醒后又尝试获取锁。
		}
		//获取堆顶节点
		node := c.expHeap[0]
		now := time.Now()
		wait := node.expire.Sub(now)
		//如果wait>0,说明还没有过期
		if wait > 0 {
			//沉睡到该节点过期
			c.mu.Unlock()
			time.Sleep(wait)
			//醒来后,要重新执行一次流程
			continue
		}
		//清理这个节点
		heap.Pop(&c.expHeap)
		c.RemoveNode(node)
		c.mu.Unlock()
	}
}

流程:

  • 获取锁
  • 检查队列是否为空,为空则等待被put唤醒
  • 获取堆顶节点,检查是否已经到达过期时间
    • 未到达过期时间,则沉睡,当被唤醒的时候,重新检查堆顶。
  • 到达了过期时间,进行清除操作

2.3、Get修改

现在引入了过期机制后,就需要检查是否过期,以及获取锁才能操作。

go 复制代码
func (c *LRUcache) Get(key int) int {
	//修改1
	c.mu.Lock()
	defer c.mu.Unlock()
	//查询节点是否存在
	if node, ok := c.KeyToNode[key]; ok {
		//修改2,检查是否过期
		if time.Now().After(node.expire) {
			//过期了,协程还没有发现,手动帮助删除
			heap.Remove(&c.expHeap, node.index)
			c.RemoveNode(node)
			return -1
		}
		//存在:从链表移除、添加到链表头部
		c.RemoveNode(node)
		c.AddNode(node)
		return node.val
	} else {
		//不存在,返回-1
		return -1
	}
}

修改的点已经标记

2.4、Put修改

go 复制代码
func (c *LRUcache) Put(key int, val int, ttl time.Duration) {
	//修改1
	c.mu.Lock()
	defer c.mu.Unlock()
	//修改2,获取过期时间
	exp := time.Now().Add(ttl)
	//检查节点是否存在
	if node, ok := c.KeyToNode[key]; ok {
		//存在:更新节点的值、移除节点、添加节点到头部
		//修改3,重新设置过期时间
		node.expire = exp

		node.val = val
		c.RemoveNode(node)
		c.AddNode(node)

		//修改4,heap需要fix
		heap.Fix(&c.expHeap, node.index)
	} else {
		//不存在,先检查容量是否上限
		if c.cnt == c.cap {
			c.RemoveTail() //移除尾部节点
		}
		//容量足够,添加节点
		newNode := &Node{key: key, val: val}

		//修改4,设置节点过期时间,添加到堆,唤醒协程
		newNode.expire = exp
		c.AddNode(newNode)
		heap.Push(&c.expHeap, newNode)
		c.expCond.Signal()
	}
}

Put方法的流程:

  • 获取锁
  • 检查节点是否存在
    • 存在:进行节点的移除、节点值更新、节点添加、heap修复
    • 不存在:检查容量,容量超出则移除尾部节点;进行节点的创建,节点的添加,heap的添加,唤醒协程

于是,我们就完成了带过期时间的LRU。

2.5、代码全览

go 复制代码
package main

import (
	"container/heap"
	"fmt"
	"sync"
	"time"
)

type Node struct {
	Next, Pre *Node
	key, val  int
	index     int
	expire    time.Time
}

type DoubleLinkList struct {
	Head *Node
	Tail *Node
}

// 最小堆实现
type MinHeap []*Node

func (h MinHeap) Less(i, j int) bool { return h[i].expire.Before(h[j].expire) }
func (h MinHeap) Len() int           { return len(h) }
func (h MinHeap) Swap(i, j int) {
	h[i], h[j] = h[j], h[i]
	h[i].index, h[j].index = i, j
}
func (h *MinHeap) Push(x interface{}) {
	n := h.Len()
	node := x.(*Node)
	node.index = n
	*h = append(*h, node)
}
func (h *MinHeap) Pop() interface{} {
	old := *h
	n := old.Len()
	node := old[n-1]
	node.index = -1
	*h = old[:n-1]
	return node
}

type LRUcache struct {
	doubleList DoubleLinkList
	KeyToNode  map[int]*Node
	cap, cnt   int

	expHeap MinHeap
	mu      sync.Mutex
	expCond sync.Cond
}

func Constructor(cap int) *LRUcache {
	head := &Node{}
	tail := &Node{}
	head.Next = tail
	tail.Pre = head
	lru := &LRUcache{
		doubleList: DoubleLinkList{
			Head: head,
			Tail: tail,
		},
		cap:       cap,
		KeyToNode: make(map[int]*Node, cap),
	}
	heap.Init(&lru.expHeap)              //初始化堆
	lru.expCond = *sync.NewCond(&lru.mu) //创建cond
	go lru.expirer()
	return lru
}
func (c *LRUcache) expirer() {
	for {
		//获取锁
		c.mu.Lock()
		//若列表没有节点,就沉睡等到被put唤醒
		for c.expHeap.Len() == 0 {
			c.expCond.Wait() //会自动释放锁,等待被唤醒后又尝试获取锁。
		}
		//获取堆顶节点
		node := c.expHeap[0]
		now := time.Now()
		wait := node.expire.Sub(now)
		//如果wait>0,说明还没有过期
		if wait > 0 {
			//沉睡到该节点过期
			c.mu.Unlock()
			time.Sleep(wait)
			//醒来后,要重新执行一次流程
			continue
		}
		//清理这个节点
		heap.Pop(&c.expHeap)
		c.RemoveNode(node)
		c.mu.Unlock()
	}
}
func (c *LRUcache) AddNode(node *Node) {
	//哈希表注册这个节点
	c.KeyToNode[node.key] = node
	//添加到链表中,头节点之后
	node.Next = c.doubleList.Head.Next
	node.Pre = c.doubleList.Head
	c.doubleList.Head.Next.Pre = node
	c.doubleList.Head.Next = node
	//更新表记录节点数
	c.cnt++
}

func (c *LRUcache) RemoveNode(node *Node) {
	//哈希表删除节点记录
	delete(c.KeyToNode, node.key)
	//更新链表
	node.Next.Pre = node.Pre
	node.Pre.Next = node.Next
	//更新计数器
	c.cnt--
}

func (c *LRUcache) RemoveTail() {
	//获取尾部节点
	node := c.doubleList.Tail.Pre
	//移除
	c.RemoveNode(node)
}

func (c *LRUcache) Get(key int) int {
	//修改1
	c.mu.Lock()
	defer c.mu.Unlock()
	//查询节点是否存在
	if node, ok := c.KeyToNode[key]; ok {
		//修改2,检查是否过期
		if time.Now().After(node.expire) {
			//过期了,协程还没有发现,手动帮助删除
			heap.Remove(&c.expHeap, node.index)
			c.RemoveNode(node)
			return -1
		}
		//存在:从链表移除、添加到链表头部
		c.RemoveNode(node)
		c.AddNode(node)
		return node.val
	} else {
		//不存在,返回-1
		return -1
	}
}

func (c *LRUcache) Put(key int, val int, ttl time.Duration) {
	//修改1
	c.mu.Lock()
	defer c.mu.Unlock()
	//修改2,获取过期时间
	exp := time.Now().Add(ttl)
	//检查节点是否存在
	if node, ok := c.KeyToNode[key]; ok {
		//存在:更新节点的值、移除节点、添加节点到头部
		//修改3,重新设置过期时间
		node.expire = exp

		node.val = val
		c.RemoveNode(node)
		c.AddNode(node)

		//修改4,heap需要fix
		heap.Fix(&c.expHeap, node.index)
	} else {
		//不存在,先检查容量是否上限
		if c.cnt == c.cap {
			c.RemoveTail() //移除尾部节点
		}
		//容量足够,添加节点
		newNode := &Node{key: key, val: val}

		//修改4,设置节点过期时间,添加到堆,唤醒协程
		newNode.expire = exp
		c.AddNode(newNode)
		heap.Push(&c.expHeap, newNode)
		c.expCond.Signal()
	}
}
func (c *LRUcache) Print() {
	for node := c.doubleList.Head; node != nil; node = node.Next {
		fmt.Printf("%d -> ", node.key)
	}
	fmt.Println()
}
func main() {
	cache := Constructor(2)
	cache.Put(10, 101, time.Second)
	cache.Print()

	time.Sleep(time.Second)
	fmt.Println(cache.Get(10))

	cache.Put(9, 99, time.Second*2)
	cache.Print()

	cache.Put(7, 77, time.Second)
	cache.Print()

	time.Sleep(2 * time.Second)
	cache.Print()
}

3、测试

go 复制代码
func (c *LRUcache) Print() {
	for node := c.doubleList.Head; node != nil; node = node.Next {
		fmt.Printf("%d -> ", node.key)
	}
	fmt.Println()
}
func main() {
	cache := Constructor(2)
	cache.Put(10, 101, time.Second)
	cache.Print()

	time.Sleep(time.Second)
	fmt.Println(cache.Get(10))

	cache.Put(9, 99, time.Second*2)
	cache.Print()

	cache.Put(7, 77, time.Second)
	cache.Print()

	time.Sleep(2 * time.Second)
	cache.Print()
}

输出如下:

cmd 复制代码
0 -> 10 -> 0 -> 
-1
0 -> 9 -> 0 -> 
0 -> 7 -> 9 -> 0 ->
0 -> 0 ->

可以看到,过期的节点已经被成功自动删除了,同时原本的LRU功能保持不变。