From 5c13599dff3cab2c182083ac4ee083c932174f38 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 2 Oct 2026 15:57:13 +0800 Subject: [PATCH] =?UTF-8?q?perf(file-storage):=20=E9=99=90=E5=88=B6=20libv?= =?UTF-8?q?ips=20=E5=B9=B6=E5=8F=91=E3=80=81=E6=94=B6=E7=B4=A7=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E7=BC=93=E5=AD=98=EF=BC=8C=E6=BA=90=E5=9B=BE=E5=8F=AA?= =?UTF-8?q?=E8=A7=A3=E7=A0=81=E4=B8=80=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 RSS 在 23 小时内从 194MB 涨到 832MB(限额 1GiB,已两次 OOM kill), 而同期 heapUsed 稳定在 71~101MB,增长全在 V8 堆外:libvips 用 glibc malloc, 每个开发出来的 worker 线程都会催生一个只涨不缩的 arena。 sharp 自带保护——检测到 "glibc 且未设置 MALLOC_ARENA_MAX" 时自动把并发降到 1; 但 .env 已显式设置 MALLOC_ARENA_MAX=2 给 arena 封顶,该保护不再触发(实测 concurrency 由 1 变 4、libvips worker 达 8 个)。故此处必须显式限制并发: - sharp.concurrency(2):把可能催生 arena 的线程数压到最低 - sharp.cache({memory:16, files:0, items:20}):默认 50MB 收紧到 16MB (注意 sharp.cache() 不做部分合并,三项必须全给) - 源图用 clone() 复用,原实现把 sharp(data) 写在循环内导致整图解两遍 输出经比对与改动前完全等价(尺寸/格式/内容类型/字节数一致)。 --- .../file-storage/file-storage-service.ts | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) 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',