基于 LRU算法实现的二级缓存

分类: 工作

问题

在做 api 网关的时候。有一个功能,是对部分接口在网关层面进行缓存。例如:获取房间主题、获取订阅二维码。本意是想让这些接口的调用更快,减缓一点后端的压力,所以很自然得使用了 redis 来做缓存。 在流量和访问量持续上升之后,redis 的压力越来越大了,redis 的 qps 达到了 10W+ 。redis 的 cpu 使用率上升开始报警,访问延时也变高了。

想法

分析那些需要缓存的接口,可以看出这些接口是一些一致性要求并不高的接口,并且不会在比较短的时间之内有比较频繁的数据变更。那么这些数据是不是可以缓存在机器内存中,并给定比较短的过期时间。在内存里就抵挡住大部分的请求,而不是全部访问到 redis 上去。 基于以上的考虑,我考虑基于 LRU 算法来实现内存缓存。

实现

在使用内存缓存的时候,最大的一个问题其实就是缓存数据淘汰了,而 LRU 算法可以解决这个问题。

LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。

LRU

LRU 算法的实现,是基于一个双向链表来实现的。在链表中查询缓存,并将元素移动到链表头部,如果没有找到,则将其添加到链表头部,每次查询都需要遍历整个链表,所以查询的效率是 O(n)。在这个基础上,使用 map 来存储 key 到 链表指针的映射,这样就可以在查询的时候,直接从 map 中查询到链表指针,然后查询链表,这样查询的效率就是 O(1) 了。

双向链表基本操作了,lru cache go 代码如下。


import (
	"sync"
	"time"
)

type LruNode struct {
	Prev    *LruNode
	Next    *LruNode
	Timeout int64
	Value   interface{}
	Key     string
}

type LocalCache struct {
	maxSize int
	size    int
	head    *LruNode
	tail    *LruNode
	cache   map[interface{}]*LruNode
	locker  *sync.RWMutex
}

func NewLocalCache(maxSize int) *LocalCache {
	return &LocalCache{
		maxSize: maxSize,
		size:    0,
		head:    nil,
		tail:    nil,
		cache:   make(map[interface{}]*LruNode),
		locker:  new(sync.RWMutex),
	}
}

// Put adds a value to the cache.expire 有效期,单位秒
func (c *LocalCache) Put(key string, val interface{}, expire time.Duration) error {

	// 确认容量是否超出,如果超出了则进行清理

	locker := c.locker

	locker.Lock()
	defer locker.Unlock()
	c.ifFullRemoveLast()

	ts := time.Now().Add(expire).Unix()

	node := &LruNode{
		Prev:    nil,
		Next:    nil,
		Timeout: ts,
		Value:   val,
		Key:     key,
	}
	c.addToHead(node)
	return nil
}

func (c *LocalCache) Get(key string) (interface{}, bool) {
	locker := c.locker

	locker.RLock()
	node, ok := c.cache[key]
	if !ok {
		locker.RUnlock()
		return nil, false
	}
	locker.RUnlock()

	locker.Lock()
	defer locker.Unlock()
	if node.Timeout < time.Now().Unix() {
		c.delete(node)
		return nil, false
	}
	c.moveToHead(node)
	return node.Value, true
}

func (c *LocalCache) delete(node *LruNode) {
	if node == nil {
		return
	}

	key := node.Key

	if c.head == node {
		c.head = node.Next
	}
	if c.tail == node {
		c.tail = node.Prev
	}

	if node.Prev != nil {
		node.Prev.Next = node.Next
	}
	if node.Next != nil {
		node.Next.Prev = node.Prev
	}

	node.Next, node.Prev = nil, nil

	delete(c.cache, key)
	c.size -= 1
}

func (c *LocalCache) moveToHead(node *LruNode) {
	if node == nil {
		return
	}
	// 已经在队列头了
	if c.head == node {
		return
	}
	// 在队列尾了
	if c.tail == node {
		c.tail = node.Prev
	}

	//
	prev := node.Prev
	if prev != nil {
		prev.Next = node.Next
	}

	head := c.head
	node.Prev, node.Next = nil, head
	head.Prev = node
	c.head = node
	return
}

func (c *LocalCache) addToHead(node *LruNode) {
	if node == nil {
		return
	}

	head := c.head

	if head == nil {
		c.head, c.tail = node, node
	} else {

		node.Next = head
		node.Prev = nil

		head.Prev = node

		c.head = node
	}
	c.cache[node.Key] = node
	c.size++
}

func (c *LocalCache) ifFullRemoveLast() {
	if c.size+1 > c.maxSize {
		c.delete(c.tail)
	}
}

效果

在上线之后,针对接口缓存在高峰时在网关层面无损削减了 80% 的流量请求。

当然这个是针对场景的,单个 url 流量越大,请求越密集,那么命中内存二级缓存的概率就很大。对请求量不那么高的 url 来说,效果就不是特别明显,需要 redis 缓存来进行兜底。


上一篇: 连接池设计导致的 Redis Proxy 负载不均衡
下一篇: 分享一个山寨币使用 GasToken 盈利方式