1644 字
约 5 分钟
3
副作用边界上的幂等

副作用边界上的幂等

面向 VOZEB PRO。数据流里「处处幂等」不是一个 HTTP 头搞定全站,而是:每一种花得起钱、写得进库、会惊动上游的动作,各自有「再来一次 = 返回上次」的键。 失败则把下一次敲门的时间写进同一行,而不是再开一张新单。


0. 什么时候用这套判断

适合:

  • ✅ 用户连点「生成」、刷新、重试、关页面后再打开
  • ✅ 上游 webhook / 支付回呼可能重放
  • ✅ Worker 或 after() 会对同一任务敲很多次
  • ✅ 扣积分、退积分、创建上游任务——任何做两次会双倍花钱的地方

不必硬套:

  • ⚠️ 只读列表、打开一张已有图(那是读路径,见 07)
  • ⚠️ 主题切换、antd 表单校验这类没有外部副作用的动作

在 VOZEB PRO 中的定位: 任务用 clientRequestId 去重;钱包每笔消费/退款带服务端 idempotencyKey;支付回呼按事件 id 去重;上游只对一条 attempt create 一次,其余只 poll。四件事键不相同,不要混用。


1. 先问:再执行一次,会多付什么

幂等要守的是副作用边界,不是「函数再进一次」。

再做一次会多出什么 本仓库的键 重复时应该怎样
多一张任务 / 多一次调度 clientRequestId(进 Route / 调度前) 返回已有 run / 任务,created: false
多扣或多退一笔积分 服务端生成的 idempotencyKey applied: false,返回上次流水
多付一笔钱 / 多发一次货 供应商事件 id、订单支付幂等 当已处理,不改第二次余额
上游再 create 一次(双倍账单) attempt 上记下的上游 id 已有 id → 只 query;从未提交才 create;说不清 → needs_review

浏览器带来的 Idempotency-Key 不能直接当钱包流水号。用户能改、能重放、能和别人撞。计费键由服务端生成(AGENTS.md)。


2. 上游:create 一次,poll 很多次

这是数据流「处理」段的核,也是和 Worker 篇的接缝。

startGenerationAttempt / finishGenerationAttempt 把「对某一个渠道候选的一次尝试」记下来:渠道、模型、积分流水、成败。runtime 的规矩是:

  1. 还没有上游 id、且确认没提交过 → create 一次,把 id 写回任务
  2. 已有上游 id → 只 poll / query 这个 id
  3. 不确定有没有创建成功 → 不要赌,进 needs_review,等人或明确策略

关标签、热更新、换副本、Worker 再领,走的都是 2,不是 1。
「用户对同一失败结果点重试」才开新的 attempt(新的上游尝试),并沿用原来的会话 / 生成记录 / 结果槽身份——这是产品语义上的第二次,不是网络重试。

不要用提示词文本当去重键。相同文案点两次,本仓库视为两次独立提交。


3. 任务、钱包、回呼:三套键三套表

任务 / Agent run
normalizeCreativeRunRequest → 按用户 + clientRequestId 查找。已存在就不再 schedule。去重发生在建任务之前,不是写完再删。

钱包
consume / credit / refund / 管理员调整都要带 idempotencyKey。Postgres 路径用唯一约束把「同一键第二下」变成读上次结果。返回里有 applied:false 表示这是回放,余额已经动过了。

支付 / 供应商回呼
按事件 id 去重。回呼网络抖动是常态;处理函数必须假设「同一条事件会来三遍」。

三套键不要合并成一个「全局请求 id」:一次用户点击可能对应「一个 clientRequestId + 一笔扣款键 + 若干次 poll(零次新的上游 create)+ 以后可能一笔退款键」。生命周期不同。


4. 失败:约下次,不另开一单

非终态任务会写未来的 nextPollAt。恢复批次(页面 after() 和 Worker 共用 runGenerationTaskRecoveryBatch)到期再领同一行。

连续错误指数退避:大约 5s 起,封顶 60s,避免把上游打穿。租约过期后任一 lane / Worker 都能领——领的是同一行,不是 insert 一行「重试任务」。

对照:

网络失败 / 进程死  →  同一任务行,nextPollAt 往后推
用户明确点重试    →  同一记录槽,新 attempt
用户再点一次生成  →  新 clientRequestId,新任务行

三种「再来」看起来都像 retry,键和行完全不同。写补丁前先说清是哪一种。


5. 和「读」的边界

幂等管的是写。读一张已登记的图、刷新任务状态、打开作品页,不需要再发明一套幂等键。那些请求可以重入,因为它们不 create 上游、不扣积分。

若一个「刷新」接口里偷偷 create 了上游或又扣了一次钱,那是接口分类错了,不是少写了一个头。


6. 写代码时的五问

  1. 这段逻辑再进一次,会多一笔钱、多一次上游、还是只多一次查询?
  2. 重复请求应该返回哪条已有记录?找不到这条记录,说明键还没落库。
  3. 键是服务端发的,还是客户端说了算?钱必须是前者。
  4. Worker 再敲门时,会不会走到 create 分支?会,先补「已有 upstream.id 只 poll」。
  5. 用户点的是「重试」还是「再生成一次」?槽位身份和 attempt 身份不要混。

7. 读完能指挥自己(或 AI)做什么

  • 「自动恢复路径禁止再次创建上游任务;已有 upstream.id 只许 query。」
  • 「扣积分必须带服务端 idempotencyKey;重复返回上次流水,applied: false。」
  • clientRequestId 已存在则 created: false,不要按提示词文本合并两次提交。」
  • 「失败写 nextPollAt 并退避,不要 insert 一行新的『重试任务』。」

相关文档

副作用边界上的幂等
http://clxhxhhr.top/posts/527/
作者
clxstart
发布于
2026-09-08
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。