Architecture transformation: - Replace Fan-out (Manager->Child->Last Child Wins) with Scatter+Aggregator pattern - API layer directly dispatches N independent jobs (no Manager) - Worker only writes its own Result row, never touches Task table (zero row-lock) - Aggregator polls groupBy for completion + zombie cleanup (replaces Sweeper) - Reduce red lines from 13 to 9, eliminate distributed complexity Documents updated (10 files): - 08-Tool3 main architecture doc: v2.0 rewrite (schema, Task 2.3/2.4, red lines, risks) - 08d-Code patterns: rewrite sections 4.1-4.6 (API dispatch, SingleWorker, Aggregator) - 08a-M1 sprint: rewrite M1-3 core (Worker+Aggregator), red lines, acceptance criteria - 08b-M2 sprint: simplify SSE (NOTIFY/LISTEN downgraded to P2 optional) - 08c-M3 sprint: milestone table wording update - New: Scatter+Polling Aggregator pattern guide v1.1 (Level 2 cookbook) - New: V2.0 architecture deep review and gap-fix report - Updated: ASL module status, system status, capability layer index Co-authored-by: Cursor <cursoragent@cursor.com>
5.0 KiB
🔍 散装与轮询架构级代码审查与优化报告
审查人: 资深架构师
审查对象: 《散装派发与轮询收口任务模式指南 v1.0》
总体评估: 🌟 极优 (Excellent)。极简、务实、高内聚低耦合。这是我目前见过的最适合中小规模技术团队处理大批量异步任务的架构模式。
审查结论: 核心逻辑 100% 成立。但在 API 防抖、轮询 QPS 优化及长队列超时上存在 3 个优化空间,建议在最终编码时微调。
🛡️ 优化点 1:API 幂等层的“伪安全” (Read-then-Write 漏洞)
❌ 审查发现(模式 1:API 层散装派发)
指南中在创建任务前,写了如下防抖逻辑:
if (idempotencyKey) {
const existing = await prisma.aslExtractionTask.findFirst({
where: { projectId, idempotencyKey },
});
if (existing) return reply.send({ success: true, taskId: existing.id });
}
const task = await prisma.aslExtractionTask.create({ ... });
隐患推演: 这依然是经典的 Read-then-Write。如果用户处于极端弱网环境(或鼠标连击),导致两个完全相同的 HTTP 请求在同一毫秒到达 Node.js,两个进程会同时执行 findFirst,同时发现为 null,然后同时执行 create。结果依然是创建了两个重复的任务并派发了 200 个 Job。
✅ 架构师修正方案:利用数据库唯一索引 (P2002)
真正的幂等必须下沉到数据库物理层。在 AslExtractionTask 表中为 idempotencyKey 添加 @unique 索引,然后利用 Prisma 捕获唯一键冲突异常:
// 优化后的 模式 1
let task;
try {
task = await prisma.aslExtractionTask.create({
data: { projectId, templateId, totalCount: docs.length, status: 'processing', idempotencyKey },
});
} catch (error) {
// Prisma 唯一键冲突错误码 P2002
if (error.code === 'P2002') {
const existing = await prisma.aslExtractionTask.findUnique({ where: { idempotencyKey } });
return reply.send({ success: true, taskId: existing.id, note: 'Idempotent return' });
}
throw error;
}
这样无论并发多高,物理上绝对不可能重复派发!
⚡ 优化点 2:前端轮询的“双倍 QPS”损耗
❌ 审查发现(模式 4:前端进度查询)
为了计算进度,您的代码同时发起了两次 count 查询:
const [successCount, failedCount] = await Promise.all([
prisma.aslExtractionResult.count({ where: { taskId, status: 'completed' } }),
prisma.aslExtractionResult.count({ where: { taskId, status: 'error' } }),
]);
隐患推演: 既然前端是每 3 秒轮询一次,如果有 10 个医生同时在线看着进度条,数据库每 3 秒就要承受 10 * 2 = 20 次 count 查询。这虽然用上了索引,但其实可以压缩一半。
✅ 架构师修正方案:复用 Aggregator 的 GROUP BY 绝技
就像您在 Aggregator 里做的那样,一次查出所有状态分布,数据库引擎扫描一次即可:
// 优化后的 模式 4
const stats = await prisma.aslExtractionResult.groupBy({
by: ['status'],
where: { taskId },
_count: true
});
const successCount = stats.find(s => s.status === 'completed')?._count || 0;
const failedCount = stats.find(s => s.status === 'error')?._count || 0;
查询请求量直接减半,极致榨取 DB 性能。
⏳ 优化点 3:pg-boss 排队超时陷阱 (expireInMinutes)
❌ 审查发现(模式 1:Job 派发设置)
指南中配置了:
options: {
expireInMinutes: 30, // 👈 潜在陷阱
}
隐患推演: 如果用户发起了 1000 篇文献的提取,而 teamConcurrency: 10。这意味这 1000 篇文献必须排队慢慢跑。假设单篇耗时 1 分钟,整个队列跑完需要 100 分钟。
如果这里的 expireInMinutes 被 pg-boss 解释为“从入队到执行完毕的总寿命(TTL)”,那么排在后面的 700 篇文献,可能会在还在排队(状态 pending)时,就被系统强制丢弃/过期!
✅ 架构师修正方案:区分排队时间与执行时间
在 pg-boss 中:
- expireInSeconds / expireInMinutes: 是指一个 Job 被 Worker 抓取后(执行中) 的最长允许耗时(防僵尸)。
- retentionDays: 才是它允许待在队列里的总寿命。
确保开发人员理解:这里的 30 分钟是**“单篇文献提取时如果卡住,最多卡 30 分钟”**,而不是这 100 篇文献必须在 30 分钟内跑完。只要配置正确,即使任务排队 5 个小时也是绝对安全的。
🏁 架构师最终结论
完美放行!(Approved for Production)
这套指南里的:
- 幽灵重试守卫(巧妙利用 count === 0 截断)
- 僵尸清理收口合一(Aggregator 顺手做清理)
充分证明了你们团队已经彻底吃透了分布式系统的状态流转精髓。
请直接将这份架构作为底座,去尽情修改和重构工具 3 的开发计划吧! 期待看到这份既轻量又极速的新版计划!