learn-tech/专栏/周志明的架构课/21_服务端缓存的三种属性.md
2024-10-16 06:37:41 +08:00

284 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
21 _ 服务端缓存的三种属性
你好,我是周志明。
在透明多级分流系统这个小章节中我们的研究思路是以流量从客户端中发出开始以流量到达服务器集群中真正处理业务的节点作为结束一起探索了在这个过程中与业务无关的一些通用组件包括DNS、CDN、客户端缓存等等。
实际上,服务端缓存也是一种通用的技术组件,它主要用于减少多个客户端相同的资源请求,缓解或降低服务器的负载压力。所以,说它是一种分流手段也是很合理的。
另外,我们其实很难界定服务端缓存到底算不算与业务逻辑无关,因为服务端缓存通常是在代码中被显式调用的,这就很难说它是“透明分流”了。但是,服务端缓存作为流量到达服务端实际处理逻辑之前的最后一道防御线,把它作为这个小章节的最后一讲,倒也是合适的。
所以这节课,我就带你来了解下服务端缓存的相关知识点,你可以从中理解和掌握缓存的三种常见属性,然后灵活运用在自己的软件开发当中。
好,接下来,我们就从引入缓存的价值开始学起吧。
为系统引入缓存的理由
关于服务端缓存,首先你需要明确的问题是,在为你的系统引入缓存之前,它是否真的需要缓存呢?
我们很多人可能都会有意无意地把硬件里那种常用于区分不同产品档次、“多多益善”的缓存如CPU L1/2/3缓存、磁盘缓存等等代入到软件开发中去。但实际上这两者的差别是很大的。毕竟服务端缓存是程序的一部分而硬件缓存是一种硬件对软件运行效率的优化手段。
在软件开发中,引入缓存的负面作用要明显大于硬件的缓存。主要有这样几个原因:
从开发角度来说引入缓存会提高系统的复杂度因为你要考虑缓存的失效、更新、一致性等问题硬件缓存也有这些问题只是不需要由你来考虑主流的ISA也都没有提供任何直接操作缓存的指令
从运维角度来说,缓存会掩盖掉一些缺陷,让问题在更久的时间以后,出现在距离发生现场更远的位置上;
从安全角度来说,缓存可能泄漏某些保密数据,这也是容易受到攻击的薄弱点。
那么,冒着前面提到的这种种风险,你还是想要给系统引入缓存,是为了什么呢?其实无外乎有两种理由。
第一种为了缓解CPU压力而做缓存。
比如说把方法运行结果存储起来、把原本要实时计算的内容提前算好、把一些公用的数据进行复用等等这些引入缓存的做法都可以节省CPU算力顺带提升响应性能。
第二种为了缓解I/O压力而做缓存。
比如说,通过引入缓存,把原本对网络、磁盘等较慢介质的读写访问,变为对内存等较快介质的访问;把原本对单点部件(如数据库)的读写访问,变为对可扩缩部件(如缓存中间件)的访问,等等,也顺带提升了响应性能。
这里请你注意缓存虽然是典型的以空间换时间来提升性能的手段但它的出发点是缓解CPU和I/O资源在峰值流量下的压力“顺带”而非“专门”地提升响应性能。
所以我的言外之意就是如果你可以通过增强CPU、I/O本身的性能比如扩展服务器的数量来满足需要的话那升级硬件往往是更好的解决方案。即使需要你掏腰包多花一点儿钱那通常也比引入缓存带来的风险更低。
这样,当你有了使用服务端缓存的明确目的后,下一步就是要如何选择缓存了。所以接下来,我们就一起讨论一下,设计或者选择缓存时要考虑哪些方面的属性。
缓存属性
其实不少软件系统最初的缓存功能都是以HashMap或者ConcurrentHashMap为起点开始的演进的。当我们在开发中发现系统中某些资源的构建成本比较高而这些资源又有被重复使用的可能性那很自然就会产生“循环再利用”的想法把它们放到Map容器中下次需要时取出重用避免重新构建。这种原始朴素的复用就是最基本的缓存了。
不过,一旦我们专门把“缓存”看作是一项技术基础设施,一旦它有了通用、高效、可统计、可管理等方面的需求,那么我们需要考虑的因素就会变得复杂起来了。通常我们在设计或者选择缓存时,至少需要考虑以下四个维度的属性:
吞吐量缓存的吞吐量使用OPS值每秒操作数Operations per Secondops/s来衡量它反映了对缓存进行并发读、写操作的效率即缓存本身的工作效率高低。
命中率:缓存的命中率即成功从缓存中返回结果次数与总请求次数的比值,它反映了引入缓存的价值高低,命中率越低,引入缓存的收益越小,价值越低。
扩展功能:缓存除了基本读写功能外,还提供了一些额外的管理功能,比如最大容量、失效时间、失效事件、命中率统计,等等。
分布式支持:缓存可以分为“进程内缓存”和“分布式缓存”两大类,前者只为节点本身提供服务,无网络访问操作,速度快但缓存的数据不能在各个服务节点中共享。后者则相反。
在今天这节课,我们就先来探讨下前三个属性(下一讲我们会重点讨论分布式缓存)。
吞吐量
首先你要知道缓存的吞吐量只在并发场景中才有统计的意义因为不考虑并发的话即使是最原始的、以HashMap实现的缓存访问效率也已经是常量时间复杂度即O(1)。其中主要涉及到碰撞、扩容等场景的处理,这些都是属于数据结构基础知识,我就不展开讲了。
但HashMap并不是线程安全的容器如果要让它在多线程并发下能正确地工作就要用Collections.synchronizedMap进行包装这相当于给Map接口的所有访问方法都自动加上了全局锁或者我们也可以改用ConcurrentHashMap来实现这相当于给Map的访问分段加锁从JDK 8起已取消分段加锁改为CAS+Synchronized锁单个元素
而无论采用怎样的实现方法,线程安全措施都会带来一定的吞吐量损失。
所以进一步说如果我们只比较吞吐量完全不去考虑命中率、淘汰策略、缓存统计、过期失效等功能该如何实现那也不必去选择哪种缓存容器更好了JDK 8改进之后的ConcurrentHashMap基本上就是你能找到的吞吐量最高的缓存容器了。
可是,在很多场景里,前面提到的这些功能至少有一两项是必须的,我们不可能完全不考虑。所以,这就涉及到了不同缓存方案的权衡问题。
根据Caffeine给出的一组目前业界主流进程内缓存的实现方案其中包括了Caffeine、ConcurrentLinkedHashMap、LinkedHashMap、Guava Cache、Ehcache和Infinispan Embedded等缓存组件库的对比。从它们在8线程、75%读操作、25%写操作下的吞吐量表现Benchmarks来看各种缓存组件库的性能差异还是十分明显的最高与最低相差了足有一个数量级你可以参考下图
8线程、75%读、25%写的吞吐量比较
其中你可以发现在这种并发读写的场景中吞吐量会受多方面因素的共同影响。比如说怎样设计数据结构以尽可能避免数据竞争、存在竞争风险时怎样处理同步主要有使用锁实现的悲观同步和使用CAS实现的乐观同步、如何避免伪共享现象False Sharing这也算是典型的用缓存提升开发复杂度的例子发生等等。
其中的第一点,“尽可能避免数据竞争”是最关键的。因为无论我们如何实现同步,都不会比直接不需要同步更快。
那么下面我就以Caffeine为例来给你介绍一些缓存如何避免竞争、提高吞吐量的设计方法。
我们知道,缓存中最主要的数据竞争来源于读取数据的同时,也会伴随着对数据状态的写入操作,而写入数据的同时,也会伴随着数据状态的读取操作。
比如说读取数据时服务器要同时更新数据的最近访问时间和访问计数器的状态后面讲命中率时会提到为了追求高效程序可能不会记录时间和次数比如通过调整链表顺序来表达时间先后、通过Sketch结构来表达热度高低以实现缓存的淘汰策略又或者在读取时服务器要同时判断数据的超期时间等信息以实现失效重加载等其他扩展功能。
那么,针对前面所讲的伴随读写操作而来的状态维护,我们可以选择两种处理思路。
一种是以Guava Cache为代表的同步处理机制。即在访问数据时一并完成缓存淘汰、统计、失效等状态变更操作通过分段加锁等优化手段来尽量减少数据竞争。
另一种是以Caffeine为代表的异步日志提交机制。这种机制参考了经典的数据库设计理论它把对数据的读、写过程看作是日志即对数据的操作指令的提交过程。
尽管日志也涉及到了写入操作而有并发的数据变更就必然面临着锁竞争。但是异步提交的日志已经将原本在Map内的锁转移到了日志的追加写操作上日志里腾挪优化的余地就比在Map中要大得多。
另外在Caffeine的实现中还设有专门的环形缓存区Ring Buffer也常称作Circular Buffer来记录由于数据读取而产生的状态变动日志。而且为了进一步减少数据竞争Caffeine给每条线程对线程取Hash哈希值相同的使用同一个缓冲区都设置了一个专用的环形缓冲。
额外知识:环形缓冲-
所谓环形缓冲并不是Caffeine的专有概念它是一种拥有读、写两个指针的数据复用结构在计算机科学中有非常广泛的应用。-
我给你举个具体例子。比如说一台计算机通过键盘输入并通过CPU读取“HELLO WIKIPEDIA”这个长14字节的单词那么通常就需要一个至少14字节以上的缓冲区才行。-
但如果是环形缓冲结构,读取和写入就应当一起进行,在读取指针之前的位置都可以重复使用。理想情况下,只要读取指针不落后于写入指针一整圈,这个缓冲区就可以持续工作下去,就能容纳无限多个新字符。否则,就必须阻塞写入操作,去等待读取清空缓冲区。
环形缓存区工作原理
然后从Caffeine读取数据时数据本身会在其内部的ConcurrentHashMap中直接返回而数据的状态信息变更就存入了环形缓冲中由后台线程异步处理。-
而如果异步处理的速度跟不上状态变更的速度,导致缓冲区满了,那此后接收的状态的变更信息就会直接被丢弃掉,直到缓冲区重新有了富余。-
所以通过环形缓冲和容忍有损失的状态变更Caffeine大幅降低了由于数据读取而导致的垃圾收集和锁竞争因而Caffeine的读取性能几乎能与ConcurrentHashMap的读取性能相同。-
另外你要知道在向Caffeine写入数据时还要求要使用传统的有界队列ArrayQueue来存放状态变更信息写入带来的状态变更是无损的不允许丢失任何状态。这是考虑到许多状态的默认值必须通过写入操作来完成初始化因此写入会有一定的性能损失。根据Caffeine官方给出的数据相比ConcurrentHashMapCaffeine在写入时大约会慢10%左右。
好,说完了吞吐量,我们接着来看看缓存的第二个属性:命中率。
命中率与淘汰策略
有限的物理存储,决定了任何缓存的容量都不可能是无限的,所以缓存需要在消耗空间与节约时间之间取得平衡,这就要求缓存必须能够自动、或者由人工淘汰掉缓存中的低价值数据。不过,由人工管理的缓存淘汰主要取决于开发者如何编码,不能一概而论,所以这里我们就只讨论由缓存自动进行淘汰的情况。
这里我所说的“缓存如何自动地实现淘汰低价值目标”,现在也被称之为缓存的淘汰策略,或者是替换策略、清理策略。
那么,在缓存实现自动淘汰低价值数据的容器之前,我们首先要定义,怎样的数据才算是“低价值”的数据。
由于缓存的通用性,这个问题的答案必须是与具体业务逻辑无关的,所以我们只能从缓存工作过程中收集到的统计结果,来确定数据是否有价值。这个通用的统计结果包括但不限于数据何时进入缓存、被使用过多少次、最近什么时候被使用,等等。
这就由此决定了,一旦确定了选择何种统计数据,以及如何通用地、自动地判定缓存中每个数据价值高低,也就相当于决定了缓存的淘汰策略是如何实现的。
那么目前,最基础的淘汰策略实现方案主要有三种,我来一一给你介绍下。
第一种FIFOFirst In First Out
即优先淘汰最早进入被缓存的数据。FIFO的实现十分简单但一般来说它并不是优秀的淘汰策略因为越是频繁被用到的数据往往越会早早地被存入缓存之中。所以如果采用这种淘汰策略很可能会大幅降低缓存的命中率。
第二种LRULeast Recent Used
即优先淘汰最久未被使用访问过的数据。LRU通常会采用HashMap加LinkedList的双重结构如LinkedHashMap来实现。也就是它以HashMap来提供访问接口保证常量时间复杂度的读取性能以LinkedList的链表元素顺序来表示数据的时间顺序在每次缓存命中时把返回对象调整到LinkedList开头每次缓存淘汰时从链表末端开始清理数据。
所以你也能发现对大多数的缓存场景来说LRU都明显要比FIFO策略合理尤其适合用来处理短时间内频繁访问的热点对象。但相反它的问题是如果一些热点数据在系统中经常被频繁访问但最近一段时间因为某种原因未被访问过那么这时这些热点数据依然要面临淘汰的命运LRU依然可能错误淘汰掉价值更高的数据。
第三种LFULeast Frequently Used
即优先淘汰最不经常使用的数据。LFU会给每个数据添加一个访问计数器每访问一次就加1当需要淘汰数据的时候就清理计数器数值最小的那批数据。
LFU可以解决前面LRU中热点数据间隔一段时间不访问就被淘汰的问题但同时它又引入了两个新的问题。
第一个问题是需要对每个缓存的数据专门去维护一个计数器,每次访问都要更新,在前面讲“吞吐量”的时候,我也解释了这样做会带来高昂的维护开销;第二个问题是不便于处理随时间变化的热度变化,比如某个曾经频繁访问的数据现在不需要了,它也很难自动被清理出缓存。
可见,缓存淘汰策略会直接影响缓存的命中率,没有一种策略是完美的、能够满足全部系统所需的。
不过随着淘汰算法的发展近几年的确出现了许多相对性能要更好、也更为复杂的新算法。下面我就以LFU分支为例针对它存在的这两个问题给你讲讲近年来提出的TinyLFU和W-TinyLFU算法都分别带来了什么样的优化效果。
TinyLFUTiny Least Frequently Used
TinyLFU是LFU的改进版本。为了缓解LFU每次访问都要修改计数器所带来的性能负担TinyLFU首先采用Sketch结构来分析访问数据。
所谓的Sketch它实际上是统计学中的概念即指用少量的样本数据来估计全体数据的特征。这种做法显然牺牲了一定程度的准确性但是只要样本数据与全体数据具有相同的概率分布Sketch得出的结论仍不失为一种在高效与准确之间做好权衡的有效结论。
所以借助CountMin Sketch算法可以看作是布隆过滤器的一种等价变种结构TinyLFU可以用相对小得多的记录频率和空间来近似地找出缓存中的低价值数据。
另外为了解决LFU不便于处理随时间变化的热度变化问题TinyLFU采用了基于“滑动时间窗”在第38讲中我们会更详细地分析这种算法的热度衰减算法。简单理解就是每隔一段时间便会把计数器的数值减半以此解决“旧热点”数据难以清除的问题。
W-TinyLFUWindows-TinyLFU
W-TinyLFU又是TinyLFU的改进版本。TinyLFU在实现减少计数器维护频率的同时也带来了无法很好地应对稀疏突发访问的问题。
所谓的稀疏突发访问是指有一些绝对频率较小但突发访问频率很高的数据比如某些运维性质的任务也许一天、一周只会在特定时间运行一次其余时间都不会用到那么此时TinyLFU就很难让这类元素通过Sketch的过滤因为它们无法在运行期间积累到足够高的频率。
而应对短时间的突发访问是LRU的强项因此W-TinyLFU就结合了LRU和LFU两者的优点。从整体上看它是LFU策略从局部实现上看它又是LRU策略。
怎么理解这个“整体”和“局部”呢?
W-TinyLFU的具体做法是把新记录暂时放入一个名为Window Cache的前端LRU缓存里面让这些对象可以在Window Cache中累积热度如果能通过TinyLFU的过滤器再进入名为Main Cache的主缓存中存储。
主缓存根据数据的访问频繁程度分为了不同的段LFU策略实际上W-TinyLFU只分了两段但单独某一段从局部来看又是基于LRU策略去实现的称为Segmented LRU。每当前一段缓存满了之后就会将低价值数据淘汰到后一段中去存储直至最后一段也满了之后该数据就彻底清理出缓存。
当然只靠这种简单的、有限的介绍你不一定能完全理解TinyLFU和W-TinyLFU的工作原理但是你肯定能看出来这些改进算法比起原来基础版本的LFU要复杂许多。
有时候,为了取得理想的效果,采用较为复杂的淘汰策略只是不得已的选择。
除了W-TinyLFU之外Caffeine官方还制定了另外两种高级淘汰策略ARCAdaptive Replacement Cache和LIRSLow Inter-Reference Recency Set。这里你可以看看这三种新的淘汰策略与基础的LFU策略之间的命中率对比
几种淘汰算法在搜索场景下的命中率对比
在搜索场景中三种高级策略的命中率比较为接近于理想曲线Optimal而LRU则差距最远。另外在Caffeine官方给出的数据库、网站、分析类等应用场景中这几种策略之间的绝对差距也不完全一样但相对排名基本上没有改变最基础的淘汰策略的命中率是最低的。如果你对其他缓存淘汰策略感兴趣的话可以参考维基百科中对Cache Replacement Policies的介绍。
好,最后我们再来看看服务端缓存的第三种属性,也就是它提供的一些额外的管理功能。
扩展功能
一般来说一套标准的Map接口或者是来自JSR 107的javax.cache.Cache接口就可以满足缓存访问的基本需要不过在“访问”之外专业的缓存往往还会提供很多额外的功能。
加载器
许多缓存都有“CacheLoader”之类的设计加载器可以让缓存从只能被动存储外部放入的数据变为能够主动通过加载器去加载指定Key值的数据加载器也是实现自动刷新功能的基础前提。
淘汰策略
有的缓存淘汰策略是固定的,也有一些缓存可以支持用户根据自己的需要,来选择不同的淘汰策略。
失效策略
失效策略就是要求缓存的数据在一定时间后自动失效(移除出缓存)或者自动刷新(使用加载器重新加载)。
事件通知
缓存可能会提供一些事件监听器让你在数据状态变动如失效、刷新、移除时进行一些额外操作。有的缓存还提供了对缓存数据本身的监视能力Watch功能
并发级别
对于通过分段加锁来实现的缓存以Guava Cache为代表往往会提供并发级别的设置。
这里你可以简单地理解为缓存内部是使用多个Map来分段存储数据的并发级别就用于计算出使用Map的数量。如果这个参数设置过大会引入更多的Map你需要额外维护这些Map而导致更大的时间和空间上的开销而如果设置过小又会导致在访问时产生线程阻塞因为多个线程更新同一个ConcurrentMap的同一个值时会产生锁竞争。
容量控制
缓存通常都支持指定初始容量和最大容量。设定初始容量的目的是减少扩容频率这与Map接口本身的初始容量含义是一致的而最大容量类似于控制Java堆的-Xmx参数当缓存接近最大容量时会自动清理掉低价值的数据。
引用方式
Java语言支持将数据设置为软引用或者弱引用而提供引用方式的设置就是为了将缓存与Java虚拟机的垃圾收集机制联系起来。
统计信息
缓存框架会提供诸如缓存命中率、平均加载时间、自动回收计数等统计信息。
持久化
也就是支持将缓存的内容存储到数据库或者磁盘中。进程内缓存提供持久化功能的作用不是太大,但分布式缓存大多都会考虑提供持久化功能。
小结
今天这节课,我给你介绍了缓存的三项属性:吞吐量、命中率和扩展功能。为了便于你回顾知识点,我把目前几款主流的进程内缓存方案整理成了一个表格,供你参考。
那么总的来说,表格里的四类就基本囊括了目前主流的进程内缓存方案。希望通过这节课的学习,你能够掌握服务端缓存的原理,能够独立分析各种缓存框架所提供的功能属性,明白它们有什么影响,有什么收益和代价。
一课一思
在这节课的开篇我就提到了缓存并非多多益善,引用它有收益也有风险。那么请你思考一下,缓存可能存在什么风险弊端?欢迎在留言区分享你的见解。这也是我们下一节课的主要话题。
好,感谢你的阅读,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。就到这里,我们下一讲再见。