进程缓存与分布式缓存

进程级缓存 | 分布式缓存 | 透明多级分流系统

Posted by Booogu on April 18, 2021
4787 字 14 分钟

前言:服务端缓存,也是一种通用的技术组件,它用于减少多个客户端相同的资源请求,缓解或降低服务器的负载压力。所以,说它是一种分流手段也是很合理的。

引入缓存的内在原因

软硬件缓存的差异

我们说的缓存特指软件层面的缓存,与硬件层面缓存(CPU L1/L2/L3缓存、磁盘缓存等)相比,有很大差别。

服务端缓存是程序的一部分,而硬件缓存是一种从硬件层面对软件运行效率的优化手段。引入软件缓存的副作用要明显大于硬件缓存,原因如下:

  • 开发角度讲,因为需要考虑缓存失效、更新、一致性等问题(硬件角度也存在,但不需要开发者考虑),引入缓存会提高系统的复杂度
  • 运维角度讲,缓存会让问题在更久时间后,出现在距离现场更远的位置,会掩盖一些缺陷
  • 安全角度讲,缓存可能泄露某些保密数据,是容易受到攻击的薄弱点。

是手段,但不是出发点

冒着以上风险,给系统引入缓存的理由,无外乎两种:

  • 第一种,为了缓解CPU压力而做缓存

    比如把方法运行结果存储起来、把原本要实时计算的内容提前算好、把一些公用的数据进行复用(比如各种算法中预存一些运行过程中必定会用到的数据),等等,这些引入缓存的做法,都可以节省 CPU 算力,顺带提升响应性能。

  • 第二种,为了缓解I/O压力而做缓存

    比如说,通过引入缓存,把原本对网络、磁盘等较慢介质的读写访问,变为对内存等较快介质的访问;把原本对单点部件(如数据库)的读写访问,变为对可扩缩部件(如缓存中间件)的访问,等等,也顺带提升了响应性能。

注意,缓存虽然是典型的以空间换时间来提升性能的手段,但它的出发点是缓解 CPU 和 I/O 资源在峰值流量下的压力,“顺带”而非“专门”地提升响应性能。如果可以通过增强 CPU、I/O 本身的性能(比如扩展服务器的数量)来满足需要的话,那升级硬件往往是更好的解决方案。

缓存的几大关键属性

吞吐量

缓存的吞吐量使用 OPS 值(每秒操作数,Operations per Second,ops/s)来衡量,它反映了对缓存进行并发读、写操作的效率,即缓存本身的工作效率高低。

主要考虑线程安全措施,会带来一定的吞吐量损失。在并发读写场景中,吞吐量会受到多方面因素共同影响,最关键的是,如何尽可能避免数据竞争

涉及到的一些容器类与处理策略:

  • 以 Guava Cache 为代表的同步处理机制。即在访问数据时一并完成缓存淘汰、统计、失效等状态变更操作,通过分段加锁等优化手段来尽量减少数据竞争。
  • 是以 Caffeine 为代表的异步日志提交机制。这种机制参考了经典的数据库设计理论,它把对数据的读、写过程看作是日志(即对数据的操作指令)的提交过程。Caffeine实现中设有专门的环形缓存工作区
  • JDK的ConcurrentHashMap

命中率

缓存的命中率即成功从缓存中返回结果次数与总请求次数的比值,它反映了引入缓存的价值高低,命中率越低,引入缓存的收益越小,价值越低。

为什么需要缓存淘汰策略

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

主流缓存淘汰策略

主要分为以下几种:

  • 第一种:FIFO(First In First Out),即优先淘汰最早进入被缓存的数据。
  • 第二种:LRU(Least Recent Used),即优先淘汰最久未被使用访问过的数据。LRU 通常会采用 HashMap 加 LinkedList 的双重结构(如 LinkedHashMap)来实现。它适用于大多数场景,尤其适合短时间内频繁访问的热点对象,它的主要问题是如果一些热点数据在系统中经常被频繁访问,但最近一段时间因为某种原因未被访问过,那么这时,这些热点数据依然要面临淘汰的命运,LRU 依然可能错误淘汰掉价值更高的数据
  • 第三种:LFU(Least Frequently Used),即优先淘汰最不经常使用的数据。LFU 会给每个数据添加一个访问计数器,每访问一次就加 1,当需要淘汰数据的时候,就清理计数器数值最小的那批数据。可以解决LRU存在的问题,同时引入两个新问题:因维护计数器带来的吞吐量降低不便于处理随时间变化的热度变化(LRU强项),比如某个曾经频繁的热点数据现在不需要了,因为计数器值还保留,导致难以被淘汰。

除了以上三种,还有最近几年提出的TingLFU/W-TinyLFU算法

扩展功能

缓存除了基本读写功能外,还提供了一些额外的管理功能,比如最大容量、失效时间、失效事件、命中率统计,等等。

专业的缓存组件,往往还会提供很多额外功能,比如:

  • 加载器:许多缓存都有“CacheLoader”之类的设计,加载器可以让缓存从只能被动存储外部放入的数据,变为能够主动通过加载器去加载指定 Key 值的数据,加载器也是实现自动刷新功能的基础前提。
  • 淘汰策略:有的缓存淘汰策略是固定的,也有一些缓存可以支持用户根据自己的需要,来选择不同的淘汰策略。
  • 失效策略:要求缓存的数据在一定时间后自动失效(移除出缓存)或者自动刷新(使用加载器重新加载)。
  • 事件通知:缓存可能会提供一些事件监听器,让你在数据状态变动(如失效、刷新、移除)时进行一些额外操作。有的缓存还提供了对缓存数据本身的监视能力(Watch 功能)。
  • 并发策略:对于通过分段加锁来实现的缓存(以 Guava Cache 为代表),往往会提供并发级别的设置。可以简单地理解为:缓存内部是使用多个 Map 来分段存储数据的,并发级别就用于计算出使用 Map 的数量。如果这个参数设置过大,会引入更多的 Map,你需要额外维护这些 Map 而导致更大的时间和空间上的开销;而如果设置过小,又会导致在访问时产生线程阻塞,因为多个线程更新同一个 ConcurrentMap 的同一个值时会产生锁竞争。
  • 容量控制:缓存通常都支持指定初始容量和最大容量。设定初始容量的目的是减少扩容频率,这与 Map 接口本身的初始容量含义是一致的;而最大容量类似于控制 Java 堆的 -Xmx 参数,当缓存接近最大容量时,会自动清理掉低价值的数据。
  • 引用方式:Java 语言支持将数据设置为软引用或者弱引用,而提供引用方式的设置,就是为了将缓存与 Java 虚拟机的垃圾收集机制联系起来。
  • 统计信息:缓存框架会提供诸如缓存命中率、平均加载时间、自动回收计数等统计信息。
  • 持久化:支持将缓存的内容存储到数据库或者磁盘中。进程内缓存提供持久化功能的作用不是太大,但分布式缓存大多都会考虑提供持久化功能。

针对以上三个缓存的主要属性,目前几款主流的进程内缓存方案对比如下:

主流进程内缓存对比

分布式缓存

分布式缓存,一般分为复制式缓存集中式缓存,其中复制式缓存(缓存中所有数据,在分布式集群的每个节点里都有一份副本)因为其过差的写入性能,已经基本被淘汰。

集中式缓存,是目前分布式缓存的主流形式,它的读写都需要网络访问。在集中式缓存的数据一致性选择上来说,Redis是典型的AP式,具有高性能、高可用的优点,并不保证强一致。

透明多级缓存

进程级缓存与分布式缓存,是互补而非竞争关系,可以根据需要搭建两者协作的透明多级缓存(Transparent Multilevel Cache,TMC)

多级缓存含义

  • 使用进程内缓存做一级缓存,分布式缓存做二级缓存
  • 若一级缓存中存在则返回,否则到二级缓存查询,再将二级缓存结果回填至一级缓存,后续Key访问无需网络请求。
  • 若二级缓存查询不到,发起对最终数据源的查询,将结果回填至一、二级缓存。

多级缓存

如何做到透明

多级缓存的代码侵入性较大,需要由开发者承担多次查询、多次回填的工作,也不便于管理,像是超时、刷新等策略,都要设置多遍,数据更新更是麻烦,很容易会出现各个节点的一级缓存、二级缓存里的数据互相不一致的问题。

因此,必须“透明地”解决上述问题,才能发挥其最大实用价值。

多级缓存常见设计原则:变更以分布式缓存中的数据为准,访问以进程内缓存的数据优先。

  • 当数据发生变动时,在集群内发送推送通知(简单点的话可以采用 Redis 的 PUB/SUB,求严谨的话可以引入 ZooKeeper 或 Etcd 来处理),让各个节点的一级缓存自动失效掉相应数据
  • 当访问缓存时,缓存框架提供统一封装好的一、二级缓存联合查询接口,接口外部只查询一次,接口内部自动实现优先查询一级缓存。如果没有获取到数据,就再自动查询二级缓存

风险与解决方案

缓存穿透

Key在缓存中不存在,导致每次请求无法命中缓存,直接触及末端数据库的现象,称为缓存穿透。 侧重点在于,缓存中本来就没有

可能的原因及解法

  • 对于业务逻辑本身无法避免的穿透现象:可以约定在一定时间内,对返回为空的 Key 值依然进行缓存(注意是正常返回但是结果为空,不要把抛异常的也当作空值来缓存了),这样在一段时间内,缓存就最多被穿透一次
  • 对于恶意攻击导致的穿透现象,通常会在缓存之前设置一个布隆过滤器来解决,如果布隆过滤器给出的判定结果是请求的数据不存在,那就直接返回即可,连缓存都不必去查。

缓存击穿

Key在缓存中本来存在,但是忽然因某种原因失效,导致请求无法命中缓存,直接触及末端数据库的现象,称为缓存击穿。侧重点在于:缓存本来有,但是因某种原因被移除了

可能的原因及解法

原因一般为,因超期而失效,此时请求到来。

  • 解法一:加锁同步。以请求该数据的 Key 值为锁(进程锁或分布式锁),这样就只有第一个请求可以流入到真实的数据源中,其他线程采取阻塞或重试策略。
  • 解法二:热点数据手动编码管理。缓存击穿是只针对热点数据被自动失效才引发的问题,所以对于这类数据,我们可以直接通过代码来有计划地完成更新、失效,避免由缓存的策略自动管理。

缓存雪崩

大批不同的Key在短时间内一起失效,导致了这些数据的请求都击穿了缓存,到达数据源,令数据源在短时间内压力剧增的现象,称为缓存雪崩。与缓存击穿类似,侧重点在于:多个Key同时大范围失效

可能的原因及解法

可能的原因为:

  • 因为系统有专门的缓存预热功能,所有缓存数据同时加载起来,在同一时刻一起失效。
  • 大量的公共数据都是由某一次冷操作同时加载的,由此载入缓存的大批数据具有相同的过期时间,在同一时刻一起失效。
  • 缓存服务由于某些原因崩溃后重启,此时也会造成大量数据同时失效

通常解法:

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

缓存污染

缓存污染是指,缓存中的数据与真实数据源中的数据不一致的现象,且连最终一致性都无法保证。

可能的原因及解法

缓存污染主要由开发者更新缓存不规范造成。因此,为了尽可能提高使用缓存时的一致性,人们总结了不少更新缓存时可以遵循的设计模式,比如 Cache Aside、Read/Write Through、Write Behind Caching,等等。

Cache Aside(旁路缓存)介绍

两条关键原则:

  • 读数据时,先读缓存,缓存没有再查询数据源并填回缓存。
  • 写数据时,先更新数据源,再处理缓存,缓存只能删除,不能更新。

关于写数据,两个问题需要注意:

为什么先数据源后缓存? 保证写入未完成时,旧缓存仍然可以为数据源分流,承担压力,否则如果先清缓存,在未更新完数据源之前,所有请求都会打到数据源上。

为什么只能删除不能更新? 更新会涉及到多次写入操作的更新时序问题,可能会出现脏读(后写先改,被覆盖)。

旁路缓存一定不会出现一致性问题吗? 并非完美,由极低概率会出错,比如,如果写入+失效缓存,发生在一次读取缓存操作之中(读取操作,读到的是数据库中,未被写入前的旧数据,最后使用此旧数据回填了缓存),就会发生不一致现象。