缓存:从上到下

2025-06-10

缓存:从上到下

文章“缓存:从上到下”首先出现在CodersCat上。

每个程序员都会遇到这个计算概念:缓存。

它是每个程序员都应该深刻理解的核心而广泛的概念,对于系统设计和性能关键型程序来说极为重要。

计算机科学中只有两件难事:

缓存失效和命名事物。

– 菲尔·卡尔顿

在计算领域,无处不在的技术都源于缓存的概念。缓存的设计和实现涵盖了多个抽象层面,包括 CDN、Web 浏览器、操作系统、CPU 以及算法设计。

图片9.png

图 1:缓存:从底部开始

什么是缓存

“缓存是一种硬件或软件组件,用于存储数据,以便将来对该数据的请求能够得到更快的处理;存储在缓存中的数据可能是早期计算的结果,也可能是存储在其他地方的数据的副本。” – 维基百科

缓存的本质是用空间来优化时间,这是大小和速度之间的权衡。点击推文

缓存用于以下场景:

缓存的好处包括增加读取吞吐量并减少后端的负载。

以下是与缓存相关的一些关键方面:

命中率

𝑁(ℎ𝑖𝑡) / (𝑁(ℎ𝑖𝑡)+𝑁(𝑚𝑖𝑠ℎ𝑖𝑡))

未命中意味着获取的内容不在缓存中,需要额外发起请求才能获取。显然,命中率越高,缓存效率越高。

缓存数据访问和更新策略

缓存策略有多​​种,我们应该根据数据访问模式(即数据的读写方式)选择合适的缓存策略。

此外,通常的缓存实现是有大小限制的。当缓存满了的时候,我们需要选择哪些缓存内容需要被清除(或者被新数据替换),这里有几种常用的策略:

  • 最近最少使用(LRU)
  • 最不常用(LFU)
  • 最近使用(MRU)
  • 先进先出(FIFO)

同时,缓存可能会引入一些其他问题,例如数据不一致。

单一或分布式缓存

分布式缓存适用于高负载站点,在分布式环境中它会更加复杂。

让我们讨论一下缓存的一些经典用法。

CDN

CDN(内容分发网络)是至关重要的互联网基础设施,实现了缓存的概念。

CDN 可以缩短网页加载时间,加快点播视频的下载和流媒体播放速度。当我们观看 Netflix 的流媒体视频时,客户端不会直接从中央服务器获取视频,而是从地理位置上距离我们较近的 CDN 节点下载视频,从而缩短了加载时间。

图片6.png

图2:来源:wiki

典型的 CDN 工作流程是:

当客户端向CDN节点请求数据时,CDN节点会检查缓存数据是否过期。

  • 如果缓存数据没有过期,则直接将缓存数据返回给客户端。
  • 否则,CDN节点向Origin Server发送请求,从Origin Server拉取最新数据,更新本地缓存,然后将最新数据返回给客户端。

这里的权衡是CDN节点将缓存内容多长时间,这直接影响“命中率”。

如果CDN缓存时间过短,CDN边缘节点上的数据很容易过期,导致频繁请求源站,加重源站负载,延迟用户访问;如果CDN缓存时间过长,可能会将过期数据分发给客户端。

后续问题:CDN 服务器如何检查客户端是否具有最新的缓存内容?

答案是指 HTTP 缓存方法。

HTTP 缓存

在网络环境中,用户阅读的频率比写作的频率高。

通过网络获取数据既慢又昂贵,因此缓存和重用以前获取的资源的能力对于优化性能至关重要。

HTTP 上下文中使用了许多缓存模式。其中最重要的缓存头是 cache-control。

图片1.png

图 3:与缓存相关的 HTTP 标头

  • 缓存控制:无存储

缓存不应该存储任何有关客户端请求或服务器响应的内容。每次请求都会发送到服务器,并下载完整的响应。

  • 缓存控制:无缓存

缓存将在发布缓存副本之前将请求发送到原始服务器进行验证。

  • 缓存控制:私有

“private” 表示该响应仅供单个用户使用,不得由共享缓存存储。在这种情况下,私有浏览器缓存可以存储该响应。

  • 缓存控制:公共

“public”指令表示响应可以被任何缓存服务器缓存。如果内容需要缓存在 CDN 中,则必须指定“public”。

*但是我们如何解决陈旧数据问题呢?*

答案是Etags/Last-Modified,服务器会检查这些头来确定客户端的本地缓存是否有效。

图片3.png

图 4:HTTP 缓存:客户端和服务器流程

如果验证通过,将发送带有 304 的 HTTP 响应,否则将发送带有最新内容的 200 响应。

解决数据过期问题的另一种方法是为资源生成一个新的唯一 URL。通常情况下,样式表文件、HTML 页面中的图片以及 JavaScript 文件都会在文件名中嵌入指纹。这样,当服务器更新内容时,客户端就会从新的 URL 获取数据。

结合缓存控制、Etags 和唯一 URL 的使用,我们可以实现最佳效果:长寿命到期时间、控制响应的缓存位置以及按需更新。

Nginx 缓存

图片10.png

图 5:图片来源:加拿大隐私局(https://privacycanada.net

在实际应用中,Nginx 通常用作应用程序前端的反向代理或负载均衡器,它也可以充当缓存服务器。Nginx 缓存的一个简单配置如下:

图片2.png

图 6:Nginx 缓存配置:https://serversforhackers.com/c/nginx-caching

它对于几乎所有后端应用程序来说都是一个透明的缓存层,这意味着简洁的架构。

这里需要注意的另一点是,我们将内存空间(用于缓存键)的大小设置为 10m,缓存的值存储在路径为 /tmp/nginx 的磁盘中。

*inactive=60m*选项用于指定项目在未被访问的情况下可以在缓存中保留多长时间。

除了更好的性能之外,Nginx 缓存还可以提高网站的可用性,我们可以使用*proxy_cache_use_stale*选项在源站关闭时提供缓存内容。

Nginx 还有流量限制、内容压缩等丰富的功能。如果你对高性能调优感兴趣,强烈推荐你阅读:Nginx 高性能缓存

Linux系统缓存

请记住,系统调用开销很大,而且磁盘上的数据操作(读/写)比内存操作慢得多。Linux 将最大限度地利用计算机内存以获得最佳性能。

让我们检查命令“free”:

图片11.png

图 7:Linux free 命令

我们可以看到,没有太多的*可用*内存,即使我们没有在系统上运行很多应用程序。

别担心,Linux 不会占用你的内存。系统只是借用了*未使用的内存*来做磁盘缓存。这看起来就像你的内存不足了。

当数据被写入时,Linux首先将其写入一个Page Cache(在内存中)并且将该页标记为Dirty,这些脏页的内容会定期传输(以及通过系统调用sync或者fsync)到底层存储设备。

让我们运行一些命令来验证它:

图片7.png

图 8:Linux 同步命令

从输出中我们可以发现,在写入200MB数据后,系统中的脏页会不断增长。

然后我们运行命令sync,它就会缩小,因为脏页中的数据已经同步到磁盘了。

文件块不仅在写入时写入页面缓存,而且在读取文件时也会写入页面缓存。

比如你连续两次读取一个100MB的文件,第二次的访问会更快,因为文件块直接来自内存中的Page Cache,不需要再从硬盘读取。

CPU缓存

CPU 缓存的发明是为了弥补 CPU 和主内存之间的速度差距。

图片4.png

图 9:图片来源 extremetech.com

CPU 缓存是小型内存池,用于存储 CPU 下一步最有可能需要的信息。所有现代 CPU 都具有多层级的 CPU 缓存。不同缓存级别的访问时间差异很大,速度较快的缓存每字节成本高于速度较慢的缓存,且容量也较小。一级缓存比二级缓存快,而二级缓存又比内存 (RAM) 快。

图片8.png

图 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;
} 
Enter fullscreen mode Exit fullscreen mode

因为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)
}
Enter fullscreen mode Exit fullscreen mode

JavaScript

如果我们分析可视化中的计算过程,会发现计算过程中存在一些重复的部分。其复杂度用大O表示法表示为𝑂(2𝑛)O(2n)。

图片12.png

图 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]
}
Enter fullscreen mode Exit fullscreen mode

JavaScript

或者我们可以采用自下而上的缓存填充,这样会产生一个迭代版本程序:

tabFib(n) {
   mem[0] = 0
   mem[1] = 1
   for i = 2...n
       mem[i] = mem[i-2] + mem[i-1]
   return mem[n]
}
Enter fullscreen mode Exit fullscreen mode

JavaScript

减少重复计算的思想也应用于*动态规划(DP)*中,DP问题的关键观察是找到重叠的子问题,并使用缓存存储重叠的结果。

总结一下

我们对不同层次的缓存技术进行了研究。缓存不仅是一种架构和设计的方法,更是一种解决问题的通用思路。

基本原理是:使用缓存来减少计算中的重复(斐波那契数列),有时在需要时将重复数据存储在更快的组件上(例如 CDN、内存缓存)。

图片5.png

图 12:经典 CS 语录

大多数情况下,Cache是​​我们解决性能问题时所需要的抽象层。

作为有抱负的程序员,我们应该掌握它!

参考

鏂囩珷鏉ユ簮锛�https://dev.to/snj/caching-from-top-to-bottom-291e
PREV
LeetCode 初学者问题
NEXT
很棒的命令行技巧,可将您的工作效率提高 10 倍