Go 如何做好缓存

缓存对于应用API提速来说不可或缺,所以在设计初始阶段如果有较高的性能要求必不可少。

在做设计阶段如果需要使用缓存,最重要的是要估算好需要使用多少内存

我们首先要明确自己需要缓存的数据有哪些内容。

在用户量不断增长的应用中,如果把所有使用的数据都进行缓存是不可取的。

因为应用的本地内存受到单机物理资源的制约,无限制的缓存数据最终会出现 OOM ,而导致应用被强制退出。

如果是分布式缓存,则高昂的硬件成本也让我们需要进行trade off。

如果物理资源没有限制的情况下,那自然是全部放入速度最快的物理设备中是最好的。

但是现实的业务场景并不允许,我们才需要将数据分成冷热数据,甚至也需要对冷数据进行适当归档和压缩,存到更加便宜的介质中。

分析哪些数据可以放到本地内存中是做好本地缓存的第一步。

有无状态应用的平衡

既然存储了数据在本地的应用,在分布式系统下,我们的应用就不再是无状态的了。

以Web 后台应用举例,如果我们部署了 10 个 Pod 作为后端的应用,如果我们在其中一个处理请求的 Pod 中增加了缓存,当相同的请求又被转发到另一个 Pod 上的时候,对应的数据就无法被获取到。

解决方法有三种:

  • 使用分布式缓存 Redis

  • 将相同的请求转发到同个Pod中

  • 在每个Pod都缓存相同的数据

第一种方式在这里无需赘述,相当于存储也变成了集中化。

第二种方式则需要有特定的标识信息,如用户 uid 来做特定的转发逻辑,受限于实际的场景。

第三种方式则会消耗较多的存储空间,相较于第二种做法,我们需要在每个 Pod 都进行数据的存储,虽然不能说是完全无状态,但是相比起第二种方式产生缓存击穿的概率会更低,因为当网关出现问题不能转发到有特定数据的 Pod 上时,其他 Pod 也能够正常处理请求。

没有哪种方式是银弹,根据实际场景去选择即可,但是缓存走的越远,需要的时间就越长。

Goim  也通过内存对齐的方式让缓存尽可能命中。

当CPU执行运算的时候,它先去L1查找所需的数据、再去L2、然后是L3,如果最后这些缓存中都没有,所需的数据就要去主内存拿。

走得越远,运算耗费的时间就越长。

淘汰策略

如果对缓存有严格的内存大小控制,那么可以使用 LRU 的方式来管理内存,下面我们看看 Go 对 LRU 缓存的实现。

LRU 缓存

适用于需要控制缓存大小,并且自动淘汰掉不常用缓存的场景。

比如只想存下 128 个 key value,那么在 LRU 中在未保存满的情况下,则会一直增加,并且会在中间使用过的时候或者重新加入新值的时候,将 key 重新推到最前面,避免被淘汰。

https://github.com/hashicorp/golang-lru 则是一个 GO 实现的LRU 缓存。

我们通过 Test 来看一下 LRU 的使用方法

func TestLRU(t *testing.T) {
 l, _ := lru.New[int, any](128)
 for i := 0; i < 256; i++ {
  l.Add(i, i+1)
 }
 // 值未被淘汰
 value, ok := l.Get(200)
 assert.Equal(t, true, ok)
 assert.Equal(t, 201, value.(int))

 // 值已经被淘汰了
 value, ok = l.Get(1)
 assert.Equal(t, false, ok)
 assert.Equal(t, nil, value)
}

可以看到 200 的key尚未被淘汰,所以是能够获取到的。

1 的 key 已经超过了 size = 128 的缓存限制,所以已经被淘汰,没办法正常进行获取了。

这种情况适用于我们在保存的数据量过大的时候,经常用的数据则会一直被移动到头部,从而提高缓存的命中率。

开源包的内部实现是通过链表来维护所有缓存的元素

每次 Add 的时候如果 key 已经存在,则将 key 移动到头部。

func (l *LruList[K, V]) move(e, at *Entry[K, V]) {
 if e == at {
  return
 }
 e.prev.next = e.next
 e.next.prev = e.prev

 e.prev = at
 e.next = at.next
 e.prev.next = e
 e.next.prev = e
}

如果 key 不存在,则通过 insert 方法来插入

func (l *LruList[K, V]) insert(e, at *Entry[K, V]) *Entry[K, V] {
 e.prev = at
 e.next = at.next
 e.prev.next = e
 e.next.prev = e
 e.list = l
 l.len++
 return e
}

如果链表缓存的 size 已经超过则会移除掉链表最末尾的元素,即加入的时间比较早并且没有使用的元素。

func (c *LRU[K, V]) removeOldest() {
 if ent := c.evictList.Back(); ent != nil {
  c.removeElement(ent)
 }
}

func (c *LRU[K, V]) removeElement(e *internal.Entry[K, V]) {
 c.evictList.Remove(e)
 delete(c.items, e.Key)
 // 删除key后的回调
 if c.onEvict != nil {
  c.onEvict(e.Key, e.Value)
 }
}

func (l *LruList[K, V]) Remove(e *Entry[K, V]) V {
 e.prev.next = e.next
 e.next.prev = e.prev
 // 防止内存泄漏,置为nil
 e.next = nil 
 e.prev = nil 
 e.list = nil
 l.len--

 return e.Value
}

缓存更新

及时更新缓存在分布式系统中能减少数据不一致的问题。

不同的方式适用的场景也有所不同。

获取缓存数据有不同的情况,比如热门榜单的话,跟用户无关,多个 Pod 的时候则需要我们在本地缓存中都进行维护,当有发生写更新操作时,需要通知所有的 Pod 都进行更新。

如果是用户自己特有的数据,那我们更希望能在固定的 Pod ,然后通过用户标识(uid)将请求稳定的打到同一个 Pod 上,这样我们也不需要再不同的 Pod 维护多份数据,也减少了内存的消耗。

大部分时候我们也希望我们的应用是无状态的,所以将这部分缓存的数据放到 Redis 上。

分布式缓存更新策略主要有三种:旁路更新策略、写缓存后写数据库、写回策略。

旁路更新策略是我们平时使用最多的,即在更新数据的时候先删除缓存,再写入数据库,然后后续读取的时候发现缓存不存在,则再从数据库读取后更新。

这种策略在读的QPS非常高的情况下会出现不一致,因为删除缓存还没更新数据库的时候,又调用了读取操作,又会将旧值写入,导致从数据库读取到的仍然是旧值。

虽然实际出现这种情况的概率不高,但是我们也要具体评估使用的场景,当出现的时候如果对系统数据是毁灭性的打击,那就不能用这种策略。

如果可以接受这种情况,但是又想尽可能的减少不一致的时间,则可以设置一个缓存过期时间,在没有写操作触发的时候,可以通过缓存主动过期,来刷新缓存的数据。

写缓存后写库和写回策略都是先更新缓存,然后再写数据库,只是刷新的一个还是一批的区别。

这种策略一个比较明显的缺点就是比较容易丢失数据,Redis 虽然也有回写磁盘的策略,但是对于QPS高的应用来说,机器掉电后丢失一秒内的数据仍然是一个非常庞大的数据量,所以要根据业务和场景的实际情况来决定是否采用该策略。

而如果 Redis 仍然无法满足我们的性能需求的话,那就需要将缓存的内容直接通过应用变量存储下来,即本地缓存,用户访问后则直接返回,无需通过网络请求获取。

所以下面我们讨论的是在分布式情况下,本地缓存进行更新的策略。

1.主动通知更新,跟旁路更新策略相同。

分布式下可以通过 ETCD 广播迅速对缓存数据进行扩散,而不用等待下次查询再进行加载。

但是这里会出现一个问题,比如 T1 时间进行了缓存更新的通知,这个时候下游服务还没有完全更新完成, T2=T1 + 1s 又产生了一个缓存更新的信号,而 T1 时间也没有完全更新完成。

这时就有可能因为更新快慢问题将 T2 更新的新值覆盖成 T1 时刻的旧值。

这种情况则可以增加一个单调递增的时间 version 来解决。当 T2 版本的数据生效之后, T1 的就无法再对 T2 的缓存进行更新,从而规避了覆盖旧值的问题。

在主动通知中,我们可以指定对应的 key 值,来对具体的缓存进行更新,从而避免对所有的缓存数据进行更新导致负载过大。

这种更新策略跟旁路更新是类似的,只是从原来更新分布式缓存变成更新本地的缓存。

2.等待缓存自动过期

这种用法适用于对数据一致性要求不高的场景。对于本地缓存来说,如果我们要扩散到所有的 Pod ,维护的策略也会相对较高。

我们可以通过使用 go 相关的开源包https://github.com/patrickmn/go-cache 来进行内存过期时间的维护,而不需要自己去实现。

下面我们看一下 go cache 如何实现本地缓存

Go Cahce

https://github.com/patrickmn/go-cache 是一个Go开源的本地缓存包

内部是通过 map 进行数据存储的

type Cache struct {
 *cache
}

type cache struct {
 defaultExpiration time.Duration
 items             map[string]Item
 mu                sync.RWMutex
 onEvicted         func(string, interface{})
 janitor           *janitor
}

items 存储了所有对应的数据。

每次 SetGet 的时候都是从 items  中获取。

janitor 则在特定时间间隔对过期的 key 进行删除,具体时间间隔可以自己指定。

func (j *janitor) Run(c *cache) {
 ticker := time.NewTicker(j.Interval)
 for {
  select {
  case <-ticker.C:
   c.DeleteExpired()
  case <-j.stop:
   ticker.Stop()
   return
  }
 }
}

会通过 Ticker 产生信号,定时通过 DeleteExpired 方法去删除过期的 key

func (c *cache) DeleteExpired() {
 // 被淘汰的kv值
 var evictedItems []keyAndValue
 now := time.Now().UnixNano()
 c.mu.Lock()
 // 找到已经过期的key 并进行删除
 for k, v := range c.items {
  if v.Expiration > 0 && now > v.Expiration {
   ov, evicted := c.delete(k)
   if evicted {
    evictedItems = append(evictedItems, keyAndValue{k, ov})
   }
  }
 }
 c.mu.Unlock()
 // 被淘汰后的回调,如果有的话会执行
 for _, v := range evictedItems {
  c.onEvicted(v.key, v.value)
 }
}

从代码上我们可以看到,缓存的过期是依赖循环淘汰的方式。

那如果我们获取了已经过期但是还没来得及 Deletekey 会是什么样的结果呢?

获取的时候也会去判断 key 的值是否会过期。

func (c *cache) Get(k string) (interface{}, bool) {
 c.mu.RLock()
 // 如果没有找到则直接返回
 item, found := c.items[k]
 if !found {
  c.mu.RUnlock()
  return nil, false
 }
 
 // 如果获取的内容已经过期,则直接返回nil ,然后等待循环去删除key
 if item.Expiration > 0 {
  if time.Now().UnixNano() > item.Expiration {
   c.mu.RUnlock()
   return nil, false
  }
 }
 c.mu.RUnlock()
 return item.Object, true
}

可以看到每次获取具体的值的时候都会进行判断,所以能够准确的不获取过期的kv。

缓存预热

启动的时候如何预加载,是否要等初始化完成才启动,可否分段启动,并发的话是否会对中间件造成压力等,都是启动时预热缓存所需要考虑的问题。

在启动时,等到所有初始化完成后再启动预加载流程的话如果对整体资源消耗较大,我们可以将初始化和预加载并行进行,但需要确保某些关键组件(如数据库连接、网络服务等)已经就绪,以避免在预加载过程中出现资源不可用的情况。

如果没有加载完就已经有请求进入应用,则需要有相应的兜底策略来保证访问的正常返回。

分段加载的好处是能通过并发来缩短初始化的时间,但是并发加载在提高预加载效率的同时,也会对中间件(如缓存服务器、数据库等)造成压力。

编码的时候需要评估系统的并发处理能力,设定合理的并发数限制。采用限流机制可以缓解并发压力,避免对中间件造成过载。

在 Go 中也可以通过 channel 的方式来实现并发数的限制。

缓存预热在实际生产场景扮演着非常重要的角色,在发布的过程中,应用本地缓存会随着重启而消失,如果是滚动更新的情况下,会有至少一个 Pod 需要进行回源,QPS 非常大的情况下,有可能这一个 Pod 的峰值QPS 就拖垮了数据库,从而导致雪崩效应。

这种情况有两种处理方式,一种就是尽量减少在高峰期进行版本升级,而是在流量低谷期,这个很容易在监控上找到。

另一种方式则是启动的时候就预先加载好数据,等到加载完成再对外提供服务,但是这个会存在一个问题,如果发布版本有问题进行回滚,服务启动的时间会被拉长,并不利于我们的快速回滚。

两种方式各有优缺点,在实际场景中根据自己的实际诉求进行抉择,但是最重要的还是要尽量减少特殊情况的依赖,需要的依赖越多,在发布的时候就越容易出现问题。

- END -


推荐阅读:

6 个必须尝试的将代码转换为引人注目的图表的工具

Go 1.23新特性前瞻

Gopher的Rust第一课:第一个Rust程序

Go早期是如何在Google内部发展起来的

2024 Gopher Meetup 武汉站活动

go 中更加强大的 traces

「GoCN酷Go推荐」我用go写了魔兽世界登录器?

Go区不大,创造神话,科目三杀进来了

想要了解Go更多内容,欢迎扫描下方👇关注公众号,扫描 [实战群]二维码  ,即可进群和我们交流~


- 扫码即可加入实战群 -

961d062cf9d45b890bca5b48309798f5.png

0b3037b5a25bf7cc0228567127d51568.png

分享、在看与点赞Go ad5170a83f15104bbbb587b7c5c7887b.gif

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值