Files
AIclinicalresearch/docs/06-测试文档/故障分析报告 (1).md
HaHafeng bbf98c4d5c fix(backend): Resolve PgBoss infinite loop issue and cleanup unused files
Backend fixes:
- Fix PgBoss task infinite loop on SAE (root cause: missing queue table constraints)
- Add singletonKey to prevent duplicate job enqueueing
- Add idempotency check in reviewWorker (skip completed tasks)
- Add optimistic locking in reviewService (atomic status update)

Frontend fixes:
- Add isSubmitting state to prevent duplicate submissions in RVW Dashboard
- Fix API baseURL in knowledgeBaseApi (relative path)

Cleanup (removed):
- Old frontend/ directory (migrated to frontend-v2)
- python-microservice/ (unused, replaced by extraction_service)
- Root package.json and node_modules (accidentally created)
- redcap-docker-dev/ (external dependency)
- Various temporary files and outdated docs in root

New documentation:
- docs/07-运维文档/01-PgBoss队列监控与维护.md
- docs/07-运维文档/02-故障预防检查清单.md
- docs/07-运维文档/03-数据库迁移注意事项.md

Database fix applied to RDS:
- Added PRIMARY KEY to platform_schema.queue
- Added 3 missing foreign key constraints

Tested: Local build passed, RDS constraints verified
2026-01-27 18:16:22 +08:00

7.6 KiB
Raw Blame History

PG Boss 任务重复故障分析与修复方案

1. 故障核心分析 (Root Cause Analysis) - 修正版

针对 "同一 TaskID 被创建 7 次" 且 "创建时间在同一毫秒" 的现象,在确认 仅有 1 个 SAE 实例 运行的情况下,我们排除了多实例并发的可能性。

结合已清理的 "rvw_review_task 有 7 个重复条目" 这一关键证据,我们得出了确切的结论:

核心根因:持久化配置重复 (Persisted Configuration Duplication)

问题不在于有多少个实例在跑,而在于数据库里存了多少份重复的指令。

💡 深度解析:为什么会有 7 个?(7 个闹钟的比喻)

你的疑问是:"每次处理应该是生成不同的任务ID不可能是重复的对吗"

答案是的pg-boss 生成了 7 个完全不同的 Job ID但它们都在做同一件事。

这就好比你为了早上 7 点起床,设置了 7 个闹钟

  1. 指令 (Schedules/Definitions):数据库里那些被清理的 "7 个重复条目",就像是 7 个闹钟配置。它们都设定在同一个触发条件下(比如 Cron 表达式,或系统启动时)。
  2. 触发 (Trigger):当时间到了,或者系统启动扫描时,这 7 个闹钟同时响了
  3. 执行 (Jobs):系统听到第 1 个闹钟,创建了 Job A听到第 2 个闹钟,创建了 Job B... 直到 Job G。
    • 结果:你在一毫秒内,被叫醒了 7 次。
    • 数据:这 7 个 Job 都有不同的 UUID(符合数据库约束),但它们的**内容Payload**全是 "处理 Task bd19c3d3"。

这就是为什么你在数据库里看到 created_on 完全一致,但 Job ID 不同。因为那 1 个 SAE 实例在极短的时间内,忠实地执行了数据库里残留的 7 条指令。

  • 机制解析pg-boss 是一个基于数据库的任务队列。它的调度Schedules和某些队列配置是持久化在 PostgreSQL 数据库中的(通常在 pgboss.schedule 表中)。
  • 故障复盘
    1. 积累阶段:在过去的历史部署或重启中,代码可能在启动时调用了 boss.schedule('queue', 'cron')。由于没有加去重逻辑,每次部署都在数据库里新增了一条调度记录,而不是更新旧的。日积月累,数据库里就有了 7 条完全一样的调度记录。
    2. 爆发阶段:当你当前的 1 个 SAE 实例 运行时pg-boss 内部的轮询器扫描数据库,读取到了这 7 条重复的记录。
    3. 瞬间执行:当触发条件满足,这单个实例在极短的 CPU 周期内,为这 7 条记录分别生成了一个 Job。
  • 证据链闭环
    • 7 次重复 对应 7 个重复的 Schedule/配置记录
    • 同一毫秒创建 对应 单实例在一次事件循环中连续处理了这 7 条指令

结论:你执行的 "清理了 32 个重复的队列定义" 操作,实际上就是关掉了多余的 6 个闹钟,这已经移除了问题的根源。

为什么 SingletonKey 之前没生效?

虽然这是单实例产生的重复,但如果代码使用的是 insert 或者是没有严格 unique constraint 保护的 send在极快的循环中Event Loop数据库可能仍未完成第一条的提交第二条就来了。

但最可能的原因是:生成 Key 的逻辑有问题,或者根本没有在产生任务的那段特定逻辑中加上 singletonKey。

2. 解决方案:三层防御体系

虽然根因(重复配置)已被你清理,但为了防止未来代码逻辑再次意外引入重复配置,或者防止前端意外的连击,我们依然强烈建议保留以下防御措施。

第一层:入队时防御 (生产者层面 - 强制去重)

这是最关键的一步。无论是因为配置重复导致被调用 7 次,还是前端点了 7 次,这里都能拦住。

修改代码建议 (Producer/Service):

// reviewTaskProducer.ts

import { PgBoss } from 'pg-boss';

// 假设这是你的入队逻辑
export async function createReviewTask(boss: PgBoss, taskId: string, payload: any) {
const queueName = 'rvw_review_task';

// 核心修复:构造确定性的 singletonKey
// 不要包含时间戳等变量,只包含业务唯一标识 (如 taskId)
const singletonKey = `review_task_${taskId}`;

// 发送任务
const jobId = await boss.send(queueName, payload, {
// 启用单例模式
singletonKey: singletonKey,
// 节流/防抖如果任务已存在且活跃300秒内不再创建
singletonSeconds: 300,
// 即使旧任务完成了保留Key一段时间以防重复触发
singletonNextSlot: false
});

if (!jobId) {
console.warn(`[Duplicate Prevented] Task ${taskId} already exists in queue.`);
return null;
}

return jobId;
}

第二层:处理时防御 (Worker 层面 - 幂等性检查)

你已经添加了状态检查,这很好。为了处理潜在的竞争(虽然单实例下竞争少,但为了健壮性),建议保持乐观锁逻辑。

修改代码建议 (Worker):

// reviewWorker.ts

export async function processReviewTask(job: Job) {
const { taskId } = job.data;

// 1. 业务状态检查 (你已经做了)
const task = await db.task.findUnique({ where: { id: taskId } });

// 状态检查:如果已经是处理中或完成,直接跳过
if (task.status === 'COMPLETED' || task.status === 'PROCESSING') {
console.log(`[Skipped] Task ${taskId} is already ${task.status}`);
return;
}

// 2. 乐观锁更新 (Database Atomic Update)
const updateResult = await db.task.updateMany({
where: {
id: taskId,
status: 'PENDING' // 👈 关键:只有当前状态是 PENDING 时才更新
},
data: {
status: 'PROCESSING',
startedAt: new Date()
}
});

if (updateResult.count === 0) {
console.log(`[Concurrency Control] Task ${taskId} claimed by another worker or logic.`);
return;
}

// 3. 执行逻辑
try {
await performReviewLogic(taskId);
await db.task.update({ where: { id: taskId }, data: { status: 'COMPLETED' }});
} catch (error) {
await db.task.update({ where: { id: taskId }, data: { status: 'FAILED' }});
throw error;
}
}

第三层:初始化代码审查 (防止复发)

针对 "32 个重复队列定义" 的来源,需要检查你的启动代码。

  1. 检查 schedule 调用
    如果你在代码里使用了 boss.schedule('queue', cron, ...),请确保不要在每次应用启动时都无脑调用。
    • 错误做法:在 main.ts 直接调用 boss.schedule(...)。每次部署都会尝试再加一个(取决于 pg-boss 版本行为)。
    • 正确做法:通常 pg-boss 会处理去重,但如果参数稍有不同(比如 cron 表达式或数据),它可能会视为新 Schedules。建议检查 pg-boss 的 schedules 表,确保没有垃圾数据。
  2. 清理脚本
    保留一个数据库迁移脚本或运维 SQL定期检查 pg-boss 的 job 表中是否有异常激增的 created 状态的任务。

3. 总结

  • 问题原因数据库中残留的历史重复配置7个重复条目导致单实例在循环中瞬间创建了 7 个任务。
  • 当前状态:你清理了重复条目,这已经解决了根源。
  • 未来保障:部署带有 singletonKey 的代码,这将是永远的防线,即使数据库里有 100 个重复配置pg-boss 也会拒绝创建第 2 到 第 100 个任务。