问题
在做 api 网关的时候。有一个功能,是对部分接口在网关层面进行缓存。例如:获取房间主题、获取订阅二维码。本意是想让这些接口的调用更快,减缓一点后端的压力,所以很自然得使用了 redis 来做缓存。 在流量和访问量持续上升之后,redis 的压力越来越大了,redis 的 qps 达到了 10W+ 。redis 的 cpu 使用率上升开始报警,访问延时也变高了。
想法
分析那些需要缓存的接口,可以看出这些接口是一些一致性要求并不高的接口,并且不会在比较短的时间之内有比较频繁的数据变更。那么这些数据是不是可以缓存在机器内存中,并给定比较短的过期时间。在内存里就抵挡住大部分的请求,而不是全部访问到 redis 上去。 基于以上的考虑,我考虑基于 LRU 算法来实现内存缓存。
实现
在使用内存缓存的时候,最大的一个问题其实就是缓存数据淘汰了,而 LRU 算法可以解决这个问题。
LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。
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 盈利方式