
九步的计划,第 7 步是调退款网关提交一笔退款,这一调回了 503。进程退出,把已经跑完的步骤落在哪、重跑时怎么读回来,决定了重启之后是接着跑、重跑一遍,还是干脆漏掉一笔。三种写法在本机各跑一遍,副作用文件按订单去重之后长这样:
计划共 9 步,第 7 步是提交退款
== 第一次运行,第 7 步拿到 503
(不看日志;第 7 步第 7 次调用会 503)
第 1 步 search_orders —— ok
第 2 步 fetch_order —— ok
第 3 步 fetch_order —— ok
第 4 步 fetch_order —— ok
第 5 步 calc_refund —— ok
第 6 步 calc_refund —— ok
第 7 步 submit_refund —— 抛错:HTTP 503 from refund-gateway,这一轮停在这里
本轮真正执行 6 步;副作用文件新增 6 行
submit_refund 累计执行 0 次:无
日志文件现在 7 行(6 条 ok + 1 条 error)
== 策略一:日志里出现过的步骤一律跳过
(日志里出现过就算做完;第 7 步第 - 次调用会 503)
第 1 步 search_orders —— 已有记录,跳过
第 2 步 fetch_order —— 已有记录,跳过
第 3 步 fetch_order —— 已有记录,跳过
第 4 步 fetch_order —— 已有记录,跳过
第 5 步 calc_refund —— 已有记录,跳过
第 6 步 calc_refund —— 已有记录,跳过
第 7 步 submit_refund —— 已有记录,跳过
第 8 步 submit_refund —— ok
第 9 步 summarize —— ok
跑到最后一步
本轮真正执行 2 步;副作用文件新增 2 行
submit_refund 累计执行 1 次:A-2
策略一那一轮只真正执行了 2 步,把 8、9 两步补完,看起来很正常。问题在别处:它把第 7 步那条报错记录也当成「做过」,于是 A-1 这笔退款从头到尾没有提交过。副作用文件里只有 A-2。
错误写法:日志里出现过就算做完
function loadDone(strict) {
const done = new Set();
if (!existsSync(LOG)) return done;
for (const line of readFileSync(LOG, "utf8").split("\n")) {
if (!line.trim()) continue;
const rec = JSON.parse(line);
if (strict && rec.status !== "ok") continue; // ← 少了这一行就是策略一
done.add(rec.k);
}
return done;
}
判断条件只写了「这条记录存在」,没问它成不成。一次失败被写进日志、下一次启动被读成已完成,这类 bug 最难受的地方是它不报错:续跑那轮的日志干干净净,全是「已有记录,跳过」,从输出上完全看不出漏了一步。
另一种错误写法:不落盘
同一份计划,去掉日志、每次从第一步开始跑:
== 策略二:只有 status 是 ok 的才算做完
(日志里只有 ok 的步骤算做完;第 7 步第 - 次调用会 503)
第 1 步 search_orders —— 已有记录,跳过
第 2 步 fetch_order —— 已有记录,跳过
第 3 步 fetch_order —— 已有记录,跳过
第 4 步 fetch_order —— 已有记录,跳过
第 5 步 calc_refund —— 已有记录,跳过
第 6 步 calc_refund —— 已有记录,跳过
第 7 步 submit_refund —— ok
第 8 步 submit_refund —— 已有记录,跳过
第 9 步 summarize —— 已有记录,跳过
跑到最后一步
本轮真正执行 1 步;副作用文件新增 1 行
submit_refund 累计执行 2 次:A-2, A-1
== 策略三:干脆不要日志,整轮重跑一次
(不看日志;第 7 步第 - 次调用会 503)
第 1 步 search_orders —— ok
第 2 步 fetch_order —— ok
第 3 步 fetch_order —— ok
第 4 步 fetch_order —— ok
第 5 步 calc_refund —— ok
第 6 步 calc_refund —— ok
第 7 步 submit_refund —— ok
第 8 步 submit_refund —— ok
第 9 步 summarize —— ok
跑到最后一步
本轮真正执行 9 步;副作用文件新增 9 行
submit_refund 累计执行 4 次:A-2, A-1, A-1, A-2
合计:副作用文件 18 行,其中 submit_refund 4 次
submit_refund {"order":"A-2","amount":"8.00"}
submit_refund {"order":"A-1","amount":"12.30"}
submit_refund {"order":"A-1","amount":"12.30"}
submit_refund {"order":"A-2","amount":"8.00"}
按订单去重: A-2×2 A-1×2
第三轮整轮重跑,9 步全部真跑了一遍,副作用文件从 6 行涨到 18 行,A-1 和 A-2 各自被提交了两次。前面那条路径是漏一笔,这条路径是多一笔,两者的日志都看不出异常。
还有一种介于两者之间的写法:把步骤编号当成续跑位置,读日志里最大的那个 i,然后从 i+1 继续。它在计划固定的情况下能用,但计划一变就错位,比如第 3 步被去掉、第 4 步变成第 3 步,位置对不上,要么重复执行要么跳过。位置是临时的,只有步骤的语义标识能跨版本对上。
认准 ok 的那一轮
只有 strict 为真、也就是只认状态为 ok 的记录时,结果才是对的:前 6 步跳过,第 7 步重新提交、这次成功,第 8、9 步接着走完,副作用文件里 A-2 与 A-1 各出现一次。同一个 503 没有变成漏单,也没有变成重单。
// 步骤键:序号 + 工具名 + 参数的哈希,三样都在才认为同一步
const k = `${i}:${step.tool}:${key(step.args)}`;
if (done.has(k)) { console.log(`第 ${i} 步 ${step.tool} 跳过`); continue; }
try {
callTool(step, i, crashAt);
writeFileSync(LOG, JSON.stringify({ k, i, tool: step.tool, status: "ok" }) + "\n", { flag: "a" });
} catch (e) {
writeFileSync(LOG, JSON.stringify({ k, i, tool: step.tool, status: "error", error: e.message }) + "\n", { flag: "a" });
break;
}
把参数也算进键里,是为了让步数被改写时旧记录自动失效。副作用写在前、日志写在后,得到的保证是「日志说完成了,就一定真的做过了」;反过来先写日志后做副作用,崩溃点刚好卡在中间,恢复时就会漏掉那一步。所以顺序是副作用、然后日志,代价是极端情况下可能重复执行一次,重复要靠工具自己的幂等键兜住。
这一步我倾向于交给下游:退款提交带一个业务幂等键,重复请求由网关识别,比在客户端花力气做精确一次更现实。
落盘的时机与内容
日志按行追加,一行一个 JSON 对象,写完就 flush,别攒在内存里等退出时统一写。进程被 kill 时缓冲区里的内容会跟着消失,恰好落在崩溃点附近的那几步丢掉,恢复时就会重跑。内容至少要装下三样:稳定的步骤键,这条记录的状态,还有失败时的错误信息;错误信息用来判断这次重跑该不该换个策略,比如 429 该退避、503 该重试、400 该停。
按订单去重: A-2×2 A-1×2
去重那一行是整篇的落点:18 行副作用、4 次提交、两个订单各两次。同一份代码改一个判断条件,结果从漏一笔变成多一笔,中间那条路要靠写日志的严格程度走出来。