feat: 字体子集化并发队列控制(服务端+客户端)

后端:
- subset_queue.ts: Semaphore 并发控制器,排队超时返回 503
- subset.ts: fontSubset 调用包裹在 withConcurrencyLimit 中
- config.ts: 新增 SUBSET_CONCURRENCY 环境变量(默认 2)
- routes/config.ts: 暴露 subsetConcurrency 到配置 API

前端:
- api.ts: ServerConfig 新增 subsetConcurrency 字段
- webfont-sdk.js: 客户端并发池(默认 4),setMaxConcurrent API
  通过 document.fonts.load 精确追踪字体加载完成释放槽位
This commit is contained in:
崮生(子虚) 2026-07-30 21:33:45 +08:00
parent abd75985b7
commit 65ba984972
8 changed files with 250 additions and 25 deletions

View File

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

View File

@ -125,6 +125,8 @@ services:
- SUBSET_CACHE_MAX_SIZE=10485760
# 临时字体保留时限(秒),超时未使用自动删除,默认 108003小时
- TEMP_RETENTION_SECONDS=10800
# 字体裁剪最大并发数,默认 2内存受限环境建议 1-3
- SUBSET_CONCURRENCY=2
```
## API

View File

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

View File

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

View File

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

114
backend/subset_queue.ts Normal file
View File

@ -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<T>(
maxConcurrency: number,
task: () => Promise<T>,
): Promise<T | null> {
/** 并发未满,直接执行 */
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<boolean> {
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,
};
}

View File

@ -30,6 +30,55 @@ var WebFont = (function () {
/** @type {Object.<string, { loadedChars: Object.<string,boolean>, 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 精确追踪字体加载完成
* 不可用时退化为 setTimeout3 秒兜底窗口覆盖绝大多数裁剪+传输时间
*/
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
};
})();

View File

@ -11,6 +11,8 @@ export interface ServerConfig {
supportedOutTypes: ("woff2" | "ttf")[];
/** 临时字体保留时限(秒) */
tempRetentionSeconds?: number;
/** 字体子集化最大并发数 */
subsetConcurrency?: number;
}
export interface UploadResult {