服务端缓存

关于服务端缓存

对于大部分互联网应用,数据访问频次分布通常满足二八定律,头部的20%数据往往占据超过80%的访问流量。在业务早期,由于流量较小,数据库就可以满足所有的访问流量,随着业务的发展,数据库无法抗住所有的流量,此时通常会引入高性能的缓存,用于缓解数据库的访问压力。得益于数据的二八分布,缓存的容量通常远远小于数据库的总容量,但是却能保证大部分的热点数据请求命中缓存。
一旦我们专门把“缓存”看作一项技术基础设施,一旦它有了通用、高效、可统计、可管理等方面的需求,其中要考虑的因素就变得复杂起来。通常,我们设计或者选择缓存至少会考虑以下四个维度的属性:

  • 吞吐量:缓存的吞吐量使用 OPS 值(每秒操作数,Operations per Second,ops/s)来衡量,反映了对缓存进行并发读、写操作的效率,即缓存本身的工作效率高低。
  • 命中率:缓存的命中率即成功从缓存中返回结果次数与总请求次数的比值,反映了引入缓存的价值高低,命中率越低,引入缓存的收益越小,价值越低。
    扩展功能:缓存除了基本读写功能外,还提供哪些额外的管理功能,譬如最大容量、失效时间、失效事件、命中率统计,等等。
  • 分布式支持:缓存可分为“进程内缓存”和“分布式缓存”两大类,前者只为节点本身提供服务,无网络访问操作,速度快但缓存的数据不能在各个服务节点中共享,后者则相反。

如何设计服务端缓存

从开发角度来说,引入缓存会提高系统复杂度,因为你要考虑缓存的失效、更新、一致性等问题(硬件缓存也有这些问题,只是不需要由你去考虑,主流的 ISA 也都没有提供任何直接操作缓存的指令);从运维角度来说,缓存会掩盖掉一些缺陷,让问题在更久的时间以后,出现在距离发生现场更远的位置上;从安全角度来说,缓存可能泄漏某些保密数据,也是容易受到攻击的薄弱点。冒着上述种种风险,仍能说服你引入缓存的理由,总结起来无外乎以下两种:

  • 为缓解 CPU 压力而做缓存:譬如把方法运行结果存储起来、把原本要实时计算的内容提前算好、把一些公用-的数据进行复用,这可以节省 CPU 算力,顺带提升响应性能。
  • 为缓解 I/O 压力而做缓存:譬如把原本对网络、磁盘等较慢介质的读写访问变为对内存等较快介质的访问,将原本对单点部件(如数据库)的读写访问变为到可扩缩部件(如缓存中间件)的访问,顺带提升响应性能。

1、加载:

需要注意对下游服务调用频率,更好的发挥缓存的作用。可以选择主动或被动加载,常见的加载方式有

  • 访问未命中时被动加载:并发访问时需要考虑,多线程、多客户端访问如果失效了大家都加载还是其他锁住等待某个线程加载完成,
  • 定时主动加载:默认一直有效,每隔xx分钟加载,有效避免并发更新缓存的场景,加载失败可以选择沿用之前的内容等待下次重试
  • 写时主动加载:当有缓存内容变更,主动更新缓存,可以有更好的数据时效性
  • 缓存预热:防止服务更新发布、宕机后恢复服务冷启时的缓存穿透,服务在启动后主动加载一遍热点数据,后面再数据失效后结合其他加载方式

⚠️预防缓存穿透
缓存的目的是为了缓解 CPU 或者 I/O 的压力,譬如对数据库做缓存,大部分流量都从缓存中直接返回,只有缓存未能命中的数据请求才会流到数据库中,这样数据库压力自然就减小了。但是如果查询的数据在数据库中根本不存在的话,缓存里自然也不会有,这类请求的流量每次都不会命中,每次都会触及到末端的数据库,缓存就起不到缓解压力的作用了,这种查询不存在数据的现象被称为缓存穿透。
对于业务逻辑本身就不能避免的缓存穿透,可以约定在一定时间内对返回为空的 Key 值依然进行缓存(注意是正常返回但是结果为空,不应把抛异常的也当作空值来缓存了),使得在一段时间内缓存最多被穿透一次。如果后续业务在数据库中对该 Key 值插入了新记录,那应当在插入之后主动清理掉缓存的 Key 值。如果业务时效性允许的话,也可以将对缓存设置一个较短的超时时间来自动处理。
⚠️预防缓存击穿
我们都知道缓存的基本工作原理是首次从真实数据源加载数据,完成加载后回填入缓存,以后其他相同的请求就从缓存中获取数据,缓解数据源的压力。如果缓存中某些热点数据忽然因某种原因失效了,譬如典型地由于超期而失效,此时又有多个针对该数据的请求同时发送过来,这些请求将全部未能命中缓存,都到达真实数据源中去,导致其压力剧增,这种现象被称为缓存击穿。要避免缓存击穿问题,通常会采取下面的两种办法:

  • 1、加锁同步,以请求该数据的 Key 值为锁,使得只有第一个请求可以流入到真实的数据源中,其他线程采取阻塞或重试策略。如果是进程内缓存出现问题,施加普通互斥锁即可,如果是分布式缓存中出现的问题,就施加分布式锁,这样数据源就不会同时收到大量针对同一个数据的请求了。
  • 2、热点数据由代码来手动管理,缓存击穿是仅针对热点数据被自动失效才引发的问题,对于这类数据,可以直接由开发者通过代码来有计划地完成更新、失效,避免由缓存的策略自动管理。

2、淘汰策略:

有限的物理存储决定了任何缓存的容量都不可能是无限的,所以缓存需要在消耗空间与节约时间之间取得平衡,这要求缓存必须能够自动或者由人工淘汰掉缓存中的低价值数据。

人工淘汰策略可以基于时间:写入后多久则回收、多久没有访问则回收,或者在数据更新时人工淘汰缓存。

自行淘汰策略一般基于容量:超过容量大小有优先回收很久没访问的、优先回收占用内存高的,或者根据使用者设置的权重回收。

缓存实现自动淘汰低价值数据的容器之前,首先要定义怎样的数据才算是“低价值”?由于缓存的通用性,这个问题的答案必须是与具体业务逻辑是无关的,只能从缓存工作过程收集到的统计结果来确定数据是否有价值,通用的统计结果包括但不限于数据何时进入缓存、被使用过多少次、最近什么时候被使用,等等。由此决定了一旦确定选择何种统计数据,及如何通用地、自动地判定缓存中每个数据价值高低,也相当于决定了缓存的淘汰策略是如何实现的。目前,最基础的淘汰策略实现方案有以下三种:

  • FIFO(First In First Out):优先淘汰最早进入被缓存的数据。FIFO 实现十分简单,但一般来说它并不是优秀的淘汰策略,越是频繁被用到的数据,往往会越早被存入缓存之中。如果采用这种淘汰策略,很可能会大幅降低缓存的命中率。
  • LRU(Least Recent Used):优先淘汰最久未被使用访问过的数据。LRU 通常会采用 HashMap 加 LinkedList 双重结构(如 LinkedHashMap)来实现,以 HashMap 来提供访问接口,保证常量时间复杂度的读取性能,以 LinkedList 的链表元素顺序来表示数据的时间顺序,每次缓存命中时把返回对象调整到 LinkedList 开头,每次缓存淘汰时从链表末端开始清理数据。对大多数的缓存场景来说,LRU 都明显要比 FIFO 策略合理,尤其适合用来处理短时间内频繁访问的热点对象。但相反,它的问题是如果一些热点数据在系统中经常被频繁访问,但最近一段时间因为某种原因未被访问过,此时这些热点数据依然要面临淘汰的命运,LRU 依然可能错误淘汰价值更高的数据。
  • LFU(Least Frequently Used):优先淘汰最不经常使用的数据。LFU 会给每个数据添加一个访问计数器,每访问一次就加 1,需要淘汰时就清理计数器数值最小的那批数据。LFU 可以解决上面 LRU 中热点数据间隔一段时间不访问就被淘汰的问题,但同时它又引入了两个新的问题,首先是需要对每个缓存的数据专门去维护一个计数器,每次访问都要更新,在上一节“吞吐量”里解释了这样做会带来高昂的维护开销;另一个问题是不便于处理随时间变化的热度变化,譬如某个曾经频繁访问的数据现在不需要了,它也很难自动被清理出缓存。
    • TinyLFU 是 LFU 的改进版本。为了缓解 LFU 每次访问都要修改计数器所带来的性能负担,TinyLFU 会首先采用 Sketch 对访问数据进行分析,所谓 Sketch 是统计学上的概念,指用少量的样本数据来估计全体数据的特征,这种做法显然牺牲了一定程度的准确性,但是只要样本数据与全体数据具有相同的概率分布,Sketch 得出的结论仍不失为一种高效与准确之间权衡的有效结论。
    • W-TinyLFU 又是 TinyLFU 的改进版本。TinyLFU 在实现减少计数器维护频率的同时,也带来了无法很好地应对稀疏突发访问的问题,所谓稀疏突发访问是指有一些绝对频率较小,但突发访问频率很高的数据,譬如某些运维性质的任务,也许一天、一周只会在特定时间运行一次,其余时间都不会用到,此时 TinyLFU 就很难让这类元素通过 Sketch 的过滤,因为它们无法在运行期间积累到足够高的频率。应对短时间的突发访问是 LRU 的强项,W-TinyLFU 就结合了 LRU 和 LFU 两者的优点,从整体上看是 LFU 策略,从局部实现上看又是 LRU 策略。

3、监控:

缓存是典型以空间换时间来提升性能的手段,出发点是缓解 CPU 和 I/O 资源在峰值流量下的压力。会占用存储资源(服务器内存,外部服务资源),引入缓存会提高系统复杂度,增加系统的风险,如果命中率不那么高就要考虑优化或去掉。
布隆过滤器可以检查值是 “可能在集合中” 还是 “绝对不在集合中”。“可能” 表示有一定的概率,也就是说可能存在一定为误判率。布隆过滤器是用最小的代价来判断某个元素是否存在于某个集合的办法。如果布隆过滤器给出的判定结果是请求的数据不存在,那就直接返回即可,连缓存都不必去查。虽然维护布隆过滤器本身需要一定的成本,但比起攻击造成的资源损耗仍然是值得的。
对于恶意攻击导致的缓存穿透,通常会在缓存之前设置一个布隆过滤器来解决。所谓恶意攻击是指请求者刻意构造数据库中肯定不存在的 Key 值,然后发送大量请求进行查询。

  • 缓存命中率,缓存容量使用率,
    在这里插入图片描述
    在这里插入图片描述

如何提高命中率/吞吐量

  • 使用布隆过滤器
    布隆过滤器可以检查值是 “可能在集合中” 还是 “绝对不在集合中”。“可能” 表示有一定的概率,也就是说可能存在一定为误判率。布隆过滤器是用最小的代价来判断某个元素是否存在于某个集合的办法。如果布隆过滤器给出的判定结果是请求的数据不存在,那就直接返回即可,连缓存都不必去查。虽然维护布隆过滤器本身需要一定的成本,但比起攻击造成的资源损耗仍然是值得的。
    对于恶意攻击导致的缓存穿透,通常会在缓存之前设置一个布隆过滤器来解决。所谓恶意攻击是指请求者刻意构造数据库中肯定不存在的 Key 值,然后发送大量请求进行查询。
  • 优化淘汰策略
    可以让更新不那么频繁、对更新/删除延迟没那么敏感的数据有更长的有效期,对命中率高的数据减少加载(回收)频率,可以有效提高缓存的命中率。如果缓存的内容很多,则需要考虑自行淘汰策略是否能有效地地判定缓存中每个数据价值高低。
  • 热点数据特殊处理
    通过热点检测功能可以将高访问量的数据优先缓存,提高缓存模块的可用性、吞吐量和命中率,参考热点检测治理

4、缓存雪崩

缓存击穿是针对单个热点数据失效,由大量请求击穿缓存而给真实数据源带来压力。有另一种可能是更普遍的情况,不需要是针对单个热点数据的大量请求,而是由于大批不同的数据在短时间内一起失效,导致了这些数据的请求都击穿了缓存到达数据源,同样令数据源在短时间内压力剧增。

出现这种情况,往往是系统有专门的缓存预热功能,也可能大量公共数据是由某一次冷操作加载的,这样都可能出现由此载入缓存的大批数据具有相同的过期时间,在同一时刻一起失效。还有一种情况是缓存服务由于某些原因崩溃后重启,此时也会造成大量数据同时失效,这种现象被称为缓存雪崩。要避免缓存雪崩问题,通常会采取下面的三种办法:

  • 提升缓存系统可用性,建设分布式缓存的集群。
  • 启用透明多级缓存,各个服务节点一级缓存中的数据通常会具有不一样的加载时间,也就分散了它们的过期时间。
  • 将缓存的生存期从固定时间改为一个时间段内的随机时间,譬如原本是一个小时过期,那可以缓存不同数据时,设置生存期为 55 分钟到 65 分钟之间的某个随机时间。

对于分布式缓存,可以把缓存时间放到数据里进行判断,再通过一个锁机制保证只有一个请求能穿透到DB,参考聊聊细节 - 你知道缓存的正确打开方式么?(1)。通过调整物理TTL(实际失效时间)和逻辑TTL(重新加载的时间),可以在缓存失效前给加载缓存留出时间,较长的物理TTL也可以在数据库服务有问题时也能保证一定的服务可用性。
在这里插入图片描述

5、扩展能力

事件通知,并发级别,引用方式,持久化(主要是分布式缓存)等

单机缓存和分布式缓存

单机缓存

当出现超热数据时,即便是redis也可能无法抗住突发的海量请求,此时我们将请求截停在本地,通过本地缓存来降低对redis的压力。由于本地可用内存相对极少,因此我们需要将好钢用在刀刃上,用有限的内存,来响应尽可能多的请求。此时就需要我们提升本地缓存命中率。
缓存中最主要的数据竞争源于读取数据的同时,也会伴随着对数据状态的写入操作,写入数据的同时,也会伴随着数据状态的读取操作。譬如,读取时要同时更新数据的最近访问时间和访问计数器的状态(后文会提到,为了追求高效,可能不会记录时间和次数,譬如通过调整链表顺序来表达时间先后、通过 Sketch 结构来表达热度高低),以实现缓存的淘汰策略;又或者读取时要同时判断数据的超期时间等信息,以实现失效重加载等其他扩展功能。对以上伴随读写操作而来的状态维护,有两种可选择的处理思路,一种是以 Guava Cache 为代表的同步处理机制,即在访问数据时一并完成缓存淘汰、统计、失效等状态变更操作,通过分段加锁等优化手段来尽量减少竞争。另一种是以 Caffeine 为代表的异步日志提交机制,这种机制参考了经典的数据库设计理论,将对数据的读、写过程看作是日志(即对数据的操作指令)的提交过程。尽管日志也涉及到写入操作,有并发的数据变更就必然面临锁竞争,但异步提交的日志已经将原本在 Map 内的锁转移到日志的追加写操作上,日志里腾挪优化的余地就比在 Map 中要大得多。
请添加图片描述
(图片来源)

分布式缓存

相比起缓存数据在进程内存中读写的速度,一旦涉及网络访问,由网络传输、数据复制、序列化和反序列化等操作所导致的延迟要比内存访问高得多,所以对分布式缓存来说,处理与网络有相关的操作是对吞吐量影响更大的因素,往往也是比淘汰策略、扩展功能更重要的关注点,这决定了尽管也有 Ehcache、Infinispan 这类能同时支持分布式部署和进程内嵌部署的缓存方案,但通常进程内缓存和分布式缓存选型时会有完全不同的候选对象及考察点。我们决定使用哪种分布式缓存前,首先必须确认自己需求是什么?
在这里插入图片描述
如今Redis广为流行,基本上已经打败了 Memcached 及其他集中式缓存框架,成为集中式缓存的首选,甚至可以说成为了分布式缓存的实质上的首选,几乎到了不必管读取、写入哪种操作更频繁,都可以无脑上 Redis 的程度。也因如此,之前说到哪些数据适合用复制式缓存、哪些数据适合集中式缓存时,笔者都在开头加了个拗口的“理论上”。尽管 Redis 最初设计的本意是 NoSQL 数据库而不是专门用来做缓存的,可今天它确实已经成为许多分布式系统中无可或缺的基础设施,广泛用作缓存的实现方案。通过redis cluster 或 redis ring(redis sential 模式结合一致性hash)的方式,可以构建高性能、高可用、可拓展的分布式缓存。
在这里插入图片描述

复制式缓存

复制式缓存可以看作是“能够支持分布式的进程内缓存”,它的工作原理与 Session 复制类似。缓存中所有数据在分布式集群的每个节点里面都存在有一份副本,读取数据时无须网络访问,直接从当前节点的进程内存中返回,理论上可以做到与进程内缓存一样高的读取性能;当数据发生变化时,就必须遵循复制协议,将变更同步到集群的每个节点中,复制性能随着节点的增加呈现平方级下降,变更数据的代价十分高昂。
在这里插入图片描述
Redis 6引入了一项名为“服务器辅助客户端缓存”的新功能,这是一种提高缓存效率的策略,Redis服务器追踪客户端所访问的键,并在这些键的值被修改时通知客户端,这样客户端可以更新或删除本地缓存中的数据,确保数据一致性。并且允许追踪特定前缀的键,从而只通知客户端关于这些键的更改。
CDN 将数据缓存到离用户物理距离最近的服务器,使得用户可以就近获取请求内容。CDN 一般缓存静态资源文件(页面,脚本,图片,视频,文件等)。cdn 本身也是一种复制式缓存。
在这里插入图片描述

集中式缓存

集中式缓存是目前分布式缓存的主流形式,集中式缓存的读、写都需要网络访问,其好处是不会随着集群节点数量的增加而产生额外的负担,其坏处自然是读、写都不再可能达到进程内缓存那样的高性能。
集中式缓存还有一个必须提到的关键特点,它与使用缓存的应用分处在独立的进程空间中,其好处是它能够为异构语言提供服务,譬如用 C 语言编写的Memcached完全可以毫无障碍地为 Java 语言编写的应用提供缓存服务;但其坏处是如果要缓存对象等复杂类型的话,基本上就只能靠序列化来支撑具体语言的类型系统(支持 Hash 类型的缓存,可以部分模拟对象类型),不仅有序列化的成本,还很容易导致传输成本也显著增加。
在这里插入图片描述

预防缓存污染

缓存污染是指缓存中的数据与真实数据源中的数据不一致的现象。尽管笔者在前面是说过缓存通常不追求强一致性,但这显然不能等同于缓存和数据源间连最终的一致性都可以不要求了。
分布式环境下的复杂性和多节点之间的交互,导致分布式缓存比单机缓存更容易引发缓存污染。多个节点可能同时对同一份数据进行操作,这增加了并发写入的可能性。如果某个节点写入了脏数据或者数据格式不正确,可能导致其他节点读取到不一致或错误的数据,从而造成缓存污染。缓存失效的管理变得更加复杂。如果缓存失效的时机没有正确处理或者某个节点缓存了错误的失效时间,可能导致数据在某些节点上过期不一致,从而引发缓存污染。 分布式缓存通常会进行数据的备份和复制,但是这也意味着数据变更时需要确保所有备份的数据都能及时同步更新,否则可能导致数据不一致,进而造成缓存污染。

缓存污染多数是由开发者更新缓存不规范造成的,譬如你从缓存中获得了某个对象,更新了对象的属性,但最后因为某些原因,譬如后续业务发生异常回滚了,最终没有成功写入到数据库,此时缓存的数据是新的,数据库中的数据是旧的。为了尽可能的提高使用缓存时的一致性,已经总结不少更新缓存可以遵循设计模式,譬如 Cache Aside、Read/Write Through、Write Behind Caching 等。。

  • Read/Write Through在这种模式下,数据的读写都通过缓存进行。当应用程序需要读取数据时,先从缓存中获取,如果不存在则从数据源读取并放入缓存。当应用程序进行写操作时,首先更新缓存,然后再更新数据库。假如两个请求在并发操作相同的一条数据,由于db的update和cache的set并不是原子性的,严格的要求更新数据库后,缓存能实时的一致更新 ,确实没有完美的的方案。参考聊聊细节 - 你知道缓存的正确打开方式么?(2)

  • Write Behind Caching(Write-Behind)在这种模式下,应用程序进行写操作时,首先将数据更新到缓存中,并且延迟一段时间再将数据写入到持久化存储(如数据库)中。这种模式下,缓存负责数据的延迟更新。减少了对持久化存储的写入次数,提高了写入性能,并且可以将多个更新操作合并成批量写入,降低了数据库的压力。但存在一定程度的数据不一致性风险,如果缓存数据丢失或异常,可能导致持久化存储中的数据与缓存中的数据不一致。

其中最简单、成本最低的 Cache Aside 模式是指, 读数据时,先读缓存,缓存没有的话,再读数据源,然后将数据放入缓存,再响应请求。
在这里插入图片描述
写数据时,先写数据源,然后失效(而不是更新)掉缓存。

Cache Aside 模式依然是不能保证在一致性上绝对不出问题的,否则就无须设计出Paxos这样复杂的共识算法了。典型的出错场景是如果某个数据是从未被缓存过的,请求会直接流到真实数据源中,如果数据源中的写操作发生在查询请求之后,结果回填到缓存之前,也会出现缓存中回填的内容与数据库的实际数据不一致的情况。但这种情况的概率是很低的,Cache Aside 模式仍然是以低成本更新缓存,并且获得相对可靠结果的解决方案。

读取缓存步骤一般没有什么问题,但是一旦涉及到数据更新:数据库和缓存更新,就容易出现缓存和数据库间的数据一致性问题。不管是先写数据库,再删除缓存;还是先删除缓存,再写库,都有可能出现数据不一致的情况。举个例子:

  1. 如果删除了缓存Redis,还没有来得及写库MySQL,另一个线程就来读取,发现缓存为空,则去数据库中读取数据写入缓存,此时缓存中为脏数据。

  2. 如果先写了库,在删除缓存前,写库的线程宕机了,没有删除掉缓存,则也会出现数据不一致情况。

  3. 对于主从延迟的情况,如果可能从从库中读到数据更新缓存,无论先删缓存再写库或先写库再删缓存,都肯能在主从同步完成之前将更新前的数据写到redis

因为写和读是并发的,没法保证顺序,就会出现缓存和数据库的数据不一致的问题。通过延迟双删可以保证高并发场景下数据的最终一致性。

参考

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值