ISR2Image 并不是某个必须安装的 npm 包,而是一种把增量静态再生思路应用到图片生成上的工程方案。它的目标很明确:图片接口不再每次请求都现场渲染,也不退回成完全静态、无法更新的文件,而是让图片拥有类似 ISR 页面那样的新鲜度与自动重建能力。下面先从传统方案的问题说起,再逐步拆解 Node.js 侧的完整实现。

一、为什么图片生成也需要增量再生
在需要服务端生成图片的场景里,常见做法有两种。第一种是每次请求都调用 sharp、canvas 或 Puppeteer 重新渲染图片,然后把二进制流返回给客户端。这种方式实时性最好,但计算成本非常高,尤其当图片包含复杂排版、水印、图表或者需要加载外部字体时,单次渲染可能耗时几百毫秒甚至几秒。并发一旦上来,CPU 和内存会迅速被打满。
第二种做法是提前把图片全部生成好,作为静态文件放到 CDN 或 Nginx 目录里。请求进来直接返回文件,性能几乎没有压力。但问题在于数据会变化:商品价格、用户头像、活动倒计时、榜单排名等信息一旦更新,静态图片不会自己跟着变。要么人工重新跑批量任务,要么接受图片展示旧数据,这对很多业务来说不可接受。
ISR2Image 要解决的就是这对矛盾:优先返回已经生成好的图片文件,降低实时计算压力;同时允许缓存过期或者在数据变化时触发后台重建,生成完成后用新图无缝替换旧图。请求方不需要关心重建过程,始终能拿到一个可用的图片。这个模型和 Next.js 的 ISR 很相似,只不过把页面换成了图片二进制文件。
二、实现ISR2Image的基本架构
整个方案可以拆成请求处理、缓存存储、后台生成三个部分。请求进来后先根据业务参数计算缓存键,例如用户 ID、卡片类型、数据版本号,组合成唯一的文件路径。缓存层负责判断文件是否存在以及是否仍然新鲜。存储层直接使用文件系统,把图片写到本地磁盘或挂载的持久化卷中,后续可以再同步到对象存储或 CDN。
一个基础的路由逻辑可以这样写:当缓存文件存在且元数据中的更新时间在有效期内,直接调用 res.sendFile 返回;如果文件存在但已经过期,仍然先把旧文件返回给用户,同时把该任务推入后台队列,由队列调度重新生成;如果文件不存在,则同步生成一次并写入缓存,避免用户拿到 404 或空白图。
const fs = require('fs');
const path = require('path');
const generateImage = require('./generator');
function getCachePaths(id) {
const dir = path.join(__dirname, 'image-cache');
return {
file: path.join(dir, id + '.png'),
meta: path.join(dir, id + '.json')
};
}
async function handleCardImage(req, res) {
const id = req.params.id;
const paths = getCachePaths(id);
const ttl = 60 * 1000;
if (fs.existsSync(paths.file) && fs.existsSync(paths.meta)) {
const meta = JSON.parse(fs.readFileSync(paths.meta, 'utf8'));
const age = Date.now() - meta.updatedAt;
if (age < ttl) {
return res.sendFile(paths.file);
}
// 过期但旧图可用,先返回并触发后台刷新
pushRegeneration(id, paths);
return res.sendFile(paths.file);
}
// 首次访问同步生成,保证一定有图返回
const buf = await generateImage(id);
fs.mkdirSync(path.dirname(paths.file), { recursive: true });
fs.writeFileSync(paths.file, buf);
fs.writeFileSync(paths.meta, JSON.stringify({ updatedAt: Date.now() }));
res.type('png').send(buf);
}
上面代码里的 pushRegeneration 负责把任务放进队列,不阻塞当前请求。首次访问的同步生成策略可以根据业务调整:如果图片渲染很慢,也可以返回一张占位图并立即触发异步生成,但用户体验会差一些。多数场景下首次生成延迟可以接受,因为后续相同 ID 的请求会直接命中磁盘缓存。
元数据文件记录最近一次成功生成的时间,用来判断新鲜度。实际系统中还可以加入数据哈希值,例如根据业务数据计算一个 MD5,放在 JSON 里。请求参数或数据变更后,新哈希与旧哈希不一致,即使时间未过期也能触发刷新,这样缓存控制会更精确。
三、防止并发和实现原子替换
当同一个图片的多个请求同时到达,或者缓存过期后瞬间涌入大量请求,如果不加控制,后台会重复生成同一个文件。重复计算不仅浪费资源,还可能导致文件写入冲突。解决方案是在进程内维护一个 Map,键为缓存 ID,值为正在执行的 Promise。生成函数先检查锁,如果已有任务则直接返回同一个 Promise,让调用方等待结果。
生成完成后不能直接覆盖原文件。如果写入过程中进程崩溃或者磁盘写满,原来的可用文件可能会损坏。正确做法是先写一个临时文件,再通过 fs.renameSync 原子替换到目标路径。这样任何时刻目标路径要么是旧文件,要么是新文件,不会出现半成品。
const locks = new Map();
async function ensureGenerate(id, paths) {
if (locks.has(id)) {
return locks.get(id);
}
const task = (async () => {
try {
const buf = await generateImage(id);
const tmp = paths.file + '.tmp-' + Date.now();
fs.mkdirSync(path.dirname(paths.file), { recursive: true });
fs.writeFileSync(tmp, buf);
fs.renameSync(tmp, paths.file);
fs.writeFileSync(paths.meta, JSON.stringify({ updatedAt: Date.now() }));
} finally {
locks.delete(id);
}
})();
locks.set(id, task);
return task;
}
这里有一个细节:task 被放入 locks 后才开始执行,后续相同 ID 的调用会复用同一个 Promise。即使生成失败,finally 也会清理锁,让下一次请求有机会重试。对于多进程部署的情况,进程内锁不够用,需要借助 Redis 的 SETNX 或分布式锁来协调,但小规模单机服务用内存锁已经能解决大部分问题。
临时文件命名带了时间戳和随机性,避免并发写临时文件时互相覆盖。写入完成后立即重命名,这个操作在同一个文件系统上通常是原子的。如果缓存目录和临时目录不在同一挂载点,rename 可能退化为复制,建议把临时文件放在与目标文件相同的目录下。
四、缓存新鲜度与数据版本管理
时间 TTL 是最简单的失效策略,但它不够聪明。比如商品价格改了,缓存还有 50 秒才过期,这 50 秒内用户会看到旧价格。对于一致性要求高的场景,可以把业务数据的版本号或哈希放进缓存键中。数据更新时生成新的哈希,图片路径随之变化,旧文件保留一段时间供 CDN 回退,新请求直接请求新路径。
版本号方案下,业务方在写入数据库后发一条消息或调用内部接口,通知图片服务某类数据已变更。图片服务根据规则计算出一批 ID,将它们加入刷新队列,提前生成新版本图片。这样用户在数据变更后的第一次请求就能拿到新图,而 ISR 的过期回退机制依然保留,作为兜底。
缓存文件也需要定期清理。如果缓存键包含版本号,旧版本文件会越积越多。可以写一个定时任务,扫描缓存目录,删除超过一定时间未被访问或者元数据标记为废弃的文件。Node.js 中可以用 setInterval 配合 fs.readdir 实现简单清理,更完善的做法是记录访问日志,结合 LRU 策略淘汰冷数据。
在此基础上,还可以把生成好的文件同步到对象存储,让 CDN 直接回源到对象存储,Node.js 服务只负责触发生成和返回最新的缓存地址。这样图片请求压力会进一步降低,服务本身只处理生成任务。整个 ISR2Image 方案从单机缓存到分布式的演进路径比较清晰,初期可以用本地磁盘快速验证,后期再逐步拆分队列、存储和 CDN。