gitbook/许式伟的架构课/docs/132656.md
2022-09-03 22:05:03 +08:00

213 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 39 | 存储与缓存
你好,我是七牛云许式伟。
前面接连三讲我们介绍了存储中间件的由来以及最为常见的存储中间件键值存储KV Storage、数据库Database、对象存储Object Storage
当然它们并不是全部。常见的存储中间件还有很多比如消息队列MQ、搜索引擎Search Engine等等。
限于篇幅我们不能一一对它们进行分析。今天我们聊一聊缓存Cache
## memcached
缓存Cache是什么
简单说缓存是存储Storage的加速器。加速的原理通常是这样几种方法
最常见的是用更高速的硬件来加速。比如,用 SSD 缓存加速 SATA 存储,用内存缓存加速基于外存的存储。
还有一种常见的方法是用更短的路径。比如,假设某个计算 y = F(x) 非常复杂,中间涉及很多步骤,发生了一系列的存储访问请求,但是这个计算经常会被用到,那么我们就可以用一个 x => y 的内存缓存来加速。
可见,缓存的数据结构从实现上来讲只需要是一个键值存储。所以它的接口可以非常简单:
```
type Cache {
...
}
func (cache *Cache) Get(key []byte) (val []byte, err error)
func (cache *Cache) Set(key, val []byte) (err error)
func (cache *Cache) Delete(key []byte) (err error)
```
第一个被广泛应用的内存缓存是 memcached。通常我们会使用多个 memcached 实例构成一个集群,通过 Hash 分片或者 Range 分片将缓存数据分布到这些实例上。
一个典型的 memcached 的使用方式如下:
```
func FastF(x TypeX) (y TypeY) {
key := toBytes(x)
hash := hashOf(key)
i := hash % countOf(memcaches)
val, err := memcaches[i].Get(key)
if err != nil {
y = F(x)
val = toBytes(y)
memcaches[i].Set(key, val)
} else {
y = fromBytes(val)
}
return
}
```
类似的缓存逻辑大家应该比较经常见到。
这个示例我们采用的是简单 Hash 分片的方法,它的好处是非常容易理解。当然不太好的地方在于,一旦我们要对 memcached 集群扩容countOf(memcaches) 就会变化,导致大量的 key 原先落在某个分片,现在就落到一个新的分片。
这会导致大量的缓存未命中Cache Miss也就是 cache.Get(key) 返回失败。在缓存未命中的情况下FastF(x) 不只是没有加速 F(x)还增加了两次网络请求cache.Get 和 cache.Set。
所以缓存系统的一个核心指标是缓存命中率Cache Hit Rate即在一段时间内FastF 缓存命中的次数 / 所有 FastF 的调用次数。
为了避免 memcached 集群扩容导致缓存命中率大幅降低,一般我们不会用简单哈希分片,而是用一致性哈希。
什么情况下需要扩容?一旦缓存命中率趋势下降,且下降到某个阈值,就要考虑给缓存集群扩容。
## 缓存 vs 存储
通过以上的介绍可以看出,缓存的基础逻辑是非常简单的。问题是:
缓存Cache和存储Storage是什么关系它也是一种存储中间件么
既是也不是。
首先,缓存和一般的存储中间件一样,也在维持着业务状态。从这个角度看,缓存的确是一类存储。
但是,缓存允许数据发生丢失,所以缓存通常是单副本的。一个内存缓存的集群挂了一个实例,或者一个外存缓存的集群坏了一块硬盘,单就缓存集群本身而言,就出现数据丢失。
缓存数据丢失这事可大可小。只要不是发生大片大片的缓存数据丢失的情形通常只是会造成后端存储Storage的短时压力变大。
但在极端的情况下,可能会出现雪崩的情况。
雪崩怎么形成首先是部分缓存实例宕机导致缓存命中率Cache Hit Rate下降大量的请求落到后端存储上导致后端存储过载也出现宕机。
这时就会出现连锁反应,形成雪崩现象。后端存储就算重新启动起来,又会继续被巨大的用户请求压垮,整个系统怎么启动也启动不了。
应该怎么应对雪崩?最简单的办法,是后端存储自己要有过载保护能力。一旦并发的请求超过预期,就要丢弃部分请求,以减少压力。
我们在本章开篇第一讲 “[34 | 服务端开发的宏观视角](https://time.geekbang.org/column/article/120049)” 中,总结服务端开发的体系架构如下:
![](https://static001.geekbang.org/resource/image/89/82/895dbf7e39fb562215e0176ca4aad382.png)
在这个图中我们并没有把缓存Cache画出来。但结合上面介绍的缓存典型使用方式我们很容易脑补它在图中处于什么样的位置。
回到前面的问题缓存Cache和存储Storage到底是什么关系
我个人认为,缓存其实应该被认为是存储的补丁,而且是理论上来说不太完美的补丁。
为什么说它是补丁?
因为如果存储本身非常匹配业务场景的话,它不应该需要缓存在它前面挡一道,内部自己就有缓存。至于把一个复杂的 F(x) 缓存起来,更根本的原因还是存储和业务场景不那么直接匹配所致。
但是实现一个存储很难,所以存储的业务场景匹配性很难做到处处都很好。
出现事务Transaction是为了改善存储的业务场景“写操作”的匹配性把一个复杂操作包装成一个原子操作。
出现缓存Cache则是为了改善存储的业务场景“读操作”的匹配性提升高频读操作的效率。
所以我们说,缓存是一个存储的补丁。
那么为什么我们说这是一个不太完美的补丁呢?
因为上面的 FastF(x) 并没有被包装成一个原子的读操作。从严谨的角度来说,这段代码逻辑是有问题的,它会破坏数据的一致性。
对于一个确定的 x 值,如果 F(x) 永远不变,这就没问题。但如果 F(x) 值会发生变化,会有多个版本的值,那就有可能会出现并发的两个 F(x) 请求得到的结果不同,从而导致缓存中的值和存储中的值不一致。
这种情况后果有可能会比较严重。尤其是如果我们有一些业务逻辑是基于 FastF(x) 得到的值,就有可能会出现逻辑错乱。
## groupcache
为了避免发生这类一致性问题memcached 的作者 Brad Fitzpatrickbradfitz搞了一个新的内存缓存系统叫 groupcache。
groupcache 基于 Go 语言实现,其 Github 主页为:
* [https://github.com/golang/groupcache](https://github.com/golang/groupcache)
从业务角度groupcache 主要做了两大变化:
其一,引入 group 的概念。这是一个重要改动,也是 groupcache 这个名字的来由。
在同一个缓存集群,可能会需要缓存多个复杂操作,比如 F(x)、G(x)。如果没有 group那么我们就不能只是记录 x => y 这样的键值对,而是要记录 F#x => yG#x => y 这样的键值对。中间的 # 只是一个分隔符,换其他的也可以。
看起来好像也还可以?
其实不然,因为 F(x)、G(x) 在同一个内存缓存集群就意味着它们相互之间会淘汰对方,这里面的淘汰规则不是我们能够控制的,很难保证结果符合我们的预期。
那么有 group 会变成什么样?首先你可以创建 F、G 两个独立的 group每个 group 可以设定独立的内存占用上限cacheBytes
这样,每个 group 就只淘汰自己这个 group 内的数据,相当于有多个逻辑上独立的内存缓存集群。
另外,在 group 中只需要记录 x => y 这样的键值对,不再需要用 F#x、G#x 这种手工连接字符串的方式来模拟出名字空间。
其二,值不可修改。一旦某个 x 值 Get 到的值为 y那么就一直为 y。它的使用方式大体如下
```
var groupF = groupcache.NewGroup("F", cacheBytes, groupcache.GetterFunc(func(ctx groupcache.Context, key string, dest groupcache.Sink) error {
x := fromString(key)
y := F(x)
return dest.SetBytes(toBytes(y))
}))
func FastF(x TypeX) (y TypeY) {
key := toString(x)
var val []byte
groupF.Get(ctx, key, groupcache.AllocatingByteSliceSink(&val))
y = fromBytes(val)
return
}
```
这当然也就意味着它也不需要引入 memcached 中的缓存失效时间这样的概念。因为值是不会过时的,它只会因为内存不足而被淘汰。
一致性问题也被解决了。既然值不可修改,那么自然就不存在一致性问题。
当然groupcache 是一个理论完美的内存缓存系统,它解决了 memcached存在的一致性缺陷。但是 groupcache 对使用者来说是有挑战的,某种意义上来说,它鼓励我们用函数式编程的方式来实现业务逻辑。
但是你也知道,函数式编程是比较小众的。所以怎么用好 groupcache挑战并不低。
## Redis
谈到存储与缓存的关系,不能不提 Redis。
Redis 在定位上特别奇怪,以至于不同的人对它的认知并不相同。有的人会认为它是内存缓存,有的人会认为它是存储。
Redis 的确可以当作缓存来用我们可以设置内存上限当内存使用达到上限后Redis 就会执行缓存淘汰算法。只不过如果我们把它当作内存缓存那么其实它只需要是一个简单的键值存储KV Storage就行。
但是 Redis 实际上是 key => document它的值可以是各类数据结构比如字符串哈希表列表集合有序集合支持 Range 查询),等等。
不仅如此Redis 还支持执行 Lua 脚本来做存储过程。
这些都让 Redis 看起来更像一个数据库类的存储中间件。
但当我们把 Redis 看作存储,我们有这样一些重要的问题需要考虑。这些问题非常非常重要,存储系统可不是闹着玩的。
问题一是持久性Durability。Redis 毕竟是基于内存的存储,虽然它也支持定期写到外存中,但是定期持久化的策略对于一个服务端的存储系统来说是不合格的。因为如果发生宕机,上一次持久化之后的新数据就丢了。
所以 Redis 需要其他的提升持久性的方案,比如多副本。
Redis 的确支持多副本。但是只是同机房多台机器的多副本是没有用的,因为它没有办法防止机房整体断电这类的故障。当出现机房级的故障时,就有极大概率会丢失数据。
对于存储系统来说,这是不可接受的。因为相比人们对持久性的要求,机房整体断电并不是一个太小概率的事件。
所以 Redis 如果要作为存储的话,必须保证用多机房多副本的方式,才能保证在持久性这一点上能够达标。
但是多机房多副本这样的方式,显然实施条件过于苛刻。会有多少企业仅仅是为了部署 Redis 去搞多个机房呢?
问题二,是重试的友好性。在 “[29 | 实战(四):怎么设计一个“画图”程序?](https://time.geekbang.org/column/article/111289)” 中我们提到过,考虑网络的不稳定性,我们设计网络协议的时候需要考虑重试的友好性。
在 Redis 的协议中有不少请求用户很友好但是对重试并不友好。比如LPUSH 请求用来给列表List增加一个元素。但是在重试时一个不小心我们很可能就往列表中添加了多个相同的元素进去。
总结来说Redis 如果我们把它作为存储的话,坑还是不少的。它和 memcached 都是实用型的瑞士军刀,很有用,但是我们站在分布式系统的理论角度看时,它们都有那么一点不完美的地方。
## 结语
今天我们讨论了存储与缓存之间的关系也分别介绍了三个模型迥异的缓存系统memcached、groupcache、Redis。
缓存是一个存储系统在服务器性能上的补丁。这个补丁并不是那么完美。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。我们服务端开发相关的基础软件介绍得差不多了,下一讲我们将聊聊服务端开发的架构建议。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。