概述
为什么要使用缓存
我们使用缓存的主要目的是加速应用的读写性能,降低后端负载。
1、加速读写。缓存通常保存在内存中,如redis,而存储层通常保存在慢速的磁盘设备,读写性能较差,使用缓存可以加速数据的读写,提升用于的体验。
2、降低后端负载。减少后端的访问请求量,避免大量的请求将后端拖垮,降低负载。
缓存更新策略
很多研发同学是这么用缓存的:在查询数据的时候,先去缓存中查询,如果命中缓存那就直接返回数据。如果没有命中,那就去数据库中查询,得到查询结果之后把数据写入缓存,然后返回。在更新数据的时候,先去更新数据库中的表,如果更新成功,再去更新缓存中的数据。流程如下图
这样使用缓存的方式有没有问题?绝大多数情况下都没问题。但是,在并发的情况下,有一定的概率会出现“脏数据”问题,缓存中的数据可能会被错误地更新成了旧数据。比如1,对同一条记录,同时产生了一个读请求和一个写请求,这两个请求被分配到两个不同的线程并行执行,读线程尝试读缓存没命中,去数据库读到了数据,这时候可能另外一个写线程抢先更新了缓存,在处理写请求的线程中,先后更新了数据和缓存,然后,拿着旧数据的第一个读线程又把缓存更新成了旧数据(概率低)。比如2两个线程对同一个条订单数据并发写,也有可能造成缓存中的“脏数据”(概率高)
1、故我们经常使用Cache Aside 模式,它们处理读请求的逻辑是完全一样的,唯一的一个小差别就是,Cache Aside 模式在更新数据的时候,并不去尝试更新缓存,而是去删除缓存。流程如下:
这种方式可以解决如上例子2中的脏数据的问题。在写策略中,能否先删除缓存,后更新数据库呢?答案是不行的,因为这样会大大提高如上事例1出现的概率。另外我们一般会配合添加一个比较短的过期时间,即使示例1的情况出现了,也只有比较短时间的脏数据。
但也要学会依情况而变。比如说新注册用户,按照这个更新策略,要写数据库,然后清理缓存。可当注册完用户后,当使用读写分离时,会出现因为主从延迟所以读不到用户信息的情况(一致性要求比较高的话,写后读在一定时间阈值里面一般去master读,此时就不会有这个问题,我会在一致性浅谈的文章里介绍)。而解决这个问题的办法恰恰是在插入新数据到数据库之后写入缓存,这样后续的读请求就会从缓存中读到数据了,因为是新注册的用户,所以不会出现并发更新情况。
2、另一种经常使用的策略是模拟MySQL的从机,通过订阅binlog的方式更新缓存,此时MySQL必须设置为row格式。一般流程图如下:
缓存异常场景
缓存穿透
缓存穿透指的是查询了一个根本不存在的数据,缓存与存储层都不会命中数据。那么按照我们上面的更新缓存方式,缓存永远都不会命中,每次请求都会到后端存储,导致后端存储压力过大,设置崩溃。我们有两种优化手段:
1、回种空值。当我们从数据库中查询到空值或者发生异常时,我们可以向缓存中回种一个空值。但是因为空值并不是准确的业务数据,并且会占用缓存的空间,所以我们会给这个空值加一个比较短的过期时间,让空值在短时间之内能够快速过期淘汰。回种空值虽然能够阻挡大量穿透的请求,但如果有大量获取未注册用户信息的请求,缓存内就会有有大量的空值缓存,也就会浪费缓存的存储空间,如果缓存空间被占满了,还会剔除掉一些已经被缓存的用户信息反而会造成缓存命中率的下降。所以这个方案,在使用的时候应该评估一下缓存容量是否能够支撑。
2、布隆过滤器。布隆过滤器有一个特点是:布隆过滤器如果返回不存在的那么一定是不存在的,但是如果返回存在,未必存在。如果布隆过滤器的多次hash函数选择的比较合理,空间预估的比较合理,那边布隆过滤器返回存在,但是不存在的概率是很小的。故我们可以使用这一特性。如新注册的用户除了需要写入到数据库中之外,同时更新用户ID到布隆过滤器。那么当我们需要查询某一个用户的信息时,先查询这个 ID 在布隆过滤器中是否存在,如果不存在就直接返回空值,而不需要继续查询数据库和缓存,这样就可以极大地减少异常查询带来的缓存穿透。
对于缓存穿透这种场景,更重要的是在请求的入口处做好非法请求的拦截。
缓存雪崩
缓存雪崩指的是由于某些原因不能提供服务,导致大量请求直接访问后端服务,造成后端服务过载,甚至崩溃。缓存雪崩主要发生在以下场景:
1、当系统初始化的时候,比如说系统升级重启或者是缓存刚上线,这个时候缓存是空的,如果大量的请求直接打过来,很容易引发大量缓存穿透导致雪崩。为了避免这种情况,可以采用灰度发布的方式,先接入少量请求,再逐步增加系统的请求数量,直到全部请求都切换完成。如果系统不能采用灰度发布的方式,那就需要在系统启动的时候对缓存进行预热:在系统初始化阶段,接收外部请求之前,先把最经常访问的数据填充到缓存里面,这样大量请求打过来的时候,就不会出现大量的缓存穿透了。
2、大量的KEY同时过期,我们经常会给大量的key设置过期时间,但是如果他们的过期时间都是一样的,那么当它们过期后,大量的请求就之间访问后端缓存,造成缓存过载甚至崩溃。我们设置过期时间的时候,可以添加一个随机的值,这样它们就不会同时过期了,也就避免了这个问题。
3、缓存故障,不能提供服务。这种情况我们需要保证缓存的高可用,可以使用集群模式部署。
但是即使做了上面的保证,缓存还是可能故障,为此我们还需要提供预防措施:
1、如果缓存系统不是一个关键服务,如果缓存不可提供服务了,可以触发熔断机制,返回固定的值。
2、如果是一个关键服务,那么就需要访问后端存储服务的时候,做好限流。
但是上面的方案都是有损方案,会影响客户的体验。另外还需要定期演练,避免真的出现故障了,上面的预防措施不起作用。
缓存击穿
如果当前KEY是一个热点KEY,按照上文介绍的缓存更新方式(缓存+过期时间),当key过期时,就有大量的并发请求到后端服务而重建缓存又不能再很短时间内完成。那么在缓存失效到缓存重建期间,有大量的请求到后端存储服务,存储服务可能会过载,甚至崩溃。这个问题的根本原因是有大量的请求访问了后端存储,故我们可以从减少访问后端请求的角度解决问题:
第一种方法是互斥锁方案:此方法只允许同一时刻只有一个线程更新缓存,具体的是在更新的时候申请互斥锁,获取到锁的线程更新缓存,其他线程等待更新完成。这种方法思路比较简单,但是可能存在死锁的风险,并且线程池可能会堵塞。
第二种方法是永远不过期:从缓存层面,不设置过期时间,从而不会出现热点KEY过期后产生的问题;从功能层面,为每个value设置逻辑过期时间,当发现超过逻辑过期时间后使用单独的线程重建缓存。逻辑过期时间增加了代码复杂度和内存成本。
最后
以上就是勤奋可乐为你收集整理的缓存设计为什么要使用缓存缓存更新策略缓存异常场景的全部内容,希望文章能够帮你解决缓存设计为什么要使用缓存缓存更新策略缓存异常场景所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复