HTTP缓存+CDN:一劳永逸的页面加速方案
发布日期: 2026/07/26 阅读总量: 0
HTTP缓存+CDN:一劳永逸的页面加速方案

一、真实场景:电商详情页的“回源噩梦”

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-ControlExpiresETag等字段,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-cachemax-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%,用户体验显著提升。