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
相关产品推荐
相关产品推荐

