Flows 与任务依赖
有些工作不是一个任务,而是一棵依赖树。例如生成报告需要先并行读取销售、库存和退款数据,所有子任务成功后,父任务才能汇总。BullMQ 的 Flow 使用父子任务表达这种依赖。
Flow 的基本语义
父任务初始处于 waiting-children,所有必需子任务成功后才进入 waiting 并被 Worker 获取。父子任务可以位于不同队列,整个 Flow 由 FlowProducer 原子添加。
import { FlowProducer } from "bullmq";
interface ReportFlowData {
reportId: string;
source?: "sales" | "inventory" | "refunds";
}
const flowProducer = new FlowProducer({
connection: { host: "127.0.0.1", port: 6379 },
});
const flow = await flowProducer.add({
name: "aggregate-report",
queueName: "report-parent",
data: { reportId: "report-42" } satisfies ReportFlowData,
children: [
{
name: "load-source",
queueName: "report-source",
data: {
reportId: "report-42",
source: "sales",
} satisfies ReportFlowData,
},
{
name: "load-source",
queueName: "report-source",
data: {
reportId: "report-42",
source: "inventory",
} satisfies ReportFlowData,
},
],
});
console.log(flow.job.id);
Flow 中的自定义 jobId 不能包含冒号 :,因为冒号被 BullMQ 用作内部键分隔符。
子任务返回结果,父任务汇总
import { Job, Worker } from "bullmq";
interface SourceJobData {
reportId: string;
source: "sales" | "inventory" | "refunds";
}
interface SourceResult {
count: number;
}
const sourceWorker = new Worker<SourceJobData, SourceResult>(
"report-source",
async (job): Promise<SourceResult> => {
return { count: await loadCount(job.data.source) };
},
{ connection: { host: "127.0.0.1", port: 6379 } },
);
const parentWorker = new Worker<ReportFlowData, number>(
"report-parent",
async (job: Job<ReportFlowData, number>): Promise<number> => {
const childrenValues =
await job.getChildrenValues<SourceResult>();
const total = Object.values(childrenValues).reduce(
(sum, result) => sum + result.count,
0,
);
return total;
},
{ connection: { host: "127.0.0.1", port: 6379 } },
);
async function loadCount(
source: SourceJobData["source"],
): Promise<number> {
console.log(`读取 ${source}`);
return 100;
}
getChildrenValues() 返回以子任务完整键为 key 的结果对象。不要依赖对象遍历顺序来表达业务顺序。
更深层的依赖树
Flow 可嵌套任意深度,处理顺序从叶子向根部推进。例如 engine -> wheels -> chassis 的嵌套定义,实际会先处理最深的 chassis,再处理 wheels,最后处理 engine。
虽然 API 允许很深的树,但深度和节点数会增加排查、Redis 数据量和失败恢复的复杂度。初学阶段建议保持 2 到 3 层,把业务流程图画清楚后再编码。
查询依赖状态
父任务可查询依赖及数量:
const state = await flow.job.getState();
const counts = await flow.job.getDependenciesCount();
const firstPage = await flow.job.getDependencies({
processed: { count: 20, cursor: 0 },
unprocessed: { count: 20, cursor: 0 },
failed: { count: 20, cursor: 0 },
ignored: { count: 20, cursor: 0 },
});
console.log({ state, counts, firstPage });
大型 Flow 必须分页获取依赖,不要一次拉取全部子任务到内存。
子任务失败时的策略
默认情况下,父任务会等待依赖满足。实际业务应明确选择失败语义,并把选项配置在对应的子任务上。
1. 关键子任务失败,让父任务失败
{
name: "load-payment-data",
queueName: "report-source",
data: { reportId: "report-42", source: "sales" },
opts: { failParentOnFailure: true },
}
failParentOnFailure: true 会让父任务失败,并可沿树向上传播。官方说明该失败是惰性完成的:父任务需要被 Worker 处理后才转换到 failed,错误表现为 UnrecoverableError。
2. 可选子任务失败,忽略该依赖
{
name: "load-optional-data",
queueName: "report-source",
data: { reportId: "report-42", source: "refunds" },
opts: { ignoreDependencyOnFailure: true },
}
父任务可以继续,并通过 getIgnoredChildrenFailures() 查看被忽略的失败。适合非关键推荐、可选统计等允许降级的步骤。
3. 子任务失败后立即唤醒父任务
BullMQ 5.58.0 起提供 continueParentOnFailure: true。父任务可立即开始补偿或清理,不必等其他子任务结束:
const failedChildren = await job.getFailedChildrenValues();
if (Object.keys(failedChildren).length > 0) {
await job.removeUnprocessedChildren();
await recordFlowFailure(job.id ?? "unknown", failedChildren);
return;
}
removeUnprocessedChildren() 只移除尚未开始的 waiting 或 delayed 子任务,不会中止已经 active 的任务。
三种策略表达不同业务含义,不要同时随意叠加:
| 策略 | 含义 |
|---|---|
failParentOnFailure |
关键依赖失败,整条流程失败 |
ignoreDependencyOnFailure |
可选依赖失败,父任务继续 |
continueParentOnFailure |
失败后立刻让父任务执行补偿逻辑 |
删除 Flow 的注意事项
- 删除父任务会递归删除其子任务。
- 删除子任务会移除父任务对它的依赖;若它是最后一个依赖,父任务可能继续推进。
- 如果待删除范围内存在被 Worker 锁定的任务,删除会失败,且不会只删除一部分。
因此,生产环境删除 Flow 前应查询状态并记录操作,不要把删除当作随意的“取消按钮”。
复杂度控制与最佳实践
- 只有存在真实依赖时才使用 Flow;普通的多个独立任务直接
addBulk()更简单。 - 为每个节点定义清晰、较小且幂等的职责。
- 控制树的深度和扇出,超大任务分批创建,避免单次生成数十万节点。
- 任务数据只保存标识符,不在每个子节点复制大对象。
- 为每一种子任务配置明确的重试与失败策略。
- 父任务聚合时校验子结果,不假设所有返回值都完整可信。
- 不用 Flow 代替数据库事务;跨任务无法获得传统 ACID 事务语义。
- 为 flow 根任务保存业务关联 ID,方便日志、监控和人工恢复。
- 关闭应用时同时关闭
FlowProducer和所有 Worker。
本章小结
Flow 将任务组织成依赖树,父任务等待子任务成功后再处理。使用 FlowProducer 原子创建,父任务通过 getChildrenValues() 聚合结果。真正困难的是失败语义:关键依赖、可选依赖和补偿流程必须分别选择合适策略,同时控制树的规模并保证每个节点幂等。