一、真实场景:电商详情页的“回源噩梦”
2023年双11前夕,我们团队负责的电商平台商品详情页平均加载时间飙到3.2秒。线上监控发现CDN回源率高达100%——每次用户请求都会穿透CDN到达源服务器。服务器QPS达到1200,CPU接近90%,频繁报警。初查原因:静态资源(JS/CSS/图片)未配置任何缓存头,CDN每30分钟强制回源一次,且用户浏览器每次从CDN拉取新文件。
更严重的是,商品详情页依赖的API数据通过GET请求传输,同样没有缓存策略,每次刷新都重新请求,用户体验极差。
二、问题分析:缓存策略为什么失效?
我们拆解了三个核心问题:
- 静态资源无hash文件名:main.js?v=1.0,每次版本更新后客户端的本地缓存依然有效,CDN节点也不刷新。
- 响应头缺失:源服务器返回的响应里没有
Cache-Control、Expires、ETag等字段,CDN和浏览器均无法判断缓存有效期。 - CDN缓存策略不当:CDN配置了全局15分钟过期,且忽略URL query string,所以即使
main.js?v=1.1,CDN仍然返回旧缓存。
三、方案对比:三种缓存策略的优劣
| 方案 | 实现方式 | 回源率 | 更新时效性 | 复杂度 |
|---|---|---|---|---|
| A: 协商缓存 | ETag + Last-Modified | ~30% | 秒级 | 低 |
| B: 强缓存+文件hash | Cache-Control: max-age=31536000 + 文件名hash | <5% | 版本更新后立即失效 | 中 |
| C: Service Worker缓存 | SW fetch拦截 + Cache API | <1% | 手动控制 | 高 |
方案A虽然简单,但每次仍要发送条件请求,回源率30%对高并发场景仍不可接受。方案C能极致降低回源,但Service Worker开发维护成本高,且不支持部分旧浏览器。最终我们选择方案B,配合CDN预热和刷新策略。
四、完整代码实现
4.1 前端构建:Webpack输出hash文件名
使用webpack@5.88.0,配置输出带contenthash的文件名:
// webpack.config.js
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
filename: '[name].[contenthash:8].js',
path: path.resolve(__dirname, 'dist'),
clean: true
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
// 自动注入带hash的script标签
})
]
};
打包后生成 main.a1b2c3d4.js,每次内容变化hash必变。
4.2 服务端静态资源托管(Node.js + Express 4.18)
使用express.static并设置超长强缓存:
// server.js
const express = require('express');
const path = require('path');
const app = express();
const PORT = 3000;
// 静态资源配置 - 强缓存1年
app.use('/static', express.static(path.join(__dirname, 'dist'), {
maxAge: 31536000000, // 单位ms,1年
immutable: true,
etag: false // 强缓存不需要ETag
}));
// API接口配置协商缓存
app.get('/api/product/:id', (req, res) => {
const product = getProductFromDB(req.params.id);
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
res.setHeader('ETag', `"${product.version}"`);
res.json(product);
});
4.3 Nginx反向代理配置
nginx/1.24.0 作为CDN回源时的入口,添加缓存头并开启gzip:
# /etc/nginx/conf.d/cache.conf
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
# 关闭etag避免返回多个头
etag off;
proxy_pass http://127.0.0.1:3000;
# 为CDN回源保留原始请求头
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
4.4 CDN配置(以阿里云CDN为例)
在CDN控制台设置缓存规则:
# CDN配置(通过API方式展示)
CDN:
domain: static.example.com
type: 文件类型
cacheRules:
- match: "*.js"
ttl: 3600 # 1小时(服务端已设置1年,CDN可以设短一些,但注意强缓存优先级)
ignoreQueryString: true # 忽略query以防缓存击穿
- match: "*.css"
ttl: 3600
- match: "*.png"
ttl: 86400 # 1天
关键点:CDN的TTL应小于源站的max-age,否则CDN过期回源后取到新文件,但浏览器仍用旧缓存。实际我们设置CDN为1小时,源站1年,保证CDN及时回源抓取新版本。
4.5 缓存版本更新脚本(CI/CD中调用)
发布新版本后,调用CDN刷新API:
#!/bin/bash
# 刷新CDN特定目录(阿里云ossutil+cdn批量刷新)
ossutil rm oss://bucket-name/static/ -r -f
cdn-refresh --object-dir /static/ --type directory
# 等待刷新完成(同步)
cdn-refresh --status --object-dir /static/
五、效果数据:压测结果对比
使用ApacheBench(ab)在1000并发下测试商品详情页主JS文件(main.xxx.js,约85KB):
# 测试命令
ab -n 10000 -c 100 http://example.com/static/main.a1b2c3d4.js
| 测试条件 | QPS | TTFB (ms) | 回源率 |
|---|---|---|---|
| 无缓存(不设任何头) | 480 | 812 | 100% |
| 仅协商缓存(ETag) | 710 | 304(取消请求) | 28% |
| 强缓存+hash(无CDN) | 1150 | 0(从本地缓存) | 0%(第一次请求后) |
| 强缓存+hash+CDN | 3020 | 45(CDN边缘节点响应) | 2.5% |
上线后,服务器CPU从90%降到20%,回源率持续在2~3%(仅初始请求和新版本发布后的短暂回源)。用户感知的首屏加载时间从3.2秒降至1.0秒。
六、原理深入:HTTP缓存分级与CDN协作
6.1 浏览器缓存层级
- 内存缓存(Memory Cache):当前页面关闭前有效,请求不发出网络。
- 磁盘缓存(Disk Cache):持久化,根据
Cache-Control判断是否过期。 - Service Worker Cache:手动控制,可拦截请求。
我们主要利用磁盘缓存。当Cache-Control: max-age=31536000, immutable时,浏览器在一年内不会发送任何请求,直接命中本地缓存。
6.2 CDN缓存策略
CDN边缘节点通常有三层缓存:L1(内存)、L2(SSD)、L3(回源到源站)。如果CDN节点未命中,则回源取数据并缓存。关键点:
- 缓存键:默认由Host + URL路径组成,通常忽略query string(可配置)。所以我们用
contenthash区分版本,而不依赖?v=1.0。 - 缓存过期:CDN的TTL决定节点数据有效期。我们设置较短(1小时),但源站返回
Cache-Control: max-age=31536000,CDN会遵守该值吗?大部分CDN会以服务端返回头为准,但很多CDN允许控制台覆盖。我们选择控制台设置1小时,源站1年,这样CDN每1小时回源检查最新版本,但浏览器仍使用本地强缓存。新版本发布时,CDN刷新强制更新边缘节点。 - stale-while-revalidate:一种优化策略,在缓存过期后仍然允许使用旧资源,同时在后台异步验证新资源。我们未启用,因为可以接受短暂的过时。
七、避坑指南(实战踩坑记录)
坑1:强缓存时间过长导致用户更新不及时
最初我们将静态资源max-age=31536000(1年),但用户浏览器缓存了旧版本后,即使我们发布新版本,CDN刷新了,用户仍然显示旧页面。原因:用户访问HTML时,HTML本身没有缓存策略,但HTML里引用的JS文件hash不变!!我们忽略了HTML文件也使用了强缓存。解决方案:HTML设置Cache-Control: no-cache或max-age=0,must-revalidate,每次请求都回源获取最新的HTML(包含带最新hash的script标签)。
// 服务端对HTML使用协商缓存
app.get('/', (req, res) => {
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
// 返回最新的index.html
res.sendFile(path.join(__dirname, 'dist/index.html'));
});
坑2:CDN忽略Query String导致cache hit
我们最初想用main.js?v=2.0更新版本,但CDN配置了ignoreQueryString: true,结果新旧版本都命中同一个CDN缓存键,导致旧文件迟迟不更新。改用文件名hash后解决。
坑3:私有资源(用户头像、订单数据)被CDN缓存泄露
用户中心接口返回的JSON数据包含了用户头像URL,该头像实际存储在CDN上,但我们早期把头像URL设计成全站共用缓存键,导致用户A看到用户B的头像。解决方法:对需要身份隔离的头像URL添加用户ID作为路径参数,同时CDN配置缓存键包含$arg_userId。
# nginx缓存键加入用户参数
location /avatar/ {
set $cache_key "avatar-$arg_userId";
proxy_cache_key $scheme$host$uri$cache_key;
}
坑4:HTTP/2 Server Push与缓存冲突
我们尝试用HTTP/2 Server Push推送静态资源,但发现浏览器会忽略服务端推送如果本地已有缓存(符合规范),但在某些场景下反而导致重复请求。最终放弃使用Push,转而使用<link rel="preload">配合强缓存。
坑5:CDN回源HOST头设置错误导致SSL验证失败
当CDN回源到Nginx时,如果proxy_set_header Host $host未正确设置,源站可能返回502错误。我们遇到过CDN设置回源HOST为源站内网IP,导致证书域名不匹配。务必保持HOST头与源站域名一致。
八、总结:HTTP缓存最佳实践Checklist
- 静态资源使用
contenthash文件名,设置Cache-Control: public, max-age=31536000, immutable。 - HTML文件设置
Cache-Control: no-cache,每次请求回源检查最新版本。 - API返回数据区分:可公共缓存的数据(如商品详情)设置
public+ ETag;用户私有数据设置private+ no-store(如购物车)。 - CDN配置:静态资源TTL < 源站max-age,并开启刷新API自动化;API不建议通过CDN缓存(或只缓存GET且非私有)。
- 发布流程:先刷新CDN,再等待CDN预热,最后发布新版前端。
- 监控:定期检查回源率,若超过5%则排查缓存头配置。
遵循以上实践,我们的电商平台在后续大促中回源率稳定在2%以下,CDN带宽成本降低40%,用户体验显著提升。