缓存的原理:为什么我们需要它?
想象一下,你是一个勤劳的外卖小哥。如果每次顾客点同一款菜,你都要跑回餐厅取,那得多累啊!所以聪明的你决定:第一次送餐时,顺便带一份备用的放在顾客家门口的保温箱里。下次顾客再点同样的菜,你先看看保温箱里的还新鲜不?新鲜就直接给他,省得再跑一趟餐厅。
这就是HTTP缓存的核心原理:在首次请求后保存一份资源的响应副本,当用户再次发起相同请求时,如果判断缓存命中则拦截请求,直接返回之前存储的响应副本,从而避免重新向服务器发起资源请求。这就像是网络世界的"能源守恒法则"------能不跑的路,就别跑!
缓存的技术种类:冰箱有大有小
缓存技术多种多样:代理缓存、浏览器缓存、网关缓存、负载均衡器及内容分发网络等,但归根结底可分为两大类:共享缓存和私有缓存。
-
共享缓存:就像公司茶水间的公共冰箱,缓存内容可被多个用户共享使用,如公司内部架设的Web代理服务器;
-
私有缓存:相当于你家里的专属冰箱,只能由单个用户使用的缓存,如浏览器缓存。
而HTTP缓存按照验证机制又可细分为两种模式:
- 强制缓存:不用询问服务器,直接使用缓存(就像你相信冰箱里的牛奶没过期,直接喝)
- 协商缓存:获取缓存资源前先询问服务器缓存是否过期,过期则重新请求资源,未过期则使用缓存内容(就像你不确定牛奶是否新鲜,先问问室友)
强制缓存:我说了算
强制缓存相关字段:两位指挥官
与强制缓存相关的两个关键字段:Expires(老将军)和Cache-Control(新统帅)。
Expires的强制缓存应用:老式的约定
Expires:响应头包含一个明确的日期/时间,表示在此时间之后,响应过期。 在Cache-Control响应头设置了"max-age"或者"s-max-age"指令时,这位老将军Expires会被完全忽略。
javascript
http.createServer((req,res) => {
res.writeHead(200, {
Expires: new Date('2022-1-1 12:00:00').toUTCString()
})
})
Expires的致命缺陷:它依赖客户端的时间戳,就像你和朋友约定下午3点见,但你们的手表时间不一致,导致约会失败。同样,当客户端时间和服务器时间不一致时,缓存判断会出错。这就是为什么我们需要一个更可靠的指挥官。
Cache-Control:现代化的缓存指挥官
Cache-Control:这是一个通用消息头字段,被用于HTTP请求和响应中,通过指定各种指令来精确控制缓存机制。缓存指令是单向的,这意味着在请求中设置的指令,不一定会被包含在响应中。
Cache-Control的强制缓存应用:精确的时间控制
设置缓存过期时间,5秒之内缓存有效,超过5秒需要重新请求资源。就像告诉你的同事:"这杯咖啡放5秒内喝完,否则我就倒掉重泡!"
javascript
http.createServer((req,res) => {
res.writeHead(200, {
'Cache-Control': 'max-age=5'
})
})
Cache-Control的其他参数:缓存的指挥棒
no-cache :强制使用协商缓存,每次都需要和服务器确认缓存是否有效。
客户端可以缓存资源,但每次使用缓存资源前都必须重新验证其有效性。每次都会发起HTTP请求,当缓存内容有效时跳过HTTP响应体的下载。就像每次吃剩菜前都要先闻一闻是否变质------谨慎使用,但不浪费。
no-store :禁止使用任何缓存。相当于"这份机密文件看完就销毁,不准保存副本!"---适用于敏感数据或经常变化的内容。
private :响应资源不可以被代理服务器缓存。就像你的私密日记,只能放在自己抽屉里,不能放在公共书架上------适用于用户个人信息。
public :响应资源可以被浏览器 和代理服务器缓存。就像公开的参考书,可以放在图书馆供所有人查阅------最大化缓存效益。
一般不容易变动的资源可以设置public属性。例如:图像、CSS文件和JS文件等静态资源。
max-age:设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。与Expires相反,时间是相对于请求的时间。就像食品的保质期,不是具体日期,而是"自购买之日起30天内食用"---更加灵活可靠。
s-maxage:当设置public属性时生效,表示代理服务器缓存失效时间。就像图书馆的借阅期限,只对公共图书有效------专门针对CDN等共享缓存设计。
Cache-Control能作为Expires的完全替代方案,并且拥有其所不具备的一些精细缓存控制特性。在现代项目实践中使用它就足够了,目前Expires还存在的唯一理由是考虑可用性方面的向下兼容。就像智能手机已经可以完全替代传统电话,但有些老年人还是习惯用按键式电话------技术演进中的过渡期现象。
Pragma:古老的缓存控制器
Pragma值有no-cache与no-store,优先级大于Cache-Control,即:
pragma -> cache-control -> expires
这就像公司里的命令链:CEO的命令 > 部门经理的命令 > 组长的命令。在现代开发中,除非需要兼容HTTP/1.0的古老客户端,否则很少单独使用它。
协商缓存:当浏览器和服务器开始讨价还价
协商缓存原理:先问一问再决定
协商缓存:使用本地缓存前,向服务器发起GET请求,与服务器协商当前本地缓存是否过期。就像使用冰箱里的食材前,先问问室友:"这个还能吃吗?"---既保证了内容的新鲜,又避免了不必要的资源传输。
主要通过Last-Modified和ETag两种机制来实现精确判断。
基于Last-Modified的协商缓存:时间戳比对法
设置Last-Modified字段为缓存内容最后修改时间,相当于给资源贴上"最后编辑于XX时间"标签。
客户端再次请求该资源时会自动携带If-Modified-Since字段,该字段值为上次请求时服务器返回的Last-Modified值。
服务端会比对这两个字段值:
- 如果两者一致,说明资源未被修改,返回304状态码告知客户端可以继续使用缓存
- 如果两者不一致,说明资源已更新,返回200状态码和最新的资源内容
请求流程图:一次完整的协商过程
赋值给if-modified-since 客户端 ->> 服务端: 老板!再来一份上次的资源! Note right of 客户端: 携带if-modified-since获取资源 Note right of 服务端: 对比if-modified-since与资源修改时间 服务端 ->> 服务端: 稍等我对比一下 Note left of 服务端: if-modified-since与资源修改时间一致
返回304告知客户端缓存未过期 服务端--x 客户端: 304! 你的资源还能用啊! 客户端--x 客户端: 好的那我用之前的吧。 服务端->> 客户端: 200!我重新给你一份吧。 Note left of 服务端: if-modified-since与资源修改时间不一致
返回200重新发送资源和新的last-modified Note left of 客户端: 万分感谢
Node代码实现:实战协商缓存
javascript
const fs = require("fs")
const http = require("http");
const url = require("url")
http.createServer((req,res) => {
const {pathname} = url.parse(req.url);
if(pathname === '/img/3.jpg') {
//读取图片文件
const data = fs.readFileSync("./img/3.jpg")
//获取文件修改时间
const { mtime } = fs.statSync('./img/3.jpg')
// 获取请求头中使用的缓存最后修改时间
const ifModifiedSince = req.headers['if-modified-since']
// 对比缓存最后修改时间与文件最后修改时间
if(ifModifiedSince === mtime.toUTCString()) {
// 缓存生效 返回304
res.statusCode = 304
res.end()
return
}
// 添加响应文件最后修改时间
res.setHeader("Last-modified", mtime.toUTCString())
// 设置为协商缓存
res.setHeader("Cache-Control", 'no-cache')
res.end(data)
}
}))
Last-Modified:时间戳版本管理的尴尬
想象一下,你是一位图书管理员,仅凭借书本的最后修改日期来判断内容是否有变化。这就是Last-Modified的工作方式,但它有两个明显的短板:
-
内容未变但时间戳变了 - 就像你重新整理了书架,书本内容一字未改,却要重新登记时间。这种情况下浏览器会不必要地重新请求资源,白白浪费网络带宽。
-
精度限制 - 时间戳精确到秒,如果资源在一秒内完成多次修改(这在高并发系统中并不罕见),浏览器可能会错过重要更新。就像你眨眼的功夫,书被翻阅并修改了,但时间戳却没变。
ETag:资源的指纹识别技术
为了解决Last-Modified的这两个问题,HTTP/1.1规范引入了ETag(Entity Tag)头信息,这是一种实体标签机制。
ETag就像资源的指纹 - 服务器通过对资源内容进行哈希运算,生成一个唯一的标识字符串。不同的资源内容会生成不同的ETag值,即使修改时间相同。这就像每本书都有独特的ISBN码,内容变了,码就变了,非常精确。
请求流程图:浏览器与服务器的对话剧
赋值给If-None-Match 客户端 ->> 服务端: 老板!再来一份上次的资源! Note right of 客户端: 携带If-None-Match获取资源 Note right of 服务端: 对比If-None-Match与资源的ETag值 服务端 ->> 服务端: 稍等我对比一下 Note left of 服务端: If-None-Match与资源的ETag值一致
返回304告知客户端缓存未过期 服务端--x 客户端: 304! 你的资源还能用啊! 客户端--x 客户端: 好的那我用之前的吧。 服务端->> 客户端: 200!我重新给你一份吧。 Note left of 服务端: If-None-Match与资源的ETag值不一致
返回200重新发送资源和新的ETag Note left of 客户端: 万分感谢
Node.js实现:几行代码搞定协商缓存
javascript
const fs = require("fs")
const http = require("http")
const url = require("url")
const etag = require('etag') // 需要安装etag包:npm install etag
http.createServer((req, res) => {
const {pathname} = url.parse(req.url);
if (pathname === '/img/4.jpg') {
// 读取文件内容
const data = fs.readFileSync("./img/4.jpg")
// 生成ETag标识字符串
const etagControl = etag(data)
// 获取请求头标识字符串
const ifNoneMatch = req.headers['if-none-match']
// 判断是否为同一个文件
if(ifNoneMatch === etagControl) {
res.statusCode = 304
res.end()
return
}
res.setHeader("etag", etagControl)
res.setHeader("Cache-Control", "no-cache") // 启用协商缓存
res.end(data)
}
}).listen(3000, () => {
console.log('Server running at http://localhost:3000/')
})
ETag的弊端:没有十全十美的技术
-
计算成本 - 生成ETag需要额外的计算资源。
当资源尺寸大或者数量多时,会像一个贪吃的CPU怪兽,悄悄蚕食服务器性能。
-
强弱验证的选择困境 - ETag字段分为强验证和弱验证。
强验证:根据资源的每一个字节生成。资源有任何变化都会生成不同的ETag,就像对资源进行了DNA鉴定。
弱验证:根据资源的部分属性值生成。生成速度快但无法保证每个字节都相同,就像只看书的目录来判断内容是否变化。 -
分布式系统的一致性挑战 - 在分布式场景下,不同服务器可能使用不同的ETag生成算法,导致浏览器从一台服务器获取资源后,到另一台服务器验证时出现ETag不匹配的情况。这就像你拿着北京图书馆的借书卡去上海图书馆借书,结果被告知卡号不匹配。
缓存策略:架构师的决策艺术
缓存策略流程图:决策树
制定缓存策略时,我们需要考虑以下几个关键问题:
- 是否需要缓存?如果想启用协商缓存,需要给Cache-Control字段添加no-cache属性值。
- 是否允许代理服务器缓存资源?通过Cache-Control字段的private和public属性控制。
- 如果使用强制缓存,需要设置过期时间,即为Cache-Control字段配置max-age=秒数的属性值。
- 如果启用了协商缓存,则可进一步设置Last-Modified和ETag实体标签等参数。
缓存策略示例:鱼和熊掌如何兼得
在使用缓存技术优化性能体验的过程中,我们面临一个经典的矛盾:既希望缓存能在客户端尽可能久地保存(减少网络请求),又希望它能在资源发生修改时及时更新(保证内容最新)。
这就像你既想吃到新鲜出炉的面包,又不想每天跑面包店一样矛盾。使用强制缓存并设置较长的过期时间可以减少请求次数,但由于强制缓存的优先级高于协商缓存,资源更新可能不及时;而使用协商缓存虽然能保证内容最新,但频繁的服务器验证会降低响应速度。
那么,如何兼顾二者的优势呢?答案是:资源分类处理。
我们可以将网站资源按照不同类型拆解,为不同类型的资源制定相应的缓存策略。以下面的HTML文件为例:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTTP缓存</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<img width="100" src="./img/2.png" alt="2.png">
<img width="100" src="./img/3.jpg" alt="3.jpg">
<img width="100" src="./img/4.jpg" alt="4.jpg">
</body>
<script src="script.js"></script>
</html>
针对不同资源类型,我们可以这样设置:
-
HTML文件:作为入口文件,内容变化需要及时反映,应使用协商缓存。
httpCache-Control: no-cache
-
图片文件:通常更新频率低,且占用空间大,适合使用强制缓存但过期时间不宜过长(例如一天)。
httpCache-Control: max-age=86400
-
CSS文件:可能会不定期修改,但又想利用强制缓存提高效率,可以在文件名中添加内容哈希(如style.51ad84f7.css),这样内容变化时URL也会变化,强制客户端重新请求。过期时间可设置较长。
httpCache-Control: max-age=31536000
-
JavaScript文件:类似CSS文件处理,使用内容哈希和较长的过期时间。如果包含用户私人信息,可添加private属性防止中间代理缓存。
httpCache-Control: private, max-age=31536000
这种组合策略就像一个精明的管家,对不同的物品采取不同的存储方式,既保证了使用效率,又确保了内容的及时更新。
缓存设置注意事项:魔鬼藏在细节中
在前面的内容中,我们提供了一种制定缓存决策的思路与示例,但需要明白:不存在适用于所有场景的最佳缓存策略。就像没有放之四海而皆准的菜谱一样,缓存策略需要根据具体场景量身定制。
以下是一些值得注意的细节:
拆分源码,分包加载:化整为零的艺术
对于大型前端应用,代码量庞大,如果只修改了几个模块就全量更新,就像为了修补衣服上的一个小洞而重新买一套衣服一样浪费。我们可以在构建过程中按模块拆分打包,这样每次只需更新变化的模块,大大减少下载量。
预估资源的缓存时效:时间管理大师
不同资源有不同的更新频率,就像牛奶和罐头的保质期不同。根据资源特点,为强制缓存设置合适的max-age值,为协商缓存提供精确的ETag标签。
控制中间代理的缓存:隐私保护卫士
包含用户隐私信息的资源应避免被中间代理缓存(使用Cache-Control: private),而对所有用户响应相同的公共资源则可以允许中间代理缓存(使用Cache-Control: public)。
避免URL冗余:保持身份唯一
缓存是根据请求资源的URL进行的,如果相同的资源有多个URL,就会导致重复缓存,就像一个人有多个身份证一样混乱。尽量确保一个资源只有一个URL。
规划缓存的层次结构:建筑师的布局思维
不仅要考虑资源类型,还要考虑文件的层次结构。就像设计一座大厦,需要考虑每一层的功能和相互关系。
网页访问刷新与缓存:不同的刷新方式,不同的缓存策略
当我们访问网页时,不同的操作方式会触发不同的缓存行为:
-
首次访问或地址栏输入URL:浏览器会按照正常的缓存策略处理,先检查强制缓存,再考虑协商缓存。
-
按刷新按钮、F5刷新或右键"重新加载":浏览器会忽略强制缓存,但仍会发送协商缓存的验证请求(带上If-Modified-Since或If-None-Match头),服务器可能返回304状态码。这就像你问餐厅服务员:"我上次点的菜单还有效吗?"
-
Ctrl+F5强制刷新:浏览器会完全忽略本地缓存,向服务器发起全新请求,不带任何缓存验证的头信息。这相当于你对服务员说:"不管上次点的什么,我要全新的菜单!"
这三种方式就像是与服务器沟通的三种态度:礼貌询问、适度怀疑和完全不信任。作为架构师,我们需要理解这些行为模式,才能设计出既高效又灵活的缓存策略。
结语:缓存设计的平衡艺术
设计HTTP缓存策略就像调配一道复杂的菜肴,需要平衡多种因素:性能与时效性、带宽节约与内容更新、用户体验与服务器负载。没有放之四海而皆准的配方,只有适合特定场景的最佳实践。
作为架构师,我们的任务不是追求完美的缓存策略,而是在各种约束条件下找到最佳平衡点。就像一位优秀的指挥家,知道何时该让哪些乐器发声,何时该保持沉默,从而奏出最和谐的乐章。
记住,缓存设计不是一次性工作,而是需要随着应用的发展而不断调整和优化的持续过程。通过监控、分析和迭代,我们可以不断完善缓存策略,为用户提供更快、更流畅的网络体验。