• 如何使用强缓存与协商缓存提高Web应用性能
  • 发布于 2个月前
  • 284 热度
    0 评论
在Web服务中,缓存是个非常重要的内容,是提高网页加载速度、减少服务器压力必备良方,一般来说分为强缓存与协商缓存两种。什么是强缓存?简单来说,就是网页加载时,直接读取浏览器的缓存,跳过与服务器的交互。

什么是协商缓存?顾名思义,就是网页加载时,先去问服务器,我这个资源有没有变化啊?服务器经过一番计算,认为没有变化,则响应304状态码,否则返回新的资源。浏览器看到304状态码,就不会从服务器接收文件内容了,改用缓存,跳过了资源的下载(网络传输)阶段。

以上就是这两种缓存的核心思想,本文先用代码样例来帮助你熟悉下这两种缓存,然后再重点讲下你平时绝对会忽略的一种强缓存。

两种缓存介绍
强缓存
当浏览器第一次请求一个资源时,服务器可以在响应中设置强缓存的相关头部信息,一般来说,主要是Expires和Cache-Control。

Expires是一个日期/时间值,表示资源的过期时间。当浏览器再次请求该资源时,如果当前时间小于过期时间,浏览器将直接从缓存中加载资源,而不发送请求到服务器。由于它依赖于客户端和服务器之间的时钟同步,如果二者存在冲突,那么Expires指定的过期时间可能就不准了,所以后来HTTP又引入下面的Cache-Control来精准控制缓存,可以说我们通常使用后者就足够了。

Cache-Control是一个控制缓存行为的指令。常见的指令包括max-age和no-cache。max-age指定资源在缓存中的最大存储时间,单位为秒;no-cache指示浏览器在使用缓存之前必须先与服务器确认资源是否过期,也就是后面要说的协商缓存;如果想要禁用缓存,则可以使用no-store。
用Deno代码来演示:
import { Application, Router } from "https://deno.land/x/oak/mod.ts";
// 堆代码 duidaima.com
const router = new Router();
router.get("/", (context) => {
  context.response.headers.set("cache-control", "max-age=3600");
  const str = new Array(100000).fill("三国演义").join("\n");
  context.response.body = str;
});
const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());

await app.listen({ port: 8000 });
在网络里看,第一个从服务器中读取,第二次则变成了disk cache(磁盘缓存):

根据设定,直到一小时后才会过期,除非你手动清除浏览器缓存,或者勾选F12网络中的停用缓存:

本质上它是在请求头中设置Cache-Control为no-cache:

协商缓存
上面说,协商缓存是浏览器向服务器询问资源有没有变化,那么服务器怎么知道有没有变化呢?主要是根据两个header。

在第一次响应时,服务器会响应一个叫etag的标头;第二次请求时,浏览器会把这个etag改了个名字为If-None-Match,发送给服务器,服务器对比这个值与自己重新计算的etag,如果一致,表示资源没有变化,就会响应304状态码,将Body置为空,告诉浏览器可以直接用之前的缓存。

用代码表示如下:
import { Application, Router } from "https://deno.land/x/oak/mod.ts";
// 堆代码 duidaima.com
const router = new Router();
router.get("/", (context) => {
  console.log("get /");
  if (context.request.headers.get("If-None-Match") === "W/abcd") {
    context.response.status = 304;
    return;
  }
  context.response.headers.set("etag", "abcd");
  const str = new Array(100000).fill("三国演义").join("\n");
  context.response.body = str;
});

const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());

await app.listen({ port: 8000 });
第一次:

第二次:

我图省事,将etag设置为abcd,主要是为你演示它的用法,在实际工作中当然不可能这样粗暴,一般需要对文件内容进行唯一值计算。

TIPS:与强缓存的Expires一样,协商缓存也有个已经不再推荐的响应标头Last-Modified。顾名思义,后者记录的是文件在服务器上最后一次的修改时间,这样显然不够准确——如果这个文件修改了半天,又回退到第一稿,那么缓存也应该可用才对。

在2023年的今天,如果你开发后端代码,没有必要再折腾这个字段的判断逻辑(对应携带的请求标头是If-Modified-Since)。

强缓存的漏网之鱼
正常来说,上面就把面试中常问的缓存策略解释完了。但事实上,强缓存真的是必须设置Expires和Cache-Control吗?当然不是。以我们某个网站为例,前端用到了CDN的yaml文件作为配置,第二次打开网站时,可以看到这个yaml文件为强缓存:

打开标头信息:

仔细看,只见Cache-Control设置为public,跟时间有关的有Age、Date、Last-Modified,其它就是一堆网关(Kong)注入的信息(X-Kong-xx、X-Request-Id)、CDN注入的信息(Server、Ali-Swift-Global-Savetime、X-Cache、X-Swift-xx)等。其中X-Cache的值为HIT开头,表示命中CDN的缓存,如果没有命中,则是类似于MISS TCP_IMS_HIT dirn:11:218091654。

CDN是个代理服务器,处于中间层,它会缓存真实服务的静态资源。我们用的阿里的CDN服务,理论上讲它当然可以设置不同的缓存策略,不过我们只是用它引出今天的问题——它为什么会走强缓存呢?这个缓存又什么时候失效呢?

仍以这个CDN的YAML文件为例,一个多小时后,才不走缓存:

响应头有变化,Age、Date都不一样了:

这次的X-Cache: MISS TCP_IMS_HIT dirn:12:557831290表示没有命中CDN的缓存,这个请求会穿透CDN,回源到我们真实的服务器。

响应标头分析
我们先看下上面的响应标头:
Age:为CDN返回的头部字段,表示该文件在CDN节点上缓存的时间,单位为秒。只有文件存在于节点上,Age字段才会出现,当文件被刷新后或者文件被清除的首次访问,在此前文件并未缓存,无Age头部字段。需要注意,当Age为0时,表示节点已有文件的缓存,但由于缓存已过期,本次无法直接使用该缓存,需回源校验。
Date:CDN节点发送响应的时间。
Last-Modified:前面协商缓存提到的文件修改时间。
X-Swift-SaveTime:CDN节点上的缓存RS(swift)的时间,即该文件是在什么时间缓存到CDN节点上。
X-Swift-CacheTime:CDN节点上的允许缓存时间,即该文件可以在CDN节点上缓存多久,是指文件在CDN节点缓存的总时间。计算还有多久需要回源刷新= X-Swift-CacheTime-Age。
抛开最后两个不提,前面这3个才是本次强缓存的关键。这就涉及到浏览器的默认缓存策略。

Chrome的缓存策略
这篇文章《从chrome源码解读cache缓存策略》把源码都列出来了,有兴趣的读者可以看看。这里我只说下结论,先是freshness_lifetime(缓存保鲜期):
如果response header里包含下面中任意一个(cache-control: no-cache、cache-control: no-store、pragma: no-cache),那么freshness_lifetime就是0(这条说的是禁止缓存)
1.如果response header里包含max-age,那么它的值就是freshness_lifetime(这条说的是强缓存)
2.如果response header里包含Expires,那么它的值减去Date的值,就是freshness_lifetime(这条还是强缓存)
3.如果response status code是200、203、206,并且header里不包含cache-control: must-revalidate,并且header包含Last-Modified,且它的值比Date小,4.那么freshness_lifetime就是(date_value - last_modified_value) * 0.10
5.如果response status code是300、301、308、410,那么freshness_lifetime就是永久有效(这条是重定向)
6.以上判断逻辑都没得到freshness_lifetime的话,最终freshness_lifetime的值是0,也就是资源无效
经过上面六步,最终freshness_lifetime > age的话,资源有效,否则无效。

我们的情况属于第4条。

age指的不是上面的响应标头,另有一番计算逻辑:
apparent_age = max(0, response_time - date_value);
response_delay = response_time - request_time;  // 强缓存的情况下,约为0
corrected_age_value = age_value + response_delay; // 在没有age响应头时,age为0,这个值约为age
corrected_initial_age = max(apparent_age, corrected_age_value); // 响应时间-Date,那就是now-Date,如果有age的话,这两个值取最大数
resident_time = now - response_time; // 强缓存,这个值也接近0
current_age = corrected_initial_age + resident_time; // now - Date
这里的age_value才是响应标头中的Age字段,在没有响应的时候为0,所以最终current_age大概是『当前时间-Date标头』。
freshness_lifetime = (date_value - last_modified_value) * 0.10;
age = now - date_value;

// 如果freshness_lifetime > age,那么算下来大概的过期时间是:
now < (date_value - last_modified_value) * 0.1 + date_value = 1.1 * date_value - 0.1 * last_modified_value
看得出来,过期时间(now)与Date成正比,与Last-Modified成反比,也就是说,如果Date越新(值越大),Last-Modified越久(值越小),就会越晚过期(强缓存时间越长)。

仔细想想,你就会发现这个策略很妙。默认情况下,CDN会一小时同步一次源数据,如果某个文件非常古老,CDN也同步了许多次,一直没有变化,那么浏览器就认为它发生改变的概率越低,就会缓存更长的时间。相反,某个文件刚修改完,那么再次修改的风险就会很大,初次缓存的时间会很短。

代码验证
用CDN来测试的成本太高了,我们还是用Deno写段代码:
import { Application, Router } from "https://deno.land/x/oak/mod.ts";
// 堆代码 duidaima.com
const router = new Router();
router.get("/", (context) => {
  console.log("get /");
  const now = new Date();
  const headers = {
    "Cache-Control": "public",
    "Date": "Mon, 14 Aug 2023 09:30:21 GMT",
    "Time": now.toGMTString(),
    "Last-Modified": "Mon, 14 Aug 2023 00:00:00 GMT",
  }

  for (const [key, value] of Object.entries(headers)) {
    context.response.headers.set(key, value);
  }
  const str = new Array(100000).fill("三国演义").join("\n");
  context.response.body = str;
});

const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());

await app.listen({ port: 8000 });
Date和Last-Modified缺一不可,一般来说,Date字段会设置为当前时间,不过我们为方便测试,手动设置这个值,读者如果有兴趣,也可以修改它进行验证。在网络中多次请求接口,我通常是打开http://localhost:8000这个页面,但不是用刷新来测试,那样是没用的,因为这时Cache-Control: max-age=0,相当于禁用缓存,我是F12里手动执行fetch('/'):

效果:

强缓存,Date为17:48分,当前时间为17:50分:

我们代入上面的公式,算出什么时候会过期:
function getExpiredTime(date, lastModified) {
    const d = new Date(date);
    const l = new Date(lastModified);
    const seconds = (d.getTime() - l.getTime()) * 0.1 / 1000;
    d.setSeconds(d.getSeconds() + seconds);
    return d.toLocaleString('zh-CN', { hour12: false });
}

const d = 'Mon, 14 Aug 2023 09:30:21 GMT'
const l = 'Mon, 14 Aug 2023 00:00:00 GMT'
console.log(getExpiredTime(d, l)); // 2023/8/14 18:27:23
计算出来到18:27分过期,还得半个小时。当然没必要等这么长时间。有两种方案,一是调整系统时间,二是修改下Last-Modified为最近的时间点,计算出来17:57过期。
const d = 'Mon, 14 Aug 2023 09:30:21 GMT'
const l = 'Mon, 14 Aug 2023 05:00:00 GMT'
console.log(getExpiredTime(d, l)); // 2023/8/14 17:57:23
重启服务,清除缓存后看新的强缓存:

到17:57:35时看缓存已经失效:

再写了个加入age的:
function getCurrentAge(date: Date, age: number) {
  const response_time = new Date();
  let apparent_age = Math.max(
    0,
    (response_time.getTime() - date.getTime()) / 1000,
  );
  let response_delay = 0;
  let corrected_age_value = age + response_delay;
  let corrected_initial_age = Math.max(apparent_age, corrected_age_value);
  let resident_time = 0;
  let current_age = corrected_initial_age + resident_time;
  return current_age;
}

export function getFreshnessLifetime(date: Date, lastModified: Date) {
  return (date.getTime() - lastModified.getTime()) / 1000 * 0.1;
}

function getExpiredSeconds(date: string, lastModified: string, age: number) {
  const d = new Date(date);
  const l = new Date(lastModified);
  const freshnessLifeTime = getFreshnessLifetime(d, l);
  const current_age = getCurrentAge(d, age);
  console.log("freshnessLifeTime", freshnessLifeTime);
  console.log("current_age", current_age);
  return freshnessLifeTime - current_age;
}

function getExpiredTime(date: string, lastModified: string, age: number) {
  const expiredSeconds = getExpiredSeconds(date, lastModified, age);
  if (expiredSeconds < 0) {
    console.warn("已过期" + Math.abs(expiredSeconds / 60) + "分");
  }
  const now = new Date();
  now.setSeconds(now.getSeconds() + expiredSeconds);
  return now.toLocaleString("zh-CN", { hour12: false });
}
把一开始的那个CDN的样例放进去:

当时14.42刷新页面看到过期,是对的:
const date = "Fri, 11 Aug 2023 04:28:58 GMT";
const lastModified = "Thu, 10 Aug 2023 06:50:20 GMT";
const age = 1527;
console.log(getExpiredTime(date, lastModified, age));

// freshnessLifeTime 7791.8
// current_age 19586.318
// 已过期196.5753分
// 2023/8/11 14:38:50
总结
本文介绍了强缓存与协商缓存这两种缓存策略,使用Deno启动服务帮你理解它们的使用及原理。重点提出我们常说的强缓存的漏网之鱼——浏览器的默认缓存策略,除了我们熟知的标头Cache-Control与Expires外,在Date、Last-Modified、Age(可选)同时存在的情况下,也会计算出是否要使用强缓存。

在Web开发中,我们要合理地使用缓存策略。强缓存适合静态资源或者不经常变化的资源,能够快速加载并减少服务器压力;而协商缓存适合经常变化的资源,能够灵活控制缓存行为并确保获取最新的资源。比如HTML通常就适用于协商缓存,而经现代工程hash化的JS、CSS及图片等资源,就可以设置超长的过期时间。
用户评论