# 浏览器缓存机制

缓存是性能优化中极其重要的一环,它可以大大减少网络传输所带来的时间成本,节省宽带流量,减少了服务器的负担,大大提高了网站性能。

常见的缓存分类有:

  • 浏览器缓存(HTTP 缓存)
  • 代理服务器缓存
  • 服务器缓存
  • 数据库缓存
  • CDN缓存
  • 应用层缓存

对于一个 HTTP 请求来说,可以分为发起网络请求、服务端处理、浏览器响应三个步骤。浏览器缓存可以帮助我们在第一和第三步骤中优化性能。比如说直接使用缓存而不发起请求就是属于第一步;发起了请求但后端存储的数据和前端一致,那么就没有必要再将数据回传回来,直接利用浏览器本地缓存,属于第三步。

# 浏览器缓存

缓存位置分为四种,并且有优先级之分,当依次查找且都没有命中的时候,才会去发送请求获取资源:

  1. Service Worker
  2. Memory Cache
  3. Disk Cache
  4. Push Cache

当上面四个缓存都没有命中,才会发送网络请求。

# Service Worker

Service Worker 是运行在浏览器背后的独立线程,使用 Service Worker 时传输协议必须为 HTTPS,因为其中涉及到请求拦截,不使 HTTPS 无法保障安全。

Service Worker 实现缓存功能分为三个步骤:

  1. 注册 Service Worker
  2. 监听到 install 事件以后就可以缓存需要的文件
  3. 下次用户访问时通过拦截请求的方式查询是否存在缓存,存在则使用

如果 Service Worker 没能命中缓存,一般情况会使用 fetch() 方法继续获取资源。这时候,浏览器就去 memory cache 或者 disk cache 继续寻找缓存。注意:经过 Service Worker 的 fetch() 方法获取的资源,即便它并没有命中 Service Worker 缓存,甚至实际走了网络请求,也会标注为 from ServiceWorker。

Service Worker 的最大特点是灵活直接,可以选择自己想要缓存的文件缓存。

Service Worker 不常用,这里就再详细介绍了,有兴趣可以查找资料继续深入了解。

# Memory Cache

Memory Cache 即内存中的缓存,Memory Cache 读取速度比 Disk Cache 快,但是可是缓存持续性很短,会随着进程的释放而释放。 一旦我们关闭页面,内存中的缓存也就被释放了。在内存极端不够用的情况下,可能在页面还没关闭前排在前面的缓存就失效了。

因为计算机内存一般比较小(相对硬盘来说),操作系统需要精打细算内存的使用,所以能让我们使用的内存并不多,操作系统会根据系统内存使用率来和文件的大小来判断是否使用内存缓存还是硬盘缓存。

# Disk Cache

Disk Cache 即存储在硬盘中的缓存,读取速度相对内存慢点,但是优点是容量大,存储时间也更长。所以绝大部分的缓存都来自 Disk Cache。

大家可能会问,那 Disk Cache 的保存时间是多久呢?

Disk Cache 的保存时间是不确定的,根据用户的使用习惯以及磁盘可用缓存大小来确定。当缓存的内容接近容量上限,浏览器便会采用特定的算法自动清理最不常用或者最老的缓存资源。

# Push Cache

Push Cache 是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。并且缓存时间也很短暂(在 Chrome 浏览器中大概只有5分钟),只在会话(Session)中存在,一旦会话结束就被释放,同时它也并非严格执行HTTP头中的缓存指令。

在 Jake Archibald 所写的 HTTP/2 push is tougher than I thought (opens new window) 这篇文章中有几个结论:

  • 所有的资源都能被推送,并且能够被缓存,但是 Edge 和 Safari 浏览器支持相对比较差
  • 可以推送 no-cache 和 no-store 的资源
  • 一旦连接被关闭,Push Cache 就被释放
  • 多个页面可以使用同一个 HTTP/2 的连接,也就可以使用同一个 Push Cache。这主要还是依赖浏览器的实现而定,出于对性能的考虑,有的浏览器会对相同域名但不同的tab标签使用同一个 HTTP 连接。
  • Push Cache 中的缓存只能被使用一次
  • 浏览器可以拒绝接受已经存在的资源推送
  • 你可以给其他域名推送资源

如果以上四种缓存都没有命中的话,那么只能发起网络请求来获取资源了。

那么为了性能上的考虑,大部分的接口都应该选择好缓存策略,通常浏览器缓存策略分为两种:强缓存和协商缓存,并且缓存策略都是通过设置 HTTP Header 来实现的。

# 缓存策略

浏览器的强缓存和协商缓存是使用浏览器下面的两种缓存机制:Memory Cache(内存缓存)和Disk Cache(磁盘缓存)。

# 强缓存

强缓存可以通过设置两种 HTTP 请求头来实现,分别是 Expires 和 Cache-Control 。强缓存表示在缓存期间不需要请求,state code 为 200。

Expires

Expires 是 HTTP/1 的产物,Expires 的值为服务端返回的数据到期时间。当再次请求时的请求时间小于返回的此时间,则直接使用缓存数据。

Expires: Wed, 16 Nov 2020 10:41:00 GMT

上面的 Expires 表示资源会在 Wed, 16 Nov 2020 10:41:00 GMT 后过期,需要再次请求。并且 Expires 受限于本地时间,如果修改了本地时间,可能会造成缓存失效。所以限制基本都是使用 Cache-Control 替代 Expires。

Cache-Control

Cache-Control有很多属性,不同的属性代表的意义也不同:

  • private:客户端可以缓存
  • public:客户端和代理服务器都可以缓存
  • max-age=x:缓存内容将在 x 秒后失效
  • no-cache:需要使用协商缓存来验证缓存数据
  • no-store:所有内容都不会缓存。
  • s-maxage: 代理服务器使用,在代理服务器(例如Nginx,CDN)中优先于 max-age
  • max-stale:能容忍的最大过期时间
  • min-fresh:能够容忍的最小新鲜度

为什么Cache-Control优先级更高?

  • Cache-Control 出现于 HTTP/1.1,优先级高于 Expires 。
  • Cache-Control可以提供更精确的缓存控制,包括max-age、no-cache、no-store等指令,可以将多个指令配合起来一起使用,达到不同的缓存目的,而Expires只能指定一个绝对的过期时间。
  • Cache-Control的指令可以通过max-age字段指定缓存的有效期,相对时间更容易计算和比较。
  • Cache-Control的指令可以在请求和响应中使用,而Expires只能在响应中使用。
  • Cache-Control的优先级高于Expires,当两者同时存在时,浏览器会优先使用Cache-Control的指令。

# 协商缓存

协商缓存可以通过设置两种 HTTP 请求头实现:Last-Modified 和 ETag 。

服务器会将缓存标识与数据一起响应给客户端,客户端将它们备份至缓存中。再次请求时,客户端会将缓存中的标识发送给服务器,服务器根据此标识判断。若未失效,返回 304 状态码,浏览器拿到此状态码就可以直接使用本地缓存数据了。

Last-Modified 和 If-Modified-Since

浏览器在第一次访问资源时,服务器返回资源的同时,在响应头中添加 Last-Modified 的 header,Last-Modified 的值是这个资源在服务器上的最后修改时间,浏览器接收后缓存文件和 header;

当浏览器再次请求服务器的时候,请求头携带 If-Modified-Since 字段来表示前面请求中缓存的 Last-Modified 值发送给服务器。

服务端收到此请求头发现有 if-Modified-Since,则与被请求资源的最后修改时间进行对比,如果一致则返回 304 和空响应体,浏览器只需要从缓存中获取信息即可,否则返回 200 和新的资源文件。

但是 Last-Modified 存在一些弊端:

  • 如果本地打开缓存文件,即使没有对文件进行修改,但还是会造成 Last-Modified 被修改,服务端不能命中缓存导致发送相同的资源
  • 因为 Last-Modified 只能以秒计时,如果在不可感知的时间内修改完成文件,那么服务端会认为资源还是命中了,不会返回正确的资源。

因为以上这些弊端,所以在 HTTP/1.1 中出现了 ETag 和If-None-Match 。

ETag 和 If-None-Match

ETag 是服务器响应请求时,返回当前资源文件的一个唯一标识(由服务器生成),只要资源有变化,ETag 就会重新生成。ETag 优先级比 Last-Modified 高。

浏览器在下一次向服务器发送请求时,会将上一次返回的 ETag 请求头的 If-None-Match 里,服务器只需比较客户端传来的 If-None-Match 的值跟自己服务器上该资源的 ETag 是否一致。

如果服务器发现 ETag 匹配不上,那么说明资源更新了,直接以常规 GET 200 回包形式将新的资源(也包括了新的 ETag)发给客户端,如果 If-None-Match 的值和 ETag 是一致的,则直接返回 304,告诉客户端直接使用本地缓存即可。

虽然 ETag 更加精准,但是 ETag 要服务器通过算法来计算出一个特定的 Hash 值,会占用服务端计算的资源。所以在性能上ETag 反而要逊于 Last-Modified,所以其实我们反而比较少使用 ETag 。

# 实际场景应用缓存策略

单纯了解理论而不付诸于实践是没有意义的,接下来我们来通过几个场景学习下如何使用这些理论。

# 频繁变动的资源

对于频繁变动的资源,首先需要使用 Cache-Control: no-cache 使浏览器每次都请求服务器,然后配合 ETag 或者 Last-Modified 来验证资源是否有效。这样的做法虽然不能节省请求数量,但是能显著减少响应数据大小。

# 代码文件

这里特指除了 HTML 外的代码文件,因为 HTML 文件一般不缓存或者缓存时间很短。

一般来说,现在都会使用工具来打包代码,那么我们就可以对文件名进行哈希处理,只有当代码修改后才会生成新的文件名。基于此,我们就可以给代码文件设置缓存有效期一年 Cache-Control: max-age=31536000,这样只有当 HTML 文件中引入的文件名发生了改变才会去下载最新的代码文件,否则就一直使用缓存。

# 代理服务器缓存

代理服务器缓存属于服务端缓存,以 nginx 为例,主要实现 nginx 服务器对客户端数据请求的快速响应。 nginx 服务器在接收到被代理服务器的响应数据之后,一方面将数据传递给客户端,另一方面根据 proxy cache 的配置将这些数据缓存到本地硬盘上。 当客户端再次访问相同的数据时,nginx 服务器直接从硬盘检索到相应的数据返回给用户,从而减少与被代理服务器交互的时间。

下面是关于如何使用 Nginx 缓存以及一些常见的使用场景:

  • 静态资源缓存:Nginx 可以缓存静态资源,如图片、CSS 和 JavaScript 文件。通过配置适当的缓存时间,可以减少服务器的负载并提高页面加载速度。

  • 反向代理缓存:Nginx 可以作为反向代理服务器,将请求转发给后端服务器,并缓存响应结果。这对于频繁请求的动态内容(如 API 响应)来说非常有用,可以减轻后端服务器的压力并提高响应速度。

  • HTTP 缓存控制:Nginx 可以根据请求头中的缓存相关字段(如 Cache-Control、Expires 和 Last-Modified)来控制缓存行为。通过合理配置这些字段,可以实现更精细的缓存策略,例如设置缓存过期时间、启用条件请求等。

  • FastCGI 缓存:Nginx 可以缓存 FastCGI 后端的响应结果,加快动态内容的处理速度。这对于使用 PHP、Python 等动态语言开发的网站来说尤其有用。

  • 代理缓存:Nginx 还支持代理缓存,可以缓存从上游代理服务器获取的响应。这对于加速跨地域访问或者减少对上游服务器的依赖非常有帮助。

在使用 Nginx 缓存时,需要注意以下几点:

  • 合理设置缓存时间:根据不同的资源类型和业务需求,设置适当的缓存时间,避免缓存过期或者过长时间的缓存导致内容更新不及时。

  • 考虑缓存清除机制:当内容发生更新时,需要及时清除相关缓存,以保证用户获取到最新的内容。可以使用 Nginx 提供的缓存清除指令或者结合其他工具实现缓存的主动清除。

  • 配置缓存规则:根据不同的请求路径、请求头或其他条件,设置不同的缓存规则,以满足不同场景的需求。

Nginx 缓存功能可以提高网站的性能和响应速度,减轻后端服务器的负载。合理配置和使用缓存可以根据不同的场景和需求,提供更好的用户体验和服务质量。

# 服务器缓存

比较常见的模式有分为两大类: Cache-aside 以及 Cache-as-SoR。其中 Cache-as-SoR(System of Record, 即直接存储数据的DB) 又包括 Read-through、Write-through、Write-behind。

# Cache-aside

Cache-aside 是比较通用的缓存模式,在这种模式,读数据的流程可以概括:

  1. 读 cache,如果 cache 存在,直接返回。如果不存在,则执行2
  2. 读 SoR,然后更新 cache,返回

写数的流程为:

  1. 写 SoR
  2. 写 cache

# Cache-as-SoR

在 Cache-aside 模式下,cache 的维护逻辑要业务端自己实现和维护,而 Cache-as-SoR 则是将 cache 的逻辑放在存储端,即 db + cache 对于业务调用方而言是透明的一个整体,业务无须关心实现细节,只需 get/set 即可。Cache-as-SoR 模式常见的有 Read Through、Write Through、Write Behind。

  • Read Through: 发生读操作时,查询 cache,如果 Miss,则由 cache 查询 SoR 并更新,下次访问 cache 即可直接访问(即在存储端实现 cacha-aside)

  • Write Through:发生写操作时,查询 cache,如果 Hit,则更新 cache,然后交由 cache model 去更新 SoR

  • Write Behind:发生写操作时,不立即更新 SoR,只更新缓存,然后立即返回,同时异步的更新 SoR(最终一致)

Read/Write Through 模式比较好理解,就是同步的更新 cache 和 SoR,读取得场景也是 cache 优先,miss 后才读 SoR。这类模式主要意义在意缓解读操作的场景下 SoR 的压力以及提升整体响应速度,对写操作并没有什么优化,适用于读多写少的场景。Write Behind 的的 cache 和 SoR 的更新是异步,可以在异步的时候通过 batch、merge 的方式优化写操作,所以能提升写操作的性能。

当前很多 DB 都自带基于内存的 cache ,能更快的响应请求,比如 Hbase 以 Block 为单位的 cache,mongo 的高性能也一定程度依托于其占用大量的系统内存做 cache 。不过在程序本地再做一层 local cache 效果会更加明显,省去了大量的网络I/O,会使系统的处理延时大幅提升,同时降低下游 cache + db 的压力。

其它还关于服务器缓存的知识还有:

  1. 缓存淘汰:缓存淘汰算是比较老的一个话题,常用的缓存策略也就那么几个,比如 FIFO、LFU、LRU。而且 LRU 算是缓存淘汰策略的标配了,当然在根据不同的的业务场景,也可能其他策略更合适

  2. 缓存击穿:在高并发场景下(比如秒杀),如果某一时间一个 key 失效了,但同时又有大量的请求访问这个 key,此时这些请求都会直接落到下游的 DB,即缓存击穿(Cache penetration),对 DB 造成极大的压力,很可能一波打死 DB 业务挂掉

    1. 这种情况下比较通用的保护下游的方法是通过互斥锁访问下游 DB,获得锁的线程/进程负责读取 DB 并更新 cache,而其他 acquire lock 失败的进程则重试整个 get的逻辑
  3. 缓存穿透:当请求访问的数据是一条并不存在的数据时,一般这种不存在的数据是不会写入 cache,所以访问这种数据的请求都会直接落地到下游 db,当这种请求量很大时,同样会给下游 db 带来风险

    1. 可以考虑适当的缓存这种数据一小段时间,将这种空数据缓存为一段特殊的值
    2. 另一种更严谨的做法是使用 BloomFilter, BloomFilter 的特点在检测 key 是否存在时,不会漏报(BloomFilter 不存在时,一定不存在),但有可能误报(BloomFilter 存在时,有可能不存在)。Hbase 内部即使用 BloomFilter 来快速查找不存在的行
  4. 缓存雪崩:当因为某种原因,比如同时过期、重启等,大量缓存在同一时间失效而导致大量的请求直接打到下游的服务或DB,为之带来巨大的压力从而可能崩溃宕机,即雪崩。对于同时过期这种场景,往往是因为冷启动或流量突增等发生,导致在极短时间内有大量的数据写入缓存,而且它们的过期时间相同,所以它们又在相似的时间内过期

    1. 一个比较简单的方法是随机过期,即每条 data 的过期时间可以设置为 expire + random
    2. 另一个比较好的方案是可以做一个二级缓存,比如之前做缓存时设计的一套 local_cache + redis 的存储方案,或者 redis + redis 的模式

# 数据库缓存

数据库的缓存机制分为两个层面:

由数据库提供,可以对数据表建立的高速缓存

数据库的数据临时保存在一个位置上,再次同样的请求直接把这个数据返回去,而不需要再次去查询各种表取数据了,减少了查数据库的时间,提升效率。并不是所有的历史记录都缓存起来,要有策略,比如只缓存两个月的数据,并且两个月之前有请求过之后不再请求该数据的时候就会回收,就是把这条记录抹掉,就近多次请求的才会保存。时间过长、使用率不高的优先清除,要不然缓存太多就失去了缓存的本质和意义。

在数据库中,数据都是存放在磁盘中的

虽然数据库层做了对应的缓存,但这种数据库层次的缓存一般针对的是查询内容,一般只有表中数据没有变更的时候,数据库对应的缓存才发挥了作用。有时并不能减少业务系统对数据库产生的增、删、查、改产生的庞大压力。此时,一般的做法是在数据库与业务服务器之间增加一个缓存服务器,比如我们熟悉的redis。客户端第一次请求的数据从数据库拿出后就放到了redis中,数据不过期或不更改的前提下,下一次的请求都从redis中直接拿数据,这样做极大的缓解了数据库的压力。

# CDN 缓存

当用户访问一个网站时,客户端直接从源站点获取数据,当服务器访问量大时会影响访问速度,影响用户体验,且无法保证客户端与源站点间的距离足够短,适合传输数据。CDN(内容分发网络),解决的正是如何将数据快速可靠地从源站点传递到客户端。通过数据分发,用户可以从一个距离较近的服务器获取数据,而不是源站点,从大达实现快速访问,减少源站点负载均衡的压力。

用户第一次访问网站后,网站的一些静态资源如图片等会被下载到本地,作为缓存,当用户第二次访问该网站的时候,浏览器就会从缓存中加载资源,不用向服务器请求资源,从而提高了网站的访问速度。若使用了CDN缓存,当浏览器本地缓存的资源过期后,浏览器不是直接向源站点请求资源,而是向CDN边缘请求资源。若CDN中的缓存过期,那就由CDN边缘节点向源站点发出回源请求来获取最新资源。

CDN节点缓存机制在不同服务商中是不同的,但一般都遵循HTTP协议,通过http响应头中的Cache-Control:max-age的字段来设置CDN节点文件缓存时间。当客户端向CDN节点请求数据时,CDN会判断缓存数据是否过期,若没有过期,则直接将缓存数据返回给客户端,否则就向源站点发出请求,从源站点拉取最新数据,更新本地缓存,并将最新数据返回给客户端。CDN服务商一般会提供基于文件后缀、目录多个维度来指定CDN缓存时间,为用户提供更精细化的缓存管理。CDN缓存时间会对“回源率”产生直接的影响,若CDN缓存时间短,则数据经常失效,导致频繁回源,增加了源站的负载,同时也增大了访问延时;若缓存时间长,数据更新时间慢,因此需要针对不同的业务需求来选择特定的数据缓存管理。

# 应用层缓存

应用层缓存大体上我们可以把他们分成两类,一种是实现跟踪浏览器用户身份功能的 Cookies 与 Session, 以及为了解决 Cookies 弊端在 HTML5 时代所发展出来的 Web Storage。另一种是针对 PWA ( Progressive Web App)即渐进式 web 应用服务,是为了下一代 web APP 服务的缓存机制。

参考链接:

服务器缓存(Cache) (opens new window)

数据库缓存 (opens new window)

关于CDN缓存 (opens new window)