You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Next.js v15.3集成Redis缓存引发CPU占用过高求助

Next.js v15.3 自定义Redis缓存处理器CPU占用过高问题

问题背景

将公司Next.js应用从v14.2升级至v15.3后,原依赖的@neshca/cache-handler v1.9.0不再兼容,改用ioredis实现自定义缓存处理器。部署后CPU使用率飙升3-5倍,峰值时段频繁触达阈值,导致多Pod扩容。由于Next.js调整了缓存数据结构(CachedRouteValue的body为Buffer、CachedAppPageValue的rscData为Buffer、segmentData为Map<string, Buffer>),已添加Buffer与String的互转逻辑,现需解决CPU占用过高问题。

相关接口定义

CachedRouteValue 接口

export interface CachedRouteValue {
  kind: CachedRouteKind.APP_ROUTE
  // this needs to be a RenderResult so since renderResponse
  // expects that type instead of a string
  body: Buffer
  status: number
  headers: OutgoingHttpHeaders
}

CachedAppPageValue 接口

export interface CachedAppPageValue {
  kind: CachedRouteKind.APP_PAGE
  // this needs to be a RenderResult so since renderResponse
  // expects that type instead of a string
  html: RenderResult
  rscData: Buffer | undefined
  status: number | undefined
  postponed: string | undefined
  headers: OutgoingHttpHeaders | undefined
  segmentData: Map<string, Buffer> | undefined
}

当前实现代码

const Redis = require("ioredis");

const redisClient = new Redis(
  process.env.REDIS_URL ?? "redis://localhost:6379",
);

redisClient.on("error", (error) => {
  console.error("Redis error:", error);
});

function calculateTtl(maxAge) {
  return maxAge * 1.5;
}

function transformBufferDataForStorage(data) {
  const value = data?.value;
  if (value?.kind === "APP_PAGE") {
    if (value.rscData && Buffer.isBuffer(value.rscData)) {
      value.rscData = value.rscData.toString();
    }
    if (value.segmentData && value.segmentData instanceof Map) {
      value.segmentData = Object.fromEntries(
        Array.from(value.segmentData.entries()).map(([key, val]) => [
          key,
          Buffer.isBuffer(val) ? val.toString() : val,
        ]),
      );
    }
  }
  if (
    value?.kind === "APP_ROUTE" &&
    value?.body &&
    Buffer.isBuffer(value.body)
  ) {
    value.body = value.body.toString();
  }
  return data;
}

function transformStringDataToBuffer(data) {
  const value = data?.value;
  if (value?.kind === "APP_PAGE") {
    if (value.rscData) {
      value.rscData = Buffer.from(value.rscData, "utf-8");
    }
    if (
      value.segmentData &&
      typeof value.segmentData === "object" &&
      !(value.segmentData instanceof Map)
    ) {
      value.segmentData = new Map(
        Object.entries(value.segmentData).map(([key, val]) => [
          key,
          Buffer.from(val, "utf-8"),
        ]),
      );
    }
  }
  if (
    value?.kind === "APP_ROUTE" &&
    value?.body &&
    !Buffer.isBuffer(value.body)
  ) {
    value.body = Buffer.from(value.body, "utf-8");
  }
  return data;
}

module.exports = class CacheHandler {
  constructor(options) {
    this.options = options || {};
    this.keyPrefix = "storefront:";
    this.name = "redis-cache";
  }

  async get(key) {
    const prefixedKey = `${this.keyPrefix}${key}`;
    try {
      const result = await redisClient.get(prefixedKey);
      if (result) {
        return transformStringDataToBuffer(JSON.parse(result));
      }
    } catch (error) {
      return null;
    }
    return null;
  }

  async set(key, data, ctx) {
    const prefixedKey = `${this.keyPrefix}${key}`;
    const ttl = calculateTtl(this.options.maxAge || 60 * 60);
    const transformedData = transformBufferDataForStorage({ ...data });
    const cacheData = {
      value: transformedData,
      lastModified: Date.now(),
      tags: ctx.tags,
    };
    try {
      await redisClient.set(prefixedKey, JSON.stringify(cacheData), "EX", ttl);
    } catch (error) {
      return false;
    }

    return true;
  }

  async revalidateTag(tags) {
    tags = [tags].flat();
    let cursor = "0";
    const tagPattern = `${this.keyPrefix}*`;
    const keysToDelete = [];

    do {
      const [nextCursor, keys] = await redisClient.scan(
        cursor,
        "MATCH",
        tagPattern,
        "COUNT",
        100,
      );

      cursor = nextCursor;

      if (keys.length > 0) {
        const pipeline = redisClient.pipeline();
        keys.forEach((key) => pipeline.get(key));
        const results = await pipeline.exec();

        for (let i = 0; i < keys.length; i++) {
          const [err, data] = results[i];
          if (!err && data) {
            try {
              const parsed = JSON.parse(data);
              if (
                parsed.tags &&
                parsed.tags.some((tag) => tags.includes(tag))
              ) {
                keysToDelete.push(keys[i]);
              }
            } catch (e) {
              console.error("Error parsing JSON from Redis:", e);
            }
          }
        }
      }
    } while (cursor !== "0");

    if (keysToDelete.length > 0) {
      const pipeline = redisClient.pipeline();
      keysToDelete.forEach((key) => pipeline.del(key));
      await pipeline.exec();
    }
  }
};

function removeRedisCacheByPrefix(prefix) {
  (async () => {
    try {
      let cursor = "0";
      do {
        const [newCursor, keys] = await redisClient.scan(
          cursor,
          "MATCH",
          `${prefix}*`,
          "COUNT",
          1000,
        );

        if (keys.length > 0) {
          const pipeline = redisClient.pipeline();
          keys.forEach((key) => pipeline.del(key));
          pipeline
            .exec()
            .catch((err) =>
              console.error("Error in fire-and-forget cache deletion:", err),
            );
        }

        cursor = newCursor;
      } while (cursor !== "0");
    } catch (error) {
      console.error("Error in fire-and-forget cache deletion:", error);
    }
  })();

  return true;
}

module.exports.removeRedisCacheByPrefix = removeRedisCacheByPrefix;

核心优化方案

1. 减少Buffer与String互转及JSON序列化开销

当前实现中,Buffer转字符串再JSON序列化、读取时反向转换的过程是CPU消耗的核心来源,尤其是大体积缓存数据。优化方向:

  • 利用JSON内置的Buffer序列化机制,避免手动转字符串
  • 减少Map与Object的相互转换遍历

优化后的代码示例:

// get方法优化:自动解析Buffer和Map
async get(key) {
  const prefixedKey = `${this.keyPrefix}${key}`;
  try {
    const result = await redisClient.get(prefixedKey);
    if (result) {
      return JSON.parse(result, (_, value) => {
        // 自动将JSON序列化的Buffer转回原始Buffer
        if (value?.type === 'Buffer' && Array.isArray(value.data)) {
          return Buffer.from(value.data);
        }
        // 将存储的数组转回Map
        if (value instanceof Array && value.every(item => Array.isArray(item) && item.length === 2)) {
          return new Map(value);
        }
        return value;
      });
    }
  } catch (error) {
    return null;
  }
  return null;
}

// set方法优化:直接序列化原始数据,无需手动转换
async set(key, data, ctx) {
  const prefixedKey = `${this.keyPrefix}${key}`;
  const ttl = calculateTtl(this.options.maxAge || 60 * 60);
  // 使用structuredClone避免修改原对象,同时Map会被序列化为数组
  const cacheData = {
    value: structuredClone(data),
    lastModified: Date.now(),
    tags: ctx.tags,
  };
  try {
    await redisClient.set(prefixedKey, JSON.stringify(cacheData), "EX", ttl);
  } catch (error) {
    return false;
  }
  return true;
}

2. 重构revalidateTag的全量扫描逻辑

当前revalidateTag通过SCAN遍历所有缓存键,再逐个读取解析判断标签,缓存键数量大时CPU开销极高。优化方案:

  • 维护标签与缓存键的映射集合,清理时直接读取对应集合的键

优化后的代码示例:

// set方法中添加标签映射
async set(key, data, ctx) {
  const prefixedKey = `${this.keyPrefix}${key}`;
  const ttl = calculateTtl(this.options.maxAge || 60 * 60);
  const cacheData = {
    value: structuredClone(data),
    lastModified: Date.now(),
    tags: ctx.tags,
  };
  const pipeline = redisClient.pipeline();
  
  pipeline.set(prefixedKey, JSON.stringify(cacheData), "EX", ttl);
  
  // 为每个标签建立键映射集合
  if (ctx.tags?.length) {
    ctx.tags.forEach(tag => {
      const tagKey = `tag:${tag}:keys`;
      pipeline.sadd(tagKey, prefixedKey);
      pipeline.expire(tagKey, ttl); // 与缓存键TTL保持一致
    });
  }
  
  try {
    await pipeline.exec();
  } catch (error) {
    return false;
  }
  return true;
}

// revalidateTag优化:直接通过标签集合获取待删除键
async revalidateTag(tags) {
  tags = [tags].flat();
  const pipeline = redisClient.pipeline();
  const allKeys = [];

  for (const tag of tags) {
    const tagKey = `tag:${tag}:keys`;
    // 获取标签关联的所有缓存键
    const keys = await redisClient.smembers(tagKey);
    allKeys.push(...keys);
    pipeline.del(tagKey); // 删除标签映射集合
  }

  // 删除对应缓存键
  if (allKeys.length) {
    allKeys.forEach(key => pipeline.del(key));
  }

  await pipeline.exec();
}

3. Redis客户端连接池优化

调整ioredis连接池配置,避免连接频繁创建销毁带来的开销:

const redisClient = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379", {
  poolSize: 15, // 根据应用并发量调整,默认10
  enableReadyCheck: true,
  lazyConnect: true,
  connectionTimeout: 2000,
  maxRetriesPerRequest: 2,
  enableOfflineQueue: false, // 禁用离线队列,避免请求堆积
});

4. 移除不必要的对象克隆

原set方法中{ ...data }的浅克隆无法处理嵌套对象,改用structuredClone实现深克隆,同时避免修改原数据对象。

内容的提问来源于stack exchange,提问作者Abu Rifat Muhammed Al Hasib

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.13 00:07:05