2.1、分布式之性能与伸缩性:服务缓存

一、简介

​ 无论是客户端,还是后台,缓存都是重要的组件,缓存主要的目的就是针对提升读多写少的业务场景的性能,现在大多数的业务场景读多写少,并且微服务的架构下,服务之间频繁交互,缓存更是必备的组件。

  • 为什么引入缓存可以提升性能?

​ 我们无论怎么提升存储的性能,也无法解决需要依赖计算得出数据的性能问题,比如点赞数,评论数,转发数。其次就像之前文章九、微服务:容错设计之补偿事务)提到的需要检测该用户是否为广告平台注册用户,如果是就去存储中获取该注册用户信息。每打开app一次,就需要去存储中查一次。所有流量直接请求到数据库,数据库很有可能宕机,而且90%的用户不会是广告平台注册的用户。针对这种可以设计一个快照,在redis中存一份唯一的标识id,比如iOS的Idfa。先用缓存挡住大多数的量,只有真实的注册用户才会去存储读详细数据。很简单的业务场景,使用缓存就可以解决性能问题。

注意:本篇主要列出缓存相关的典型问题以及设计重点,其他不做更详细的描述,针对这些注意点,Google一下,很多解决方案。部分篇幅主要以实践,分析go-cache的实现

二、缓存更新模式

缓存更新获取一般就是以下几种模式。

2.1、Cache Aside 模式

  • 客户端读取Cache数据,未命中cache,客户端读数据库,读取成功后客户端将数据更新到cache。
  • 客户端读取Cache数据,读取到了返回。
  • 客户端更新Cache数据,先更新数据库,更新数据库成功后让缓存失效。

2.2、Read/Write Through 模式

  • Read Through :客户端读取Cache服务,未命中,Cache服务读取数据库,并且自己更新数据,对客户端透明。
  • Write Through :客户端更新数据的时候,命中Cache则更新缓存,Cache服务自身去更新数据库。未命中Cache,直接去更新数据库。

2.3、Write Behind Caching 模式

更新数据时,只更新缓存。缓存服务异步批量去更新数据库,比如按照时间周期,定期更新一次,等等。

  • 优势:因为只更新缓存(内存),所以I/O非常快。合并多次更新去操作数据库,并且更新数据库是异步的,因此性能客观。
  • 缺点:非强一致性,如果在未到更新数据库的时间节点之前,系统崩溃,和可能丢失数据。

算法领域:要么时间换空间,要么空间换时间。同理,强一致性与高性能也是有冲突的。根据业务来设定自己更新策略

三、缓存设计重点

3.1、缓存独立

在微服务架构中,如果A服务有20个实例,那么如果缓存不独立,那么20个实例都需要在对应机器上构建缓存。并且随着服务的运行。很有可能造成20个缓存副本不一致,增加系统的复杂性。

  • 优势:缓存独立可以降低系统复杂度,避免多态机器部署,节省资源。
  • 缺点:独立缓存如果出现问题,可能导致大量请求直接穿透到DB。

针对缺点,需要保证缓存服务的高可用,高性能,很多公司在生产环境中会使用Redis来构建缓存系统。

3.2、缓存穿透

3.2.1、存储存在数据

访问缓存与数据库中都存在的数据,所有数据生成缓存可能需要大量时间或消耗大量资源,在生成过程中,所有的请求没有命中Cache,直接穿透到数据库。

  • 针对以上情况,可以通过分页缓存来解决。比如一个视频分类页:有推荐,关注,搞笑,娱乐,体育等多个tab,根据用户点击的tab,以及分页来计算缓存。基本上缓存10页数据也就差不多了。(但是需要注意爬虫,爬虫不像真实用户,不会从第一页翻到最后一页,爬虫会从第一页数据,爬取到最后一页。需要注意设置好限流,保证数据库不会发生雪崩)

3.2.2、存储不存在数据

访问缓存与数据库中都不存在的数据,如果缓存没有命中,按照策略去DB中查询,但是数据库也没有查到。下一次依旧会穿透到数据库,如果是客户端写错字段,那么可能会造成意想不到问题,甚至拖垮数据库。

  • 针对以上情况,可以无论是否在数据库中查询到,我们都为该字段在缓存中建立一个默认值,然后该字段的缓存有效期设置的时间短一些(防止后面数据库中该字段有值了,很久得不到更新)。避免所有的请求都直接穿透到数据库。

3.3、分布式锁

使用Cache Aside模式更新缓存的时候,先更新数据库,成功后,让缓存失效。那么问题来了,如果大量更新操作,那么大量的缓存会失效,下次访问,所有的请求直接穿透Cache去数据库获取数据,然后在更新缓存。可能造成数据库的宕机。

  • 针对这种问题,可以使用分布式锁,只有拿到锁的实例才能去更新缓存。分布式锁可以使用ZooKeeper,redis等实现。

3.4、热点缓存

针对热点数据,要多副本缓存,为什么这么做?缓存服务在高,也存在性能瓶颈。例如一个大V拥有千万粉丝,该大V发了一个视频,大量人来围观。因为针对热点数据做了多副本缓存,大量的请求被打散到不同的缓存服务上去获取数据。减轻了系统压力。副本的有效期要设置不同时间,防止同时失效,造成雪崩。

3.5、小结

​ 这么多策略,方案,仅仅是抽样化的理论,基本的建议方案,缓存的策略应该是根据业务场景,根据业务发展来实际设定的。缓存服务自更新,客户端更新,甚至是异步批量更新,这些策略不是定死的。可以灵活运用,甚至是根据不同的业务场景,组合使用。

​ 缓存设计并不复杂,但是需要注意细节,这些细节没有考虑到,可能在某一天海量请求下,直接导致雪崩。缓存是牺牲强制一致性,换来高性能,确定好自己的业务是否需要引入缓存。

四、实践

实践以分析go-cache的实现为目标。

4.1、使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import (
"fmt"
"github.com/patrickmn/go-cache"
"time"
)
func main() {
// 创建一个默认过期时间为5分钟,清理间隔时间为10分钟的高速缓存
c := cache.New(5*time.Minute, 10*time.Minute)
// 设置“foo”键的值为“bar”,默认过期时间
c.Set("foo", "bar", cache.DefaultExpiration)
// 设置“baz”为42,不过期
// 如果没有重置或者删除的话,它不会被删除
c.Set("baz", 42, cache.NoExpiration)
// 获取"foo"对应的字符串
foo, found := c.Get("foo")
if found {
fmt.Println(foo)
}
// 因为Go是一种静态类型语言,而cache可以存储任何类型,因此可以使用断言来判断任意类型
foo, found := c.Get("foo")
if found {
MyFunction(foo.(string))
}
// 需要高性能?那就存指针吧
c.Set("foo", &MyStruct, cache.DefaultExpiration)
if x, found := c.Get("foo"); found {
foo := x.(*MyStruct)
// ...
}
}

4.2、Set实现

设置缓存使用读写锁,发过设置DefaultExpiration,根据初始化的设置,缓存有效时间为5分钟。e为缓存失效的时间点。每个Item包含一个我们缓存的元素和该元素过期的时间节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (c *cache) Set(k string, x interface{}, d time.Duration) {
// "Inlining" of set
var e int64
if d == DefaultExpiration {
d = c.defaultExpiration
}
if d > 0 {
e = time.Now().Add(d).UnixNano()
}
c.mu.Lock()
c.items[k] = Item{
Object: x,
Expiration: e,
}
// TODO: Calls to mu.Unlock are currently not deferred because defer
// adds ~200 ns (as of go1.)
c.mu.Unlock()
}

4.3、Get实现

获取数据的时候使用读写锁。读是判断该数据是否过期,如果过期返回失败。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (c *cache) Get(k string) (interface{}, bool) {
c.mu.RLock()
// "Inlining" of get and Expired
item, found := c.items[k]
if !found {
c.mu.RUnlock()
return nil, false
}
if item.Expiration > 0 {
if time.Now().UnixNano() > item.Expiration {
c.mu.RUnlock()
return nil, false
}
}
c.mu.RUnlock()
return item.Object, true
}

4.4、过期缓存清除

缓存过期后,需要清除掉,这里的清除时间就是Cache初始化的时候。

1
c := cache.New(5*time.Minute, 10*time.Minute)

实现原理很简单,就是设置一个定时器,定期去执行清空操作。runJanitor是我们的定时器,runtime.SetFinalizer的目的是为了如果发生内存不足,触发GC操作的时候,主动释放Cache。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func newCacheWithJanitor(de time.Duration, ci time.Duration, m map[string]Item) *Cache {
c := newCache(de, m)
// This trick ensures that the janitor goroutine (which--granted it
// was enabled--is running DeleteExpired on c forever) does not keep
// the returned C object from being garbage collected. When it is
// garbage collected, the finalizer stops the janitor goroutine, after
// which c can be collected.
C := &Cache{c}
if ci > 0 {
runJanitor(c, ci)
runtime.SetFinalizer(C, stopJanitor)
}
return C
}

j.Run(c)启动定时器。触犯定时调用DeleteExpired。清除过期数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func runJanitor(c *cache, ci time.Duration) {
j := &janitor{
Interval: ci,
stop: make(chan bool),
}
c.janitor = j
go j.Run(c)
}

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

DeleteExpired使用读写锁,遍历所有item。找到过期缓存直接delete掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Delete all expired items from the cache.
func (c *cache) DeleteExpired() {
var evictedItems []keyAndValue
now := time.Now().UnixNano()
c.mu.Lock()
for k, v := range c.items {
// "Inlining" of expired
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)
}
}

代码很容易读。更详细的可以直接查看go-cache源码。