admin管理员组

文章数量:1585963

软件开发过程中,缓存是一个必不可少的组件。使用缓存,可以暂存复杂计算的中间结果或者临时存储需要长时间执行的任务。从算法的本质来说,缓存是一种空间换时间的设计。
但是,软件开发没有银弹,缓存设计在减少磁盘IO访问、提升性能的同时,也会引入复杂度、带来数据不一致等问题。所以,有必要梳理清楚缓存使用的相关设计,掌握缓存使用的最佳实践。

什么是缓存

缓存(cache)是用于存储数据的硬件或软件的组成部分,以使得后续更快访问相应的数据。简单来说,缓存就是数据交换的缓冲区。缓存是一种空间换时间的设计。缓存中的数据可能是提前计算好的结果、数据的副本等。
典型的应用场景:有 CPU cache,磁盘 cache,进程内缓存,分布式缓存,数据库缓存等。

缓存的使用意义

缓存设计最早应用于 CPU cache,用来缓解CPU和内存之间的速度差异( CPU 和内存之间的瓶颈,也被称为冯诺依曼瓶颈(Von Neumann Bottleneck))。
在现在云服务应用中,常使用分布式缓存来加速系统的访问速度。将数据或中间结果临时存储缓存,可以减少对低速存储设备的请求次数,缓解低速存储设备访问压力,从而提高系统的整体访问性能。

缓存关键特征

使用缓存时,如何判断一个缓存的使用是有效的呢?又该根据哪些指标去量化缓存的价值?关键的特征有三个:缓存命中率、缓存最大空间、缓存清空策略。

缓存命中率

缓存命中率 = 从缓存中返回正确结果数 / 请求缓存次数 缓存命中率 = 从缓存中返回正确结果数 / 请求缓存次数 缓存命中率=从缓存中返回正确结果数/请求缓存次数
命中率问题是缓存中的一个非常重要的问题,命中率是衡量缓存有效性的重要指标。命中率越高,表明缓存的使用率越高。

缓存最大空间

缓存最大空间就是可以存放的最大元素的数量,一旦缓存中元素数量超过这个值(或者缓存数据所占空间超过其最大支持空间),那么将会触发缓存启动清空策略。根据不同的场景合理的设置最大元素值往往可以一定程度上提高缓存的命中率,
从而更有效的时候缓存。

缓存清空策略

缓存的存储空间有限制,当缓存空间被占满时,需要在业务正常的同时,保证命中率。这种场景需要缓存清空策略来处理,设计适合自身数据特征的清空策略能有效提升命中率。常见的缓存清空策略有FIFO(First In First Out, 先进先出)、LFU(Less Frequently Used, 最少使用策略)、 LRU(least recently used, 最近最少使用)等。
(1) FIFO(first in first out)
先进先出策略,最先进入缓存的数据在缓存空间不够的情况下(超出最大元素限制)会被优先被清除掉,以腾出新的空间接受新的数据。该策略主要比较缓存元素的创建时间。在保证最新数据有效性场景下可选择该策略。
(2) LFU(less frequently used)
最少使用策略,无论元素是否过期,根据元素被使用次数,清除使用次数较少的元素。该策略主要比较元素的hit count(命中次数)。在保证高频数据有效性场景下可选择该策略。
(3) LRU(least recently used)
最近最少使用策略,无论元素是否过期,根据元素最后一次被使用的时间戳,清除最远使用时间戳的元素。该策略主要比较元素最近一次被使用时间。在保证热点数据有效性场景下可选择该策略。
除此之外,还有一些简单策略,比如:
(a) 根据元素过期时间,清除过期时间最长的元素
(b) 根据元素过期时间,清除最近要过期的元素
© 随机清除
(d) 根据关键字(或元素内容)长短清除

缓存设计模式

在使用缓存时,有一些缓存使用策略,也称缓存设计模式。注意,在讨论缓存设计模式时,先假设更新数据源和更新缓存都可以成功的情况(后面单独讨论缓存和数据源的一致性问题)。缓存的设计模式有五类:cache aside, read through, write through, write around, write behind。注意,下面的示例中均假设数据源是数据库。

cache aside

cache aside是最常用的缓存模式。在cache aside模式中,业务代码直接访问缓存或数据库。其处理逻辑如下图所示:

(1) 业务代码访问缓存,如果命中缓存(cache hit),则直接读取缓存的数据并返回给客户端。
(2) 如果缓存未命中(cache miss),则从数据库中读取数据并返回给客户端。
(3) 业务代码将缓存未命中的数据,写入缓存。
cache aside非常适合读多写少的场景。使用cache aside的系统需要对缓存故障(cache failure)保证弹性(resilience)。如果缓存出现故障(缓存不可用),系统应仍可以直接访问数据库。(尽管如果缓存在访问峰值期间出现故障,这并没有多大帮助(因为在最坏的情况下,数据库可能会达到访问上限,从而被打死,使其停止工作),但是,这可以保证数据库未达到访问上限时,业务仍能正常)。使用缓存的一个好处是,缓存中的数据模型可以不同于数据库中的数据模型。这可以加速某些场景的查询性能。如果对于一些聚合数据访问的场景,如果直接存储在缓存中,则可以减少聚合数据的计算次数。
使用cache aside模式时,最常见的写入策略是直接将数据写入数据库。假设有这样一种场景,如果数据库中数据需要更新,在更新数据库的同时并未同步更新缓存。这时会带来缓存与数据库的不一致。为了解决这个问题,开发人员通常对缓存数据设置生存时间 (Time to Live, TTL),直到 TTL 过期。如果必须保证数据新鲜度(data freshness),开发人员要么使缓存数据无效,要么使用适当的写入策略。

read through

read through模式下,缓存负责保持与数据库的一致。当数据未命中时,缓存会主动从数据库中读取该未命中数据,并回写缓存,然后将这部分数据返回给业务代码。其处理逻辑如下图所示:

(1) 业务代码访问缓存,如果命中缓存(cache hit),则直接读取缓存的数据并返回给客户端。
(2) 如果缓存未命中(cache miss),缓存会从数据库中读取数据并回写缓存,然后返回给客户端。
cache-aside 和 read-through 策略都是延迟加载(load lazily)策略,即仅在第一次读取时加载。但两种模式至少有以下两个关键区别:
(1) 对于cache aside模式,业务代码负责从数据库中获取数据并回填缓存。而在read through模式中,这部分逻辑通常由独立缓存实现。
(2) 与 cache aside模式不同,read through 模式中的数据模型必须与数据库中的数据模型保持一致。
与cache aside模式一样,read through 模式也适合读多写少的场景,特别是多次请求相同数据的场景。但是,这种模式的一个缺点是当第一次请求数据时,总是会导致缓存未命中,并额外带来将数据加载到缓存的操作。开发人员通过手动发出查询来“预热”缓存来处理这个问题(缓存预热)。此外,与 cache aside 模式一样,read through 模式下缓存和数据库之间的数据也有可能不一致。假设有这样一种场景,如果数据库中数据需要更新,在更新数据库的同时并未同步更新缓存。这时会带来缓存与数据库的不一致。

write through

write through模式下,数据首先写入缓存,然后写入数据库。 与read through 一样,写入总是通过缓存到数据库。其处理逻辑如下图所示:

(1) 业务代码写入数据到缓存。
(2) 缓存将数据写入数据库。
write through模式看似没有太大作用,这种模式会引入额外的写入延迟,因为数据先写入缓存,然后再写入数据库。但是,这种模式与read through模式搭配,就可以获得read through的所有优势,并且还可以获得数据一致性保证,免于使用缓存失效技术。DynamoDB Accelerator (DAX) 是read/ write through 模式的一个很好的例子。

write around

write around 模式下,数据直接由Application写入数据库,然后让Cache无效。
write around 模式可以与 read through 模式结合使用,并在数据仅写入一次且读取频率较低或从不读取的情况下提供良好的性能。如,实时日志或聊天室消息。 同样,此模式也可以与cache aside模式结合使用。

write behind / write back

write behind 模式下,业务代码将数据写入缓存后,缓存会立即确认,并在延迟一段时间后将数据写回数据库。其处理逻辑如下图所示:

write behind 模式提高了写入性能,适用于写多读少的场景。与read through 模式结合使用时,非常适用读写频繁的场景,其中最近读写数据始终在缓存中可用。
write behind 模式对数据库故障具有弹性,并且可以容忍一段时间的数据库停机。如果缓存支持批处理或合并操作,则可以进一步减少对数据库的整体写入次数,从而减少负载并降低成本(数据库按请求数量收费场景)。
一些开发人员将 Redis 用于cache aside和write behind模式,以更好地处理峰值期间的流量。但是,这种方式的主要缺点是如果缓存失效,数据则有可能永久丢失。
大多数关系数据库存储引擎(如 InnoDB)在其内部默认启用write behind模式,数据首先写入内存,并最终刷新到磁盘中。
细心的同学可以发现,Write Behind 模式与Linux文件系统的Page Cache算法类似,有兴趣的同学可以自行查找资料并学习。

缓存实践

缓存的使用并不是零成本的,使用缓存会遇到两大问题:(1) 系统复杂度增加。使用缓存后,为保证缓存有效性,需要考虑缓存预热、缓存雪崩、缓存穿透、缓存击穿等问题; (2) 缓存与数据源的数据不一致问题。使用缓存后,相当于同一份数据存储于缓存和数据源。

缓存预热(cache warm-up)

缓存预热并不是一个问题,而是使用缓存时的一个优化方案,它可以提高用户的使用体验。缓存预热指的是在系统启动的时候,先把查询结果预存到缓存中,以便用户后面查询时可以直接从缓存中读取,以节约用户的等待时间。缓存预热的实现思路有以下三种:
(1) 把需要缓存的方法写在系统初始化的方法中,这样系统在启动的时候就会自动的加载数据并缓存数据。
(2) 把需要缓存的方法挂载到某个页面或后端接口上,手动触发缓存预热。
(3) 设置定时任务,定时自动进行缓存预热。

缓存雪崩(cache avalanche)

缓存雪崩是指在某一个时间段,缓存集中过期失效。产生雪崩的原因主要有两种情况:(1)大多数缓存数据的过期时间一致;(2)缓存组件宕机引起的雪崩。
对于"因缓存数据设置相同的过期时间,导致某段时间内缓存失效,从而导致请求全部走数据库”的情况,一种常见的解决策略是在缓存数据时,给过期时间加上一个随机值,这样就会大幅度的减少缓存在同一时间过期(缓存时间增加随机值)。
数据集中过期不是最致命,最致命的是缓存服务器中某个节点宕机或断网。因为缓存服务节点的宕机,对数据库服务器造成的压力是不可预知的,很有可能瞬间把数据库打垮。对于“缓存组件宕机,请求全部走数据库”这种情况,有以下的思路:

事前:实现缓存组件的高可用性(以redis为例,可以实现主从架构+Sentinel 或 Redis Cluster),尽量避免缓存组件挂掉这种情况发生。  
事中:万一缓存组件真的挂掉,可以设置本地缓存 + 限流,尽量避免数据库被打挂(起码能保证服务还是能正常工作的)  
事后:缓存组件支持持久化,重启后自动从磁盘上加载数据,快速恢复缓存数据。  

缓存穿透(cache penetration)

缓存穿透是指查询一个数据库一定不存在的数据。这样,请求每次都会访问数据库。
正常的使用缓存流程大致是,数据查询先进行缓存查询,如果key不存在或者key已经过期,再对数据库进行查询,并把查询到的对象,放进缓存。如果数据库查询对象为空,则不放进缓存。如,传入一个数据库没有存储的数据,那么每次都去查询数据库,而每次查询都是空,每次又都不会进行缓存。假如有恶意攻击,就可以利用这个漏洞,对数据库造成压力,甚至压垮数据库。
针对缓存穿透的场景,其解决方案是:
(1) 缓存空值
采用缓存空值的方式,也就是说,如果从数据库查询的对象为空,也放入缓存。因为对空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间(如果是攻击,问题更严重),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。
(2) 布隆过滤器拦截
布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远超过一般的算法,缺点是有一定的误识别率和删除困难。
如果想判断一个元素是不是在一个集合里,一般想到的是将集合中所有元素保存起来,然后通过比较确定。链表、树、哈希表等数据结构都是这种思路。但是随着集合中元素的增加,我们需要的存储空间越来越大。同时检索速度也越来越慢,上述三种结构的检索时间复杂度分别为 O(n),O(log n),O(n/k)。
布隆过滤器的原理是,当一个元素被加入集合时,通过K个散列函数将这个元素映射成一个位数组中的K个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就知道集合中有没有它了:如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的基本思想。

缓存击穿(cache breakdown)

缓存击穿,是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。缓存击穿的解决方案有:
(1) 使用互斥锁。如mutex或synchronized关键字修饰的同步代码块(客户端)
(2) 使用分布式锁。如redis等
(3) 设置热点数据永远不过期
(4) 如果过期则或者在快过期之前更新,如有变化,主动刷新缓存数据

数据一致性问题

使用缓存可以缓解低效存储的访问性能,从而提高系统性能。但是,缓存本质是一个副本,也会引入副本与原始数据的数据一致性问题。这里以缓存和数据库为例,简单介绍下缓存场景下的数据一致性问题。
无论是先更新缓存,还是先更新数据库,都会存在缓存和数据库无法都更新成功的情况,如更新数据库成功,但是更新缓存失败(),或者反之,在另一方数据未更新的时间内,缓存和数据库出现数据不一致的问题。
对于业务上需要保证强一致性但对性能要求不高的场景,可以使用“两阶段提交协议”、Paxos等强一致性算法保证缓存和数据库的一致性(也可以考虑引入分布式锁,但是不建议这么做)。针对强一致性场景,可以参考笔者之前的分布式事务,通过实现分布式事务来实现缓存和数据库的强一致性。对于业务上无需保证强一致性且性能要求高的场景,可以实现最终一致性。本文重点介绍下缓存和数据库的最终一致性方案。
注意,这里没有提到强一致性且性能要求高、弱一致性且性能要求不高两种场景,主要是对于强一致性且性能要求高的场景,无法同时得到满足,而对于弱一致性且性能要求不高的场景没有讨论价值且可以通过弱一致性且性能要求高的场景实现。

设置缓存过期时间

为解决缓存和数据库的不一致性问题,一种解决方案是给缓存设置过期时间。现在主流的缓存组件均提供了对过期时间的支持。过期时间可以保证在缓存过期前数据库跟缓存的数据是不一致的,但缓存和数据库最终将一致,即保证了最终一致性。这种方案常作为一个最后的兜底手段,以应对缓存和数据库的数据不一致问题。在实际的编码中,务必确保缓存设置了过期时间。

引入重试删除机制

另一种解决缓存和数据库的不一致性问题的方案是引入重试机制。当数据库中有数据更新时,首先尝试删除缓存,如果删除成功,则立即结束。如果删除不成功,则将删除失败的数据存储到消息队列,然后异步的重试删除。因为业务需要,对于重试删除失败的场景,不能无限制的重试。一般的重试次数是三次。对于重试删除仍没有删除的数据,应使用缓存过期策略兜底。重试删除的流程图如下:

基于 biglog 删除

第三个解决缓存和数据不一致性问题的方案是基于 MySQL 数据库增量日志进行解析和消费,这里较为流行的是阿里巴巴开源的作为 MySQL binlog 增量获取和解析的组件 canal。笔者并没有使用过这种方式,只是参到相关资料。canal大致工作流程如下:canal sever 模拟 MySQL slave 的交互协议,伪装为 MySQL slave ,向 MySQL master 发送 dump 协议,MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal sever ),canal sever 解析 binary log 对象(原始为 byte 流),可由 canal client 拉取进行消费,同时 canal server 也默认支持将变更记录投递到 MQ 系统中,主动推送给其他系统进行消费。在 ack 机制的加持下,不管是推送还是拉取,都可以有效的保证数据按照预期被消费。基于 biglog 删除的流程图如下:

数据一致性总结

这里仅介绍了常用的三种解决方案,其他方案还有延时双删策略、云服务场景下的数据传输服务等。对于延时双删策略方案,因为引入过多的删除操作,增加了系统的复杂度且效率不高,这里不推荐使用,所以没有过多介绍。有兴趣的同学可以查阅下相关资料。对于数据传输服务方案,因为要依赖特定的云服务(只能在特定的云平台上使用),这里也不过多介绍。
针对设置缓存过期时间、引入重试删除机制、基于 biglog 删除三种缓存和数据库的一致性方案,设置缓存过期时间常作为兜底方案,有广泛应用的应用基础。引入重试删除机制方案因需要编写重试删除代码,对业务代码具有一定入侵性。但是,随着三方件的成熟,越来越多的三方件内置了重试机制,引入重试删除机制方案应用频率也越来越高。基于 biglog 删除方案,虽然代码耦合不高,但是额外引入了binlog同步组件,其系统复杂度更高,且只能适用于MySQL数据库,其使用频率不高。但是,因为基于 biglog 删除方案可以达到秒级同步,对于MySQL使用场景,可以覆盖大部分的应用场景。对于异地容灾、数据汇总等场景,推荐使用这种方式。

缓存使用场景

缓存的使用场景广泛。这里重点介绍Web应用、云服务中使用缓存的场景。根据缓存在Web应用或云服务的部署位置,可以将其分为三类:操作系统缓存、客户端缓存、服务器缓存等。

操作系统缓存

操作系统中使用缓存的场景很多,这里介绍一些常见的场景:
(1) CPU和磁盘之间的缓存。
(2) Linux文件系统的Page Cache。

客户端缓存

对于一个Web应用或云服务应用来说,可以在客户端使用缓存。常见场景有:
(1) HTTP 缓存。
(2) 浏览器缓存。
(3) APP 缓存(如Android、IOS等)。

服务器缓存

对于一个Web应用或云服务应用来说,也可以在服务器使用缓存。常见的场景有:
(1) CDN 缓存:存放 HTML、CSS、JS 等静态资源。
(2) 反向代理缓存:动静分离,只缓存用户请求的静态资源。
(3) 数据库缓存:数据库(如 MySQL)自身一般也有缓存。
(4) 进程内缓存:缓存应用字典等常用数据。
(5) 分布式缓存:缓存数据库中的热点数据。

缓存使用场景总结

缓存使用的主要目的是加速系统的访问速度,特别是读多写少的场景。但是,缓存的场景不局限于加速系统,如分布式缓存可以提供分布式锁能力,保证在多服务实例的场景下,保证请求处理的串行性。但缓存的主要使用场景还是加速系统的访问速度。缓存的使用并不是零成本的,使用缓存会增加系统的复杂度。使用缓存后,为保证缓存有效性,需要考虑缓存预热、缓存雪崩、缓存穿透、缓存击穿等问题。此外,缓存本质上是原始数据的副本,使用缓存也会带来缓存与原始数据的不一致性问题。
与其设计方案一样,缓存也不是银弹。在使用缓存时,在充分享受缓存价值的同时,也要充分考虑缓存引入的新问题,确保业务功能正常。

参考

https://xie.infoq/article/5a17e24974a46aee8e1815595 解析分布式系统的缓存设计
https://blog.csdn/monarch91/article/details/123688424 浅谈缓冲的理论与实践
https://zhuanlan.zhihu/p/94847283 缓存的设计与使用
https://blog.csdn/weixin_47450807/article/details/122680032 浏览器缓存机制详解
https://tech.meituan/2017/03/17/cache-about.html 缓存那些事
https://coolshell/articles/17416.html 缓存更新的套路
https://hazelcast/blog/a-hitchhikers-guide-to-caching-patterns/ A Hitchhiker’s Guide to Caching Patterns
https://codeahoy/2017/08/11/caching-strategies-and-how-to-choose-the-right-one/ Caching Strategies and How to Choose the Right One
https://developer.aliyun/article/773205 布隆过滤器
https://www.jianshu/p/8e9b77504a2d Redis 缓存雪崩、缓存穿透、缓存击穿、缓存预热
https://www.section.io/blog/what-is-cache-warming/ What is Cache Warming
https://aws.amazon/cn/caching/best-practices/ 缓存最佳实践
https://juejin/post/6844904136207499277 缓存系统设计精要
https://blog.csdn/weixin_44259720/article/details/126597821 Redis 与 DB 的数据一致 / 双写一致性问题
https://www.usenix/system/files/conference/nsdi13/nsdi13-final170_update.pdf Scaling Memcache at Facebook
https://gitee/itwanger/toBeBetterJavaer/blob/master/docs/mysql/redis-shuju-yizhixing.md MySQL 和 Redis 的数据一致性
https://blog.csdn/ym123456677/article/details/80063491 Redis理解之缓存设计
https://zhuanlan.zhihu/p/82468904 聊聊数据库与缓存数据一致性问题
https://wwwblogs/rjzheng/p/9041659.html 分布式之数据库和缓存双写一致性方案解析
https://cloud.tencent/developer/article/1932934 浅谈缓存最终一致性的解决方案
https://blog.csdn/qq_45660133/article/details/123486661 Redis和MySQL如何保持数据一致性
https://blog.csdn/qq_44096670/article/details/121632471 操作系统缓冲区管理
https://spongecaptain.cool/SimpleClearFileIO/1. page cache.html Linux Page Cache
https://xie.infoq/article/49947a60376964f1c16369a8b 缓存的五种设计模式

本文标签: 缓存软件系统