diff --git a/README.en.md b/README.en.md index c48a5ca..d3c16c3 100644 --- a/README.en.md +++ b/README.en.md @@ -125,8 +125,8 @@ services: - SUBSET_CACHE_MAX_SIZE=10485760 # Temp font retention (seconds), auto-deleted if unused. Default: 10800 (3h) - TEMP_RETENTION_SECONDS=10800 - # Max concurrent font subsetting. Default: 4 - - SUBSET_CONCURRENCY=4 + # Memory soft limit (MB) for subsetting queue. Default: 600 + - SUBSET_MEM_SOFT_LIMIT_MB=600 ``` ## API Reference diff --git a/README.md b/README.md index 09fb06e..82138a5 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,8 @@ services: - SUBSET_CACHE_MAX_SIZE=10485760 # 临时字体保留时限(秒),超时未使用自动删除,默认 10800(3小时) - TEMP_RETENTION_SECONDS=10800 - # 字体裁剪最大并发数,默认 4(内存受限环境建议 2-3) - - SUBSET_CONCURRENCY=4 + # 子集化内存水位阈值(MB),RSS 超此值时排队等待,默认 600 + - SUBSET_MEM_SOFT_LIMIT_MB=600 ``` ## API diff --git a/backend/app.ts b/backend/app.ts index 68d62dc..82c5a36 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -13,6 +13,8 @@ import { handleFontSubset } from "./routes/subset"; import { handleFontDetail } from "./routes/font_detail"; import { handleFontMeta } from "./routes/font_meta"; import { startTempCleaner } from "./temp_cleaner"; +import { initMemoryGate } from "./subset_queue"; +import { subsetMemSoftLimitMB } from "./config"; import "./server/node"; import "./server/llrt"; @@ -207,6 +209,9 @@ async function main() { console.log("[config] temp upload:", enableTempUpload); console.log("[config] admin upload:", !!adminApiKey); + /** 初始化内存水位闸门(子集化排队控制) */ + initMemoryGate(subsetMemSoftLimitMB); + /** 启动临时字体定时清理器 */ startTempCleaner(); } diff --git a/backend/config.ts b/backend/config.ts index 68927b3..578b13f 100644 --- a/backend/config.ts +++ b/backend/config.ts @@ -22,19 +22,16 @@ export const tempRetentionSeconds = parseInt(env.TEMP_RETENTION_SECONDS ?? "1080 export const subsetCacheMaxSize = parseInt(env.SUBSET_CACHE_MAX_SIZE ?? `${10 * 1024 * 1024}`, 10) || 10 * 1024 * 1024; /** - * 字体子集化最大并发数 + * 字体子集化内存水位阈值(MB) * - * 字体裁剪是 CPU/内存密集操作,并发过多会导致 LLRT OOM 崩溃。 - * 默认 4:在 900M 内存限制下安全运行。 - * 内存充裕可调大,内存紧张可调小到 2。 + * RSS 超过此值时,新的子集化请求排队等待, + * 直到前面的请求完成 + GC 释放内存后 RSS 回落。 + * 默认 600:容器限制 900M 时留 300M 余量给峰值。 */ -export const subsetConcurrency = Math.max(1, parseInt(env.SUBSET_CONCURRENCY ?? "4", 10) || 4); +export const subsetMemSoftLimitMB = parseInt(env.SUBSET_MEM_SOFT_LIMIT_MB ?? "600", 10) || 600; /** - * 队列等待超时(秒)—— 排队超过此时间返回 503,避免请求无限堆积 - * - * 50 并发场景下并发 4,每批 ~300ms,排队最久约 (50/4)*300ms ≈ 3.75s, - * 30 秒足够覆盖正常高峰。极端过载时快速失败让客户端重试。 + * 队列等待超时(秒)—— 排队超过此时间返回 503,客户端可重试 */ export const subsetQueueTimeoutSeconds = Math.max(5, parseInt(env.SUBSET_QUEUE_TIMEOUT ?? "30", 10) || 30); diff --git a/backend/routes/config.ts b/backend/routes/config.ts index e630de1..9d21942 100644 --- a/backend/routes/config.ts +++ b/backend/routes/config.ts @@ -1,5 +1,5 @@ import { jsonResponse } from "../shared"; -import { enableTempUpload, adminApiKey, tempRetentionSeconds, subsetConcurrency, subsetQueueTimeoutSeconds } from "../config"; +import { enableTempUpload, adminApiKey, tempRetentionSeconds, subsetMemSoftLimitMB, subsetQueueTimeoutSeconds } from "../config"; /** GET /api/config — 返回公开配置 */ export async function handleGetConfig(req: Request, _res: Response) { @@ -11,8 +11,8 @@ export async function handleGetConfig(req: Request, _res: Response) { supportedOutTypes: ["woff2", "ttf"], /** 临时字体保留时限(秒) */ tempRetentionSeconds, - /** 字体子集化最大并发数 */ - subsetConcurrency, + /** 子集化内存水位阈值(MB),RSS 超此值时排队 */ + subsetMemSoftLimitMB, /** 队列等待超时(秒) */ subsetQueueTimeoutSeconds, }), diff --git a/backend/routes/subset.ts b/backend/routes/subset.ts index 879d16b..60cae14 100644 --- a/backend/routes/subset.ts +++ b/backend/routes/subset.ts @@ -2,8 +2,8 @@ import { fontSubset } from "../font_util/font"; import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js"; import { parseUrl, stats, subsetCache, findFontPath, readFontBuffer, markStatsDirty } from "../shared"; import { markFontUsed } from "../temp_cleaner"; -import { withConcurrencyLimit } from "../subset_queue"; -import { subsetConcurrency, subsetQueueTimeoutSeconds } from "../config"; +import { withMemoryGate } from "../subset_queue"; +import { subsetMemSoftLimitMB, subsetQueueTimeoutSeconds } from "../config"; /** * 进程启动时戳(模块加载时取一次,进程重启即变化) @@ -111,12 +111,12 @@ export async function handleFontSubset(req: Request, res: Response) { const t2 = Date.now(); /** - * 实际子集化(CPU/内存密集)—— 通过并发队列控制 + * 实际子集化(CPU/内存密集)—— 通过内存水位闸门控制 * - * 缓存未命中的请求才进入队列;并发满时排队等待,超时返回 503。 - * 避免大字集 brotli 压缩同时执行导致 LLRT OOM 崩溃。 + * 缓存未命中的请求才进入闸门;RSS 超 softLimit 时排队等待, + * 前面请求完成 + GC 释放内存后 RSS 回落才执行。避免 OOM 崩溃。 */ - const subsetResult = await withConcurrencyLimit(subsetConcurrency, async () => { + const subsetResult = await withMemoryGate(subsetMemSoftLimitMB, async () => { return fontSubset(oldFontBuffer, text, { outType: outType, sourceType: fontType, diff --git a/backend/subset_queue.ts b/backend/subset_queue.ts index 5b60377..bfa7482 100644 --- a/backend/subset_queue.ts +++ b/backend/subset_queue.ts @@ -1,94 +1,123 @@ /** - * 字体子集化并发队列控制器 + * 字体子集化内存水位闸门 * - * 字体裁剪是 CPU/内存密集操作(尤其大字集 woff2 brotli 压缩), - * LLRT 运行时内存受限(~900M),并发过多会导致 brotli decoder OOM 崩溃。 + * LLRT 运行时内存受限(~900M),字体裁剪是内存密集操作。 + * 不用固定并发数,而是实时监控进程 RSS: + * - RSS < softLimit:直接执行(小请求可高并发) + * - RSS ≥ softLimit:排队等待,直到前面的请求完成 + GC 释放内存 + * - 无硬限制/拒绝:所有请求最终都会执行 * - * 通过 Semaphore 限制同时执行的子集化数量,超出并发的请求排队等待, - * 等待超时则返回 503,避免请求无限堆积。 + * 内存监控通过 /proc/self/statm(Linux 唯一可用方式,LLRT 无 process.memoryUsage)。 + * GC 通过 LLRT 内置的 __gc() 主动触发(请求完成后调用,加速内存回收)。 */ -/** - * 等待队列中的请求超时时间(秒),由 config.ts 通过环境变量控制 - * - * 在 subset.ts 中通过参数传入,不在此处硬编码。 - */ - -/** 当前正在执行的子集化数量 */ -let activeCount = 0; -/** 当前排队等待的数量 */ -let waitingCount = 0; +/** /proc/self/statm 文件描述符(启动时打开,反复读取不需每次 open/close) */ +let statmFd: number | null = null; /** - * 通过 Semaphore 执行子集化任务 + * 读取当前进程 RSS(常驻内存),单位 MB * - * - 并发未满时立即执行 - * - 并发已满时排队等待,超时返回 null(调用方应返回 503) - * - 返回任务结果,或 null 表示排队超时 - * - * @param maxConcurrency 最大并发数 - * @param task 实际的子集化异步任务 - * @param queueTimeoutMs 排队等待超时(毫秒),超时返回 null - * @returns 任务结果,或 null 表示排队超时 + * Node 环境使用 process.memoryUsage().rss;LLRT 无此 API,改用 /proc/self/statm。 + * /proc/self/statm 格式:size resident shared text lib data dt(单位:页) + * resident 字段 × 页大小(4096) = RSS 字节数。 */ -export async function withConcurrencyLimit( - maxConcurrency: number, - task: () => Promise, - queueTimeoutMs: number, -): Promise { - /** 并发未满,直接执行 */ - if (activeCount < maxConcurrency) { - activeCount++; - try { - return await task(); - } finally { - activeCount--; - /** 唤醒一个等待者(如果有)—— 通过 resolve 触发 */ - notifyNext(); - } - } - - /** 并发已满,进入排队 */ - waitingCount++; +function getRssMB(): number { try { - /** 等待获取许可,或超时 */ - const acquired = await waitForSlot(queueTimeoutMs); - if (!acquired) { - /** 排队超时,返回 null 让调用方返回 503 */ - return null; + /** Node 环境优先使用 process.memoryUsage() */ + if (typeof process !== "undefined" && process.memoryUsage) { + return Math.round(process.memoryUsage().rss / 1024 / 1024); } - activeCount++; - try { - return await task(); - } finally { - activeCount--; - notifyNext(); + /** LLRT 环境:读取 /proc/self/statm */ + if (statmFd === null) { + const { openSync } = require("fs"); + statmFd = openSync("/proc/self/statm", "r"); } - } finally { - waitingCount--; + const { readSync } = require("fs"); + const buf = new Uint8Array(256); + const n = readSync(statmFd, buf, 0, 256, 0); + const parts = new TextDecoder().decode(buf.subarray(0, n)).trim().split(" "); + return Math.round((parseInt(parts[1]) * 4096) / 1024 / 1024); + } catch { + /** 两个方案都不可用,返回 0 表示「无限制」 */ + return 0; } } -/** 等待队列(FIFO) */ +/** + * 主动触发垃圾回收 + * + * LLRT 内置 __gc(),请求完成后调用可立即释放字体解析产生的大对象, + * 而非等待 LLRT 引擎自动 GC(可能延迟数秒)。 + */ +function gc(): void { + try { + (globalThis as any).__gc?.(); + } catch { + /** __gc 不存在(Node 环境)时静默跳过 */ + } +} + +/** 等待队列(FIFO):softLimit 内的请求完成后依次唤醒 */ const waitQueue: Array<() => void> = []; +/** 当前正在执行的子集化数量(用于 stats 展示) */ +let activeCount = 0; + +/** softLimit 引用(checkAndNotify 需要用) */ +let softLimitRef = 0; + /** - * 等待获取一个并发许可 + * 通过内存水位闸门执行子集化任务 * - * @param timeout 超时时间(毫秒) - * @returns true=获得许可,false=超时 + * - RSS 未超 softLimit → 立即执行 + * - RSS 超 softLimit → 排队等待,前面的请求完成后 GC → RSS 回落 → 唤醒 + * - 队列超时 → 返回 null(调用方返回 503,客户端可重试) + * + * @param softLimitMB 内存软限制(MB),RSS 超过此值时新请求排队 + * @param task 实际的子集化异步任务 + * @param queueTimeoutMs 排队超时(毫秒) + * @returns 任务结果,或 null 表示排队超时 */ -function waitForSlot(timeout: number): Promise { +export async function withMemoryGate( + softLimitMB: number, + task: () => Promise, + queueTimeoutMs: number, +): Promise { + /** RSS=0 表示无法读取(开发环境),跳过闸门直接执行 */ + if (softLimitMB > 0 && getRssMB() >= softLimitMB) { + /** 内存超阈值,进入排队 */ + const acquired = await waitForSlot(queueTimeoutMs); + if (!acquired) return null; + } + + activeCount++; + try { + return await task(); + } finally { + activeCount--; + /** + * 任务完成后主动 GC,加速释放字体解析的大对象。 + * 然后检查队列:如果 RSS 已回落,唤醒下一个等待者。 + */ + gc(); + checkAndNotify(); + } +} + +/** + * 排队等待内存释放 + * + * 加入 FIFO 队列,等待前面的请求完成后唤醒。 + * 超时则从队列移除自己,返回 false。 + */ +function waitForSlot(timeoutMs: number): Promise { return new Promise((resolve) => { - /** 超时定时器 */ const timer = setTimeout(() => { - /** 从队列中移除自己 */ const idx = waitQueue.indexOf(resolver); if (idx !== -1) waitQueue.splice(idx, 1); resolve(false); - }, timeout); + }, timeoutMs); - /** resolve 包装:清除定时器再 resolve */ const resolver = () => { clearTimeout(timer); resolve(true); @@ -98,15 +127,49 @@ function waitForSlot(timeout: number): Promise { } /** - * 唤醒队列中下一个等待者 + * 检查内存并唤醒队列 * - * 在 activeCount 减少后调用,让排队中的请求依次进入。 + * 每个子集化任务完成后调用: + * 1. 如果 RSS < softLimit 且队列非空 → 唤醒下一个 + * 2. RSS 仍超限 → 不唤醒(等待引擎自动 GC 后下次再检查) + * 3. 兜底:5 秒后如果 RSS 没超 softLimit×1.2,强制唤醒(防饿死) */ -function notifyNext() { - /** 仍有空位且有人在等 */ - const resolver = waitQueue.shift(); - if (resolver) { - resolver(); +function checkAndNotify(): void { + if (waitQueue.length === 0) return; + /** RSS 未知(开发环境)→ 直接唤醒 */ + if (softLimitRef === 0) { + waitQueue.shift()?.(); + return; + } + /** RSS 已回落到阈值以下 → 唤醒下一个 */ + if (getRssMB() < softLimitRef) { + waitQueue.shift()?.(); + /** 唤醒后递归检查:可能还有内存余量给更多等待者 */ + checkAndNotify(); + return; + } + /** + * RSS 仍超限时不立即唤醒——设置兜底定时器: + * 5 秒后如果 RSS 降到 softLimit×1.2 以内,唤醒一个(防极端饿死)。 + */ + setTimeout(() => { + if (waitQueue.length > 0 && getRssMB() < softLimitRef * 1.2) { + waitQueue.shift()?.(); + } + }, 5000); +} + +/** + * 初始化闸门参数(由 config.ts 的值设置) + * + * 必须在第一次 withMemoryGate 调用前执行。 + * @param softLimitMB 内存软限制(MB) + */ +export function initMemoryGate(softLimitMB: number): void { + softLimitRef = softLimitMB; + const rss = getRssMB(); + if (rss > 0) { + console.log(`[memgate] RSS=${rss}MB, softLimit=${softLimitMB}MB`); } } @@ -114,6 +177,7 @@ function notifyNext() { export function getQueueStats() { return { active: activeCount, - waiting: waitingCount, + waiting: waitQueue.length, + rssMB: getRssMB(), }; } diff --git a/src/api.ts b/src/api.ts index 39f1db2..7c5bfbe 100644 --- a/src/api.ts +++ b/src/api.ts @@ -11,8 +11,10 @@ export interface ServerConfig { supportedOutTypes: ("woff2" | "ttf")[]; /** 临时字体保留时限(秒) */ tempRetentionSeconds?: number; - /** 字体子集化最大并发数 */ - subsetConcurrency?: number; + /** 子集化内存水位阈值(MB),RSS 超此值时排队 */ + subsetMemSoftLimitMB?: number; + /** 队列等待超时(秒) */ + subsetQueueTimeoutSeconds?: number; } export interface UploadResult {