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 }} + + {{ selectedLabel }} + 临时 @@ -161,11 +168,15 @@ function handleOutTypeChange(e: Event) { :key="f.name" @click="selectFont(f.name)" @mouseenter="($event.currentTarget as HTMLElement).style.background = '#f5f5f5'" - @mouseleave="($event.currentTarget as HTMLElement).style.background = '#fff'" - style="padding: 8px 12px; font-size: 14px; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap" + @mouseleave="($event.currentTarget as HTMLElement).style.background = f.name === selectedFont ? '#e6f4ff' : '#fff'" + style="padding: 8px 12px; font-size: 14px; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 6px" :style="{ background: f.name === selectedFont ? '#e6f4ff' : '#fff', color: f.name === selectedFont ? '#1677ff' : '#333', fontWeight: f.name === selectedFont ? '500' : 'normal' }" > - {{ f.name }} + {{ f.name }} + 临时 diff --git a/src/api.ts b/src/api.ts index 7c5bfbe..695adaf 100644 --- a/src/api.ts +++ b/src/api.ts @@ -11,8 +11,8 @@ export interface ServerConfig { supportedOutTypes: ("woff2" | "ttf")[]; /** 临时字体保留时限(秒) */ tempRetentionSeconds?: number; - /** 子集化内存水位阈值(MB),RSS 超此值时排队 */ - subsetMemSoftLimitMB?: number; + /** 字体子集化最大并发数 */ + subsetConcurrency?: number; /** 队列等待超时(秒) */ subsetQueueTimeoutSeconds?: number; } diff --git a/src/components/CodeBlock.vue b/src/components/CodeBlock.vue new file mode 100644 index 0000000..96b1825 --- /dev/null +++ b/src/components/CodeBlock.vue @@ -0,0 +1,306 @@ + + + diff --git a/src/pages/Home.vue b/src/pages/Home.vue index bcec730..989be52 100644 --- a/src/pages/Home.vue +++ b/src/pages/Home.vue @@ -35,6 +35,7 @@ import UploadSection from "../UploadSection.vue"; import StatsPanel from "../StatsPanel.vue"; import SelectorRow from "../FontSelector.vue"; import FontDebugPreview from "../FontDebugPreview.vue"; +import CodeBlock from "../components/CodeBlock.vue"; const text = ref("天地无极,乾坤借法"); const fonts = ref([]); @@ -102,6 +103,16 @@ const cssStyle = computed(() => { }`; }); +/** 基础用法代码示例(依赖 origin,需 computed) */ +const basicUsageCode = computed(() => { + return '\n

\u4f60\u7684\u6587\u5b57

'; +}); + +/** JS SDK 代码示例 */ +const jsSdkCode = computed(() => { + return '