Node.js 图片压缩服务小产品也要管住队列和失败一、图片压缩不是一个同步接口能解决的任务独立产品经常需要上传头像、封面、作品图或导出预览。图片压缩看起来简单接收文件调用 sharp返回 URL。真正上线后会发现图片大小、格式、并发和失败重试都会影响稳定性。把压缩放在同步请求里用户上传几张大图就可能拖垮接口。更稳妥的模型是异步任务。上传接口只负责校验和入队压缩 worker 负责处理前端轮询或订阅状态。这样用户体验多一步但系统边界更清楚。尤其对资源有限的小产品来说队列比盲目扩容更实用。二、压缩链路要把入口、队列和产物分开图片服务至少包含四个对象原始文件、任务、派生产物、错误记录。不要只保存最终 URL。压缩失败时系统需要知道原文件是否还在、任务是否可重试、失败原因是否可展示。flowchart TD A[上传入口] -- B[文件校验] B -- C[对象存储原图] C -- D[压缩任务入队] D -- E[Worker 拉取任务] E -- F[生成多尺寸产物] F -- G[写入产物表] E -- H[失败记录] G -- I[前端读取状态] H -- I这个链路的好处是可恢复。即使 worker 挂了任务仍在。即使某个尺寸生成失败也能标记具体产物而不是让整个上传过程变成黑盒。三、Worker 代码要限制并发并保留错误上下文图片处理是 CPU 和内存敏感任务。并发过高会导致进程内存上涨甚至触发容器重启。下面示例用一个简单并发池表达核心思路。import sharp from sharp; async function processImageJob(job: ImageJob, storage: Storage) { if (!job.sourceKey) throw new Error(missing source image); const input await storage.read(job.sourceKey, { timeoutMs: 2000 }); const variants [ { name: thumb, width: 320 }, { name: preview, width: 960 }, ]; for (const variant of variants) { try { const output await sharp(input) .rotate() .resize({ width: variant.width, withoutEnlargement: true }) .webp({ quality: 82 }) .toBuffer(); await storage.write(${job.id}/${variant.name}.webp, output, { contentType: image/webp }); } catch (error) { throw new Error(image variant failed: ${variant.name}, { cause: error }); } } }生产环境里还要限制输入大小拒绝异常尺寸图片并清理 EXIF 中不需要的信息。错误里保留 variant 名称是为了后续定位问题而不是只看到一个泛化失败。四、图片服务最容易被忽视的是成本边界压缩服务会带来存储成本。原图是否永久保存需要明确策略。很多场景只需要保留原图 7 到 30 天用于重新生成产物长期展示只依赖压缩结果。否则一个小产品的存储会慢慢被历史上传填满。队列也要有背压。任务积压超过阈值时可以降低并发、暂停大图处理或提示用户“稍后完成”。不要让所有任务平等排队。头像和封面可能需要更高优先级批量导入的历史图片可以放低。还有安全边界。图片解析库可能遇到恶意文件或损坏文件。入口要限制 MIME、大小和像素数量worker 要运行在受限环境。不要因为这是一个“轻量功能”就把它放到主业务进程里硬扛。五、总结Node.js 图片压缩服务要按异步任务设计。入口负责校验和入队worker 控制并发并生成产物存储层保留可恢复信息。小产品不需要复杂平台但需要清楚队列、失败和成本边界。图片压缩做得安静前提是系统已经认真处理了最吵的失败场景。