diff --git a/README.en.md b/README.en.md index b76190e..1f3dfeb 100644 --- a/README.en.md +++ b/README.en.md @@ -125,6 +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: 2 + - SUBSET_CONCURRENCY=2 ``` ## API Reference diff --git a/README.md b/README.md index 244fe5b..37dff8c 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,8 @@ services: - SUBSET_CACHE_MAX_SIZE=10485760 # 临时字体保留时限(秒),超时未使用自动删除,默认 10800(3小时) - TEMP_RETENTION_SECONDS=10800 + # 字体裁剪最大并发数,默认 2(内存受限环境建议 1-3) + - SUBSET_CONCURRENCY=2 ``` ## API diff --git a/backend/config.ts b/backend/config.ts index dbb4f90..4673f2a 100644 --- a/backend/config.ts +++ b/backend/config.ts @@ -21,5 +21,14 @@ export const tempRetentionSeconds = parseInt(env.TEMP_RETENTION_SECONDS ?? "1080 /** 字体裁剪结果内存缓存容量上限(字节),默认 10MB */ export const subsetCacheMaxSize = parseInt(env.SUBSET_CACHE_MAX_SIZE ?? `${10 * 1024 * 1024}`, 10) || 10 * 1024 * 1024; +/** + * 字体子集化最大并发数 + * + * 字体裁剪是 CPU/内存密集操作,并发过多会导致 LLRT OOM 崩溃。 + * 默认 2:在 900M 内存限制下安全运行。 + * 服务器内存充足时可适当调大(如 4-8)。 + */ +export const subsetConcurrency = Math.max(1, parseInt(env.SUBSET_CONCURRENCY ?? "2", 10) || 2); + /** 字体搜索目录(按优先级排序:admin > 普通 > 临时) */ export const fontDirs = ["font/admin", "font", "font/temp"] as const; diff --git a/backend/routes/config.ts b/backend/routes/config.ts index f533bef..be3ee05 100644 --- a/backend/routes/config.ts +++ b/backend/routes/config.ts @@ -1,5 +1,5 @@ import { jsonResponse } from "../shared"; -import { enableTempUpload, adminApiKey, tempRetentionSeconds } from "../config"; +import { enableTempUpload, adminApiKey, tempRetentionSeconds, subsetConcurrency } from "../config"; /** GET /api/config — 返回公开配置 */ export async function handleGetConfig(req: Request, _res: Response) { @@ -11,6 +11,8 @@ export async function handleGetConfig(req: Request, _res: Response) { supportedOutTypes: ["woff2", "ttf"], /** 临时字体保留时限(秒) */ tempRetentionSeconds, + /** 字体子集化最大并发数 */ + subsetConcurrency, }), }; } diff --git a/backend/routes/subset.ts b/backend/routes/subset.ts index 8c6bd94..28e22f8 100644 --- a/backend/routes/subset.ts +++ b/backend/routes/subset.ts @@ -2,6 +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 } from "../config"; /** * 进程启动时戳(模块加载时取一次,进程重启即变化) @@ -108,10 +110,34 @@ export async function handleFontSubset(req: Request, res: Response) { /** readFontBuffer 结束时间戳(磁盘 IO / buffer 缓存命中) */ const t2 = Date.now(); - const newFont = await fontSubset(oldFontBuffer, text, { - outType: outType, - sourceType: fontType, + /** + * 实际子集化(CPU/内存密集)—— 通过并发队列控制 + * + * 缓存未命中的请求才进入队列;并发满时排队等待,超时返回 503。 + * 避免大字集 brotli 压缩同时执行导致 LLRT OOM 崩溃。 + */ + const subsetResult = await withConcurrencyLimit(subsetConcurrency, async () => { + return fontSubset(oldFontBuffer, text, { + outType: outType, + sourceType: fontType, + }); }); + + /** 排队超时,返回 503 让客户端重试 */ + if (subsetResult === null) { + return { + req, + res: new Response("Server busy, please retry", { + status: 503, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Retry-After": "1", + }, + }), + }; + } + + const newFont = subsetResult; /** fontSubset 结束时间戳(实际裁剪,亚毫秒级应在此体现) */ const t3 = Date.now(); diff --git a/backend/subset_queue.ts b/backend/subset_queue.ts new file mode 100644 index 0000000..d337fe6 --- /dev/null +++ b/backend/subset_queue.ts @@ -0,0 +1,114 @@ +/** + * 字体子集化并发队列控制器 + * + * 字体裁剪是 CPU/内存密集操作(尤其大字集 woff2 brotli 压缩), + * LLRT 运行时内存受限(~900M),并发过多会导致 brotli decoder OOM 崩溃。 + * + * 通过 Semaphore 限制同时执行的子集化数量,超出并发的请求排队等待, + * 等待超时则返回 503,避免请求无限堆积。 + */ + +/** 等待队列中的请求超时时间(毫秒),默认 10 秒 */ +const QUEUE_TIMEOUT_MS = 10_000; + +/** 当前正在执行的子集化数量 */ +let activeCount = 0; +/** 当前排队等待的数量 */ +let waitingCount = 0; + +/** + * 通过 Semaphore 执行子集化任务 + * + * - 并发未满时立即执行 + * - 并发已满时排队等待,超时返回 null(调用方应返回 503) + * - 返回任务结果,或 null 表示排队超时 + * + * @param maxConcurrency 最大并发数 + * @param task 实际的子集化异步任务 + * @returns 任务结果,或 null 表示排队超时 + */ +export async function withConcurrencyLimit( + maxConcurrency: number, + task: () => Promise, +): Promise { + /** 并发未满,直接执行 */ + if (activeCount < maxConcurrency) { + activeCount++; + try { + return await task(); + } finally { + activeCount--; + /** 唤醒一个等待者(如果有)—— 通过 resolve 触发 */ + notifyNext(); + } + } + + /** 并发已满,进入排队 */ + waitingCount++; + try { + /** 等待获取许可,或超时 */ + const acquired = await waitForSlot(QUEUE_TIMEOUT_MS); + if (!acquired) { + /** 排队超时,返回 null 让调用方返回 503 */ + return null; + } + activeCount++; + try { + return await task(); + } finally { + activeCount--; + notifyNext(); + } + } finally { + waitingCount--; + } +} + +/** 等待队列(FIFO) */ +const waitQueue: Array<() => void> = []; + +/** + * 等待获取一个并发许可 + * + * @param timeout 超时时间(毫秒) + * @returns true=获得许可,false=超时 + */ +function waitForSlot(timeout: number): Promise { + return new Promise((resolve) => { + /** 超时定时器 */ + const timer = setTimeout(() => { + /** 从队列中移除自己 */ + const idx = waitQueue.indexOf(resolver); + if (idx !== -1) waitQueue.splice(idx, 1); + resolve(false); + }, timeout); + + /** resolve 包装:清除定时器再 resolve */ + const resolver = () => { + clearTimeout(timer); + resolve(true); + }; + waitQueue.push(resolver); + }); +} + +/** + * 唤醒队列中下一个等待者 + * + * 在 activeCount 减少后调用,让排队中的请求依次进入。 + */ +function notifyNext() { + /** 仍有空位且有人在等 */ + const resolver = waitQueue.shift(); + if (resolver) { + resolver(); + } +} + +/** 获取当前队列状态(用于 stats / 日志) */ +export function getQueueStats() { + return { + active: activeCount, + waiting: waitingCount, + }; +} diff --git a/public/webfont-sdk.js b/public/webfont-sdk.js index 009e431..c0b703d 100644 --- a/public/webfont-sdk.js +++ b/public/webfont-sdk.js @@ -30,6 +30,55 @@ var WebFont = (function () { /** @type {Object., injectedStyles: Element[], applied: boolean, fontName: string, family: string, baseUrl: string }>} */ var loaders = {}; + /** + * 全局并发请求池 + * + * 字体 @font-face 注入 DOM 后浏览器立即发起请求。 + * 页面同时加载多种字体(如列表页预览)时,短时间内大量请求打满服务端子集化队列。 + * 通过排队控制同时挂载的 @font-face 数量,避免服务端过载。 + * + * 默认 4:在浏览器同域 6 并发限制内留出余量给其他资源。 + * 用户可通过 WebFont.setMaxConcurrent(n) 调整。 + */ + var maxConcurrent = 4; + /** 当前正在执行的字体加载任务数 */ + var activeFontLoads = 0; + /** 待执行的字体加载任务队列(FIFO) */ + var fontLoadQueue = []; + + /** + * 设置最大并发请求数 + * + * @param {number} n - 并发数,最小 1 + */ + function setMaxConcurrent(n) { + maxConcurrent = Math.max(1, n | 0); + } + + /** + * 通过并发池执行字体加载 + * + * @param {function} fn - 实际执行 loadChars 的函数 + */ + function enqueueFontLoad(fn) { + if (activeFontLoads < maxConcurrent) { + activeFontLoads++; + fn(doneFontLoad); + } else { + fontLoadQueue.push(fn); + } + } + + /** 一个加载完成,唤醒队列中下一个 */ + function doneFontLoad() { + activeFontLoads--; + if (fontLoadQueue.length > 0 && activeFontLoads < maxConcurrent) { + var next = fontLoadQueue.shift(); + activeFontLoads++; + next(doneFontLoad); + } + } + /** * 生成 fontKey,同一字体+family 归入同一组 */ @@ -94,34 +143,51 @@ var WebFont = (function () { /** * 差量加载新字符,生成 unicode-range CSS 并注入 + * + * 通过并发队列控制:同时挂载的 @font-face 不超过 maxConcurrent, + * 避免页面同时加载大量字体时打满服务端子集化队列。 * @param {Object} loader - getLoader 返回的加载器对象 * @param {string[]} newChars - 待加载的新字符数组 */ function loadChars(loader, newChars) { if (newChars.length === 0) return; - var fontName = loader.fontName; - var family = loader.family; - var baseUrl = loader.baseUrl; - var loadedChars = loader.loadedChars; + enqueueFontLoad(function (done) { + var fontName = loader.fontName; + var family = loader.family; + var baseUrl = loader.baseUrl; + var loadedChars = loader.loadedChars; - var text = newChars.join(""); - var outType = loader.outType || "woff2"; - var url = baseUrl + "/api?font=" + encodeURIComponent(fontName) + "&text=" + encodeURIComponent(text) + "&outType=" + outType; - var formatStr = outType === "woff2" ? "woff2" : "truetype"; - var unicodeRanges = newChars - .map(function (c) { return "U+" + c.codePointAt(0).toString(16).padStart(4, "0"); }) - .join(", "); + var text = newChars.join(""); + var outType = loader.outType || "woff2"; + var url = baseUrl + "/api?font=" + encodeURIComponent(fontName) + "&text=" + encodeURIComponent(text) + "&outType=" + outType; + var formatStr = outType === "woff2" ? "woff2" : "truetype"; + var unicodeRanges = newChars + .map(function (c) { return "U+" + c.codePointAt(0).toString(16).padStart(4, "0"); }) + .join(", "); - var style = document.createElement("style"); - style.textContent = - '@font-face {\n' + - ' font-family: "' + family + '";\n' + - ' src: url("' + url + '") format("' + formatStr + '");\n' + - ' unicode-range: ' + unicodeRanges + ';\n' + - '}\n'; - document.head.appendChild(style); - loader.injectedStyles.push(style); + var style = document.createElement("style"); + style.textContent = + '@font-face {\n' + + ' font-family: "' + family + '";\n' + + ' src: url("' + url + '") format("' + formatStr + '");\n' + + ' unicode-range: ' + unicodeRanges + ';\n' + + '}\n'; + document.head.appendChild(style); + loader.injectedStyles.push(style); + + /** + * 释放并发槽位的策略: + * + * 优先用 FontFaceSet API 精确追踪字体加载完成; + * 不可用时退化为 setTimeout(3 秒兜底窗口,覆盖绝大多数裁剪+传输时间)。 + */ + if (document.fonts && document.fonts.load) { + document.fonts.load(outType === "woff2" ? "16px \"" + family + "\"" : "16px \"" + family + "\"").then(done, function () { done(); }); + } else { + setTimeout(done, 3000); + } + }); } /** @@ -424,6 +490,8 @@ var WebFont = (function () { loadFont: loadFont, observeFont: observeFont, loadText: loadText, - disposeAll: disposeAll + disposeAll: disposeAll, + /** 设置客户端最大并发字体请求数(默认 4) */ + setMaxConcurrent: setMaxConcurrent }; })(); diff --git a/src/api.ts b/src/api.ts index 0a08a83..39f1db2 100644 --- a/src/api.ts +++ b/src/api.ts @@ -11,6 +11,8 @@ export interface ServerConfig { supportedOutTypes: ("woff2" | "ttf")[]; /** 临时字体保留时限(秒) */ tempRetentionSeconds?: number; + /** 字体子集化最大并发数 */ + subsetConcurrency?: number; } export interface UploadResult {