文章通过重复发送付款通知的案例说明,工作流执行成功并不保证外部副作用恰好发生一次。作者介绍在 HTTP 请求完成、成功状态尚未持久化时模拟崩溃,以检验故障边界。
这是我的公开构建系列的一部分:我正在推出一个小型 n8n workflow 模板商店(AI Automation Lab),以及一个放在 GitHub 上的免费开源实验项目。
我以前觉得,自己的 n8n workflow 很可靠,因为执行列表里全是绿色。直到一个支付通知 workflow 把同一封邮件发了两遍,而那些绿色对勾依然是绿色。
n8n 能告诉你 workflow 执行过了,却不能告诉你副作用是否恰好发生了一次。
绿色只代表执行结束。至于外部操作——邮件发送、扣款、行记录写入——究竟发生了零次、一次还是两次,它什么也说明不了。看清这个差距之后,所谓「exactly-once」就不再是一个勾选即可开启的选项,而是一条必须通过测试来验证的边界。
下面这些,是我实际学到的东西。大部分都是在艰难地测试这条边界时学到的。
人们说「我们测试了故障处理」时,通常指的是比较容易的那一类。实际上有两类:
已确认服务商接受请求,但回执丢失。 HTTP node 已经返回,你拿到了服务商的响应,但进程在持久化「done」之前崩溃。在 HTTP 调用之后放一个抛出异常的 Code node,就能准确复现这个场景。它是确定性的,也是容易处理的那一类——因为你已经观察到请求被接受,只要曾把 provider id 记录在任何持久化存储中,就能据此核对状态。
已确认服务商接受请求,但回执丢失。 HTTP node 已经返回,你拿到了服务商的响应,但进程在持久化「done」之前崩溃。在 HTTP 调用之后放一个抛出异常的 Code node,就能准确复现这个场景。它是确定性的,也是容易处理的那一类——因为你已经观察到请求被接受,只要曾把 provider id 记录在任何持久化存储中,就能据此核对状态。
请求已提交,但始终没有观察到响应。 请求抵达服务商,副作用也已经提交,但你的进程没有看到响应——可能是在提交后连接被重置,写入完成后发生超时,或者进程在发送与接收之间被终止。这类情况让 exactly-once 无法实现,迫使你引入 Unknown/review 状态。而且,你无法在 n8n 内部可靠地复现它,因为终止进程时,请求是否已经从 socket 发出,取决于竞态,而不是一个可控条件。
请求已提交,但始终没有观察到响应。 请求抵达服务商,副作用也已经提交,但你的进程没有看到响应——可能是在提交后连接被重置,写入完成后发生超时,或者进程在发送与接收之间被终止。这类情况让 exactly-once 无法实现,迫使你引入 Unknown/review 状态。而且,你无法在 n8n 内部可靠地复现它,因为终止进程时,请求是否已经从 socket 发出,取决于竞态,而不是一个可控条件。
第二点用一句话就概括了整篇文章:大家都在跑的测试只覆盖第一类,所以真正导致邮件重复发送的缺陷——第二类——根本没有被测试。
与其终止 n8n,再祈祷终止时机刚好落在正确的窗口里,不如把故障变成一个开关。在服务商前面放一个小型本地 HTTP endpoint(也可以用一个仍会记录请求接受情况的 mock),然后通过 header 或环境变量选择行为:
fail-before-forward——服务商完全看不到请求。这种情况必须能够安全重试。forward-then-drop-response——服务商完成提交后,relay 挂起或关闭连接,不返回响应。这就是状态不明确的情况。在这里运行你的 sweeper。forward-and-respond——正常路径。最终必须进入 done,而且 sweeper 不得处理它。forward-then-drop-response 比「发送后终止 n8n」更干净,因为执行时序由 relay 决定。这样每次运行都能命中同一条边界,不必祈祷终止操作恰好落在正确的那一毫秒。
完成这个测试之后,再在 workflow 中注入异常,作为成本更低的第二个测试,覆盖第一类场景。
还要把断言写对,因为大多数人正是在这里骗过了自己:
Unknown/review,绝不能自动重试。只有第一类——已经观察到请求被接受,并且持久化了 provider id——才可以依据回执完成收尾。一旦你接受「崩溃的执行无法安全恢复」这一点,设计就会改变:别再尝试恢复那次执行,而是恢复任务。
n8n 的执行状态不是持久化队列。重启之后,原来的执行就没了——Wait node 保存的内容,也只有在等待时间较短时才安全(超过 65 秒的等待会转存到 DB,这说明等待状态能在重启后保留下来,但无法说明副作用是否已经在崩溃前提交)。恢复一个 Wait,并不能证明外部系统的状态。
所以,把延期执行的任务保存在你能控制的存储里(默认选 Postgres;只有必须采用无代码方案时,才选 Sheets/Airtable),并把 n8n 纯粹当作 worker:
持久化一条信息完整、可独立处理的任务记录。 包括从源事件派生的稳定 dedup key(绝不能用 $execution.id、$now 或新生成的 uuid),以及 state、payload、attempts、claimed_at、updated_at、last_error、provider_ref。
以原子操作认领任务,不要先检查再插入。 经典 bug 就是先查询、再插入:两个同时到达的 webhook 都读到「还没见过」,于是都继续执行。把它变成一条语句:
INSERT INTO idem (idem_key, status, claimed_at)
VALUES ($1, 'pending', now())
ON CONFLICT (idem_key) DO NOTHING
RETURNING idem_key;
成功插入的那次执行会得到一行结果;竞争失败的执行得到零行——而 n8n 不会在空分支上执行下游节点,所以只有胜出者能到达产生副作用的节点。不需要 IF node;SQL 本身就是这道关卡。
让完成状态只能向前推进。 保留状态条件,防止迟到的重复请求把状态改回去:
UPDATE idem SET status='done', done_at=now()
WHERE idem_key=$1 AND status='pending';
按计划执行恢复,不要依赖重启。 真正负责清理崩溃后遗留任务的,是扫描 state='claimed' AND claimed_at < now() - interval '10 minutes' 的 sweeper。
保留三种状态,而不是两种:pending / done / review。 pending 应该只短暂存在;长时间停留在 pending 的任务,恰恰应该触发人工告警。
放在 metadata 中的 key,只是核对状态的凭据,不是幂等性保证。幂等性是接收系统的属性——你的 payload 只是告诉接收方,这是哪一个逻辑操作。因此,服务商可以分成两类,你应该分别采用不同的设计:
Idempotency-Key、DB 的 unique constraint / ON CONFLICT、允许客户端提供 reference 的订单 API)。在这里,直接重试是安全的,副作用也确实能做到 exactly-once。第二类有一个令人不舒服的事实:workflow 永远无法区分「已经提交但未确认」和「没有提交」。它只能拒绝自动重复执行。对于一封发送状态不确定的邮件,我宁愿触发人工告警,也不愿悄悄重试——这是正确的取舍,不是能力不足。
一个看起来不错的设计,往往会在 sweeper 这里悄悄变回重复发邮件的设计。它必须根据服务商所属类别,先分类,再行动:
done。没查到 → 可以安全重试。这是唯一真正的 exactly-once 恢复路径。review 并告警,由人工或第二个 workflow 决定下一步。对于需要审查的记录,要在副作用发生之前写入标记,并带上用于核对状态的凭据和 execution id。这样,处理的人就能去服务商侧查询,而不是靠猜。在这里悄悄重发,恰恰就是重复邮件最初产生的原因。
两个测试,比所有绿色对勾都更有价值:
done 之间注入故障(使用 Stop node 或抛出异常的节点),然后运行 sweeper,断言任务进入 review,而不是重新发送。第二个测试,才是真正能找出崩溃窗口缺陷的测试——而它几乎没人做。
在编写任何一个节点之前,先针对发送流程回答一个问题:服务商是否在服务端强制保证唯一性,还是 key 只是一个提示?仅仅这一条事实,就决定了你的恢复策略是「自动重发」还是「触发人工告警」。其他一切——relay、任务表、sweeper——都是围绕这个分岔点展开的实现细节。
n8n 很擅长运行 workflow。但它不是,也从来不应该是你判断外部世界究竟发生了什么的事实依据。
如果这篇文章对你有帮助,免费的实验项目(包含 workflow,采用 MIT 许可证)在这里:github.com/zhp910318/n8n-ai-automation-lab——我也在 AI Automation Lab 提供了一小组经过生产环境验证的 n8n 模板。
如需采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。