对于服务端同学来说,缓存是一个避不开的话题,相信大多数同学接触缓存是在操作系统的课上。缓存就是将低效存储介质(比如磁盘)的内容,临时在高效存储介质(比如内存)中保存一份,以提高读写效率。当然了,这个高效低效是相对的,比如CPU中还有L1/2/3级别缓存,可以说其是计算机内存的缓存。
在服务端,使用缓存主要目的:
1)降低服务器(通常是数据库)压力,进而提高接口响应速度;
2)降低硬件成本,使用缓存可以替代原本需要多台数据库服务器才能承载的请求量。
但在我们引入缓存的同时提高了软件的复杂性,比如要关注缓存失效、污染、淘汰等问题,因此在维护软件服务端过程中引入缓存也是要慎重的,不能当做银弹,如果可以通过提高硬件(CPU、I/O)性能解决问题的时候,那升级硬件往往是更合适的解决方案。
本文主要聊基于数据库的缓存,当然,也可以举一反三到其它场景。
缓存介质以Redis为例,数据库以MySQL为例
缓存预热是指服务上线时,提前将相关的业务数据进行构造后写到缓存介质中,避免由于请求量太大而使刚上线的服务压力太大,甚至打垮。
一般来说我们用户第一次读时加载缓存就足够了,那什么情况下使用预加载合适呢:
在聊缓存污染之前,先说下缓存更新的方法:
缓存污染是指缓存与数据库数据不一致。这种不一致常常由开发者更新缓存不规范造成的,更新缓存分为四种:
注意我们是在更新数据库,更新/删除缓存一定成功的条件下讨论
我们考虑先后来了a,b两个写请求,要对数据X进行更新。
我们考虑先后来了a,b两个写请求,要对数据X进行更新。
我们考虑当前数据库和缓存中的X=B,先后来了a写请求要对数据X进行更新,b读请求读取X的值。
我们考虑当前数据库中的X=A,缓存中X不存在,先后来了a读请求读取X的值,b写请求要对数据X进行更新。
通过对以上四种方案的分析,都有可能导致缓存不一致。这还没考虑数据库和缓存写失败,那怎么办呢?
那么就要结合实际进行分析了:
基于以上三点共识:
综上 先更新数据库再删除缓存,读时再更新 方案是最优的,真实项目中往往也采用这种方案。
我们通过2.1-2.5小节讨论都没考虑【更新数据库,更新/删除缓存】的情况,针对最后胜出的 【先更新数据库再删除缓存,读时再更新】讨论下。
更新数据库失败的话,直接报错即可。
删除缓存失败的话:
针对【先更新数据库再删除缓存,读时再更新】方案,为了避免2.4小节讨论的异常情况发生,可以考虑在删除缓存成功后,等待若干时间(一般1s),再执行一次删除缓存操作,这样可以极大的降低异常概率发生,可以当做不可能事件了。当然,这个等待若干时间可以程序内起一个定时任务,也可以使用消息队列延迟队列。
旁路缓存就是【先更新数据库再删除缓存,读时再更新】方案。
1:读操作:先读缓存,若命中直接返回,否则读数据库后写入缓存再返回
2:写操作:写数据库,再删除对应的缓存
除了旁路缓存策略,还有Read-Through/Write-Through或者Write-Behind(Write-Back),见文章
缓存的使用主要是为了降低数据库压力,提高响应速度,同时缓存的成本也是高昂的。所以随着缓存机制的运行,一些缓存内容命中率很低或不会再命中,就可以淘汰了。常见的淘汰指标有:
1)基于空间:设置缓存空间大小,到达阈值开始淘汰;
2)基于容量:设置缓存存储记录数,到达阈值开始淘汰;
3)基于时间,到达阈值开始淘汰
TTL(Time To Live,即存活期)缓存数据从创建到过期的时间。
TTI(Time To Idle,即空闲期)缓存数据多久没被访问的时间。
淘汰算法细化有很多:
FIFO:先进先出。在这种淘汰算法中,达到阈值(空间大小or记录数),则先进入缓存的会先被淘汰。这种可谓是最简单的了,但是会导致我们命中率很低。试想一下我们如果有个访问频率很高的数据是所有数据第一个访问的,而那些不是很高的是后面再访问的,那这样就会把我们的首个数据但是他的访问频率很高给挤出。
LRU:最近最少使用算法。其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。在这种算法中避免了上面的问题,每次访问数据都会将其放在我们的队尾,如果需要淘汰数据,就只需要淘汰队首即可。但是这个依然有个问题,如果有个数据在 1 个小时的前 59 分钟访问了 1 万次(可见这是个热点数据),再后一分钟没有访问这个数据,但是有其他的数据访问,就导致了我们这个热点数据被淘汰。
LFU:最近最少频率使用。在这种算法中又对上面进行了优化,利用额外的空间记录每个数据的使用频率,然后选出频率最低进行淘汰。这样就避免了 LRU 不能处理时间段的问题。
TTL:设置过期时间,即给缓存key设置过期时间,到期就被淘汰。实现手段有:1)读的时间判断下是否过期,过期就删除缓存记录再返回空、2)定时任务,检查key是否过期,过期就删除(每个key都一个定时任务[时间轮算法]?还是一个定时任务删一批缓存记录)。Redis就是二者结合使用的。
TTI: 设置空闲期,即给缓存key设置空闲期,每被访问一次就延期。
在摘要
中提到引入缓存有利有弊,在缓解服务器CPU和I/O压力的同时,也提高了软件编写的复杂度,带来了一些隐蔽的问题。其中缓存失效是除了缓存污染之外最需要注意的问题。
我们来看看缓存正常的模式:
当缓存失效时:
通过对比,大家知道缓存失效带来的可怕后果了吧,缓存一但失效,客户请求就越过缓存这道拦截直接触达数据库,令数据库抗本来扛不住的请求量,一不小心可能把数据库干歇菜了。
主要是因为在做资源规划时,当前数据库配置可以抗住每秒5000的请求量,加上缓存增加到每秒10000的请求量,甚至更高。突然缓存因为某种原因失效了,每秒10000的请求量直接打到数据库了,数据库瞬间压力飙升,最终造成服务异常,客户指责声一片。
想要解决这样的窘境,就要先一起了解下缓存失效的原因吧。
缓存雪崩是指在同一时间点,大批不同的缓存数据失效。概念是很好理解的,那是如何发生的呢?
针对这种情况很好解决,就是在加载这批缓存数据时给每个缓存设置不同的过期时间即可。具体操作就是随机取值,比如要设置60min过期,可以在[55,65]之间随机取值。
事前:缓存介质部署要满足高可用特性,并做好监控。以Redis为例,最好采用哨兵模式或集群模式部署,还要做好内存,CPU等资源的监控等。
事中:在软件设计时要考虑到这种情况,就要安排好本地缓存,限流,降级,熔断等保护措施。在缓存介质崩溃后,通过诸如此类的手段保证大部分客户可用。
事后:缓存要做持久化,这样缓存介质重启后能快速使服务恢复正常。当前也要注意缓存与DB不一致的问题,这个要具体项目具体分析了。
对于事中,这里要多一嘴,在崩溃的瞬间,如何让保护措施生效呢?如果在没生效前就把数据库打崩了呢?以Redis为例,可以在编码时对Redis的请求异常计数,在若干时间内达到某个阈值就自动开启当前服务的本地缓存,限流,降级,熔断等措施,否则就重新计数。当然了,这只是一种可行方法,欢迎大家评论区发挥奇思妙想。
我们设计缓存时一般习惯先请求缓存介质(Redis),如果存在数据就直接返回,不存在就请求数据库,数据库存在就设置到缓存中再返回,否则直接返回。
如下图
可以发现,如果查询的数据在数据库中不存在,那么缓存中自然也不存在,这样在缓存中查不到,就去查询数据库。当这样的请求多了后,数据库的压力也很大。极端的,遇到黑客攻击,模式大量数据库不存在的数据进行DDOS攻击,更是可怕。
说到这里缓存穿透就很好理解了,是指通过查询一个不存在的数据引起缓存失效的场景。
解决办法主要有两种:
当数据库不存在查询值时,不直接返回,而是将一个表示空值的标志设置到缓存里。下次再请求的时候,如果从缓存获取到表示空值的标志就返回空值,而不是去数据库查询。不过这种情况一般会将其设置一个较短的过期时间。当然了,也可以与正常值设置一样的时间,不过为了保证缓存与DB的最终一致性,就要在更新该数据时也更新or删除该空值。
对于绝对不存在数据的请求,可以在参数校验时直接拦截。比如订单ID,肯定不会是负值,遇到负数的请求直接返回即可。
对于可能存在的数据的请求,可以使用布隆过滤器。还以订单ID为例,我们可以将已经存在的订单ID插入到布隆过滤器中,这样只有通过布隆过滤器的校验才可以继续往下走。不过布隆过滤器肯能误判,即不存在的数据可能被判定为存在,不过想不收益,这点误差可以忽略不计。
PS:缓存所用的内存相比数据库使用的磁盘是昂贵的。第一种方案对于较为集中地缓存穿透是非常适合的。但是若数据库不存在的请求值是非常分散的,就会耗用大量缓存,这个时候第二种方案更好。
缓存击穿可以理解为缓存崩溃的一种特例,即某些热点数据突然失效(一般是因为缓存超期)导致瞬时大量请求打到数据库上的场景。
要避免缓存击穿的问题,一般采用以下两个办法:
这里加锁并不是粗暴地将该批请求变成线性的进行处理,而是以请求该数据的Key值为锁,似的只有第一个请求可以触达真实的数据库,其他请求则采取阻塞或重试策略。如果是本地缓存出问题则加互斥锁即可,如果是分布式缓存出问题则加分布式锁。这样虽说在某一刻同时接收到大量同类请求,但只有一个请求触达数据库,完美解决了缓存击穿问题。
缓存击穿是针对热点数据的,其实我们是可以通过某些手段监控那些数据是热点数据的,针对这些数据可以通过定时任务之类的有计划的进行更新,而不是完全交由缓存策略自动管理,也可以有效避免缓存击穿问题。
针对加锁方案,这里细说一下,其实它有个专属概念,叫请求合并,即针对某一刻接收到大量同类请求,只处理第一个,其他的阻塞等待第一个请求的处理结果,待拿到第一个请求的处理结果,然后将之复制给其他请求。
如下图
在go语言中有一个SingleFlight 库,就是这种思想的具体实现,有兴趣的可以看下。
在4.3小节有聊到这个概念,只是一笔带过,这里详细说说。
热点缓存很好理解,就是某个或某些数据请求量特别大,缓存节点就很容易出现过载、卡顿,甚至 Crash,这种场景会被称作缓存热点。
那如何解决呢?
我们知道 软件架构有单点过度到微服务、MySQL由单点到主从再到分库分表、Redis有单点到哨兵再到集群、MongoDB有单点到复制集再到集群等等诸如此的设计,都是为了解决大量数据情况下性能依旧很好的问题,它们的核心思想就是分而治之。
同样的道理,针对热点数据一样的策略。以Redis作为缓存介质为例:
首先要明确 热点key只会命中Redis集群的某一个节点,这样该节点就会承受巨大压力,甚至崩溃。
那就要拆分,将其复制成n份数据,比如在key后面增加一个序号,key-0,key-1 … key-n,这n份数据映射到n个Redis集群中的节点。每次请求时,应用程序随机访问一个即可。
一个完善的解决方案如下:
这样一旦监控到hot key,就通过配置中心通知应用程序【实际是缓存基础库】(通知hot key分成几份之类的关键信息),应用程序收到后就自动支持随机访问,这样可以快速、动态扩容hot key 。
在4.1小节提到了本地缓存,在加上分布式缓存(Redis),就构成了一个多级缓存。这种思想来自于CPU的L1/2/3级缓存的设计。
在微服务中本地缓存并不流行,主要是因为用户请求是随机打到某个服务上的,如果用本地缓存就会有大量冗余,当然了本地缓存的性能是超高的。所以在微服务中本地缓存一般作为一种补救措施,类似在4.1小节中扮演的角色,在分布式缓存失效后它才生效。
所以说本地缓存与分布式缓存二者各有所长,是互补而非排斥的关系,在软件设计中合理利用,可以达到1+1>2的效果。
根据示意图是很好理解的,本地缓存作为一级缓存,分布式缓存作为二级缓存。如果请求内容在一级缓存就直接返回,否则到二级缓存查询,存在就回填会一级缓存并返回,否则就到数据源查询,存在就回填一二级缓存并返回。
不过多级缓存还是要慎用,级别多了,缓存污染发生的概率大大增加,当然了,失效问题会降低。不过一般项目只用分布式缓存即可,本地缓存作为一种兜底的保障措施即可。
本文讲了缓存预热、淘汰、污染、雪崩、穿透、击穿、热点、多级等多种概念与解决方案。特别是缓存污染与缓存失效两大问题。
在2.8小结简单描述了旁路缓存的思想,它是缓存设计常用的一种模式,这里再进一步总结。
读流程图如下
写流程图如下