Cache API 高级策略:Stale-while-revalidate 的手动实现

Cache API 高级策略:Stale-while-revalidate 的手动实现

各位开发者朋友,大家好!今天我们来深入探讨一个在现代 Web 应用中非常实用但又常被忽视的缓存策略——Stale-while-Revalidate(过期后仍可验证)。它是一种“既保证性能、又保障数据新鲜度”的高级缓存机制,特别适用于对实时性要求不高但又不能完全依赖旧数据的场景。

我们不会只停留在理论层面,而是会通过 手动实现的方式,一步步带你从零构建一个支持 Stale-while-Revalidate 的缓存系统,并结合真实代码演示如何在浏览器或 Node.js 环境下使用它。


一、什么是 Stale-while-Revalidate?

定义与原理

Stale-while-Revalidate 是 HTTP 协议中的一个缓存指令(HTTP Cache-Control header),其含义是:

允许使用过期的缓存内容响应请求,同时后台自动发起更新请求以获取最新版本的数据。

换句话说:

  • 如果缓存未过期 → 直接返回缓存;
  • 如果缓存已过期 → 先返回旧数据(用户无感知),再异步拉取新数据并替换缓存。

这种策略非常适合以下场景:

  • 用户首次加载页面时希望快速响应;
  • 后台可以容忍短暂延迟更新(如新闻列表、商品信息等);
  • 减少服务器压力,提升用户体验。

对比常见缓存策略

缓存策略 响应行为 数据新鲜度 性能表现
Fresh Only(仅新鲜) 只有未过期才返回 最高 较慢(需等待服务器)
Stale-while-Revalidate 过期也返回 + 异步刷新 中等(有延迟) 极快(本地缓存)
No-Cache 每次都查服务器 最高 最慢(每次都网络请求)

✅ Stale-while-Revalidate 是一种“权衡”型策略,在性能和准确性之间找到了最佳平衡点。


二、为什么需要手动实现?

虽然现代浏览器原生支持 Cache-Control: stale-while-revalidate,但在实际项目中你可能会遇到这些限制:

  1. 服务端不支持该 Header(比如老旧接口);
  2. 你需要更细粒度控制(比如缓存失效时间、重试逻辑);
  3. 想在 Node.js 或 SSR 中复用逻辑
  4. 想自定义缓存键、存储方式(localStorage / Redis / 内存)

因此,手动实现不仅能让你理解底层机制,还能灵活适配各种业务需求。


三、设计思路与核心组件

我们要实现一个通用的缓存管理器,包含以下功能模块:

模块 功能描述
Cache Storage 存储缓存对象(key, value, timestamp, ttl)
Get With Stale 获取缓存数据,若过期则返回旧值并触发更新
Update Background 后台拉取最新数据并写回缓存
Expiration Check 判断是否过期(基于 TTL)

我们将使用 JavaScript 实现这个系统,兼容浏览器和 Node.js(Node.js 版本可用 process.env.NODE_ENV 区分环境)。


四、完整代码实现(带注释)

class StaleWhileRevalidateCache {
  constructor(options = {}) {
    this.storage = options.storage || window.localStorage; // 默认用 localStorage
    this.ttlSeconds = options.ttlSeconds || 60 * 5; // 默认 5 分钟
    this.maxRetries = options.maxRetries || 3;
    this.retryDelayMs = options.retryDelayMs || 1000;
  }

  /**
   * 设置缓存项
   * @param {string} key - 缓存键
   * @param {*} value - 缓存值(任意类型)
   * @returns {void}
   */
  async set(key, value) {
    const entry = {
      value,
      timestamp: Date.now(),
      ttl: this.ttlSeconds
    };
    try {
      this.storage.setItem(key, JSON.stringify(entry));
    } catch (e) {
      console.warn('Failed to store cache:', e.message);
    }
  }

  /**
   * 获取缓存项(支持 stale-while-revalidate)
   * @param {string} key - 缓存键
   * @param {Function} fetchFn - 获取新数据的函数(用于更新缓存)
   * @returns {Promise<any>} 返回缓存数据(可能为过期数据)
   */
  async get(key, fetchFn) {
    const raw = this.storage.getItem(key);
    if (!raw) return null;

    const entry = JSON.parse(raw);
    const now = Date.now();
    const isExpired = now - entry.timestamp > entry.ttl * 1000;

    // 如果未过期,直接返回
    if (!isExpired) {
      return entry.value;
    }

    // 已过期:先返回旧数据,再异步更新
    const staleData = entry.value;

    // 启动后台更新任务(非阻塞)
    this._updateInBackground(key, fetchFn).catch(err => {
      console.error(`Failed to update cache for key "${key}":`, err.message);
    });

    return staleData;
  }

  /**
   * 后台更新缓存(异步执行)
   * @private
   */
  async _updateInBackground(key, fetchFn) {
    let retryCount = 0;
    while (retryCount < this.maxRetries) {
      try {
        const newValue = await fetchFn(); // 调用外部 API 获取最新数据
        await this.set(key, newValue);
        return; // 成功就退出
      } catch (err) {
        retryCount++;
        if (retryCount >= this.maxRetries) {
          console.error(`Max retries reached for key "${key}"`);
          break;
        }
        await new Promise(resolve => setTimeout(resolve, this.retryDelayMs * retryCount));
      }
    }
  }

  /**
   * 清除指定缓存
   */
  clear(key) {
    this.storage.removeItem(key);
  }

  /**
   * 清空所有缓存(调试用)
   */
  clearAll() {
    this.storage.clear();
  }
}

五、使用示例(浏览器环境)

假设我们有一个 API 接口 /api/news 返回最新的新闻列表,我们希望:

  • 第一次加载时快速显示缓存内容;
  • 同时在后台拉取最新新闻;
  • 若失败也不影响前端展示。
// 创建缓存实例(TTL=30秒)
const cache = new StaleWhileRevalidateCache({
  ttlSeconds: 30,
  maxRetries: 2
});

async function fetchNews() {
  const res = await fetch('/api/news');
  if (!res.ok) throw new Error('Network error');
  return res.json();
}

// 使用缓存获取新闻
async function loadNews() {
  const news = await cache.get('news', fetchNews);
  if (news) {
    renderNews(news); // 渲染到 DOM
    console.log('✅ 使用缓存数据渲染完成');
  } else {
    console.log('⚠️ 缓存不存在,等待后台加载...');
  }
}

// 模拟用户点击刷新按钮
document.getElementById('refresh-btn').addEventListener('click', () => {
  cache.clear('news'); // 手动清除缓存测试效果
  loadNews();
});

此时你会发现:

  • 第一次访问 → 快速显示旧数据(即使过期);
  • 页面不会卡顿,因为异步更新;
  • 后台成功拉取新数据后,下次调用将拿到最新结果。

六、Node.js 版本改造(适合 SSR/API)

如果你在 Express 或 NestJS 中做服务端渲染(SSR),也可以用同样的逻辑:

// node-cache.js
const fs = require('fs').promises;
const path = require('path');

class NodeStaleCache {
  constructor(storagePath = './cache.json') {
    this.storagePath = storagePath;
    this.ttlSeconds = 60 * 5;
  }

  async readStorage() {
    try {
      const data = await fs.readFile(this.storagePath, 'utf8');
      return JSON.parse(data);
    } catch (err) {
      return {};
    }
  }

  async writeStorage(data) {
    await fs.writeFile(this.storagePath, JSON.stringify(data, null, 2));
  }

  async set(key, value) {
    const storage = await this.readStorage();
    storage[key] = {
      value,
      timestamp: Date.now(),
      ttl: this.ttlSeconds
    };
    await this.writeStorage(storage);
  }

  async get(key, fetchFn) {
    const storage = await this.readStorage();
    const entry = storage[key];
    if (!entry) return null;

    const now = Date.now();
    const isExpired = now - entry.timestamp > entry.ttl * 1000;

    if (!isExpired) {
      return entry.value;
    }

    // 返回旧数据,后台更新
    const staleData = entry.value;
    this._updateInBackground(key, fetchFn).catch(console.error);
    return staleData;
  }

  async _updateInBackground(key, fetchFn) {
    try {
      const newValue = await fetchFn();
      await this.set(key, newValue);
    } catch (err) {
      console.error(`Update failed for ${key}:`, err.message);
    }
  }
}

这样就可以在 Node.js 中实现类似浏览器的行为,尤其适合 API 网关或中间件层缓存。


七、进阶优化建议

1. 添加缓存命中统计(可用于监控)

this.hitCount = 0;
this.missCount = 0;

// 在 get 方法中增加计数逻辑
if (!isExpired) {
  this.hitCount++;
  return entry.value;
} else {
  this.missCount++;
  ...
}

2. 支持不同 TTL(按 key 精细化配置)

set(key, value, ttlSeconds = this.ttlSeconds) {
  // 存储时带上 TTL
}

3. 加入缓存锁防止并发更新(避免多个请求同时触发更新)

const locks = new Map();

async _updateInBackground(key, fetchFn) {
  if (locks.has(key)) return; // 已有更新任务,跳过
  locks.set(key, true);

  try {
    // 更新逻辑...
  } finally {
    locks.delete(key);
  }
}

八、总结与思考

今天我们从理论出发,亲手实现了 Stale-while-Revalidate 缓存策略的核心逻辑,不仅加深了对 HTTP 缓存机制的理解,还掌握了如何将其应用到实际开发中。

✅ 这种策略的优势在于:

  • 提升首屏加载速度;
  • 减少不必要的网络请求;
  • 用户体验平滑过渡;
  • 易于扩展(支持多种存储介质、TTL 控制等);

🚫 但它也有局限性:

  • 不适合强一致性场景(如支付金额、订单状态);
  • 失败重试机制必须合理设计(否则可能雪崩);
  • 需要明确区分哪些资源适合用此策略。

📌 最佳实践建议:

  • 对静态内容(如文章、商品详情)优先采用;
  • 结合 ETag 或 Last-Modified 做条件请求进一步优化;
  • 在关键路径上加入日志埋点,便于排查问题。

如果你想进一步研究,推荐阅读:

希望这篇文章对你有所帮助!欢迎留言交流你的应用场景或改进建议。感谢收听!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注