mirror of
https://github.com/2234839/web-font.git
synced 2026-09-04 23:02:27 +08:00
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:
parent
b86d2fcb53
commit
7403414439
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<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 环境)时静默跳过 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 等待队列(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<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(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -11,8 +11,10 @@ export interface ServerConfig {
|
||||
supportedOutTypes: ("woff2" | "ttf")[];
|
||||
/** 临时字体保留时限(秒) */
|
||||
tempRetentionSeconds?: number;
|
||||
/** 字体子集化最大并发数 */
|
||||
subsetConcurrency?: number;
|
||||
/** 子集化内存水位阈值(MB),RSS 超此值时排队 */
|
||||
subsetMemSoftLimitMB?: number;
|
||||
/** 队列等待超时(秒) */
|
||||
subsetQueueTimeoutSeconds?: number;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user