欢迎关注文章系列 ,关注我
《提升能力,涨薪可待》
《面试知识,工作可待》
《实战演练,拒绝996》
如果此文对你有帮助、喜欢的话,那就点个赞呗,点个关注呗!

一、引言

本文谈及的是后台业务服务缓存问题,在构建和优化业务服务时,

第一想到的应该是优化数据库,比如数据库模型设计、SQL结构化查询语句优化,慢查询往往是系统性能杀手;

第二便是使用缓存。在应用中使用缓存的技术手段并不新鲜,计算机组成原理中我们已经学习过,早期的计算机CPU通过fsb(中央处理器数据总线)直接与主存(内存)连接的方式导致CPU吞吐率下降,内存成为性能瓶颈,同时又由于内存访问热点数据的集中性,诞生了位于CPU和内存之间的高速缓存。

这类比于高并发的场景,数据库读库性能成为瓶颈,需要在DB和高并发的调用之间加上缓存。缓存所带来的性能提升效果更直接、高效。但是相比其他优化手段,缓存的使用并不是零成本的,任何系统使用缓存,都会遇到两大问题:

  1. 数据不一致问题
  2. 系统复杂度增加

深入业务,才能有好的设计。在设计缓存的时候,要根据我们的业务特点,明白什么是不容易变的,什么是相对容易变,什么是真正触达用户的,哪些数据是关键。这样才能发现真正应该cache的点。

二、缓存设计与使用

衡量缓存设计好坏的衡量指标是缓存命中率,缓存的命中率=缓存命中次数/请求次数,命中率越高,缓存的使用率越高。

缓存的适用场景总的来说是混存访问高频读低频写的数据。相反的对数据要求苛刻,变化频率高、访问频率低的数据不能缓存。设计和使用缓存首先要了解服务、DB、Cache的性能情况,一般情况下,DB的响应是10ms级(优化充足的情况下,SQL平均耗时1ms。这是因为命中了索引,并且命中了MySQL缓冲池(内存中)。

如果命中索引但不命中缓冲池,且查询数据量不大,磁盘并发量不高,则大约耗时10ms(磁盘寻道)。如果这些都不满足,耗时就大了),分布式缓存在ms级,进程内缓存在ns级。图1

缓存的分类及工作原理(缓存的设计与使用)(1)

图1 缓存响应时间

本地缓存可用hashmap(注意并发)、guava-cache(推荐)等实现。对于一致性要求不高且缓存命中率较高的数据服务,本地缓存可以减少服务端调用次数;集中缓存又称分布式缓存,实现由redis(推荐)、memcache等。

2.1 分布式缓存的设计与使用

缓存的设计模式一般分为四种Cache aside, Read through, Write through, Write behind caching。

2.1.1 Cache Aside Pattern

Cache aside pattern是最常用的方式,其具体逻辑如下:

失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。

命中:应用程序从cache中取数据,取到后返回。

更新:先把数据存到数据库中,成功后,再让缓存失效。


缓存的分类及工作原理(缓存的设计与使用)(2)

缓存的分类及工作原理(缓存的设计与使用)(3)

这是标准的design pattern,包括Facebook的论文《Scaling Memcache at Facebook》也使用了这个策略。为什么不是写完数据库后更新缓存?你可以看一下Quora上的这个问答《Why does Facebook use delete to remove the key-value pair in Memcached instead of updating the Memcached during write request to the backend?》,主要是怕两个并发的写操作导致脏数据。

那么,是不是Cache Aside这个就不会有并发问题了?不是的,比如,一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,这时会造成脏数据。

但,这个case理论上会出现,不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。

所以,这也就是Quora上的那个答案里说的,要么通过2PC或是Paxos协议保证一致性,要么就是拼命的降低并发时脏数据的概率,而Facebook使用了这个降低概率的玩法,因为2PC太慢,而Paxos太复杂。当然,最好还是为缓存设置上过期时间。

2.1.2Read/Write Through Pattern

我们可以看到,在上面的Cache Aside套路中,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。所以,应用程序比较啰嗦。而Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。

Read Through

Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。

Write Through

Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)

下图自来Wikipedia的Cache词条。其中的Memory你可以理解为就是我们例子里的数据库。


缓存的分类及工作原理(缓存的设计与使用)(4)

2.1.3Write Behind Caching Pattern

Write Behind 又叫 Write Back。一些了解Linux操作系统内核的同学对write back应该非常熟悉,这不就是Linux文件系统的Page Cache的算法吗?

是的,你看基础这玩意全都是相通的。在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,write back还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。

但是,其带来的问题是,数据不是强一致性的,而且可能会丢失(我们知道Unix/Linux非正常关机会导致数据丢失,就是因为这个事)。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理,有时候,强一致性和高性能,高可用和高性性是有冲突的。软件设计从来都是取舍Trade-Off。

另外,Write Back实现逻辑比较复杂,因为他需要track有哪数据是被更新了的,需要刷到持久层上。操作系统的write back会在仅当这个cache需要失效的时候,才会被真正持久起来,比如,内存不够了,或是进程退出了等情况,这又叫lazy write。

在wikipedia上有一张write back的流程图,基本逻辑如下:


缓存的分类及工作原理(缓存的设计与使用)(5)

2.2 使用本地缓存

本地缓存是应用程序在同一进程内的缓存,通常是ConcurrentHashMap。优点是耗时低得忽略不计,缺点是占用本地内存、多机会冗余、数据不同步。当集群里每个App Server都有个缓存,会有很多数据是重复的,而且某台机器更新了一条数据,别的机器不知道,给应用带来了额外同步的工作。

同步问题可以用消息队列来解决,一台有更新,广播消息给其他机器,准实时同步。

同步问题也可以用Redis 发布订阅模式来通知其它机器进行同步

同步问题也可以用etcd的事件机制来解决多机同步问题

缓存除了加快访问以外,也能提高负载能力。因为数据库连接是有限的资源——不支持NIO(none block IO),每个连接同时只能服务一个线程,有多少连接就有多少并发。如果有慢查询占住了连接,系统性能会急剧下降!缓存就能减轻对数据库连接的依赖。

本地缓存和分布式缓存各有千秋,一般建议用一致性更高的分布式缓存,当性能需要极端调优时,使用本地缓存,或者整体缓存数据量不是很大,具有高频访问几乎不修改的特点的,建议本地缓存,典型的如业务的标签功能。

三、缓存淘汰策略

在只需要缓存一部分热点数据的场景下,比如直播平台的热门房间,需要设计缓存淘汰策略。

3.1 FIFO(First In, First Out)

即先进先出,淘汰最早进来的混存数据,使用一个标准的队列即可

3.2 LRU(Least RecentlyUsed)

即最近最少使用,淘汰最近不使用的缓存数据。即按照最后被使用时间淘汰

和FIFO不同的是,需要对链表做基本模型,读写的时间复杂度是O(1),写入新数据进入头部,链表满了数据从尾部淘汰;

最近时间被访问的数据移动到头部,实现算法有很多,如hashmap 双向链表等等;

问题在于若是偶发性某些key被最近频繁访问,而非常态,则数据受到污染。

2.LFU(Least Frequently used)

即最近使用次数最少的数据被淘汰,注意和LRU的区别在于LRU的淘汰规则是基于访问时间。LFU按照使用次数淘汰数据

LFU中的每个数据块都有一个引用计数,数据块按照引用计数排序,若是恰好具有相同引用计数的数据块则按照时间排序;

因为新加入的数据访问次数为1,所以插入到队列尾部;

队列中的数据被新访问后,引用计数增加,队列重新排序;

当需要淘汰数据时,将已经排序的列表最后的数据块删除;

有很明显问题是若短时间内被频繁访问多次,比如访问异常或者循环没有控制住,而后很长时间未使用,则此数据会因为频率高而被错误的保留下来,没有被淘汰。尤其对于新来的数据,由于其起始的次数是1,所以即便被正常使用也会因为比不过老的数据而被淘汰。所以维基百科说纯粹的LFU算法不经常单独使用而是组合在其他策略中使用。

四、缓存引入的问题

缓存系统一定程度上极大提升系统并发能力,但同样也增加额外技术考虑因素,比如缓存雪崩、缓存穿透、缓存更新与数据一致性

4.1、缓存穿透

缓存穿透是指查询的key不存在,从而缓存查询不到而查询了数据库。若是这样的key恰好并发请求很大,那么就会对数据库造成不必要的压力。比如黑客用一堆不存在的key访问数据,大量请求发送到数据库,数据库压力过大而宕机,怎么解决呢?

1、把所有存在的key都存到另外一个存储的Set集合里,查询时可以先查询key是否存在;

2、将这些key对应的值设置为null 丢到缓存里面去。后面再出现查询这个key 的请求的时候,直接返回null,再根据业务需求设置过期时间。

3、BloomFilter 类似于一个hbase set 用来判断某个元素(key)是否存在于某个集合中。这种方式在大数据场景应用比较多,比如 Hbase 中使用它去判断数据是否在磁盘上。这种方案可以加在第1/2种方案中,在缓存之前加一层 BloomFilter ,在查询的时候先去 BloomFilter 去查询 key 是否存在,如果不存在就直接返回,存在再走查缓存 -> 查 DB。

4.2、缓存击穿

在高并发的系统中,大量的请求同时查询一个 key 时,这个key刚好失效了,就会导致大量的请求都打到数据库上面去。这种现象我们称为缓存击穿。造成缓存击穿的原因是多个线程同时去查询数据库,那么我们可以在第一个查询数据的请求上使用一个 互斥锁。其他的线程进入等待状态,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。

4.3、缓存雪崩

定义:缓存雪崩是指缓存系统失效,导致大量请求同时进行数据回源,导致数据源压力骤增而崩溃。两种情况会导致此问题:1、多个缓存数据同时失效;2、缓存系统崩溃,缓存同时失效

针对多个缓存数据同时失效的问题,可以将缓存时间离散化,根据缓存数据访问规律和缓存数据不一致的敏感性要求来选择缓存时间。

如果是第二个问题,缓存系统整体故障,则整个缓存系统不可用,大量回源请求,且由于缓存系统故障无法回写缓存,导致无法快速恢复。

这是缓存系统的引入,在解决高性能、高并发的同时,引入新的故障点。

考虑此问题,应从事前、事故中、事后不同阶段考虑:

事前:增加缓存系统高可用方案设计,避免出现系统性故障

事故中:增加多级缓存,在单一缓存故障时,仍有其他缓存系统可用,如之前项目中使用的三级缓存方案:内存级缓存->Memcached->Redis这样的方案;启用熔断限流机制,只允许可承受流量,避免全部流量压垮系统

事后:缓存数据持久化,在故障后快速恢复缓存系统

五、总结

使用缓存会增加系统复杂性,应根据实际业务考虑是否需要使用以及使用分布式缓存还是本地缓存,设置合理的缓存策略可以提高系统的性能,同时要合理的规避可能由于引入缓存而增加的风险。

文章链接:https://www.jianshu.com/p/4f0caa00b2cc

如果此文对你有帮助、喜欢的话,那就点个赞呗,点个关注呗!

,