# 缓存
解析:缓存的面试其实分成两大块:
- 缓存的基本理论
- 缓存中间件的应用
这里我们讨论缓存的一些基本理论,缓存中间件 Redis 等,在对应的中间件章节里面看里面查看。
缓存的基本理论,目前来说考察比较多的是:
- 缓存和 DB 一致性的问题
- 缓存模式
- 缓存穿透、缓存击穿、缓存雪崩
### 缓存和 DB 一致性问题
为了方便讨论,这里就将问题简化为 DB 和缓存一致性。也就是更新只需要更新 DB 和缓存。
首先要记住,缓存一致性的问题根源于两个原因:
- 不同线程并发更新 DB 和数据库;
- 即便是同一个线程,更新 DB 和更新缓存是两个操作,容易出现一个成功一个失败的情况;
缓存和 DB 一致性的问题可以说是无最优解的。无论选择哪个方案,总是会有一些缺点。
最常用的是三种必然会引起不一致的方案,这三种方案大同小异。面试的时候要记住为什么它们会引起不一致。这三种方案都是有一个显著特征,就是如果缓存是会过期的,那么它们最终都会一致。
1. 先更新 DB,再更新缓存。不一致的情况:
1. A 更新 DB,DB中数据被更新为1
2. B 更新 DB,DB中数据被更新为2
3. B 更新缓存,缓存中数据被更新为2
4. A 更新缓存,缓存中数据被更新为1
5. 此时缓存中数据为1,而DB中数据为2。这种不一致会一直持续到缓存过期,或者缓存和DB再次被更新,并且被修改正确;
![](img/db_before_cache.png)
1. 先更新缓存,再更新 DB。不一致的情况;
1. A 更新缓存,缓存中数据被更新为1
2. B 更新缓存,缓存中数据被更新为2
3. B 更新 DB,DB中数据被更新为2
4. A 更新 DB,DB中数据被更新为1
5. 此时缓存中数据为2,但是DB 中数据为1。这种不一致会一直持续到缓存过期,或者缓存和DB再次被更新,并且被修改正确;
![](img/cache_before_db.png)
1. 先更新 DB,再删除缓存。不一致的情况;
1. A 从数据库中读取数据1
2. B 更新数据库为2
3. B 删除缓存
4. A 更新缓存为1
5. 此时缓存中数据为1,数据库中数据为2
![](img/db_remove_cache.png)
所以本质上,没有完美的解决方案,或者说仅仅考虑这种更新顺序,是不足以解决缓存一致性问题的。
与这三个类似的一个方案是利用 CDC 接口,异步更新缓存。但是本质上,也是要忍受一段时间的不一致性。比如说典型的,应用只更新 MySQL,然后监听 MySQL 的 binlog,更新缓存。
而如果需求强一致性的话,那么比较好的方案就是:
- 第一个是负载均衡算法结合 singleflight
- 第二个是分布式锁。严格来说,分布式锁的方案,我一点都不喜欢,毫无技术含量
第一个方案,稍微有点奇诡。我们可以考虑对 key 采用哈希一致性算法来作为负载均衡算法,那么我们可以确保,同一个 key 的请求,永远会落到同一台实例上。然后结合单机 singleflight,那么可以确保永远只有一个线程更新缓存或者 DB,自然就不存在一致性问题了。
这个方案要注意的是在哈希一致性算法因为扩容,或者缩容,或者重新部署,导致 key 迁移到别的机器上的时候,会出现问题。假设请求1、2都操作同一个 key:
- 请求1被路由到机器 C 上
- 扩容,加入了 C1 节点
- 请求2被路由到了 C1 节点上
- (以先写DB为例)请求1更新DB
- 请求2更新DB,请求2更新缓存
- 请求1更新缓存
在这种情况下,你不管怎么搞都会出现不一致的问题。那么可能的解决方案就是:
- 要么在部署 C1 之前,在 C 上禁用缓存
- 要么在部署 C1 之后,先不使用缓存,在等待一段时间之后,确保 C 上的迁移key的请求都被处理完了,C1 再启用缓存
分布式锁的方案就没什么好说的了,咔嚓一把分布式锁一了百了。分布式锁适用于写请求特别少的例子,因为读是没有必要加分布式锁的。读完全没有必要加分布式锁,即便此时有人正在更新缓存或者 DB,当前的请求要么读到更新前的,要么读到更新后的,不会有什么问题。
注意我说的写,是写缓存的写。也就是说,如果要是缓存过期,然后用 DB 的数据更新缓存,同样要参与抢夺这个分布式锁。
另外,一个可行的分布式锁方案的优化是在单机上引入 singleflight,确保一个实例针对一个特定的 key 只会有一个线程去参与抢全局的分布式锁。
注意!前面的这些方案,我们都有一个基本的假设,就是更新 DB 和更新缓存两个步骤都会成功。但是很显然这个假设是站不住脚的,也就是说,真正寻求强一致性,还要进一步解决更新 DB 和更新缓存一个成功一个失败的问题。
这里,也就是只有三个选项:
- 追求强一致性,选用分布式事务;
- 追求最终一致性,可以引入重试机制;
- 如果可以使用本地事务,那么应该是:开启本地事务-更新DB-更新缓存-提交事务
然后一个问题是:我用了分布式事务,我还需要分布式锁吗?答案是,要的。因为分布式事务既解决不了多个线程同时更新的问题,也解决不了一个线程更新,一个线程从数据库读数据刷缓存的问题。
### 缓存模式
缓存模式主要是所谓的 cache-aside, read-through, write-through, write-back, refresh ahead 以及更新缓存使用到的 singleflight 模式。这些模式并不是银弹,如果说某个模式优于其它的模式,这就是在扯淡了。因此,选择何种缓存模式,也就是一个业务层面上考虑的问题,大多数时候,选取任何一种模式都不会有问题。
- write-back:这个稍微有点意思。因为标准的 write-back 是在缓存过期的时候,然后再将缓存刷新到 DB 里面。因此,它的弊端就是,在缓存刷新到 DB 之前,如果缓存宕机了,比如说 Redis 集群崩溃了,那么数据就永久丢失了;但是好处就在于,因为过期才把数据刷新到 DB 里面,因为读写都操作的是缓存。如果缓存是 Redis 这种集中式的,那么意味着大家读写的都是同一份数据,也就没有一致性的问题。但是,如果你设置了过期时间,那么缓存过期之后重新从数据库里面加载的同时,又有一个线程更新缓存,那么两者就会冲突,出现不一致的问题;
- refresh ahead 这种其实就是前面说的利用 CDC 的方案
### 缓存异常场景
缓存穿透、击穿和雪崩,本质上就是一个问题:缓存没起效果。只不过根据不起效的原因进行了进一步的细分。
我一直觉得这三个东西的命名特别沙雕,因为穿透和击穿在中文语境下区别就不大,也不知道是哪个卧龙凤雏搞出来的名字。
其实,这三个就是描述了三种场景:
- 你数据库本来就没数据
- 你数据库有,但是缓存里面没有
- 你缓存本来有,但是突然一大批缓存集体过期了
数据库本来就没数据,所以请求来的时候,肯定是查询数据库的。但是因为数据库里面没有数据,所以不会刷新回去,也就是说,缓存里面会一直没有。因此,如果有一些黑客,一直发一些请求,这些请求都无法命中缓存,那么数据库就会崩溃。
如果数据库有,但是缓存里面没有。理论上来说,只要有人请求数据,就会刷新到缓存里面。问题就在于,如果突然来了一百万个请求,一百万个线程都尝试从数据库捞数据,然后刷新到缓存,那么数据库也会崩溃。