存储(三)—— 缓存和局部性原理

因何而生

书接上回,存储(二)—— 内存、SSD、磁盘,上次提到,不同的存储介质之间的IO速度差异巨大,这样就会造成一个问题:数据在不同介质时间传输时,速度快的永远再等速度慢的,最终系统的数据处理速度被IO最慢的存储层”拖累“,面对每秒几千转的磁盘,上GHz的CPU唯有叹息。
怎么解决这种问题呢?
一个最直观的想法是:啥?不就是慢吗?直接都用最快的介质不就行了,磁盘慢,那就都用内存啊。
这种想法很简单,通常简单的想法都是有效的,但是,在当前时代,至少有以下几种理由说明属于这个想法的时代还没有来临。

  1. 内存并不是最快的,内存的速度和高速缓存、寄存器还是有很大差别。
  2. 内存掉电后数据不能保持。
  3. 内存、高速缓存、寄存器终究受体积的限制,你始终无法把几十块硬盘组成的磁盘阵列集成到你手边的笔记本里面。
  4. 瞅瞅各种存储设备的价格,然后再瞅瞅钱包里的硬币兄弟们。。。。
    换个思路,想想食堂卖饭的窗口,每次人多而我又想吃饺子的时候,食堂阿姨总是一次煮一锅,煮完盛五盘,然后来一个,直接端走,再来一个,再端走。柜台上摆好的显然是比锅里煮的快的。
    计算机似乎也可以这样,把要使用或者将要使用的数据,从速度慢的介质里面放到速度快的介质里面,一次”放一锅“,这样下次在有数据读取的需求时,就可以直接从速度快的介质用拿数据了。
    而这部分提前准备着的数据,就是缓存了。
    所以呢?缓存因何而生?缓存的出现是为了缓冲不同存储介质IO速度之间的天壤之别。

缓存金字塔

单从大多数程序员熟悉的应用层来看,缓存也许约等于从数据库或者文件中读出来放到内存或者redis等中的一些数据,但是如果我们把视野扩展到整个计算机系统中时,缓存又是个什么样子的呢?
神书CSAPP中有这样一张图:

我们大部分时间所做的,也许仅仅是L4那一层而已。。。
让我们把视野再扩大一些,又是怎样的呢?

再去食堂溜一圈,后厨应该放着已经和好的面团,而库房里面应该还有几十袋面粉。
所以,缓存是无处不在的,有速度差异的地方,就有缓存。

局部性原理

解释了缓存是何物,因何而生,又位于何处的问题,现在还有一个问题:缓存怎么选?什么样的数据需要从下一层更慢的介质中读出来缓存到上一层更快的介质中呢?
我们平时的做法是需要时再加载,比如查询一个用户的信息,缓存中没有,就把数据从数据库中读出来放到缓存中。
这样做没问题,但是,有没有更天马行空一些的想法呢?比如这是个招聘网站,猎头或者HR看完这个用户的身份信息,有没有可能接下来再去看看用户的履历呢。又或者这是个社交网站,你看了一个朋友的一条动态,接下来,有没有可能去看看他最近三个月的动态呢?
如果每次都费劲的从磁盘读出来放到DB中,是不是不太划算,何不像煮饺子一样,一次煮一锅?
我们的操作系统早就已经这么做了:CPU会把邻近的指令一起从L1缓存读到寄存器中,从磁盘读取数据到内存时,也是一次性把邻近的磁盘扇区的数据读进内存。
计算机为何要如此设计?
计算机在赌,赌的就是,当某些数据被使用后,那么这些数据相邻的数据也会马上被使用。
很幸运,计算机大多数时候都能赌赢。
如一个人果能一直赌,一直赢,那么一定不是这个人运气好,他要么作弊了,要么。。。不是人。

局部性原理,作为原理,一般都是有道理的,我们现在来看看,局部性原理的道理。
拿cpu缓存邻近指令来说,这样做其实是基于程序最本质的考虑,想想我们程序执行的过程,大多数代码基本只有两种流程:顺序和循环
顺序就是,执行完了第一行代码,大概率会执行下一行。顺序是局部性原理在空间上的体现。
循环就是,执行完这一段代码之后,大概率一会儿会再次执行这一段代码。循环是局部性原理在时间上的体现。
17年面试时,面试官问了这这样一个问题:堆排序和快速排序时间复杂度都是O(nlgn),就稳定性来说,堆排序更稳定,为何大范围使用的是快速排序而不是堆排序?
我说不知道。然后又问:知道局部性原理吗?我说不知道。。。
各位同学闲来无事时,不妨也考虑一下这个问题。

如果说局部性原理在CPU执行程序时的应用是得益于程序本身的顺序和循环的特点,那么从磁盘到内存呢?局部性原理又是怎样运作的呢?
这个其实是“人为”的。
磁盘没转动一次,都是大工程,好不容易转完了,总得多度几个扇区到内存吧。
既然多读了几个扇区到内存,那么我们肯定希望多读的那几个扇区的数据很快就会被使用。怎么才能做到这些呢?这就要求程序的设计者在存储数据时把相关数据存到一起。
这个相关数据体现在社交系统上可能是某个用户连续时间内发表的动态,体现在招聘系统上可能就是一份简历的所有内容。
明确了计算机运作原理之后,结合这种原理去设计应用程序,使得局部性原理尽可能的生效。所以说这种局部性原理的实现,是“人为”的。
提到这个,读者们不妨想想MySQL索引B+树的结构,也不妨再想想LSM的思想。

缓存淘汰、LRU、预热

用户缓存的空间是昂贵的、有限的,有缓存需要的数据是无限的,所以必须有一个淘汰机制,删掉旧的缓存数据,腾出地方来给新的需要缓存的数据用。
可是,那些数据应该被淘汰掉呢?
缓存淘汰一个比较常用的做法是LRU,LRU = Least Recently Used = 最近最少使用。
LRU基于一个假设:最近最少被使用的数据,在接下来的时间里,也不会被用到。这些最近最少被使用的数据就是需要被淘汰掉的数据了。
反过来,缓存中如果一条数据频繁被访问(缓存命中),那么这条数据很安全,不会被淘汰。
很多系统的缓存都有基于LRU的实现,Java的LinkedHashMap,Redis,MySQL的BufferPool等等。
有些系统刚启动时需要“预热”一下,把一些常用的数据加载到缓存中之后,再开始对外提供服务。
拿MySQL来说,系统上线后,先把常用的数据select一次,使这些热点数据进入MySQL的BufferPool中,对外提供服务时,MySQL就可以直接从缓存中读取数据,而不必访问磁盘了。
使用LRU时,有一个问题值得注意,设想这样一个场景:
一个基于LRU的缓存一共可以存10个数据,其中有4个数据是热点数据,访问频繁,我们不希望这4个数据被淘汰。可是此时在一次读取中,一次性从从磁盘中读了10条数据出来,这样这10条数据会把之前缓存中的所有数据替换掉(包括我们不希望被删除的那4个热点数据)替换掉之后,我们发现,这10条数据命中率很低,于是之前的4条热点数据不得不被从磁盘中读出,再次放到缓存中。。。
针对这样的场景,MySQL专门做了优化,把缓存划分成两部分,八分之三用来存热点数据,八分之五用来存可以被淘汰的冷数据,所有新进的(比如被从磁盘select出来的新数据)数据先进入到八分之五的部分,当这些数据被频繁访问时,可以“晋升”到八分之三里面。
真是巧妙。

缓存一致性

怎么保证DB和缓存的一致性一直都是个让人头疼的问题。
先更新DB再更新缓存?
先更新缓存再更新DB?
先删除缓存再更新DB?
先更新DB再删除缓存?
各种方案和其中的Trade-Off,这篇文章已经说得很好很好了,强烈建议一读:缓存更新的套路
补充一点文中没有提到的MySQL等数据库更新缓存的方式:
首先,为了保证性能,MySQL采用了和操作系统一样的方式:先更新缓存,然后异步把数据写到磁盘上。
这样宕机时显然会有数据丢失的问题,为了保证数据不丢失,得在更新缓存之前做点什么,毕竟内存是“不可信任”的。
大多数数据库应该都是采用WAL(Write Ahead Log)来解决这个问题的:数据进来之后,先把数据以log的方式写到磁盘上(这一步是顺序写log文件,很快),写完之后,数据就安全了,然后就可以放心进行后续更新缓存的操作了。一旦机器宕机,没来得及刷新到磁盘上的数据就会丢失,重启后,可以从WAL中重新把数据读(推导)出来,然后更新到缓存,然后异步刷磁盘。。。

缓存并发,缓存雪崩和缓存并发

通常来说,效果越好,代价越大,比如缓存。。。

缓存并发

这样一种场景,某条缓存中的数据访问量很高,但是缓存过期时间到了,这时一瞬间所有的请求都会找不到缓存,去数据库中查询,数据库瞬间的流量会很高很高。
为了避免缓存并发的这种场景,我们得想个办法堵住进入数据库的流量。
办法有二:

  1. 想办法不让缓存失效,每隔一段时间,当有请求进来时,在缓存过期之前,去更新一把缓存的过期时间。这个和Google Guava包中的Cache类的expireAfterAccess思想是一样的。
  2. 在缓存失效后,利用分布式锁,保证只有一个线程去DB中load数据放入缓存,其他线程可以等,可以重试,知道数据重新放入缓存中为止。

缓存雪崩

缓存雪崩的场景是这样的:缓存中的大量数据在同一时间过期,这回导致比缓存并发更多的流量打到DB上。
为了解决这个问题,我们可以在设置缓存过期时间时,加上一个随机的因素,比如30分钟+一个随机的毫秒数,尽量把缓存过期的时间点打散。

缓存击穿

还有这样一个场景:无意的或者是恶意的,大量请求打过来,缓存中没有,好吧,去数据库中查,也没有!!这种请求,每次都会越过缓存,打到DB上。
如果每次打过来的请求数据是一样的,比如有人搞坏事情,每次请求一个id是777777的用户信息,这个用户的信息是不存在的,这种情况还好解决,可以放一个布隆过滤器,把这些请求过滤掉,额,当然布隆过滤器有小概率的误杀。。。
如果搞事情的人再坏一点,每次都用一些随机的不同的不存在的id来请求用户信息,咋办?
这种情况,得根据自己数据库中的情况写代码过滤了,比如你的id有一定的规则,那么不符合规则的请求直接返回,再比如你的id目前的范围是1~1000W,那么明显在这个范围之外的请求,直接返回。

结束语

关于缓存的问题,很多很多,关于缓存的技术也很有意思,本人水平有限,只能讲这么多了。
讲了存储介质,讲了缓存,接下来轮到怎么在这些存储介质为我们所用,建立适合的存储系统了。
且看下回:存储(四)———— 块存储、文件存储、对象存储。


因僧问我西来意,我话山居不记年。
草履只栽三个耳,麻衣曾补两番肩。
东庵每见西庵雪,下涧常流上涧泉 。
半夜白云消散后,一轮明月到床前。