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

148 lines
7.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# **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 个任务。