重生之我用Nginx玩缓存

有关HTTP缓存的文章相信大家都已经看过很多了,优质的文章数不胜数。相信绝大多数公司的服务器配置权限都掌握在运维大佬手中,作为一名小小前端想改配置可谓是痴人说梦。那么你有没有自己动手体验过呢?本文尝试用最低成本的方式,带你亲手配置各种HTTP缓存。有条件的读者一定要被我手摸手带着撸一遍。

纸上得来终觉浅,绝知此事要躬行。 ------ 宋 · 陆游

原料准备

1. Windows系统电脑

过程略。

2. VScode

过程略。

3. Chrome(v125.0.6422.77)

过程略。

4. Nginx(v1.26.0)

点击下载后,解压缩至随便哪个你想放的文件夹中,双击nginx-1.26.0文件夹进入,你会看到这样的目录:

在文件夹空白处右键,选择通过Code打开,在弹出的VScode中打开自带的命令行工具(我的是Git Bash),输入以下内容并执行,启动nginx服务

然后打开浏览器输入localhost127.0.0.1

启动成功!

先别急,在nginx-1.26.0\html目录下新建一个文件夹\assets,等下会用到

5. Node(v20.8.1) + npm(v10.1.0)

推荐使用nvm管理Node版本,当然如果你的Node版本与我不同而你不想折腾,问题也不大。

6. 老朋友doge

图片上右键 -> 图片另存为 -> 保存到我们刚才新建的nginx-1.26.0\html\assets\下,起个名字,就叫doge.jpg

现在如果你在浏览器中输入http://localhost/assets/doge.jpg,你应该可以看到你自己电脑上的doge了。

打开F12找到doge的请求看看请求头和响应头,嚯,东西可真不少呢~

不急不急,后边我们慢慢讲~

7. Playground

再一次地,随便选一个你喜欢的目录,新建一个playground文件夹,右键选择通过Code打开

创建一个index.html文件,按下shift+1,使用VSCode内置的Emmet初始化一个HTML模板。

在HTML中插入我们的doge

HTML 复制代码
<body>
  <img src="http://127.0.0.1/assets/doge.jpg" />
<body/>

8. live-server

npm包,建议全局安装,方便快速启动一个小型的服务

js 复制代码
npm install live-server -g

接着在刚刚打开的VSCode Playground工作区中ctrl + ~打开终端,执行live-server,按住ctrl点击打印出的ip地址唤起index.html页面

为什么一定要通过服务的方式访问页面?file://的形式不是同样可以吗?
答:网络上一般把file://称做本地文件传输协议,但严格的来讲它并不是一种协议。我们都知道URI(统一资源标识符),用于标识各类抽象或物理资源等对象的统一符号和编码规则,例如网页上的资源、邮件地址、电话号码、书籍和现实世界的对象如人和地点、概念等。虽然URI可以标识一切,但在标识文件时又有特定的方案:File URI Scheme(文件统一资源标识方案),而file://就是File URI Scheme的特定语法。 ------ wiki
再答:其实上边这一大段跟问题并没有什么卵关系,只是我想表达file://不是一种协议。至于为什么要用live-server,真正原因是:使用file://访问HTML页面时,资源的缓存策略会与我们所知道的传统HTTP缓存方式有所出入。例:Data URI scheme的资源(比如base64图片),会正常使用memory cache,其他所有类型资源的缓存都强制使用disk cache


原料准备完成后,让我们进入正题!

Memory Cache & Disk Cache

如果我们用浏览器打开一个页面之后再开启F12开发者工具,Network是看不到已经加载过的接口、静态资源的信息的,所以本着严谨的态度,我们打开一个空的Chrome无痕标签页,在访问页面前先打开F12控制台。

在地址栏输入127.0.0.1:${live-server端口号}并打开

可以看到,此时doge是正常加载的,没有走缓存。HTTP Header头内容如下:

可以看到,因为是第一次加载,Request Header中没有任何与缓存相关的字段,而由于我们的nginx版本默认使用的是HTTP1.1,所以Response Header中返回了协商缓存的2个字段Etag、Last-Modified


好的,下面让我们刷新下页面

有些观众老爷可能会看到如下图,红框部分是disk cache磁盘缓存

也有些观众老爷则有可能看到的不一样,红框部分是memory cache内存缓存

造成这种区别的因素是:页面的闲置时长

磁盘缓存(disk cache)是什么?内存缓存(memory cache)又是什么?

答:自己查去

没错,想必能点进来看文的观众老爷或多或少都对HTTP缓存有些了解,而此时心里一定在想:写的啥玩意,也不过如此嘛

所以不必浪费篇幅去写那些随随便便就能查到的知识,让我们来聊些不一样的

浏览器对磁盘缓存和内存缓存的分配策略是怎么样的

首先,比较遗憾的是,这部分内容在Chrome的文档里并没有体现,而且不同的浏览器厂商、不同的浏览器内核、甚至不同的内核版本,处理逻辑的细节都千差万别,但好在大体思路是相同的。

在尝试阅读Chrome blink的源码以及请教了做浏览器内核的大佬后,总结出以下几点:

1. 与资源的大小有关:

例如:在早期的blink版本中,会设定一个资源体积的阈值,在小于这个阈值且满足另外其他条件时,会优先使用memory cache;反之则使用disk cache

而在近期的blink版本中,考虑到终端设备的硬件水平不断提升,固定阈值的方案已经跟不上时代,所以改变策略使用体积占比的方式:浏览器在运行时,操作系统为其分配了一部分内存,浏览器又按照运行时的一些影响因素将这些内存分配给不同的tab页,每个tab页将分配来的内存的一部分用来做memory cache。如果资源体积小于用来做memory cache的空闲内存的一个比例,且满足另外其他条件时,就会优先使用memory cache。同时,浏览器会通过LRU(最近最少使用算法)不断优化用来做memory cache的内存空间。

2. 与时间有关

这里的时间有两个角度:

其一,即上文提到的LRU所关心的最近使用时间。如果某个已经被写进memory cache的资源长时间未被再次访问,那么它就会被释放掉,再之后的第一次访问会从disk cache里读取。

其二,是资源的存活时间。按照大佬的说法,意思大概是:非file://外,资源加载完毕一定时间后,才会往内存缓存里带Key的写,这个Key,在110版本后,会由主域名、子域名、端口、最外层iframe一起来构成(不过,该说法我未能在Chrome 125.0.6422.77版本得到正确验证,有懂的大佬可以补充一下)

3. 与资源类型有关

例如:内联在页面中的base64图片,因为其本身就不是一个具有实体的文件,所以浏览器一般都会忽视字符编码的体积,将其存储在memory cache中。而且大概率也不会有人无聊到将几十上百兆的文件通过Data URI Scheme内联在页面中。

再例如:前边提到的file://。在使用file://访问页面时,可以把浏览器想象成一个具有页面预览功能的资源管理器。HTTP缓存的目的是减少请求、节省带宽、加快页面响应速度,基于这个前提在memory cachedisk cache之间做抉择,而通过file://访问页面时,就相当于所有的非内联资源默认都已经有了disk cache,还何必去多费工费写memory cache呢?


短暂的开了个小差后,我们回到主线来

无论刚才你刷新后看到的是memory cache还是disk cache,经过多次快速刷新后,都会始终看到memory cache,也都应该已经大概明白了产生差异的原因

但,新的问题来了:现在使用的是强缓存还是协商缓存?

在以往的知识储备中,我们知道,如果用的是强缓存

  1. 第一次请求的Response Header中应该有Cache-Control: max-age=xxxxExpires: xxxx存在
  2. 之后(缓存失效前)的请求状态码为200(disk cache)200(memory cache)

如果使用的是协商缓存

  1. 第一次请求的Response Header中应该有Last-Modified: xxxxxEtag: xxxxx存在
  2. 后续发起缓存验证的请求Request Header中应有If-None-MatchIf-Modified-Since存在,且状态码为304

很显然,两种缓存的条件都没有同时满足,现在的情况是:

  1. 初次请求的Response Header中有Last-Modified: xxxxxEtag:xxxxx
  2. 状态码200(memory cache)
  3. 后续请求的Request Header被折叠,没有If-None-MatchIf-Modified-Since(其实根本没有发出请求)

解答这个问题前,请允许我先做下准备工作,然后变个小魔术,把状态码304给变出来

使用电脑上任何可以编辑图片的软件,打开doge.jpg,不做任何改动(或做尽可能小的改动)后保存,保证文件的修改日期更新到当前时间

回到index.html,在doge.jpg这条请求上右键选择清除浏览器缓存,确认后刷新,看到了一条全新的状态码为200的请求

把注意力放在DateLast-Modified上,用Date.toLocaleString解析一下这两个时间:

请求时间Date和上次修改时间Last-Modified差了7分14秒,也就是434秒

如果我在请求时间Date + 434 / 10秒后,也就是大概10:09分之后再刷新页面,就能见证奇迹
细心的读者可能会发现截图中的Last-Modified与前边的不同了,这是因为Chrome即使在命中了协商缓存时也不会稳定的返回304状态码,即使该条请求除HTTP CODE=200外与其他命中协商缓存的请求一模一样,所以我反复操作了多次上边的步骤。而Edge就没有上述问题,这大概是Chrome的一个BUG,网络上也能看到有朋友反馈同样的问题。

So, What Happened?

这就不得不提到一个概念:启发式新鲜度计算 ,即在服务器没有明确指出缓存的有效期(新鲜度)时,例如使用max-ageExpires字段指定。客户端为了避免每次使用缓存都需要向服务器发送请求验证缓存是否有效(缓存验证/协商缓存),而自主通过启发式的计算为缓存设定一个有效期,RFC7234中并没有指定标准的计算方式,所以各个浏览器的实现可能稍有不同,但一般不会超过(请求时间 - 文件上次修改时间) * 10%

所以,在我编辑了图片后,使得计算出的新鲜度非常短,很快就能触发浏览器的缓存验证机制收到CODE为304的响应了。

那如果在当前的条件下,连Last-Modified也没有了呢?无聊的我这次没有让大家失望,通过给nginx的配置文件nginx-1.26.0\conf\nginx.conf添加一行代码,使Last-Modified的值非法

重启nginx让修改生效

bash 复制代码
./nginx.exe -s reload

得到的结论就是:缓存永不生效!

除了借助启发式新鲜度计算 外,还有没有别的方式可以控制304的出现?

答:max-age,也就是启发式新鲜度计算计算出来的那个东西。

并且,它可以强制每次都发起缓存验证。How?

Cache-Control: max-age=0

nginx.confadd_header修改成如下模样

./nginx.exe -s reload重启,清除浏览器缓存后刷新,是不是每次都返回304

在处理大体积更新频繁 的资源时使用这个技巧能带来巨大的收益,并且随着体积的增大,收益率也是直线上升,毕竟一个304的响应,体积是固定的。

不过,频繁的计算客户端资源是否过期,对服务器也是一种不小的负担,如果有一个单独的缓存服务器能做这部分工作来缓解业务服务器的压力,效果会更好。

一个不留神又讲了点CDN的原理,亏了亏了

max-age不为0的情况是什么样的处理逻辑,还需要演示一遍吗?不需要吗?算了还是来一遍吧~~

再次修改add_header Cache-Control "max-age=5";,设置资源的新鲜度为5秒max-age的单位是秒。

./nginx.exe -s reload重启

每两个CODE为304或200(非cache)的请求之间,会掺杂着几个CODE为200、走本地缓存的响应。用比较专业的话描述一下:

CODE为304200(非cache)的真实服务器响应会重置 缓存新鲜度倒计时,缓存新鲜度倒计时结束前,所有对该资源的请求,(若有本地缓存)都会走本地缓存(memory/disk)。

我不新鲜了

相信看到这里的观众老爷已经明白前边魔术的原理了,但是过程不太规范。因为启发式新鲜度计算 具有不确定性,真正上了舞台表演,是很有可能翻车滴,正确的做法应该是要辅以max-age处理新鲜度。

到目前为止,我们玩的都是缓存验证通过了的,下边我们来找点别的乐子。

清空缓存,刷新页面,我们得到了一条新鲜的缓存

打开画图软件,保存一下,更新最后修改时间

刷新页面,得到了这样一条请求:

  • CODE为200
  • EtagIf-None-Match成对出现,且值不相等
  • Last-ModifiedIf-Modified-Since成对出现,且值不相等

典型的一个缓存验证没有通过,服务器返回完整资源请求。

那如果是Etag修改时间其中一个维度不相等,缓存验证是否可以通过?

首先来分析一下,修改时间相等&Etag不相等修改时间不相等&Etag相等 两种情况是否可能发生?

假设Etag的生成规则是强验证类型(完全根据文件的内容生成),则有:

  • 修改时间相等Etag不相等:即在同一瞬间改出了一个文件不同内容的2个版本。在一台机器上不可能出现,但是在多台缓存服务器之间是有可能出现的。
  • 修改时间不相等Etag相等:即有2个版本的文件生成了相同的Etag,由于是强验证类型,所以这种情况是不可能发生的。

实际上情况远比我们想象中复杂的多:强验证弱验证,并不是Etag的生成方式,而是浏览器针对Etag字段进行缓存验证时的两种方式,而针对这两种方式各自有不同的Etag生成规则。

弱验证 :以弱验证标识符W/开头,以文件的size、最后修改信息、inode编号等信息hash后作为主体拼接而成。验证时只比较标识符后的hash部分字符串是否相等即可。

强验证:以文件内容和文件各种元数据hash后得到。但背后的验证除了比较字符串是否相等外,还会去验证资源本身的元数据是否匹配。

几种类型验证的情况,RFC7232给出的规范如下:

扯这么一出我其实是想说:即使我通过配置强行使EtagIf-None-Match的值相等,也无法绕过浏览器背后的验证逻辑触发304响应。但验证通过忽略验证 能达到相似的效果,我们可以通过配置Etag: *来达成目的。

同样的,我们通过把Last-Modified配置成空值来忽略其验证。


Etag通过验证(放行),Last-Modified不通过验证的全周期:

新鲜期内,memory cache

新鲜期后,更改Last-Modified前,通过缓存验证,304 Not Modified

新鲜期后,更改Last-Modified

值不相等未通过验证,200返回完整的资源,并记录Last-Modified、更新新鲜期倒计时。

Last-Modified通过验证(放行),Etag不通过验证全流程:

新鲜期内,memory cache

新鲜期后,更改Etag前,通过缓存验证,304 Not Modified

新鲜期后,更改Etag

值不相等未通过验证,200返回完整的资源,并记录Etag、更新新鲜期倒计时。

补充:与If-None-MatchIf-Modified-Since相对的,还有If-MatchIf-Unmodified-Since,从语义上来看完全相反:表示如果资源是XXX版本,do something / 如果资源自XXX时间起没有被修改过,do something。而应用场景也比较容易联想到:在线协同类软件(防止操作冲突)、云盘(防止无效操作)等。

别动,我自己来

下面,我们再过分一些,如果服务器没有提供缓存验证的机制,浏览器自己是怎么处理缓存的。

首先,刷新页面看下请求的Date,拿到当前的GMT时间

在这个时间的基础上加个2分钟,作为Expires的值更新到nginx.conf,同时关闭掉其他所有用于缓存验证的字段

重启nginx,看配置是否更新成功

然后你会发现,在Date小于Expires时,无论你怎么刷新,始终都会使用本地缓存

直到

Date超过了Expires,才重新发出了一个请求,并得到了CODE为200的完整响应。

这个过程比较简单粗暴:

  • 客户端:哥,我想要一张图片
  • 服务器:给,在这个时间之前,不要再来烦我!
  • 客户端:好的哥
  • 客户端:我想要一....哦还没到点,先用上次的吧
  • 客户端:我想要一....哦还没到点,先用上次的吧
  • 客户端:我想要一....哦还没到点,先用上次的吧
  • 客户端:到点了!哥,我想要一张图片
  • 服务器:!@#¥%......&

手摸手教学到这里基本就接近尾声了

你心里一定在想(切,除了最后一个Expires,别的谁用啊,大家不都是强缓存一把梭。面试造火箭,入职拧螺丝?)

说时迟那时快,你司的一个运维小弟一路小跑过来,满头大汗的对你说:哥,坏事了,我把咱生产环境的Nginx配置改错了

你故作镇定,问:啥啊,能不能淡定点,别一惊一乍的

小弟:我把缓存过期时间Expires给配到2099年去了...

你:就这?我还以为多大事呢

小弟:哥展开讲讲...

你:你去和老大说,明天给用户推送一条消息,把他们的设备时间都调整到2100

小弟:6

...

...

...

一些奇怪的小知识

max-age能达到和Expires相同的效果,也有着类似的缺陷

都存在时,优先级:max-age > Expires

max-age表示一个相对(客户端本地)时间,它和EtagLast-Modified那一群兄弟都是HTTP1.1的产物;Expires则表示一个绝对时间,是HTTP1.0的产物。总之用时间的维度来管理缓存,总有那么些不靠谱

浏览器的【清空缓存并硬性重新加载】,真的清空缓存了吗?

相信在座的各位都清理过手机app的缓存,动不动大几个G,清理起来十分耗时;而浏览器的清空缓存并硬性重新加载 几乎是瞬间就完成的。通过观察点了按钮之后的请求,我们发现,多了2个no-cache

清晰了是不是?考虑到这么快的完成速度,大概率不是清理了整个浏览器的缓存,而是仅仅针对当前页面。通过请求字段,我们能确定的是:不使用缓存、强制进行了验证缓存

所以,清没清缓存不一定,但一定更新了缓存

在写文章之前,我甚至以为所有的HTTP缓存字段都是需要手动添加的

但实际上,浏览器以及常见的Web服务器都已经帮我们做好了这些工作,就好像开箱即用的组件,开发者甚至还为我们提供了默认参数。

你需要做的仅仅是针对部分特殊资源做一些定制化配置。但通常,这部分工作也轮不到我们小小前端。

结语

前前后后吭哧了好几天,也只是验证了几个最基本的缓存场景。

涉及代理服务器的完全没有接触到,但我认为应对面试已经足够;有心的读者可以在这个基础上再部署一个Nginx作为代理缓存服务器,自己验证其他的场景。

也许你在读文的时候所处环境不允许搭建环境一步一步的跟着操作,但真心希望有条件了坐下来一个一个配置、一个一个场景都跑一遍,在这过程中碰到问题,去翻阅文档思考、解决,这样才能对整个缓存的脉络有清晰的认知。

比如我之前的概念里,一直觉得强缓存协商缓存 是两种类型的缓存形式,但经过这一波实践下来,我现在觉得它们更像是一种缓存形式下层层递进、不断加强控制力度的手段

说来也巧,上篇文章好像大概也是这个点写完的,下班下班~

欢迎真诚交流,但如果你来抬杠?阿,对对对~ 你说的都对~

相关推荐
麻辣韭菜27 分钟前
网络基础 【HTTP】
网络·c++·http
万叶学编程1 小时前
Day02-JavaScript-Vue
前端·javascript·vue.js
苹果醋31 小时前
大模型实战--FastChat一行代码实现部署和各个组件详解
java·运维·spring boot·mysql·nginx
前端李易安3 小时前
Web常见的攻击方式及防御方法
前端
PythonFun3 小时前
Python技巧:如何避免数据输入类型错误
前端·python
知否技术3 小时前
为什么nodejs成为后端开发者的新宠?
前端·后端·node.js
hakesashou3 小时前
python交互式命令时如何清除
java·前端·python
天涯学馆3 小时前
Next.js与NextAuth:身份验证实践
前端·javascript·next.js
HEX9CF3 小时前
【CTF Web】Pikachu xss之href输出 Writeup(GET请求+反射型XSS+javascript:伪协议绕过)
开发语言·前端·javascript·安全·网络安全·ecmascript·xss