引言:HTTP缓存的重要性

HTTP缓存是Web性能优化的核心技术之一,它通过在客户端(浏览器)和中间代理服务器上存储资源副本,显著减少网络请求、降低服务器负载并提升用户体验。根据Google的研究,将页面加载时间从1秒增加到3秒,跳出率会增加32%;而HTTP缓存策略的合理配置,通常能将重复访问的页面加载时间减少50%以上。

HTTP缓存机制主要依赖于HTTP协议中的头部字段(Headers)来控制资源的缓存行为。理解这些头部字段的工作原理,对于构建高性能的Web应用至关重要。本文将深入解析HTTP缓存的各个层面,从基础概念到高级策略,并提供实际的代码示例和问题排查指南。

HTTP缓存基础:缓存的分类与工作流程

在深入细节之前,我们需要理解HTTP缓存的两个主要分类:

1. 强缓存(Strong Caching)

强缓存是最快的缓存机制,它直接从浏览器本地缓存读取资源,不会向服务器发送任何请求。强缓存主要通过以下两个响应头控制:

  • Cache-Control
  • Expires

2. 协商缓存(Negotiated Caching)

当强缓存过期或不适用时,浏览器会向服务器发起请求,进行缓存有效性验证。如果资源未修改,服务器返回304状态码(Not Modified),浏览器继续使用本地缓存。协商缓存主要通过以下头部控制:

  • Last-Modified / If-Modified-Since
  • ETag / If-None-Match

HTTP缓存工作流程图解

浏览器发起请求
    ↓
检查强缓存(Cache-Control/Expires)
    ↓
┌─── 未过期? → 直接使用本地缓存(200 from memory cache)
│
└─── 已过期? → 向服务器发起请求
    ↓
携带协商缓存头部(If-None-Match/If-Modified-Since)
    ↓
服务器验证资源状态
    ↓
┌─── 资源未修改? → 返回304 Not Modified
│
└─── 资源已修改? → 返回200 OK + 新资源
    ↓
浏览器更新缓存

强缓存详解:Cache-Control与Expires

Cache-Control:现代缓存控制的核心

Cache-Control是HTTP/1.1规范中引入的头部,用于精确控制缓存行为,它比Expires更灵活且优先级更高。它可以在请求头和响应头中使用,但在缓存策略中主要用在响应头中。

常用指令详解

  1. public vs private
    • public:响应可以被任何缓存(包括CDN、代理服务器)缓存
    • private:响应只能被用户浏览器缓存,不能被中间代理缓存

示例场景

   # 用户个人数据页面,只能在浏览器缓存
   Cache-Control: private, max-age=3600
   
   # 静态资源,可以被CDN缓存
   Cache-Control: public, max-age=31536000
  1. max-ages-maxage
    • max-age:指定资源在浏览器中的最大缓存时间(秒)
    • s-maxage:指定资源在共享缓存(如CDN)中的最大缓存时间,优先级高于max-age

代码示例

   # 浏览器缓存1小时,CDN缓存24小时
   Cache-Control: public, max-age=3600, s-maxage=86400
  1. no-cacheno-store
    • no-cache不是不缓存,而是每次使用缓存前必须向服务器验证(相当于强制协商缓存)
    • no-store:完全不缓存,每次都要从服务器重新获取

常见误解澄清

   # 错误理解:no-cache = 不缓存
   # 正确理解:no-cache = 可以缓存,但使用前必须验证
   
   # 典型应用场景
   Cache-Control: no-cache  # 用于需要实时验证的资源,如API响应
   Cache-Control: no-store  # 用于敏感数据,如银行交易页面
  1. must-revalidateproxy-revalidate
    • must-revalidate:缓存过期后必须向服务器验证,不能使用过期缓存
    • proxy-revalidate:仅对共享缓存(如CDN)有效

示例

   # 关键资源,过期后必须重新验证
   Cache-Control: public, max-age=600, must-revalidate

Expires:传统的过期时间控制

Expires是HTTP/1.0时代的产物,它指定一个绝对过期时间。由于它依赖于客户端和服务器的时钟同步,且精度只能到秒,现代应用中已逐渐被Cache-Control取代。

示例

Expires: Wed, 21 Oct 2025 07:28:00 GMT

优先级规则

  • 如果同时存在Cache-Control: max-ageExpiresmax-age会覆盖Expires
  • 但为了兼容旧客户端,建议同时设置两者

协商缓存详解:ETag与Last-Modified

当强缓存过期后,浏览器会发起带有协商缓存头部的请求,服务器根据这些头部判断资源是否需要重新传输。

Last-Modified与If-Modified-Since

工作原理

  1. 服务器首次响应时,返回Last-Modified头部,表示资源的最后修改时间
  2. 浏览器下次请求时,携带If-Modified-Since头部,值为上次收到的Last-Modified
  3. 服务器比较资源的实际修改时间和If-Modified-Since
    • 如果资源未修改,返回304状态码
    • 如果资源已修改,返回200状态码和新资源

代码示例

# 首次响应
HTTP/1.1 200 OK
Last-Modified: Wed, 21 Oct 2024 07:28:00 GMT
Cache-Control: max-age=86400

# 浏览器下次请求(强缓存过期后)
GET /style.css HTTP/1.1
If-Modified-Since: Wed, 21 Oct 2024 07:28:00 GMT

# 服务器响应(资源未修改)
HTTP/1.1 304 Not Modified

局限性

  • 时间精度只能到秒
  • 如果文件内容改变但修改时间未变(如某些编辑器操作),会导致缓存失效
  • 无法检测文件重命名等情况

ETag与If-None-Match

ETag(Entity Tag)是资源的唯一标识符,通常基于文件内容的哈希值生成,解决了Last-Modified的局限性。

工作原理

  1. 服务器首次响应时,返回ETag头部,表示资源的唯一标识
  2. 浏览器下次请求时,携带If-None-Match头部,值为上次收到的ETag
  3. 服务器比较资源的实际ETag和If-None-Match
    • 如果匹配,返回304状态码
    • 如果不匹配,返回200状态码和新资源

代码示例

# 首次响应
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control: max-age=86400

# 浏览器下次请求
GET /style.css HTTP/1.1
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

# 服务器响应(资源未修改)
HTTP/1.1 304 Not Modified

ETag的类型

  • 强ETag:资源内容的任何变化都会改变ETag值
  • 弱ETag:只在资源内容发生重大变化时改变ETag值,格式为W/"版本号"

示例

ETag: "版本号"           # 强ETag
ETag: W/"版本号"         # 弱ETag

缓存策略配置:不同资源的最佳实践

1. HTML文件:谨慎缓存

HTML文件通常包含动态内容和资源引用,应避免过度缓存。

推荐配置

Cache-Control: no-cache 或 max-age=0, must-revalidate

原因

  • 确保用户总能获取最新的HTML,从而加载最新的资源引用
  • 避免用户因缓存旧HTML而加载过时的JS/CSS

2. 静态资源(JS/CSS/图片):长期缓存

静态资源应使用文件名哈希或版本号,实现长期缓存。

推荐配置

Cache-Control: public, max-age=31536000, immutable

代码示例

// Webpack配置示例:生成带哈希的文件名
module.exports = {
  output: {
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js'
  }
}

部署流程

  1. 构建时生成带哈希的文件名:app.a1b2c3d4.js
  2. HTML文件引用新文件名:<script src="app.a1b2c3d4.js"></script>
  3. 设置长期缓存:Cache-Control: public, max-age=31536000, immutable
  4. 当代码更新时,哈希值改变,浏览器自动加载新文件

3. API响应:动态控制

API响应的缓存策略应根据业务需求动态调整。

推荐配置

# 实时数据,不缓存
Cache-Control: no-store

# 可缓存1分钟的数据
Cache-Control: private, max-age=60

# 需要实时验证的数据
Cache-Control: no-cache

4. 字体文件:长期缓存

字体文件通常不经常变化,适合长期缓存。

推荐配置

Cache-Control: public, max-age=31536000, immutable

服务器端实现示例

Node.js (Express) 实现

const express = require('express');
const fs = require('fs');
const crypto = require('crypto');
const path = require('path');

const app = express();

// 计算文件ETag的辅助函数
function calculateETag(filePath) {
  const fileBuffer = fs.readFileSync(filePath);
  return crypto.createHash('md5').update(fileBuffer).digest('hex');
}

// 静态资源中间件(带缓存控制)
app.use('/static', (req, res, next) => {
  const filePath = path.join(__dirname, 'public', req.path);
  
  // 检查文件是否存在
  if (!fs.existsSync(filePath)) {
    return next();
  }

  const stats = fs.statSync(filePath);
  const etag = calculateETag(filePath);
  const lastModified = stats.mtime.toUTCString();

  // 设置协商缓存头部
  res.setHeader('ETag', etag);
  res.setHeader('Last-Modified', lastModified);

  // 检查协商缓存
  const ifNoneMatch = req.headers['if-none-match'];
  const ifModifiedSince = req.headers['if-modified-since'];

  if ((ifNoneMatch && ifNoneMatch === etag) || 
      (ifModifiedSince && ifModifiedSince === lastModified)) {
    res.status(304).end();
    return;
  }

  // 设置强缓存
  const ext = path.extname(filePath);
  if (['.js', '.css', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.woff', '.woff2'].includes(ext)) {
    res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
  } else {
    res.setHeader('Cache-Control', 'no-cache');
  }

  // 发送文件
  res.sendFile(filePath);
});

// API接口示例
app.get('/api/user/:id', (req, res) => {
  // 根据业务需求动态设置缓存
  const userId = req.params.id;
  
  if (req.query.refresh === 'true') {
    // 强制刷新,不缓存
    res.setHeader('Cache-Control', 'no-store');
  } else {
    // 可缓存30秒
    res.setHeader('Cache-Control', 'private, max-age=30');
  }

  // 设置ETag
  const userData = { id: userId, name: 'John Doe', timestamp: Date.now() };
  const etag = crypto.createHash('md5').update(JSON.stringify(userData)).digest('hex');
  res.setHeader('ETag', etag);

  // 检查协商缓存
  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end();
  }

  res.json(userData);
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

Nginx 配置示例

# 静态资源缓存配置
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
    root /path/to/your/static/files;
    
    # 强缓存1年
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
    
    # 协商缓存
    etag on;
    add_header Last-Modified $date_gmt;
    
    # 开启Gzip压缩
    gzip on;
    gzip_types text/css application/javascript image/svg+xml;
}

# HTML文件配置
location ~* \.html$ {
    root /path/to/your/html/files;
    
    # 不缓存HTML
    add_header Cache-Control "no-cache, no-store, must-revalidate";
    add_header Pragma "no-cache";
    add_header Expires "0";
    
    # 仍然开启ETag用于验证
    etag on;
}

# API接口配置
location /api/ {
    proxy_pass http://backend_server;
    
    # 默认不缓存
    add_header Cache-Control "no-store";
    
    # 根据请求参数动态调整(需要Lua模块或后端设置)
    # 这里仅作为示例,实际应在后端代码中处理
}

Apache .htaccess 配置

# 启用mod_headers和mod_expires
<IfModule mod_expires.c>
    ExpiresActive On
    
    # HTML文件 - 不缓存
    ExpiresByType text/html "access plus 0 seconds"
    Header set Cache-Control "no-cache, no-store, must-revalidate"
    Header set Pragma "no-cache"
    
    # CSS和JavaScript - 1年
    ExpiresByType text/css "access plus 1 year"
    ExpiresByType application/javascript "access plus 1 year"
    Header set Cache-Control "public, max-age=31536000, immutable"
    
    # 图片 - 1年
    ExpiresByType image/jpeg "access plus 1 year"
    ExpiresByType image/png "access plus 1 year"
    ExpiresByType image/gif "access plus 1 year"
    ExpiresByType image/svg+xml "access plus 1 year"
    Header set Cache-Control "public, max-age=31536000, immutable"
    
    # 字体文件 - 1年
    ExpiresByType font/woff "access plus 1 year"
    ExpiresByType font/woff2 "access plus 1 year"
    Header set Cache-Control "public, max-age=31536000, immutable"
</IfModule>

# ETag配置
<IfModule mod_headers.c>
    # 移除ETag(可选,如果使用文件哈希命名)
    # Header unset ETag
</IfModule>

# Gzip压缩
<IfModule mod_deflate.c>
    AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/javascript
</IfModule>

常见缓存失效问题及解决方案

问题1:缓存策略未生效

症状:浏览器仍然缓存了旧版本的资源,导致更新后用户看不到变化。

排查步骤

  1. 检查浏览器开发者工具的Network面板,确认响应头是否包含正确的Cache-ControlETag
  2. 检查资源URL是否包含查询参数(如?v=1),查询参数可能会影响缓存行为
  3. 确认服务器配置是否正确应用

解决方案

// 使用文件哈希代替查询参数
// 错误做法:app.js?v=123
// 正确做法:app.123abc.js

// Webpack配置
output: {
  filename: '[name].[contenthash:8].js'
}

问题2:304状态码未返回

症状:即使资源未修改,服务器仍然返回200状态码和完整资源。

可能原因

  1. 服务器未正确设置ETagLast-Modified
  2. 客户端请求未携带正确的If-None-MatchIf-Modified-Since
  3. 服务器端逻辑错误,未正确处理协商缓存头部

排查代码

// Express中间件调试版
app.use((req, res, next) => {
  console.log('Request headers:', req.headers);
  console.log('If-None-Match:', req.headers['if-none-match']);
  console.log('If-Modified-Since:', req.headers['if-modified-since']);
  next();
});

问题3:CDN缓存导致更新延迟

症状:更新资源后,部分用户仍然看到旧版本,特别是通过CDN访问时。

原因:CDN节点缓存了旧资源,且缓存时间较长。

解决方案

  1. 文件名哈希:最可靠的方案,确保文件内容变化时URL必然变化
  2. CDN缓存刷新:手动刷新CDN缓存(不同CDN提供商API不同)
  3. 设置合适的s-maxage:控制CDN缓存时间

AWS CloudFront刷新示例

const AWS = require('aws-sdk');
const cloudfront = new AWS.CloudFront();

// 创建刷新请求
const params = {
  DistributionId: 'YOUR_DISTRIBUTION_ID',
  InvalidationBatch: {
    Paths: {
      Quantity: 2,
      Items: [
        '/static/app.*.js',
        '/static/style.*.css'
      ]
    },
    CallerReference: Date.now().toString()
  }
};

cloudfront.createInvalidation(params, (err, data) => {
  if (err) console.error(err);
  else console.log('Cache invalidation created:', data);
});

问题4:浏览器缓存空间不足

症状:在某些设备上,缓存被意外清除或无法存储。

原因:浏览器缓存空间有限,可能被其他资源挤占。

解决方案

  1. 使用Service Worker实现更精细的缓存控制
  2. 合理设置缓存时间,避免缓存过多不必要资源

Service Worker示例

// sw.js
const CACHE_NAME = 'my-app-v1';
const urlsToCache = [
  '/',
  '/static/app.a1b2c3d4.js',
  '/static/style.e5f6g7h8.css'
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(urlsToCache))
  );
});

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(response => {
        // 缓存命中,直接返回
        if (response) return response;
        
        // 缓存未命中,发起网络请求
        return fetch(event.request).then(response => {
          // 只缓存成功的响应
          if (!response || response.status !== 200 || response.type !== 'basic') {
            return response;
          }
          
          // 克隆响应,因为响应体只能读取一次
          const responseToCache = response.clone();
          
          caches.open(CACHE_NAME).then(cache => {
            cache.put(event.request, responseToCache);
          });
          
          return response;
        });
      })
  );
});

问题5:移动端缓存异常

症状:iOS Safari或Android WebView中缓存行为与桌面浏览器不一致。

原因:移动端浏览器对缓存的实现可能存在差异,特别是WebView组件。

解决方案

  1. 避免使用no-cache:在某些旧版本WebView中可能不被正确解析
  2. 使用must-revalidate:确保过期缓存被正确验证
  3. 测试具体设备:在目标设备上实际测试缓存行为

移动端优化配置

# 兼容性更好的配置
Cache-Control: public, max-age=3600, must-revalidate

高级缓存策略

1. 缓存键(Cache Key)优化

在多租户或个性化内容场景下,需要根据请求特征生成不同的缓存键。

Nginx Lua示例

# 需要安装ngx_http_lua_module
location /api/personalized {
    access_by_lua_block {
        local user_id = ngx.var.http_x_user_id or "anonymous"
        local accept_language = ngx.var.http_accept_language or "default"
        
        -- 生成自定义缓存键
        ngx.var.cache_key = user_id .. "|" .. accept_language .. "|" .. ngx.var.uri
    }
    
    proxy_pass http://backend;
    proxy_cache_valid 200 5m;
}

2. 缓存分层策略

// 多层缓存示例
const cacheLayers = {
  // L1: 内存缓存(最快)
  memory: new Map(),
  
  // L2: Redis缓存(分布式)
  redis: require('redis').createClient(),
  
  // L3: CDN缓存(边缘节点)
  cdn: {
    // 通过设置Cache-Control头部控制
    setHeaders: (res, maxAge) => {
      res.setHeader('Cache-Control', `public, max-age=${maxAge}`);
    }
  }
};

// 获取数据的多层缓存逻辑
async function getDataWithCache(key, fetchFn, options = {}) {
  const { memoryTtl = 60, redisTtl = 300, cdnTtl = 3600 } = options;
  
  // L1: 检查内存缓存
  if (cacheLayers.memory.has(key)) {
    return cacheLayers.memory.get(key);
  }
  
  // L2: 检查Redis缓存
  try {
    const redisData = await cacheLayers.redis.get(key);
    if (redisData) {
      const parsed = JSON.parse(redisData);
      // 回填内存缓存
      cacheLayers.memory.set(key, parsed, memoryTtl * 1000);
      return parsed;
    }
  } catch (err) {
    console.error('Redis error:', err);
  }
  
  // L3: 回源获取
  const data = await fetchFn();
  
  // 回填所有缓存层
  cacheLayers.memory.set(key, data, memoryTtl * 1000);
  try {
    await cacheLayers.redis.setex(key, redisTtl, JSON.stringify(data));
  } catch (err) {
    console.error('Redis set error:', err);
  }
  
  // 返回数据,由HTTP层设置CDN缓存头部
  return data;
}

3. 缓存预热

在部署新版本后,主动预热缓存,避免用户首次访问时遇到性能问题。

// 部署后预热脚本
const axios = require('axios');
const criticalUrls = [
  '/',
  '/static/app.a1b2c3d4.js',
  '/static/style.e5f6g7h8.css',
  '/api/config'
];

async function warmupCache() {
  console.log('开始预热缓存...');
  
  for (const url of criticalUrls) {
    try {
      const start = Date.now();
      const response = await axios.get(`http://localhost:3000${url}`);
      const duration = Date.now() - start;
      
      console.log(`✓ ${url} - ${response.status} - ${duration}ms`);
      console.log(`  Cache-Control: ${response.headers['cache-control']}`);
      console.log(`  ETag: ${response.headers['etag']}`);
    } catch (error) {
      console.error(`✗ ${url} - ${error.message}`);
    }
  }
  
  console.log('预热完成!');
}

warmupCache();

缓存监控与性能分析

1. 使用Chrome DevTools分析缓存

步骤

  1. 打开DevTools → Network面板
  2. 勾选”Disable cache”来测试无缓存情况
  3. 取消勾选后刷新页面,观察哪些资源返回304
  4. 查看Size列:
    • (memory cache)(disk cache) 表示强缓存命中
    • 304 表示协商缓存命中
    • 200 表示无缓存或缓存失效

2. 使用Lighthouse进行缓存审计

Lighthouse可以检测缓存策略是否合理:

# 运行Lighthouse CLI
lighthouse https://your-site.com --output=json --output-path=report.json

# 检查"Serve static assets with an efficient cache policy"审计项

3. 服务器端缓存命中率监控

// Express中间件:记录缓存命中率
const cacheStats = {
  hits: 0,
  misses: 0,
  total: 0
};

app.use((req, res, next) => {
  const originalEnd = res.end;
  
  res.end = function(...args) {
    cacheStats.total++;
    
    if (res.statusCode === 304) {
      cacheStats.hits++;
    } else if (res.statusCode === 200) {
      cacheStats.misses++;
    }
    
    // 每100个请求打印一次统计
    if (cacheStats.total % 100 === 0) {
      const hitRate = (cacheStats.hits / cacheStats.total * 100).toFixed(2);
      console.log(`Cache Hit Rate: ${hitRate}% (${cacheStats.hits}/${cacheStats.total})`);
    }
    
    originalEnd.apply(this, args);
  };
  
  next();
});

总结与最佳实践清单

✅ 最佳实践清单

  1. HTML文件:使用Cache-Control: no-cachemax-age=0, must-revalidate
  2. 静态资源:使用文件名哈希 + Cache-Control: public, max-age=31536000, immutable
  3. API响应:根据业务需求动态设置,通常使用no-store或短时间max-age
  4. 始终设置ETag:启用协商缓存,减少不必要的数据传输
  5. 避免使用查询参数缓存:使用文件名哈希代替
  6. CDN配置:设置合适的s-maxage,考虑使用文件名哈希避免CDN刷新
  7. 移动端测试:在目标设备上实际测试缓存行为
  8. 监控缓存命中率:定期检查并优化缓存策略

❌ 常见错误

  1. HTML文件长期缓存:导致用户无法获取最新版本
  2. 使用查询参数缓存app.js?v=1可能被代理服务器忽略
  3. 忽略ETag:浪费协商缓存机会
  4. 所有资源使用相同缓存策略:未区分动态和静态内容
  5. 不测试移动端:WebView缓存行为可能不同

通过合理配置HTTP缓存策略,你可以显著提升网站性能,减少服务器负载,并为用户提供更快的加载体验。记住,缓存策略不是一成不变的,需要根据业务需求和用户反馈持续优化。# 深入解析HTTP缓存策略与实现:如何有效提升网站性能并解决常见缓存失效问题

引言:HTTP缓存的重要性

HTTP缓存是Web性能优化的核心技术之一,它通过在客户端(浏览器)和中间代理服务器上存储资源副本,显著减少网络请求、降低服务器负载并提升用户体验。根据Google的研究,将页面加载时间从1秒增加到3秒,跳出率会增加32%;而HTTP缓存策略的合理配置,通常能将重复访问的页面加载时间减少50%以上。

HTTP缓存机制主要依赖于HTTP协议中的头部字段(Headers)来控制资源的缓存行为。理解这些头部字段的工作原理,对于构建高性能的Web应用至关重要。本文将深入解析HTTP缓存的各个层面,从基础概念到高级策略,并提供实际的代码示例和问题排查指南。

HTTP缓存基础:缓存的分类与工作流程

在深入细节之前,我们需要理解HTTP缓存的两个主要分类:

1. 强缓存(Strong Caching)

强缓存是最快的缓存机制,它直接从浏览器本地缓存读取资源,不会向服务器发送任何请求。强缓存主要通过以下两个响应头控制:

  • Cache-Control
  • Expires

2. 协商缓存(Negotiated Caching)

当强缓存过期或不适用时,浏览器会向服务器发起请求,进行缓存有效性验证。如果资源未修改,服务器返回304状态码(Not Modified),浏览器继续使用本地缓存。协商缓存主要通过以下头部控制:

  • Last-Modified / If-Modified-Since
  • ETag / If-None-Match

HTTP缓存工作流程图解

浏览器发起请求
    ↓
检查强缓存(Cache-Control/Expires)
    ↓
┌─── 未过期? → 直接使用本地缓存(200 from memory cache)
│
└─── 已过期? → 向服务器发起请求
    ↓
携带协商缓存头部(If-None-Match/If-Modified-Since)
    ↓
服务器验证资源状态
    ↓
┌─── 资源未修改? → 返回304 Not Modified
│
└─── 资源已修改? → 返回200 OK + 新资源
    ↓
浏览器更新缓存

强缓存详解:Cache-Control与Expires

Cache-Control:现代缓存控制的核心

Cache-Control是HTTP/1.1规范中引入的头部,用于精确控制缓存行为,它比Expires更灵活且优先级更高。它可以在请求头和响应头中使用,但在缓存策略中主要用在响应头中。

常用指令详解

  1. public vs private
    • public:响应可以被任何缓存(包括CDN、代理服务器)缓存
    • private:响应只能被用户浏览器缓存,不能被中间代理缓存

示例场景

   # 用户个人数据页面,只能在浏览器缓存
   Cache-Control: private, max-age=3600
   
   # 静态资源,可以被CDN缓存
   Cache-Control: public, max-age=31536000
  1. max-ages-maxage
    • max-age:指定资源在浏览器中的最大缓存时间(秒)
    • s-maxage:指定资源在共享缓存(如CDN)中的最大缓存时间,优先级高于max-age

代码示例

   # 浏览器缓存1小时,CDN缓存24小时
   Cache-Control: public, max-age=3600, s-maxage=86400
  1. no-cacheno-store
    • no-cache不是不缓存,而是每次使用缓存前必须向服务器验证(相当于强制协商缓存)
    • no-store:完全不缓存,每次都要从服务器重新获取

常见误解澄清

   # 错误理解:no-cache = 不缓存
   # 正确理解:no-cache = 可以缓存,但使用前必须验证
   
   # 典型应用场景
   Cache-Control: no-cache  # 用于需要实时验证的资源,如API响应
   Cache-Control: no-store  # 用于敏感数据,如银行交易页面
  1. must-revalidateproxy-revalidate
    • must-revalidate:缓存过期后必须向服务器验证,不能使用过期缓存
    • proxy-revalidate:仅对共享缓存(如CDN)有效

示例

   # 关键资源,过期后必须重新验证
   Cache-Control: public, max-age=600, must-revalidate

Expires:传统的过期时间控制

Expires是HTTP/1.0时代的产物,它指定一个绝对过期时间。由于它依赖于客户端和服务器的时钟同步,且精度只能到秒,现代应用中已逐渐被Cache-Control取代。

示例

Expires: Wed, 21 Oct 2025 07:28:00 GMT

优先级规则

  • 如果同时存在Cache-Control: max-ageExpiresmax-age会覆盖Expires
  • 但为了兼容旧客户端,建议同时设置两者

协商缓存详解:ETag与Last-Modified

当强缓存过期后,浏览器会发起带有协商缓存头部的请求,服务器根据这些头部判断资源是否需要重新传输。

Last-Modified与If-Modified-Since

工作原理

  1. 服务器首次响应时,返回Last-Modified头部,表示资源的最后修改时间
  2. 浏览器下次请求时,携带If-Modified-Since头部,值为上次收到的Last-Modified
  3. 服务器比较资源的实际修改时间和If-Modified-Since
    • 如果资源未修改,返回304状态码
    • 如果资源已修改,返回200状态码和新资源

代码示例

# 首次响应
HTTP/1.1 200 OK
Last-Modified: Wed, 21 Oct 2024 07:28:00 GMT
Cache-Control: max-age=86400

# 浏览器下次请求(强缓存过期后)
GET /style.css HTTP/1.1
If-Modified-Since: Wed, 21 Oct 2024 07:28:00 GMT

# 服务器响应(资源未修改)
HTTP/1.1 304 Not Modified

局限性

  • 时间精度只能到秒
  • 如果文件内容改变但修改时间未变(如某些编辑器操作),会导致缓存失效
  • 无法检测文件重命名等情况

ETag与If-None-Match

ETag(Entity Tag)是资源的唯一标识符,通常基于文件内容的哈希值生成,解决了Last-Modified的局限性。

工作原理

  1. 服务器首次响应时,返回ETag头部,表示资源的唯一标识
  2. 浏览器下次请求时,携带If-None-Match头部,值为上次收到的ETag
  3. 服务器比较资源的实际ETag和If-None-Match
    • 如果匹配,返回304状态码
    • 如果不匹配,返回200状态码和新资源

代码示例

# 首次响应
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control: max-age=86400

# 浏览器下次请求
GET /style.css HTTP/1.1
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

# 服务器响应(资源未修改)
HTTP/1.1 304 Not Modified

ETag的类型

  • 强ETag:资源内容的任何变化都会改变ETag值
  • 弱ETag:只在资源内容发生重大变化时改变ETag值,格式为W/"版本号"

示例

ETag: "版本号"           # 强ETag
ETag: W/"版本号"         # 弱ETag

缓存策略配置:不同资源的最佳实践

1. HTML文件:谨慎缓存

HTML文件通常包含动态内容和资源引用,应避免过度缓存。

推荐配置

Cache-Control: no-cache 或 max-age=0, must-revalidate

原因

  • 确保用户总能获取最新的HTML,从而加载最新的资源引用
  • 避免用户因缓存旧HTML而加载过时的JS/CSS

2. 静态资源(JS/CSS/图片):长期缓存

静态资源应使用文件名哈希或版本号,实现长期缓存。

推荐配置

Cache-Control: public, max-age=31536000, immutable

代码示例

// Webpack配置示例:生成带哈希的文件名
module.exports = {
  output: {
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js'
  }
}

部署流程

  1. 构建时生成带哈希的文件名:app.a1b2c3d4.js
  2. HTML文件引用新文件名:<script src="app.a1b2c3d4.js"></script>
  3. 设置长期缓存:Cache-Control: public, max-age=31536000, immutable
  4. 当代码更新时,哈希值改变,浏览器自动加载新文件

3. API响应:动态控制

API响应的缓存策略应根据业务需求动态调整。

推荐配置

# 实时数据,不缓存
Cache-Control: no-store

# 可缓存1分钟的数据
Cache-Control: private, max-age=60

# 需要实时验证的数据
Cache-Control: no-cache

4. 字体文件:长期缓存

字体文件通常不经常变化,适合长期缓存。

推荐配置

Cache-Control: public, max-age=31536000, immutable

服务器端实现示例

Node.js (Express) 实现

const express = require('express');
const fs = require('fs');
const crypto = require('crypto');
const path = require('path');

const app = express();

// 计算文件ETag的辅助函数
function calculateETag(filePath) {
  const fileBuffer = fs.readFileSync(filePath);
  return crypto.createHash('md5').update(fileBuffer).digest('hex');
}

// 静态资源中间件(带缓存控制)
app.use('/static', (req, res, next) => {
  const filePath = path.join(__dirname, 'public', req.path);
  
  // 检查文件是否存在
  if (!fs.existsSync(filePath)) {
    return next();
  }

  const stats = fs.statSync(filePath);
  const etag = calculateETag(filePath);
  const lastModified = stats.mtime.toUTCString();

  // 设置协商缓存头部
  res.setHeader('ETag', etag);
  res.setHeader('Last-Modified', lastModified);

  // 检查协商缓存
  const ifNoneMatch = req.headers['if-none-match'];
  const ifModifiedSince = req.headers['if-modified-since'];

  if ((ifNoneMatch && ifNoneMatch === etag) || 
      (ifModifiedSince && ifModifiedSince === lastModified)) {
    res.status(304).end();
    return;
  }

  // 设置强缓存
  const ext = path.extname(filePath);
  if (['.js', '.css', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.woff', '.woff2'].includes(ext)) {
    res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
  } else {
    res.setHeader('Cache-Control', 'no-cache');
  }

  // 发送文件
  res.sendFile(filePath);
});

// API接口示例
app.get('/api/user/:id', (req, res) => {
  // 根据业务需求动态设置缓存
  const userId = req.params.id;
  
  if (req.query.refresh === 'true') {
    // 强制刷新,不缓存
    res.setHeader('Cache-Control', 'no-store');
  } else {
    // 可缓存30秒
    res.setHeader('Cache-Control', 'private, max-age=30');
  }

  // 设置ETag
  const userData = { id: userId, name: 'John Doe', timestamp: Date.now() };
  const etag = crypto.createHash('md5').update(JSON.stringify(userData)).digest('hex');
  res.setHeader('ETag', etag);

  // 检查协商缓存
  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end();
  }

  res.json(userData);
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

Nginx 配置示例

# 静态资源缓存配置
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
    root /path/to/your/static/files;
    
    # 强缓存1年
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
    
    # 协商缓存
    etag on;
    add_header Last-Modified $date_gmt;
    
    # 开启Gzip压缩
    gzip on;
    gzip_types text/css application/javascript image/svg+xml;
}

# HTML文件配置
location ~* \.html$ {
    root /path/to/your/html/files;
    
    # 不缓存HTML
    add_header Cache-Control "no-cache, no-store, must-revalidate";
    add_header Pragma "no-cache";
    add_header Expires "0";
    
    # 仍然开启ETag用于验证
    etag on;
}

# API接口配置
location /api/ {
    proxy_pass http://backend_server;
    
    # 默认不缓存
    add_header Cache-Control "no-store";
    
    # 根据请求参数动态调整(需要Lua模块或后端设置)
    # 这里仅作为示例,实际应在后端代码中处理
}

Apache .htaccess 配置

# 启用mod_headers和mod_expires
<IfModule mod_expires.c>
    ExpiresActive On
    
    # HTML文件 - 不缓存
    ExpiresByType text/html "access plus 0 seconds"
    Header set Cache-Control "no-cache, no-store, must-revalidate"
    Header set Pragma "no-cache"
    
    # CSS和JavaScript - 1年
    ExpiresByType text/css "access plus 1 year"
    ExpiresByType application/javascript "access plus 1 year"
    Header set Cache-Control "public, max-age=31536000, immutable"
    
    # 图片 - 1年
    ExpiresByType image/jpeg "access plus 1 year"
    ExpiresByType image/png "access plus 1 year"
    ExpiresByType image/gif "access plus 1 year"
    ExpiresByType image/svg+xml "access plus 1 year"
    Header set Cache-Control "public, max-age=31536000, immutable"
    
    # 字体文件 - 1年
    ExpiresByType font/woff "access plus 1 year"
    ExpiresByType font/woff2 "access plus 1 year"
    Header set Cache-Control "public, max-age=31536000, immutable"
</IfModule>

# ETag配置
<IfModule mod_headers.c>
    # 移除ETag(可选,如果使用文件哈希命名)
    # Header unset ETag
</IfModule>

# Gzip压缩
<IfModule mod_deflate.c>
    AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/javascript
</IfModule>

常见缓存失效问题及解决方案

问题1:缓存策略未生效

症状:浏览器仍然缓存了旧版本的资源,导致更新后用户看不到变化。

排查步骤

  1. 检查浏览器开发者工具的Network面板,确认响应头是否包含正确的Cache-ControlETag
  2. 检查资源URL是否包含查询参数(如?v=1),查询参数可能会影响缓存行为
  3. 确认服务器配置是否正确应用

解决方案

// 使用文件哈希代替查询参数
// 错误做法:app.js?v=123
// 正确做法:app.123abc.js

// Webpack配置
output: {
  filename: '[name].[contenthash:8].js'
}

问题2:304状态码未返回

症状:即使资源未修改,服务器仍然返回200状态码和完整资源。

可能原因

  1. 服务器未正确设置ETagLast-Modified
  2. 客户端请求未携带正确的If-None-MatchIf-Modified-Since
  3. 服务器端逻辑错误,未正确处理协商缓存头部

排查代码

// Express中间件调试版
app.use((req, res, next) => {
  console.log('Request headers:', req.headers);
  console.log('If-None-Match:', req.headers['if-none-match']);
  console.log('If-Modified-Since:', req.headers['if-modified-since']);
  next();
});

问题3:CDN缓存导致更新延迟

症状:更新资源后,部分用户仍然看到旧版本,特别是通过CDN访问时。

原因:CDN节点缓存了旧资源,且缓存时间较长。

解决方案

  1. 文件名哈希:最可靠的方案,确保文件内容变化时URL必然变化
  2. CDN缓存刷新:手动刷新CDN缓存(不同CDN提供商API不同)
  3. 设置合适的s-maxage:控制CDN缓存时间

AWS CloudFront刷新示例

const AWS = require('aws-sdk');
const cloudfront = new AWS.CloudFront();

// 创建刷新请求
const params = {
  DistributionId: 'YOUR_DISTRIBUTION_ID',
  InvalidationBatch: {
    Paths: {
      Quantity: 2,
      Items: [
        '/static/app.*.js',
        '/static/style.*.css'
      ]
    },
    CallerReference: Date.now().toString()
  }
};

cloudfront.createInvalidation(params, (err, data) => {
  if (err) console.error(err);
  else console.log('Cache invalidation created:', data);
});

问题4:浏览器缓存空间不足

症状:在某些设备上,缓存被意外清除或无法存储。

原因:浏览器缓存空间有限,可能被其他资源挤占。

解决方案

  1. 使用Service Worker实现更精细的缓存控制
  2. 合理设置缓存时间,避免缓存过多不必要资源

Service Worker示例

// sw.js
const CACHE_NAME = 'my-app-v1';
const urlsToCache = [
  '/',
  '/static/app.a1b2c3d4.js',
  '/static/style.e5f6g7h8.css'
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(urlsToCache))
  );
});

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(response => {
        // 缓存命中,直接返回
        if (response) return response;
        
        // 缓存未命中,发起网络请求
        return fetch(event.request).then(response => {
          // 只缓存成功的响应
          if (!response || response.status !== 200 || response.type !== 'basic') {
            return response;
          }
          
          // 克隆响应,因为响应体只能读取一次
          const responseToCache = response.clone();
          
          caches.open(CACHE_NAME).then(cache => {
            cache.put(event.request, responseToCache);
          });
          
          return response;
        });
      })
  );
});

问题5:移动端缓存异常

症状:iOS Safari或Android WebView中缓存行为与桌面浏览器不一致。

原因:移动端浏览器对缓存的实现可能存在差异,特别是WebView组件。

解决方案

  1. 避免使用no-cache:在某些旧版本WebView中可能不被正确解析
  2. 使用must-revalidate:确保过期缓存被正确验证
  3. 测试具体设备:在目标设备上实际测试缓存行为

移动端优化配置

# 兼容性更好的配置
Cache-Control: public, max-age=3600, must-revalidate

高级缓存策略

1. 缓存键(Cache Key)优化

在多租户或个性化内容场景下,需要根据请求特征生成不同的缓存键。

Nginx Lua示例

# 需要安装ngx_http_lua_module
location /api/personalized {
    access_by_lua_block {
        local user_id = ngx.var.http_x_user_id or "anonymous"
        local accept_language = ngx.var.http_accept_language or "default"
        
        -- 生成自定义缓存键
        ngx.var.cache_key = user_id .. "|" .. accept_language .. "|" .. ngx.var.uri
    }
    
    proxy_pass http://backend;
    proxy_cache_valid 200 5m;
}

2. 缓存分层策略

// 多层缓存示例
const cacheLayers = {
  // L1: 内存缓存(最快)
  memory: new Map(),
  
  // L2: Redis缓存(分布式)
  redis: require('redis').createClient(),
  
  // L3: CDN缓存(边缘节点)
  cdn: {
    // 通过设置Cache-Control头部控制
    setHeaders: (res, maxAge) => {
      res.setHeader('Cache-Control', `public, max-age=${maxAge}`);
    }
  }
};

// 获取数据的多层缓存逻辑
async function getDataWithCache(key, fetchFn, options = {}) {
  const { memoryTtl = 60, redisTtl = 300, cdnTtl = 3600 } = options;
  
  // L1: 检查内存缓存
  if (cacheLayers.memory.has(key)) {
    return cacheLayers.memory.get(key);
  }
  
  // L2: 检查Redis缓存
  try {
    const redisData = await cacheLayers.redis.get(key);
    if (redisData) {
      const parsed = JSON.parse(redisData);
      // 回填内存缓存
      cacheLayers.memory.set(key, parsed, memoryTtl * 1000);
      return parsed;
    }
  } catch (err) {
    console.error('Redis error:', err);
  }
  
  // L3: 回源获取
  const data = await fetchFn();
  
  // 回填所有缓存层
  cacheLayers.memory.set(key, data, memoryTtl * 1000);
  try {
    await cacheLayers.redis.setex(key, redisTtl, JSON.stringify(data));
  } catch (err) {
    console.error('Redis set error:', err);
  }
  
  // 返回数据,由HTTP层设置CDN缓存头部
  return data;
}

3. 缓存预热

在部署新版本后,主动预热缓存,避免用户首次访问时遇到性能问题。

// 部署后预热脚本
const axios = require('axios');
const criticalUrls = [
  '/',
  '/static/app.a1b2c3d4.js',
  '/static/style.e5f6g7h8.css',
  '/api/config'
];

async function warmupCache() {
  console.log('开始预热缓存...');
  
  for (const url of criticalUrls) {
    try {
      const start = Date.now();
      const response = await axios.get(`http://localhost:3000${url}`);
      const duration = Date.now() - start;
      
      console.log(`✓ ${url} - ${response.status} - ${duration}ms`);
      console.log(`  Cache-Control: ${response.headers['cache-control']}`);
      console.log(`  ETag: ${response.headers['etag']}`);
    } catch (error) {
      console.error(`✗ ${url} - ${error.message}`);
    }
  }
  
  console.log('预热完成!');
}

warmupCache();

缓存监控与性能分析

1. 使用Chrome DevTools分析缓存

步骤

  1. 打开DevTools → Network面板
  2. 勾选”Disable cache”来测试无缓存情况
  3. 取消勾选后刷新页面,观察哪些资源返回304
  4. 查看Size列:
    • (memory cache)(disk cache) 表示强缓存命中
    • 304 表示协商缓存命中
    • 200 表示无缓存或缓存失效

2. 使用Lighthouse进行缓存审计

Lighthouse可以检测缓存策略是否合理:

# 运行Lighthouse CLI
lighthouse https://your-site.com --output=json --output-path=report.json

# 检查"Serve static assets with an efficient cache policy"审计项

3. 服务器端缓存命中率监控

// Express中间件:记录缓存命中率
const cacheStats = {
  hits: 0,
  misses: 0,
  total: 0
};

app.use((req, res, next) => {
  const originalEnd = res.end;
  
  res.end = function(...args) {
    cacheStats.total++;
    
    if (res.statusCode === 304) {
      cacheStats.hits++;
    } else if (res.statusCode === 200) {
      cacheStats.misses++;
    }
    
    // 每100个请求打印一次统计
    if (cacheStats.total % 100 === 0) {
      const hitRate = (cacheStats.hits / cacheStats.total * 100).toFixed(2);
      console.log(`Cache Hit Rate: ${hitRate}% (${cacheStats.hits}/${cacheStats.total})`);
    }
    
    originalEnd.apply(this, args);
  };
  
  next();
});

总结与最佳实践清单

✅ 最佳实践清单

  1. HTML文件:使用Cache-Control: no-cachemax-age=0, must-revalidate
  2. 静态资源:使用文件名哈希 + Cache-Control: public, max-age=31536000, immutable
  3. API响应:根据业务需求动态设置,通常使用no-store或短时间max-age
  4. 始终设置ETag:启用协商缓存,减少不必要的数据传输
  5. 避免使用查询参数缓存:使用文件名哈希代替
  6. CDN配置:设置合适的s-maxage,考虑使用文件名哈希避免CDN刷新
  7. 移动端测试:在目标设备上实际测试缓存行为
  8. 监控缓存命中率:定期检查并优化缓存策略

❌ 常见错误

  1. HTML文件长期缓存:导致用户无法获取最新版本
  2. 使用查询参数缓存app.js?v=1可能被代理服务器忽略
  3. 忽略ETag:浪费协商缓存机会
  4. 所有资源使用相同缓存策略:未区分动态和静态内容
  5. 不测试移动端:WebView缓存行为可能不同

通过合理配置HTTP缓存策略,你可以显著提升网站性能,减少服务器负载,并为用户提供更快的加载体验。记住,缓存策略不是一成不变的,需要根据业务需求和用户反馈持续优化。