perf(file-storage): 限制 libvips 并发、收紧操作缓存,源图只解码一次

后端 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) 写在循环内导致整图解两遍

输出经比对与改动前完全等价(尺寸/格式/内容类型/字节数一致)。
This commit is contained in:
yml2213
2026-10-02 15:57:13 +08:00
parent 142c4ee141
commit 5c13599dff
@@ -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',