Просмотр исходного кода

fix: 章节音频合并加全局串行信号量(并发=1),防止合并风暴压垮服务器

仅加章节级锁还不够: resumeInterruptedTasks+AudioScanner 会把多本书/多章节
的合并一次性全推出去, N 个 ffmpeg 并发把 load 飙到 48+.

加全局合并信号量 MAX_CONCURRENT_MERGE=1, 多章节就绪时排队串行合并.
取槽后复查 audioUrl 幂等, 避免排队期间已被合并的重复执行.
合并非延迟敏感, 串行可彻底消除 CPU/磁盘风暴.
MyFramework User 2 месяцев назад
Родитель
Сommit
fa245faf1e
1 измененных файлов с 46 добавлено и 5 удалено
  1. 46 5
      server/src/modules/book-generator/book-generator.store.ts

+ 46 - 5
server/src/modules/book-generator/book-generator.store.ts

@@ -159,6 +159,34 @@ function computeContentHash(content: string): string {
 // 造成 ffmpeg 进程堆积、CPU 打满、临时文件撑爆磁盘。
 const mergingChapters = new Set<number>();
 
+// 全局合并并发上限:同一时刻最多跑 1 个章节合并。
+// 即使多本书/多章节同时就绪,也排队串行执行,避免 N 个 ffmpeg 并发把
+// 服务器 load 推爆(曾观测到 load 飙到 48+)。合并非延迟敏感,串行即可。
+let mergeRunning = 0;
+const mergeWaitQueue: Array<() => void> = [];
+const MAX_CONCURRENT_MERGE = 1;
+
+async function acquireMergeSlot(): Promise<() => void> {
+  if (mergeRunning < MAX_CONCURRENT_MERGE) {
+    mergeRunning++;
+    return () => {
+      mergeRunning--;
+      const next = mergeWaitQueue.shift();
+      if (next) next();
+    };
+  }
+  return new Promise<() => void>((resolve) => {
+    mergeWaitQueue.push(() => {
+      mergeRunning++;
+      resolve(() => {
+        mergeRunning--;
+        const next = mergeWaitQueue.shift();
+        if (next) next();
+      });
+    });
+  });
+}
+
 export async function tryAutoMerge(
   completedLeafId: number,
   bookId: number | null,
@@ -272,17 +300,29 @@ export async function tryAutoMerge(
       }
     }
 
-    // 6. 触发合并(加内存锁,防止同一章节并发重复合并
+    // 6. 触发合并(章节级内存锁防重复 + 全局信号量限并发
     if (mergingChapters.has(chapterId)) {
-      // 该章节已有合并任务在进行中,跳过本次触发
+      // 该章节已有合并任务在进行中/排队中,跳过本次触发
       return;
     }
     mergingChapters.add(chapterId);
-    console.log(
-      `[AutoMerge] 🎵 章节${chapterId}下所有${leafNodesUnderChapter.length}个叶节点音频已就绪,开始自动合并...`,
-    );
 
+    // 全局串行:同时只跑 1 个合并,其余排队,避免并发 ffmpeg 压垮服务器
+    const releaseSlot = await acquireMergeSlot();
     try {
+      // 取到槽位后复查一次:可能在排队期间该章节已被其它流程合并完成
+      const fresh = await prisma.bookChapter.findUnique({
+        where: { id: chapterId },
+        select: { audioUrl: true },
+      });
+      if (fresh?.audioUrl && fresh.audioUrl.includes('_merged')) {
+        console.log(`[AutoMerge] 章节${chapterId}已被合并,跳过`);
+        return;
+      }
+
+      console.log(
+        `[AutoMerge] 🎵 章节${chapterId}下所有${leafNodesUnderChapter.length}个叶节点音频已就绪,开始自动合并...`,
+      );
       const mergedUrl = await mergeChapterAudios(chapterId);
       if (mergedUrl) {
         console.log(`[AutoMerge] ✅ 章节${chapterId}音频自动合并完成: ${mergedUrl}`);
@@ -290,6 +330,7 @@ export async function tryAutoMerge(
         console.warn(`[AutoMerge] ⚠️ 章节${chapterId}合并返回空结果`);
       }
     } finally {
+      releaseSlot();
       mergingChapters.delete(chapterId);
     }
   } catch (err) {