构建面向高并发 Nuxt.js 服务端渲染的多级缓存架构


一个 Nuxt.js 服务端渲染(SSR)应用的 Time To First Byte (TTFB) 指标开始变得无法接受。最初,每个页面请求都会在服务器端触发 asyncDatafetch 钩子,这些钩子会调用后端的 RESTful API 获取数据,然后实时渲染页面。在低流量下这套机制工作得很好,但随着负载攀升,后端 API 和数据库的压力剧增,导致 SSR 响应时间从理想的 100ms 飙升到 800ms 以上。问题已经明确:渲染瓶颈在于后端数据获取的同步阻塞。

初步的构想是引入缓存,但不是简单的 API 缓存。我们需要一个多层次的策略,既能缓解数据库压力,又能最大限度地减少 Node.js 服务器本身的渲染开销。技术选型聚焦于 Memcached,因为它足够简单、纯粹,基于内存的 key-value 存取速度极快,非常适合存储生命周期不长的 API 响应或渲染片段,并且没有 Redis 那些我们在此场景下用不到的复杂数据结构和持久化开销。

整个环境将通过容器化进行管理。这不仅仅是为了部署,更是为了确保开发、测试、生产环境的高度一致性,避免因环境差异导致缓存策略失效或出现难以复现的问题。

第一步:搭建统一的容器化开发环境

在真实项目中,任何架构调整都始于一个稳定、可复现的环境。我们将使用 Docker Compose 来编排整个技术栈:Nuxt.js 应用、一个模拟的后端 RESTful API 服务,以及 Memcached 实例。

docker-compose.yml 文件是这一切的核心:

# docker-compose.yml
version: '3.8'

services:
  # 1. Nuxt.js SSR 应用
  frontend:
    container_name: nuxt_ssr_app
    build:
      context: ./frontend # 指向 Nuxt 项目的 Dockerfile
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      # 将依赖服务的地址注入环境变量,实现服务发现
      API_BASE_URL: http://backend:8080
      MEMCACHED_HOST: memcached
      MEMCACHED_PORT: 11211
    volumes:
      - ./frontend:/app
      - /app/node_modules # 挂载匿名卷,防止本地 node_modules 覆盖容器内的
      - /app/.nuxt
    depends_on:
      - backend
      - memcached
    command: npm run dev

  # 2. 模拟的后端 RESTful API (基于 Express)
  backend:
    container_name: mock_api_service
    build:
      context: ./backend # 指向 Express 项目的 Dockerfile
      dockerfile: Dockerfile
    environment:
      - NODE_ENV=development
    volumes:
      - ./backend:/app
      - /app/node_modules
    # 不暴露端口到宿主机,仅供内部服务通信
    expose:
      - "8080"
    command: npm run dev

  # 3. Memcached 服务
  memcached:
    container_name: memcached_instance
    image: memcached:1.6-alpine
    ports:
      # 暴露到宿主机便于调试
      - "11211:11211"

networks:
  default:
    name: ssr_cache_network

Nuxt.js 应用的 Dockerfile 需要精细配置以获得良好的构建性能和开发体验:

# frontend/Dockerfile
FROM node:18-alpine

WORKDIR /app

# 优化依赖安装:仅在 package.json 变动时才重新安装
COPY package*.json ./
RUN npm install

# 复制所有项目文件
COPY . .

# 暴露 Nuxt 默认端口
EXPOSE 3000

# 默认启动命令
CMD ["npm", "run", "start"]

这个容器化环境解决了服务依赖和网络问题。frontend 服务可以通过 http://backend:8080 访问 API,通过 memcached:11211 访问缓存,无需关心具体的 IP 地址。

第二层缓存:API 响应缓存

这是最直接的优化。我们在 Nuxt 服务端中间件(Server Middleware)中拦截所有对后端 API 的请求,在发起真实请求前先查询 Memcached。

首先,我们需要一个健壮的 Memcached 客户端封装。直接使用原生客户端库会在业务代码中引入过多重复的连接管理和错误处理逻辑。

frontend/server-middleware/memcached-client.js:

// A robust Memcached client wrapper for Nuxt server side.
import Memcached from 'memcached';
import pino from 'pino';

// 使用 pino 进行结构化日志记录,便于后续分析
const logger = pino({ level: 'info' });

const host = process.env.MEMCACHED_HOST || 'localhost';
const port = process.env.MEMCACHED_PORT || '11211';

// 配置 Memcached 客户端
// 在生产环境中,这些参数需要更精细的调整
const memcached = new Memcached(`${host}:${port}`, {
  retries: 2,           // 失败重试次数
  retry: 3000,          // 重试间隔 (ms)
  timeout: 1000,        // 操作超时时间 (ms)
  remove: true,         // 发生故障时移除故障节点
  reconnect: 5000,      // 节点重连间隔 (ms)
  failures: 3,          // 节点被标记为故障前的最大失败次数
  idle: 10000,          // 连接空闲超时 (ms)
});

// 监听关键事件,提供可观测性
memcached.on('failure', (details) => {
  logger.error({
    event: 'memcached_failure',
    server: details.server,
    messages: details.messages,
  }, `Memcached server ${details.server} went down.`);
});

memcached.on('reconnecting', (details) => {
  logger.warn({
    event: 'memcached_reconnecting',
    server: details.server,
  }, `Reconnecting to Memcached server ${details.server}.`);
});

/**
 * 从 Memcached 获取数据 (Promise-based)
 * @param {string} key - The cache key.
 * @returns {Promise<any | null>} - The cached data or null if not found or error.
 */
function get(key) {
  return new Promise((resolve) => {
    memcached.get(key, (err, data) => {
      if (err) {
        logger.error({ err, key, operation: 'get' }, 'Failed to get data from Memcached.');
        resolve(null); // 在错误情况下返回 null,避免阻塞主流程
        return;
      }
      resolve(data);
    });
  });
}

/**
 * 向 Memcached 存储数据 (Promise-based)
 * @param {string} key - The cache key.
 * @param {any} value - The value to cache.
 * @param {number} lifetime - Cache lifetime in seconds.
 * @returns {Promise<boolean>} - True on success, false on failure.
 */
function set(key, value, lifetime) {
  return new Promise((resolve) => {
    memcached.set(key, value, lifetime, (err) => {
      if (err) {
        logger.error({ err, key, operation: 'set' }, 'Failed to set data to Memcached.');
        resolve(false);
        return;
      }
      resolve(true);
    });
  });
}

export default { get, set };

这个封装考虑了错误处理、日志记录和重连机制,这是生产级代码的必要部分。现在,我们可以创建 API 缓存中间件。

frontend/server-middleware/api-cache-proxy.js:

import { createProxyMiddleware } from 'http-proxy-middleware';
import MemcachedClient from './memcached-client';

const logger = pino();
const API_BASE_URL = process.env.API_BASE_URL;

// 缓存有效期(秒),例如 5 分钟
const CACHE_LIFETIME_SECONDS = 300;

export default function (req, res, next) {
  // 我们只缓存 GET 请求
  if (req.method !== 'GET') {
    return createProxyMiddleware({ target: API_BASE_URL, changeOrigin: true })(req, res, next);
  }

  // 使用完整的请求 URL 作为缓存键,确保唯一性
  const cacheKey = `api_cache:${req.originalUrl}`;
  logger.info({ cacheKey, operation: 'lookup' }, 'Attempting to fetch from API cache.');

  MemcachedClient.get(cacheKey)
    .then(cachedData => {
      if (cachedData) {
        logger.info({ cacheKey, status: 'HIT' }, 'API cache hit.');
        res.setHeader('Content-Type', 'application/json; charset=utf-8');
        res.setHeader('X-Cache-Status', 'HIT');
        res.end(cachedData); // 直接返回缓存数据
      } else {
        logger.info({ cacheKey, status: 'MISS' }, 'API cache miss.');
        // 缓存未命中,代理请求到真实 API
        const proxy = createProxyMiddleware({
          target: API_BASE_URL,
          changeOrigin: true,
          selfHandleResponse: true, // 我们需要自己处理响应,以便写入缓存
          onProxyRes: (proxyRes, req, res) => {
            let body = [];
            proxyRes.on('data', (chunk) => body.push(chunk));
            proxyRes.on('end', () => {
              const responseBody = Buffer.concat(body).toString();
              // 只缓存成功的响应 (2xx)
              if (proxyRes.statusCode >= 200 && proxyRes.statusCode < 300) {
                logger.info({ cacheKey, operation: 'write' }, 'Writing response to API cache.');
                MemcachedClient.set(cacheKey, responseBody, CACHE_LIFETIME_SECONDS);
              }
              res.setHeader('X-Cache-Status', 'MISS');
              res.statusCode = proxyRes.statusCode;
              // 将原始 header 复制到响应中
              Object.keys(proxyRes.headers).forEach(key => {
                res.setHeader(key, proxyRes.headers[key]);
              });
              res.end(responseBody);
            });
          },
        });
        proxy(req, res, next);
      }
    })
    .catch(err => {
      // 即使缓存客户端出错,也应该降级到直接请求 API
      logger.error({ err }, 'Memcached client promise rejected. Bypassing cache.');
      createProxyMiddleware({ target: API_BASE_URL, changeOrigin: true })(req, res, next);
    });
}

nuxt.config.js 中启用这个中间件:

// nuxt.config.js
export default {
  // ...
  serverMiddleware: [
    { path: '/api', handler: '~/server-middleware/api-cache-proxy.js' }
  ]
  // ...
}

现在,所有 Nuxt 应用中对 /api/* 的请求(无论是 asyncData 还是客户端 AJAX)都会先经过我们的缓存层。TTFB 显著改善,数据库压力骤减。

第一层缓存:渲染结果缓存 (HTML 页面缓存)

API 缓存解决了数据源的瓶颈,但 Nuxt 的 SSR 过程本身——即执行 Vue 组件的 serverPrefetch、创建 VNode、渲染成 HTML 字符串——仍然消耗 CPU。对于那些内容不经常变化但访问量极大的页面(如首页、文章详情页),我们可以更进一步,直接缓存最终渲染的 HTML。

这是一个更高层次的缓存,它的命中率可能低于 API 缓存,但一旦命中,收益是巨大的,因为它跳过了整个 VDOM 渲染过程。

frontend/server-middleware/page-cache.js:

import MemcachedClient from './memcached-client';
import pino from 'pino';

const logger = pino();
// 页面缓存有效期,通常比 API 缓存短,例如 60 秒
const PAGE_CACHE_LIFETIME_SECONDS = 60;

// 定义哪些路径需要被缓存
const CACHEABLE_PATHS = [
  /^\/$/, // 首页
  /^\/posts\/\d+$/, // 文章详情页
];

function isCacheable(path) {
  return CACHEABLE_PATHS.some(regex => regex.test(path));
}

export default async function (req, res, next) {
  // 只缓存匿名用户的 GET 请求
  const isAnonymousGet = req.method === 'GET' && !req.headers.cookie;

  if (!isAnonymousGet || !isCacheable(req.originalUrl)) {
    return next();
  }

  const cacheKey = `page_cache:${req.originalUrl}`;
  logger.info({ cacheKey, operation: 'lookup' }, 'Attempting to fetch from page cache.');

  try {
    const cachedPage = await MemcachedClient.get(cacheKey);

    if (cachedPage) {
      logger.info({ cacheKey, status: 'HIT' }, 'Page cache hit.');
      res.setHeader('Content-Type', 'text/html; charset=utf-8');
      res.setHeader('X-Page-Cache-Status', 'HIT');
      return res.end(cachedPage);
    }

    logger.info({ cacheKey, status: 'MISS' }, 'Page cache miss.');
    res.setHeader('X-Page-Cache-Status', 'MISS');

    // 缓存未命中,劫持原始的 res.end 方法
    const originalEnd = res.end;
    const chunks = [];

    res.end = function (chunk, encoding) {
      if (chunk) {
        chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding));
      }
      
      // 只有在响应成功时才写入缓存
      if (res.statusCode === 200) {
        const pageHtml = Buffer.concat(chunks).toString('utf-8');
        logger.info({ cacheKey, operation: 'write' }, 'Writing rendered page to cache.');
        MemcachedClient.set(cacheKey, pageHtml, PAGE_CACHE_LIFETIME_SECONDS);
      }

      // 调用原始的 end 方法,将响应发回客户端
      originalEnd.apply(res, arguments);
    };

    next();

  } catch (err) {
    logger.error({ err }, 'Page cache middleware error. Bypassing.');
    next();
  }
}

这个中间件必须在 nuxt.config.jsserverMiddleware 数组中位于 Nuxt 核心渲染器之前。一种简单的方法是创建一个单独的模块来注入它。但更直接的方式是在配置文件中排序:

// nuxt.config.js
export default {
  // ...
  serverMiddleware: [
    // 页面缓存必须在最前面
    '~/server-middleware/page-cache.js',
    { path: '/api', handler: '~/server-middleware/api-cache-proxy.js' }
  ]
  // ...
}

现在的请求流程是:

graph TD
    A[Client Request] --> B{Page Cache Middleware};
    B -- HIT --> C[Return Cached HTML];
    B -- MISS --> D{Nuxt Renderer};
    D --> E{asyncData / fetch};
    E --> F{API Cache Proxy};
    F -- HIT --> G[Return Cached API JSON];
    F -- MISS --> H[Real RESTful API];
    H --> I[Write API Cache];
    I --> G;
    G --> J[Nuxt Renders Page];
    J --> K[Write Page Cache];
    K --> L[Return Rendered HTML];
    C --> M[Response End];
    L --> M;

保障代码质量:ESLint 与 Git Hooks

随着缓存逻辑的增加,代码复杂度也在上升。异步处理、错误边界和回调函数很容易引入难以察вершен的问题。在团队协作中,统一的代码风格和质量标准至关重要。ESLint 是解决这个问题的关键工具。

我们的 .eslintrc.js 配置不仅包含基础规则,还加入了针对 Promise 和异步代码的最佳实践:

// frontend/.eslintrc.js
module.exports = {
  root: true,
  env: {
    browser: true,
    node: true,
  },
  parserOptions: {
    parser: '@babel/eslint-parser',
    requireConfigFile: false,
  },
  extends: [
    '@nuxtjs',
    'plugin:nuxt/recommended',
    'prettier'
  ],
  plugins: [
    'promise'
  ],
  rules: {
    // 强制 promise 的 .catch() 中必须有 return 或 throw
    'promise/catch-or-return': 'error',
    // 确保 promise 最终被处理
    'promise/always-return': 'error',
    // 避免在 new Promise 中使用 async
    'promise/no-async-in-promise': 'error',
    // 禁止在循环中使用 await,鼓励并行处理
    'no-await-in-loop': 'warn',
    // 单元测试中常见的 expect(await ...),可以豁免
    'no-unused-expressions': ['error', { 'allowShortCircuit': true, 'allowTernary': true }],
    'vue/multi-word-component-names': 'off',
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
  },
};

仅仅有配置文件是不够的,必须强制执行。我们使用 huskylint-staged 在代码提交(pre-commit)阶段自动检查和修复待提交的代码。

package.json 中添加配置:

// frontend/package.json
{
  "scripts": {
    "lint": "eslint --ext .js,.vue --ignore-path .gitignore .",
    "lint:fix": "npm run lint -- --fix"
  },
  "devDependencies": {
    "eslint": "^8.50.0",
    "husky": "^8.0.0",
    "lint-staged": "^14.0.0",
    // ...其他依赖
  },
  "lint-staged": {
    "*.{js,vue}": "eslint --fix"
  }
}

然后执行安装脚本:

# 在 frontend 目录下执行
npm install husky lint-staged --save-dev
npx husky install
npx husky add .husky/pre-commit "npx lint-staged"

现在,任何开发者在 git commit 时,lint-staged 都会自动对暂存区内的 .js.vue 文件运行 eslint --fix。不符合规范且无法自动修复的代码将导致提交失败。这从源头上保证了进入代码库的都是符合团队规范的高质量代码,极大地降低了因低级错误导致生产环境缓存失效或应用崩溃的风险。

架构的局限性与未来迭代方向

当前这套基于单实例 Memcached 的多级缓存架构,虽然极大地提升了单体 Nuxt 应用的 SSR 性能,但它也存在明显的局限性。

首先,缓存是本地的。当 Nuxt 应用横向扩展为多个实例以应对更高流量时,每个实例都将维护自己的一份缓存。这会导致数据不一致,并且会造成缓存穿透的“惊群效应”——当一个缓存项过期时,所有实例可能会同时回源到后端 API,瞬间压垮下游服务。解决方案是引入一个共享的、分布式的 Memcached 集群(例如使用 Twemproxy 或 AWS ElastiCache)或迁移到支持集群模式的 Redis。

其次,缓存失效机制过于简单。目前完全依赖于 TTL(生存时间)。对于需要即时更新的内容(例如,后台编辑发布了一篇文章),无法主动让缓存失效。一个可行的改进路径是,后端 API 在数据变更时,通过消息队列(如 RabbitMQ)或直接调用一个内部接口,发布一个缓存清除事件,Nuxt 服务端订阅这些事件并精确地删除 page_cacheapi_cache 中对应的键。

最后,页面缓存策略相对粗糙。它缓存了整个 HTML,对于页面中包含用户特定信息(如登录状态、购物车)的场景完全不适用。更精细的方案是“组件级缓存”或“片段缓存”,在 Vue 组件级别进行缓存控制,将页面分解为可缓存的静态片段和不可缓存的动态片段,在服务端渲染时进行组合。这需要更深度的框架集成,但能提供更大的灵活性和更高的缓存命中率。


  目录