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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user