feat: 用内存水位闸门替代固定并发限制

- subset_queue: 新增 withMemoryGate,基于 RSS 决定排队/执行
- getRssMB: Node 用 process.memoryUsage(),LLRT 用 /proc/self/statm
- 请求完成后主动 __gc() 加速内存回收
- config: SUBSET_MEM_SOFT_LIMIT_MB=600 替代 SUBSET_CONCURRENCY=4
- 小请求可高并发(内存够),大请求自动排队(防 OOM)
This commit is contained in:
崮生(子虚) 2026-07-30 22:51:37 +08:00
parent b86d2fcb53
commit 7403414439
8 changed files with 165 additions and 97 deletions

View File

@ -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

View File

@ -125,8 +125,8 @@ services:
- SUBSET_CACHE_MAX_SIZE=10485760
# 临时字体保留时限(秒),超时未使用自动删除,默认 108003小时
- TEMP_RETENTION_SECONDS=10800
# 字体裁剪最大并发数,默认 4内存受限环境建议 2-3
- SUBSET_CONCURRENCY=4
# 子集化内存水位阈值(MB)RSS 超此值时排队等待,默认 600
- SUBSET_MEM_SOFT_LIMIT_MB=600
```
## API

View File

@ -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();
}

View File

@ -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);

View File

@ -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,
/** 子集化内存水位阈值MBRSS 超此值时排队 */
subsetMemSoftLimitMB,
/** 队列等待超时(秒) */
subsetQueueTimeoutSeconds,
}),

View File

@ -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,

View File

@ -1,94 +1,123 @@
/**
*
*
*
* CPU/ woff2 brotli
* LLRT ~900M brotli decoder OOM
* LLRT ~900M
* RSS
* - RSS < softLimit
* - RSS softLimit + GC
* - /
*
* Semaphore
* 503
* /proc/self/statmLinux 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().rssLLRT API /proc/self/statm
* /proc/self/statm size resident shared text lib data dt
* resident × (4096) = RSS
*/
export async function withConcurrencyLimit<T>(
maxConcurrency: number,
task: () => Promise<T>,
queueTimeoutMs: number,
): Promise<T | null> {
/** 并发未满,直接执行 */
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 环境)时静默跳过 */
}
}
/** 等待队列FIFOsoftLimit 内的请求完成后依次唤醒 */
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 MBRSS
* @param task
* @param queueTimeoutMs
* @returns null
*/
function waitForSlot(timeout: number): Promise<boolean> {
export async function withMemoryGate<T>(
softLimitMB: number,
task: () => Promise<T>,
queueTimeoutMs: number,
): Promise<T | null> {
/** 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<boolean> {
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<boolean> {
}
/**
*
*
*
* 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(),
};
}

View File

@ -11,8 +11,10 @@ export interface ServerConfig {
supportedOutTypes: ("woff2" | "ttf")[];
/** 临时字体保留时限(秒) */
tempRetentionSeconds?: number;
/** 字体子集化最大并发数 */
subsetConcurrency?: number;
/** 子集化内存水位阈值MBRSS 超此值时排队 */
subsetMemSoftLimitMB?: number;
/** 队列等待超时(秒) */
subsetQueueTimeoutSeconds?: number;
}
export interface UploadResult {