From 23728ba02bb7d3326aff08f6d662d565fb54a49c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=AE=E7=94=9F=EF=BC=88=E5=AD=90=E8=99=9A=EF=BC=89?= <2234839456@qq.com> Date: Thu, 30 Jul 2026 23:11:54 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=9B=9E=E9=80=80=E5=86=85?= =?UTF-8?q?=E5=AD=98=E9=97=B8=E9=97=A8=E4=B8=BA=E5=9B=BA=E5=AE=9A=E5=B9=B6?= =?UTF-8?q?=E5=8F=91(=E4=BF=9D=E7=95=99=E5=88=86=E7=BB=84=E8=B0=83?= =?UTF-8?q?=E5=BA=A6)=20+=20=E5=89=8D=E7=AB=AF=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - subset_queue.ts: 移除基于内存的门控(LLRT GC 不释放 RSS), 回退到固定并发模型,保留分组调度优化(相同字体批处理重用缓存) - config.ts: 恢复 subsetConcurrency / subsetQueueTimeoutSeconds, 移除 subsetMemSoftLimitMB - routes/subset.ts, app.ts, routes/config.ts: 同步恢复 - api.ts, README: 同步更新 前端: - FontSelector.vue: 下拉项和触发器标记临时字体(橙色徽章) - CodeBlock.vue: 新增轻量代码块组件,内置 CSS/HTML/JS 分词高亮(0依赖) - Home.vue: 集成 CodeBlock 替换原始
代码块
---
README.en.md | 4 +-
README.md | 4 +-
backend/app.ts | 6 +-
backend/config.ts | 12 +-
backend/routes/config.ts | 6 +-
backend/routes/subset.ts | 4 +-
backend/subset_queue.ts | 141 ++++------------
src/FontSelector.vue | 21 ++-
src/api.ts | 4 +-
src/components/CodeBlock.vue | 306 +++++++++++++++++++++++++++++++++++
src/pages/Home.vue | 17 +-
11 files changed, 387 insertions(+), 138 deletions(-)
create mode 100644 src/components/CodeBlock.vue
diff --git a/README.en.md b/README.en.md
index d3c16c3..c48a5ca 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
- # Memory soft limit (MB) for subsetting queue. Default: 600
- - SUBSET_MEM_SOFT_LIMIT_MB=600
+ # Max concurrent font subsetting. Default: 4
+ - SUBSET_CONCURRENCY=4
```
## API Reference
diff --git a/README.md b/README.md
index 82138a5..09fb06e 100644
--- a/README.md
+++ b/README.md
@@ -125,8 +125,8 @@ services:
- SUBSET_CACHE_MAX_SIZE=10485760
# 临时字体保留时限(秒),超时未使用自动删除,默认 10800(3小时)
- TEMP_RETENTION_SECONDS=10800
- # 子集化内存水位阈值(MB),RSS 超此值时排队等待,默认 600
- - SUBSET_MEM_SOFT_LIMIT_MB=600
+ # 字体裁剪最大并发数,默认 4(内存受限环境建议 2-3)
+ - SUBSET_CONCURRENCY=4
```
## API
diff --git a/backend/app.ts b/backend/app.ts
index 82c5a36..3e3da9e 100644
--- a/backend/app.ts
+++ b/backend/app.ts
@@ -14,7 +14,7 @@ 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 { subsetConcurrency } from "./config";
import "./server/node";
import "./server/llrt";
@@ -209,8 +209,8 @@ async function main() {
console.log("[config] temp upload:", enableTempUpload);
console.log("[config] admin upload:", !!adminApiKey);
- /** 初始化内存水位闸门(子集化排队控制) */
- initMemoryGate(subsetMemSoftLimitMB);
+ /** 初始化子集化并发队列(含字体分组调度) */
+ initMemoryGate(0, subsetConcurrency);
/** 启动临时字体定时清理器 */
startTempCleaner();
diff --git a/backend/config.ts b/backend/config.ts
index 578b13f..3e4e1b2 100644
--- a/backend/config.ts
+++ b/backend/config.ts
@@ -22,16 +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)
+ * 字体子集化最大并发数
*
- * RSS 超过此值时,新的子集化请求排队等待,
- * 直到前面的请求完成 + GC 释放内存后 RSS 回落。
- * 默认 600:容器限制 900M 时留 300M 余量给峰值。
+ * 字体裁剪是 CPU/内存密集操作,并发过多会导致 LLRT OOM 崩溃。
+ * 默认 4:在 900M 内存限制下安全运行。
+ * 内存充裕可调大,内存紧张可调小到 2。
*/
-export const subsetMemSoftLimitMB = parseInt(env.SUBSET_MEM_SOFT_LIMIT_MB ?? "600", 10) || 600;
+export const subsetConcurrency = Math.max(1, parseInt(env.SUBSET_CONCURRENCY ?? "4", 10) || 4);
/**
- * 队列等待超时(秒)—— 排队超过此时间返回 503,客户端可重试
+ * 队列等待超时(秒)—— 排队超过此时间返回 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 9d21942..e630de1 100644
--- a/backend/routes/config.ts
+++ b/backend/routes/config.ts
@@ -1,5 +1,5 @@
import { jsonResponse } from "../shared";
-import { enableTempUpload, adminApiKey, tempRetentionSeconds, subsetMemSoftLimitMB, subsetQueueTimeoutSeconds } from "../config";
+import { enableTempUpload, adminApiKey, tempRetentionSeconds, subsetConcurrency, 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,
- /** 子集化内存水位阈值(MB),RSS 超此值时排队 */
- subsetMemSoftLimitMB,
+ /** 字体子集化最大并发数 */
+ subsetConcurrency,
/** 队列等待超时(秒) */
subsetQueueTimeoutSeconds,
}),
diff --git a/backend/routes/subset.ts b/backend/routes/subset.ts
index 013ddce..ba46a38 100644
--- a/backend/routes/subset.ts
+++ b/backend/routes/subset.ts
@@ -3,7 +3,7 @@ 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 { withMemoryGate } from "../subset_queue";
-import { subsetMemSoftLimitMB, subsetQueueTimeoutSeconds } from "../config";
+import { subsetConcurrency, subsetQueueTimeoutSeconds } from "../config";
/**
* 进程启动时戳(模块加载时取一次,进程重启即变化)
@@ -116,7 +116,7 @@ export async function handleFontSubset(req: Request, res: Response) {
* 缓存未命中的请求才进入闸门;RSS 超 softLimit 时排队等待,
* 前面请求完成 + GC 释放内存后 RSS 回落才执行。避免 OOM 崩溃。
*/
- const subsetResult = await withMemoryGate(subsetMemSoftLimitMB, async () => {
+ const subsetResult = await withMemoryGate(subsetConcurrency, async () => {
return fontSubset(oldFontBuffer, text, {
outType: outType,
sourceType: fontType,
diff --git a/backend/subset_queue.ts b/backend/subset_queue.ts
index 175921b..88a826e 100644
--- a/backend/subset_queue.ts
+++ b/backend/subset_queue.ts
@@ -1,107 +1,58 @@
/**
- * 字体子集化内存水位闸门
+ * 字体子集化并发队列(带字体分组调度)
*
* LLRT 运行时内存受限(~900M),字体裁剪是内存密集操作。
- * 不用固定并发数,而是实时监控进程 RSS:
- * - RSS < softLimit:直接执行(小请求可高并发)
- * - RSS ≥ softLimit:排队等待,直到前面的请求完成 + GC 释放内存
- * - 无硬限制/拒绝:所有请求最终都会执行
+ * 通过固定并发数限制同时执行的子集化任务,防止 OOM 崩溃。
*
- * 内存监控通过 /proc/self/statm(Linux 唯一可用方式,LLRT 无 process.memoryUsage)。
- * GC 通过 LLRT 内置的 __gc() 主动触发(请求完成后调用,加速内存回收)。
+ * 分组优化:排队中的请求按字体(groupKey)分组,同字体的请求优先连续处理,
+ * 使字体 buffer / 解析对象在缓存窗口内被下一个请求复用,降低内存峰值。
*/
-/** /proc/self/statm 文件描述符(启动时打开,反复读取不需每次 open/close) */
-let statmFd: number | null = null;
-
-/**
- * 读取当前进程 RSS(常驻内存),单位 MB
- *
- * Node 环境使用 process.memoryUsage().rss;LLRT 无此 API,改用 /proc/self/statm。
- * /proc/self/statm 格式:size resident shared text lib data dt(单位:页)
- * resident 字段 × 页大小(4096) = RSS 字节数。
- */
-function getRssMB(): number {
- try {
- /** Node 环境优先使用 process.memoryUsage() */
- if (typeof process !== "undefined" && process.memoryUsage) {
- return Math.round(process.memoryUsage().rss / 1024 / 1024);
- }
- /** LLRT 环境:读取 /proc/self/statm */
- if (statmFd === null) {
- const { openSync } = require("fs");
- statmFd = openSync("/proc/self/statm", "r");
- }
- 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;
- }
-}
-
-/**
- * 主动触发垃圾回收
- *
- * LLRT 内置 __gc(),请求完成后调用可立即释放字体解析产生的大对象,
- * 而非等待 LLRT 引擎自动 GC(可能延迟数秒)。
- */
-function gc(): void {
- try {
- (globalThis as any).__gc?.();
- } catch {
- /** __gc 不存在(Node 环境)时静默跳过 */
- }
-}
-
/** 等待队列项:resolver + groupKey(用于同字体分组连续唤醒) */
interface QueueItem {
- /** 唤醒函数 */
resolve: () => void;
/** 分组键(通常为 fontPath),同组优先连续处理以复用字体缓存 */
groupKey: string;
}
-/** 等待队列:checkAndNotify 按分组优先级唤醒 */
+/** 等待队列:release 按分组优先级唤醒 */
const waitQueue: QueueItem[] = [];
-/** 当前正在执行的子集化数量(用于 stats 展示) */
+/** 当前正在执行的子集化数量 */
let activeCount = 0;
+/** 最大并发数(由 initConcurrency 设置) */
+let maxConcurrency = 4;
+
/** 最近一次执行的 groupKey——同组请求优先连续唤醒 */
let lastGroupKey = "";
-/** softLimit 引用(checkAndNotify 需要用) */
-let softLimitRef = 0;
-
/**
- * 通过内存水位闸门执行子集化任务
+ * 通过并发队列执行子集化任务
*
- * - RSS 未超 softLimit → 立即执行
- * - RSS 超 softLimit → 排队等待,前面的请求完成后 GC → RSS 回落 → 唤醒
+ * - active < maxConcurrency → 立即执行
+ * - active ≥ maxConcurrency → 排队等待,前面的完成后唤醒(同 groupKey 优先)
* - 队列超时 → 返回 null(调用方返回 503,客户端可重试)
*
* 分组优化:同 groupKey(同一字体)的排队请求优先连续唤醒,
- * 使字体 buffer / 解析对象在 GC 窗口内被下一个请求复用,降低内存峰值。
+ * 使字体 buffer / 解析对象在缓存窗口内被下一个请求复用,降低内存峰值。
*
- * @param softLimitMB 内存软限制(MB),RSS 超过此值时新请求排队
+ * 函数名保留 withMemoryGate 以减少调用方改动(subset.ts 等)。
+ *
+ * @param _softLimitMB 废弃保留(兼容签名),不再使用
* @param task 实际的子集化异步任务
* @param queueTimeoutMs 排队超时(毫秒)
* @param groupKey 分组键(通常为 fontPath),同组连续处理以复用缓存
* @returns 任务结果,或 null 表示排队超时
*/
export async function withMemoryGate(
- softLimitMB: number,
+ _softLimitMB: number,
task: () => Promise,
queueTimeoutMs: number,
groupKey = "",
): Promise {
- /** RSS=0 表示无法读取(开发环境),跳过闸门直接执行 */
- if (softLimitMB > 0 && getRssMB() >= softLimitMB) {
- /** 内存超阈值,进入排队 */
+ /** 并发已满,进入排队 */
+ if (activeCount >= maxConcurrency) {
const acquired = await waitForSlot(queueTimeoutMs, groupKey);
if (!acquired) return null;
}
@@ -112,17 +63,12 @@ export async function withMemoryGate(
return await task();
} finally {
activeCount--;
- /**
- * 任务完成后主动 GC,加速释放字体解析的大对象。
- * 然后检查队列:如果 RSS 已回落,唤醒下一个等待者(同 groupKey 优先)。
- */
- gc();
- checkAndNotify();
+ release();
}
}
/**
- * 排队等待内存释放
+ * 排队等待并发槽位
*
* 加入队列,等待前面的请求完成后唤醒(同 groupKey 优先)。
* 超时则从队列移除自己,返回 false。
@@ -138,7 +84,6 @@ function waitForSlot(timeoutMs: number, groupKey: string): Promise {
if (idx !== -1) waitQueue.splice(idx, 1);
resolve(false);
}, timeoutMs);
- /** 覆盖 resolve 以便唤醒时清 timer */
item.resolve = () => {
clearTimeout(timer);
resolve(true);
@@ -163,50 +108,27 @@ function pickNext(): QueueItem | undefined {
}
/**
- * 检查内存并唤醒队列
- *
- * 每个子集化任务完成后调用:
- * 1. 如果 RSS < softLimit 且队列非空 → 唤醒下一个(同 groupKey 优先)
- * 2. RSS 仍超限 → 不唤醒(等待引擎自动 GC 后下次再检查)
- * 3. 兜底:5 秒后如果 RSS 没超 softLimit×1.2,强制唤醒(防饿死)
+ * 释放一个并发槽位,唤醒下一个等待者(同 groupKey 优先)
*/
-function checkAndNotify(): void {
+function release(): void {
if (waitQueue.length === 0) return;
- /** RSS 未知(开发环境)→ 直接唤醒 */
- if (softLimitRef === 0) {
+ if (activeCount < maxConcurrency) {
pickNext()?.resolve();
- return;
}
- /** RSS 已回落到阈值以下 → 唤醒下一个 */
- if (getRssMB() < softLimitRef) {
- pickNext()?.resolve();
- /** 唤醒后递归检查:可能还有内存余量给更多等待者 */
- checkAndNotify();
- return;
- }
- /**
- * RSS 仍超限时不立即唤醒——设置兜底定时器:
- * 5 秒后如果 RSS 降到 softLimit×1.2 以内,唤醒一个(防极端饿死)。
- */
- setTimeout(() => {
- if (waitQueue.length > 0 && getRssMB() < softLimitRef * 1.2) {
- pickNext()?.resolve();
- }
- }, 5000);
}
/**
- * 初始化闸门参数(由 config.ts 的值设置)
+ * 初始化并发参数(由 config 设置)
*
* 必须在第一次 withMemoryGate 调用前执行。
- * @param softLimitMB 内存软限制(MB)
+ * @param _softLimitMB 废弃(兼容签名)
+ * @param concurrency 最大并发数
*/
-export function initMemoryGate(softLimitMB: number): void {
- softLimitRef = softLimitMB;
- const rss = getRssMB();
- if (rss > 0) {
- console.log(`[memgate] RSS=${rss}MB, softLimit=${softLimitMB}MB`);
+export function initMemoryGate(_softLimitMB: number, concurrency?: number): void {
+ if (concurrency && concurrency > 0) {
+ maxConcurrency = concurrency;
}
+ console.log(`[subset-queue] maxConcurrency=${maxConcurrency}`);
}
/** 获取当前队列状态(用于 stats / 日志) */
@@ -214,6 +136,5 @@ export function getQueueStats() {
return {
active: activeCount,
waiting: waitQueue.length,
- rssMB: getRssMB(),
};
}
diff --git a/src/FontSelector.vue b/src/FontSelector.vue
index 1575c4a..afe4425 100644
--- a/src/FontSelector.vue
+++ b/src/FontSelector.vue
@@ -43,6 +43,9 @@ const filteredFonts = computed(() => {
});
});
+/** 当前选中的字体对象(用于判断是否临时字体) */
+const selectedFontInfo = computed(() => props.fonts.find((f) => f.name === props.selectedFont));
+
/** 当前选中字体的显示名(无选中时显示占位文字) */
const selectedLabel = computed(() => props.selectedFont || t("pleaseSelect"));
@@ -135,8 +138,12 @@ function handleOutTypeChange(e: Event) {
style="width: 100%; border: none; outline: none; font-size: 14px; background: transparent; padding: 0; color: #000"
/>
-
- {{ selectedLabel }}
+
+
+ 临时