故障档案 FA-024

同一批活儿,扣了两遍钱

点「批量生视频」把 10 张已经出好的分镜图整套重跑并再次扣费。两处独立成因:前端把祖先闭包当目标传给后端,后端落库的行 ID 与唯一约束不一致导致重复派发。

STRUCTURE ── 篇幅剖面6 节 · 点击跳转

用户点「批量生视频」,10 张已经出好的分镜图被整套重跑,再扣一遍钱。

生产数据核实过:两次运行的 image.generate 都是 charged。不是显示问题,是真的扣了两次。

修的过程里翻出两处独立成因,以及一条让它们同时隐身的原因。

一、祖先闭包不该当目标传

画布上有一个「运行整组」的入口。前端传目标节点给后端:

onRunGroup={
  onRunTargets && runState && !['estimating', 'submitted', 'running'].includes(runState)
    ? (groupId) => {
        const targetNodes = ancestorClosure(group.nodeIds, flow.edges);
        onRunTargets(targetNodes);
      }
    : undefined
}

它把组内节点连同祖先闭包一起传了过去。

后端的复用逻辑是这样的:reuseCandidates = runSet − targetSet。已经在目标集合里的节点不会被复用,要重新执行。祖先一旦也算目标,复用候选就成了空集——本来该被复用的上游,全部重跑。

而那 10 张分镜图正是祖先。

修法是把这一步交回给后端:

? () => {
    // 只传组内节点当目标,**绝不能把祖先闭包当目标传**:
    // 后端 reuseCandidates = runSet − targetSet,祖先若也算目标,
    // 复用候选就成了空集,已出图的上游会被整套重跑并再次扣费
    // (线上真实事故:点「批量生视频」把 10 张分镜图重扣了一遍)。
    // 祖先闭包由后端 pruneFlowForTargets 自己算。
    onRunTargets(group.nodeIds);
  }

祖先闭包本来就由后端的 pruneFlowForTargets 算。前端多算一遍,算的不只是重复劳动,而是一个语义不同的集合:后端要的是「用户想跑的」,前端给的是「跑这个需要的」。

补的守卫测试很直白——组件层的回调只能接收一个组 ID:

it("运行整组时只把组内节点当目标,不能传祖先闭包(线上重复扣费事故)", () => {
  fireEvent.click(screen.getByRole("button", { name: "整组执行" }));
  expect(onRunGroup).toHaveBeenCalledWith(mockGroup.id);
  expect(onRunGroup.mock.calls[0]).toHaveLength(1);
});

同一处 ancestorClosure 在运行面板里保留着,那里是用它显示「运行 N 个(复用 M 个)」的,不参与提交。

二、落库的行对不上

第二处成因更早,也更深。

画布运行会把每个节点展开成若干行写入 CanvasFlowNodeRun。批量框要求一个节点跑 N 份,所以展开后的行需要一个复合标识:

const nodeRuns = expandedNodes.map((node) => ({
  id: generateNodeRunId(),
  runId,
  nodeId: node.nodeId,   // 展开后的复合 id
  batchIndex: node.batchIndex,
  // ...
}))

问题在这张表的唯一约束是 @@unique([runId, nodeId, batchIndex]),而 nodeId 落的是展开后的复合 id

于是 executor 那一侧全线对不上。它按 nodeId + batchIndex 反查行、统计每份的项数、构建调度状态——落库的键和它查的键不是一套。

调度器找不到已派发的记录,于是重复派发同一个批量单元。周期的量级是 100 毫秒。

修法是把复合 id 留在内存里,落库只落原始 ID:

// 行必须以「原始 nodeId + batchIndex」落库(对齐 @@unique([runId, nodeId, batchIndex]))。
// 展开后的复合 id 只活在调度器内存里:落了复合 id,executor 的
// itemCountsFromRows/buildSchedulerState 就全都对不上行。

配套改了 executor 三处状态写库的定位方式,并在 ready 循环里加了一层 in-flight 防重派兜底,防止同类问题再犯。

三、为什么单测全绿

这一处的成因能藏住,是因为测试的写法。

批量链路的每个环节都有自己的单测:创建函数有自己的用例,executor 也有。两组用例各自手写 fixture——创建函数的用例断言它写出了正确的行,executor 的用例喂给它一组正确的行,断言它正确调度。两组都绿。

错的正是「创建函数写出的行」和「executor 期望的行」之间的那个接口。

修的时候补了一个贯通测试环境,理由写在文件头:

存在的意义是让「写读贯通」测试成为可能——行由真实的创建函数产生、由真实的 executor 消费,
中间不允许手写 fixture(批量行约定矛盾就是靠各自手写 fixture 的单测互相全绿才漏网的)。

新的契约测试从创建一路跑到执行,中间不插桩。断言的是端到端的账:itemCount=3 时,成员 3 行、每份恰好执行一次。

四、项数从哪来

修完上面两处,还有一个数对不上:预估和实扣。

批量框跑几份,原先是从上游推断的:

if (upstreamNode.nodeDefId === 'material.input') {
  const count = (upstreamNode.params as any)?.count ?? 1;
  // ...
}

注册表里从来没有 material.input 这个节点——真名是 asset.input。这个分支从未命中过,项数永远回落 1。

改成框上显式配置:

/**
 * 框内子图跑几份(1~50,缺省 1)。配在框上、由用户手动设置——
 * 「按上游 list 项数自动展开」的推断从未走通过(框架节点没注册),
 * 手动份数是当前唯一的项数来源。
 */
itemCount: z.number().int().min(1).max(50).optional(),

同时把预估也切到同一个来源:

/**
 * 批量倍数:与执行侧同源——run-create 落行的份数就是框上的 itemCount,
 * 预估必须用同一个数,否则「预计 1 份、实扣 3 份」。
 */
export function batchMultipliersFromFlow(flow: CanvasFlow): Record<string, number> {

创建、预估、重试三个路由统一用这个函数。

同一轮里还修了预估的一个老问题:视频节点的预估用的是后台的一口价资源键 canvas_video_generate,而执行侧用的是「模型 + 分辨率 + 是否带上游视频」算出的键,按秒计价。两套键,两套价——预估和实扣自然对不上。预估改用执行同款键。

以及一处显眼的占位:

// 改前
const totalEstimatedCost = 0; // TODO: Pass in actual estimate

运行记录里的预估成本一直是 0。

五、续跑为什么不重复扣费

同一轮加了单节点重试。既然重复扣费是这个月的主线,这条功能的实现方式值得记下来。

失败运行的续跑不是「重新跑一遍」:

/**
 * 单节点重试:给失败/被取消的运行造一个「续跑」运行。成功节点的行原样回填
 * (产物、billingRef、时间戳都保留)——executor 的调度器见到 succeeded 行
 * 会直接当作上游已就绪,不会重新执行,也就不会重复扣费;其余节点
 * (failed / cancelled / pending)重置成全新的 pending 行,正常调度重跑。
 * 不修改原运行:重试是一条新的 CanvasFlowRun,历史记录保持完整。
 */

关键在于复用判定落在行状态上,而不是「这次运行是新是旧」。所以续跑不需要额外的豁免逻辑:被判成功的节点带上原来的 billingRef,调度器看到它就不再执行。

预估也跟着只算子集,余额检查同理。

同一次改动里还补了一个幂等入口——按 userId + clientRequestId 查重,重复提交返回已有的运行 ID。归属校验用带 userId 的查询。

界面文案也改了,从「重试」改成「重试失败节点」,带一句说明:

成功节点的产物直接沿用,只有失败的节点会重新执行并计费。

六、重复扣费这道题

两处成因的形式完全不同:

  • 一处是前端算了一个它不该算的集合。祖先闭包的计算本身没错,错在把它当成了「目标」。
  • 一处是落库的键和查库的键不是一套。两边各自都有定义,各自都有测试,只是没人对着比过一次。

两条链路的共同点是:扣费发生的时刻离「用户点了什么」很远。用户在界面上点的是一个按钮,扣费发生在调度器认为某个节点未被执行的那一刻。中间隔着目标集合的计算、行的展开、行的落库、调度状态的重建。

链路上每一段都「合理」,叠起来就是收两次钱。

防守这类问题的办法不是加校验,是把口径收成一份

  • 目标集合由后端算,前端只传用户选了什么。
  • 批量份数由框上配置,预估与执行读同一个函数。
  • 复用与否由行状态决定,续跑沿用这套判定,不加例外。
  • 贯通测试不允许手写 fixture——两侧各自造数据,就只能测出两侧各自的正确性。

最后那条是这次最实在的收获。一组测试全绿但系统是错的,通常不是因为用例写得不好,而是因为用例的输入各自构造。第三方造的数据一定和另一方一致,因为它们都来自同一个人的同一份理解;真实的接口不一致,恰恰是因为两边由不同的人、在不同的时间实现。

星野的头像

星野 XINGYE

全栈工程师。这里记录 82 篇复盘:24 份故障档案、OTA、架构演进与工作流。