缓存技术
下图左侧为客户端直接调用存储层的架构,右侧为比较典型的缓存层+存储层架构。
收益:
①加速读写:因为缓存通常都是全内存的,而存储层通常读写性能不够强悍(例如MySQL),通过缓存的使用可以有效地加速读写,优化用户体验。
②降低后端负载:帮助后端减少访问量和复杂计算(例如很复杂的SQL语句),在很大程度降低了后端的负载。
成本:
①数据不一致性:缓存层和存储层的数据存在着一定时间窗口的不一致性,时间窗口跟更新策略有关。
②代码维护成本:加入缓存后,需要同时处理缓存层和存储层的逻辑,增大了开发者维护代码的成本。
③运维成本:以Redis Cluster为例,加入后无形中增加了运维成本。缓存的使用场景基本包含如下两种:
①开销大的复杂计算:以MySQL为例子,一些复杂的操作或者计算(例如大量联表操作、一些分组计算),如果不加缓存,不但无法满足高并发量,同时也会给MySQL带来巨大的负担。
②加速请求响应:即使查询单条后端数据足够快(例如select*from tablewhere id=),那么依然可以使用缓存,以Redis为例子,每秒可以完成数万次读写,并且提供的批量操作可以优化整个IO链的响应时间。
缓存更新策略
缓存中的数据会和数据源中的真实数据有一段时间窗口的不一致,需要利用某些策略进行更新,下面会介绍几种主要的缓存更新策略。
①LRU/LFU/FIFO算法剔除:剔除算法通常用于缓存使用量超过了预设的最大值时候,如何对现有的数据进行剔除。例如Redis使用maxmemory-policy这个配置作为内存最大值后对于数据的剔除策略。
②超时剔除:通过给缓存数据设置过期时间,让其在过期时间后自动删除,例如Redis提供的expire命令。如果业务可以容忍一段时间内,缓存层数据和存储层数据不一致,那么可以为其设置过期时间。在数据过期后,再从真实数据源获取数据,重新放到缓存并设置过期时间。例如一个视频的描述信息,可以容忍几分钟内数据不一致,但是涉及交易方面的业务,后果可想而知。
③主动更新:应用方对于数据的一致性要求高,需要在真实数据更新后,立即更新缓存数据。例如可以利用消息系统或者其他方式通知缓存更新。(推荐的方法)
有两个建议:
①低一致性业务建议配置最大内存和淘汰策略的方式使用。
②高一致性业务可以结合使用超时剔除和主动更新,
这样即使主动更新出了问题,也能保证数据过期时间后删除脏数据。
缓存的适用场景
- 对于数据实时性要求不高
对于一些经常访问但是很少改变的数据,读明显多于写,适用缓存就很有必要。比如一些网站配置项。
- 对于性能要求高
比如一些秒杀活动场景。
缓存三种模式
一般来说,缓存有以下三种模式:
- Cache Aside 更新模式
- Read/Write Through 更新模式
- Write Behind Caching 更新模式
Cache Aside 更新模式
这是最常用的缓存模式了,具体的流程是:
- 失效:应用程序先从 cache 取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
- 命中:应用程序从 cache 中取数据,取到后返回。
- 更新:先把数据存到数据库中,成功后,再让缓存失效。
但是为了避免这种极端情况造成脏数据所产生的影响,我们还是要为缓存设置过期时间。
Read/Write Through 更新模式
在上面的 Cache Aside 更新模式中,应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。而在Read/Write Through 更新模式中,应用程序只需要维护缓存,数据库的维护工作由缓存代理了。
Read Through
Read Through 模式就是在查询操作中更新缓存,也就是说,当缓存失效的时候,Cache Aside 模式是由调用方负责把数据加载入缓存,而 Read Through 则用缓存服务自己来加载。
Write Through
Write Through 模式和 Read Through 相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后由缓存自己更新数据库(这是一个同步操作)。
Write Behind Caching 更新模式
Write Behind Caching 更新模式就是在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是直接操作内存速度快。因为异步,Write Behind Caching 更新模式还可以合并对同一个数据的多次操作到数据库,所以性能的提高是相当可观的。
缓存穿透
缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中,通常出于容错的考虑,如果从存储层查不到数据则不写入缓存层。
通常可以在程序中分别统计总调用数、缓存层命中数、存储层命中数,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。造成缓存穿透的基本原因有两个。第一,自身业务代码或者数据出现问题,第二,一些恶意攻击、爬虫等造成大量空命中。下面我们来看一下如何解决缓存穿透问题。
1.缓存空对象:
如图下所示,当第2步存储层不命中后,仍然将空对象保留到缓存层中,之后再访问这个数据将会从缓存中获取,这样就保护了后端数据源。存储短期空对象防止缓存穿透 5min?
2.布隆过滤器拦截
如下图所示,在访问缓存层和存储层之前,将存在的key用布隆过滤器提前保存起来,做第一层拦截。例如:一个推荐系统有4亿个用户id,每个小时算法工程师会根据每个用户之前历史行为计算出推荐数据放到存储层中,但是最新的用户由于没有历史行为,就会发生缓存穿透的行为,为此可以将所有推荐数据的用户做成布隆过滤器。如果布隆过滤器认为该用户id不存在,那么就不会访问存储层,在一定程度保护了存储层。
也就是说定义了有效的id集合,在这个集合内的id才做查询,这个集合存储到缓存中去
3.存空对象和布隆过滤器方案对比
雪崩优化
由于缓存层承载着大量请求,有效地保护了存储层,但是如果缓存层由于某些原因不能提供服务,于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会级联宕机的情况。
预防和解决缓存雪崩问题,可以从以下三个方面进行着手:
- 保证缓存层服务高可用性。如果缓存层设计成高可用的,即使个别节点、个别机器、甚至是机房宕掉,依然可以提供服务,例如前面介绍过的Redis Sentinel和Redis Cluster都实现了高可用。
- 依赖隔离组件为后端限流并降级。在实际项目中,我们需要对重要的资源(例如Redis、MySQL、HBase、外部接口)都进行隔离,让每种资源都单独运行在自己的线程池中,即使个别资源出现了问题,对其他服务没有影响。但是线程池如何管理,比如如何关闭资源池、开启资源池、资源池阀值管理,这些做起来还是相当复杂的。
- 提前演练。在项目上线前,演练缓存层宕掉后,应用以及后端的负载情况以及可能出现的问题,在此基础上做一些预案设。
热点key重建优化
开发人员使用“缓存+过期时间”的策略既可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求。但是有两个问题如果同时出现,可能就会对应用造成致命的危害:
当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大。
重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的SQL、多次IO、多个依赖等。在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃。
要解决这个问题也不是很复杂,但是不能为了解决这个问题给系统带来更多的麻烦,所以需要制定如下目标:
减少重建缓存的次数
数据尽可能一致
较少的潜在危险
①互斥锁:此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可,整个过程如图所示。
②永远不过期
永远不过期”包含两层意思: 从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期后产生的问题,也就是“物理”不过期。 从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。
从实战看,此方法有效杜绝了热点key产生的问题,但唯一不足的就是重构缓存期间,会出现数据不一致的情况,这取决于应用方是否容忍这种不一致。
两种热点key的解决方法