缓存:从上到下
每个程序员都会遇到这个计算概念:缓存。
它是每个程序员都应该深刻理解的核心而广泛的概念,对于系统设计和性能关键型程序来说极为重要。
计算机科学中只有两件难事:
缓存失效和命名事物。
– 菲尔·卡尔顿
在计算领域,无处不在的技术都源于缓存的概念。缓存的设计和实现涵盖了多个抽象层面,包括 CDN、Web 浏览器、操作系统、CPU 以及算法设计。
图 1:缓存:从底部开始
什么是缓存
“缓存是一种硬件或软件组件,用于存储数据,以便将来对该数据的请求能够得到更快的处理;存储在缓存中的数据可能是早期计算的结果,也可能是存储在其他地方的数据的副本。” – 维基百科
缓存的本质是用空间来优化时间,这是大小和速度之间的权衡。点击推文
缓存用于以下场景:
- 读取操作数多于写入操作数。
- 运算遵循局部性原则。
缓存的好处包括增加读取吞吐量并减少后端的负载。
以下是与缓存相关的一些关键方面:
命中率
𝑁(ℎ𝑖𝑡) / (𝑁(ℎ𝑖𝑡)+𝑁(𝑚𝑖𝑠ℎ𝑖𝑡))
未命中意味着获取的内容不在缓存中,需要额外发起请求才能获取。显然,命中率越高,缓存效率越高。
缓存数据访问和更新策略
缓存策略有多种,我们应该根据数据访问模式(即数据的读写方式)选择合适的缓存策略。
此外,通常的缓存实现是有大小限制的。当缓存满了的时候,我们需要选择哪些缓存内容需要被清除(或者被新数据替换),这里有几种常用的策略:
- 最近最少使用(LRU)
- 最不常用(LFU)
- 最近使用(MRU)
- 先进先出(FIFO)
同时,缓存可能会引入一些其他问题,例如数据不一致。
单一或分布式缓存
分布式缓存适用于高负载站点,在分布式环境中它会更加复杂。
让我们讨论一下缓存的一些经典用法。
CDN
CDN(内容分发网络)是至关重要的互联网基础设施,实现了缓存的概念。
CDN 可以缩短网页加载时间,加快点播视频的下载和流媒体播放速度。当我们观看 Netflix 的流媒体视频时,客户端不会直接从中央服务器获取视频,而是从地理位置上距离我们较近的 CDN 节点下载视频,从而缩短了加载时间。
图2:来源:wiki
典型的 CDN 工作流程是:
当客户端向CDN节点请求数据时,CDN节点会检查缓存数据是否过期。
- 如果缓存数据没有过期,则直接将缓存数据返回给客户端。
- 否则,CDN节点向Origin Server发送请求,从Origin Server拉取最新数据,更新本地缓存,然后将最新数据返回给客户端。
这里的权衡是CDN节点将缓存内容多长时间,这直接影响“命中率”。
如果CDN缓存时间过短,CDN边缘节点上的数据很容易过期,导致频繁请求源站,加重源站负载,延迟用户访问;如果CDN缓存时间过长,可能会将过期数据分发给客户端。
后续问题:CDN 服务器如何检查客户端是否具有最新的缓存内容?
答案是指 HTTP 缓存方法。
HTTP 缓存
在网络环境中,用户阅读的频率比写作的频率高。
通过网络获取数据既慢又昂贵,因此缓存和重用以前获取的资源的能力对于优化性能至关重要。
HTTP 上下文中使用了许多缓存模式。其中最重要的缓存头是 cache-control。
图 3:与缓存相关的 HTTP 标头
- 缓存控制:无存储
缓存不应该存储任何有关客户端请求或服务器响应的内容。每次请求都会发送到服务器,并下载完整的响应。
- 缓存控制:无缓存
缓存将在发布缓存副本之前将请求发送到原始服务器进行验证。
- 缓存控制:私有
“private” 表示该响应仅供单个用户使用,不得由共享缓存存储。在这种情况下,私有浏览器缓存可以存储该响应。
- 缓存控制:公共
“public”指令表示响应可以被任何缓存服务器缓存。如果内容需要缓存在 CDN 中,则必须指定“public”。
*但是我们如何解决陈旧数据问题呢?*
答案是Etags/Last-Modified,服务器会检查这些头来确定客户端的本地缓存是否有效。
图 4:HTTP 缓存:客户端和服务器流程
如果验证通过,将发送带有 304 的 HTTP 响应,否则将发送带有最新内容的 200 响应。
解决数据过期问题的另一种方法是为资源生成一个新的唯一 URL。通常情况下,样式表文件、HTML 页面中的图片以及 JavaScript 文件都会在文件名中嵌入指纹。这样,当服务器更新内容时,客户端就会从新的 URL 获取数据。
结合缓存控制、Etags 和唯一 URL 的使用,我们可以实现最佳效果:长寿命到期时间、控制响应的缓存位置以及按需更新。
Nginx 缓存
图 5:图片来源:加拿大隐私局(https://privacycanada.net)
在实际应用中,Nginx 通常用作应用程序前端的反向代理或负载均衡器,它也可以充当缓存服务器。Nginx 缓存的一个简单配置如下:
图 6:Nginx 缓存配置:https://serversforhackers.com/c/nginx-caching
它对于几乎所有后端应用程序来说都是一个透明的缓存层,这意味着简洁的架构。
这里需要注意的另一点是,我们将内存空间(用于缓存键)的大小设置为 10m,缓存的值存储在路径为 /tmp/nginx 的磁盘中。
*inactive=60m*选项用于指定项目在未被访问的情况下可以在缓存中保留多长时间。
除了更好的性能之外,Nginx 缓存还可以提高网站的可用性,我们可以使用*proxy_cache_use_stale*选项在源站关闭时提供缓存内容。
Nginx 还有流量限制、内容压缩等丰富的功能。如果你对高性能调优感兴趣,强烈推荐你阅读:Nginx 高性能缓存
Linux系统缓存
请记住,系统调用开销很大,而且磁盘上的数据操作(读/写)比内存操作慢得多。Linux 将最大限度地利用计算机内存以获得最佳性能。
让我们检查命令“free”:
图 7:Linux free 命令
我们可以看到,没有太多的*可用*内存,即使我们没有在系统上运行很多应用程序。
别担心,Linux 不会占用你的内存。系统只是借用了*未使用的内存*来做磁盘缓存。这看起来就像你的内存不足了。
当数据被写入时,Linux首先将其写入一个Page Cache(在内存中)并且将该页标记为Dirty,这些脏页的内容会定期传输(以及通过系统调用sync或者fsync)到底层存储设备。
让我们运行一些命令来验证它:
图 8:Linux 同步命令
从输出中我们可以发现,在写入200MB数据后,系统中的脏页会不断增长。
然后我们运行命令sync,它就会缩小,因为脏页中的数据已经同步到磁盘了。
文件块不仅在写入时写入页面缓存,而且在读取文件时也会写入页面缓存。
比如你连续两次读取一个100MB的文件,第二次的访问会更快,因为文件块直接来自内存中的Page Cache,不需要再从硬盘读取。
CPU缓存
CPU 缓存的发明是为了弥补 CPU 和主内存之间的速度差距。
图 9:图片来源 extremetech.com
CPU 缓存是小型内存池,用于存储 CPU 下一步最有可能需要的信息。所有现代 CPU 都具有多层级的 CPU 缓存。不同缓存级别的访问时间差异很大,速度较快的缓存每字节成本高于速度较慢的缓存,且容量也较小。一级缓存比二级缓存快,而二级缓存又比内存 (RAM) 快。
图 10:图片来源https://hazelcast.com/glossary/memory-caching/
根据局部性原则,程序花费的大部分时间都集中在核心操作上,CPU很可能在短时间内重复访问同一组内存位置。
遵循这一原则至关重要,因为缓存的高命中率可能会导致程序性能下降。
我们来检查一下这两个 C 函数,它们之间有什么区别?
为什么第一个函数比后一个函数快近2倍?
int array_sum_row(int a[M][N]) {
int i,j,sum=0;
for(i = 0; i<M; i++)
for(j = 0; j<N; j++)
sum += a[i][j];
return sum;
}
int array_sum_col(int a[M][N]) {
int i,j,sum=0;
for(i = 0; i<N; i++)
for(j = 0; j<M; j++)
sum += a[j][i];
return sum;
}
因为C/C++编译器采用的是内存中的行主布局。
当访问 a[i][0] 中的数据时,其附近的 a[i][1] ~ a[i][K] 数据都会被加载到 Cache 中。按照迭代顺序,由于附近的元素都已经被缓存,因此缓存的命中率会比较高。
但如果我们将迭代顺序反转为 col-major,由于之后不会访问已加载的数据,并且大多数数据不会从缓存中获取,因此会引发高错误命中率问题并导致运行时间性能不佳。
算法中的缓存
在算法设计中,为了提高时间效率,我们通常会将计算结果存储在缓存中。我们来深入分析一下经典的斐波那契算法的递归版本:
function fib(n) {
if (n < 2) {
return n
}
return fib(n - 1) + fib(n - 2)
}
JavaScript
如果我们分析可视化中的计算过程,会发现计算过程中存在一些重复的部分。其复杂度用大O表示法表示为𝑂(2𝑛)O(2n)。
图 11:图片来源:https://medium.com/@porzingod
可以使用记忆化(自上而下的缓存填充)来优化性能,我们使用数组来存储计算结果:
memFib(n) {
if (mem[n] is undefined)
if (n < 2) result = n
else result = memFib(n-2) + memFib(n-1)
mem[n] = result
return mem[n]
}
JavaScript
或者我们可以采用自下而上的缓存填充,这样会产生一个迭代版本程序:
tabFib(n) {
mem[0] = 0
mem[1] = 1
for i = 2...n
mem[i] = mem[i-2] + mem[i-1]
return mem[n]
}
JavaScript
减少重复计算的思想也应用于*动态规划(DP)*中,DP问题的关键观察是找到重叠的子问题,并使用缓存存储重叠的结果。
总结一下
我们对不同层次的缓存技术进行了研究。缓存不仅是一种架构和设计的方法,更是一种解决问题的通用思路。
基本原理是:使用缓存来减少计算中的重复(斐波那契数列),有时在需要时将重复数据存储在更快的组件上(例如 CDN、内存缓存)。
图 12:经典 CS 语录
大多数情况下,Cache是我们解决性能问题时所需要的抽象层。
作为有抱负的程序员,我们应该掌握它!
参考
- CDN:https://www.globaldots.com
- CDN缓存:https://support.stackpath.com
- HTTP 缓存[1]:https://tools.ietf.org
- HTTP 缓存[2]: https://developer.mozilla.org
- Nginx 缓存:https://docs.nginx.com
- CPU 缓存:https://www.extremetech.com