diff --git a/apps/backend/src/services/file-storage/file-storage-service.ts b/apps/backend/src/services/file-storage/file-storage-service.ts index 21fa8747..65c8d970 100644 --- a/apps/backend/src/services/file-storage/file-storage-service.ts +++ b/apps/backend/src/services/file-storage/file-storage-service.ts @@ -10,6 +10,32 @@ import { nowIso } from '../../utils/time.js' import { getObjectStorage, normalizeObjectKey } from './object-storage.js' import { logWarn } from '../../utils/logger.js' +/** + * libvips 并发与操作缓存调优(进程级,必须早于任何 sharp 调用执行)。 + * + * 背景:libvips 默认按 CPU 数开 worker 线程,而 glibc 会为产生 malloc 竞争的线程各建一个 + * arena,arena 只涨不缩。生产实测后端 RSS 在 23 小时内从 194MB 涨到 832MB(容器限额 1GiB), + * 9/30 与 10/1 各被 OOM kill 一次;同期 heapUsed 稳定在 71~101MB,说明增长全在 V8 堆外。 + * + * 为什么必须显式设置:sharp 自带一层保护——检测到 "glibc 分配器且未设置 MALLOC_ARENA_MAX" 时 + * 会把并发降到 1。但本服务已在 .env 显式设置 MALLOC_ARENA_MAX=2(用于给 arena 数量封顶), + * 该保护因此不再触发,libvips 会退回按核数开线程(实测 8 个 libvips worker)。所以这里必须 + * 显式限制,否则等于"arena 封了顶、线程却放开了"。 + * + * 取值 2:前端已把图片压到长边 ≤1600 的 WebP,单图处理量级只有十几 MB,上传约 4~5 次/分钟, + * 留 2 个线程即可,同时把可能催生 arena 的线程数压到最低。 + */ +sharp.concurrency(2) + +/** + * 操作缓存默认上限 50MB / 20 文件 / 100 条目,这里收紧到 16MB 并只保留少量条目。 + * + * 注意:sharp.cache() 不做部分合并——未传的字段会以 undefined 透传给原生层, + * 因此 memory / files / items 三项必须全部显式给出。 + * 保留非零的 memory 是为了让下面的 clone() 有机会复用同一份已解码源图。 + */ +sharp.cache({ memory: 16, files: 0, items: 20 }) + const IMAGE_VARIANT_THUMB = 'thumb' const IMAGE_VARIANT_MEDIUM = 'medium' const THUMB_MAX_SIDE = 480 @@ -368,10 +394,15 @@ async function generateImageVariants(objectKey: string, data: Buffer, contentTyp ] const variants: Array<{ name: string; key: string; content: Buffer; contentType: string }> = [] + // 源图只解码一次,两个尺寸通过 clone() 复用同一份输入。 + // sharp 文档:Cloned instances inherit the input of their parent instance, + // 可让多条处理管线共享同一个输入流;原实现把 sharp(data) 写在循环内,源图会被完整解码两次。 + const source = sharp(data).rotate() + for (const config of configs) { try { const isAlpha = contentType === 'image/png' || contentType === 'image/webp' - const pipeline = sharp(data).rotate().resize({ + const pipeline = source.clone().resize({ width: config.maxSide, height: config.maxSide, fit: 'inside',