幂等、并发和审计怎样跨越本地与远程事务边界
员工批准退款后,Ticket Agent 更新本地任务,再通过 HTTP 调用 JDK8 业务系统。就在远端完成退款、准备返回响应时,网络断了。此时调用方只知道“没收到结果”,并不知道退款有没有发生。
回滚本地事务无法撤销远程动作,直接重试又可能重复退款。网络异常和业务失败必须分开:系统要能表达确定成功、确定拒绝、结果未知和本地程序错误,并为每种结果安排后续处理。
确认通过后,项目先把任务改为 EXECUTING,再用基于 actionId 的稳定幂等键调用 Legacy Tool。合法 2xx 进入 COMPLETED,明确 4xx 进入 FAILED,超时、5xx、空响应或协议损坏则进入 EXECUTION_UNCERTAIN,等待查询或对账。
本地事务无法覆盖远程 HTTP
数据库事务只能控制同一事务资源。把 HTTP 调用放在 @Transactional 方法里,只会让本地数据库连接和锁持有更久,不会让远程系统参与原子提交。
可能出现四种组合:
| 本地状态 | 远程动作 | 原因 |
|---|---|---|
| 未提交 | 未执行 | 调用前本地失败 |
| 已提交 | 未执行 | 远程明确拒绝或调用未到达 |
| 已提交 | 已执行 | 正常成功 |
| 已提交 | 可能已执行 | 超时、断连、5xx 或响应损坏 |
最后一种无法通过异常类型直接判断。连接超时可能发生在建立连接前,也可能由代理或客户端包装;响应超时通常意味着请求已经发出。业务系统甚至可能完成写入后在序列化响应时失败。
因此不要写成:
try {
legacyClient.refund(command);
task.complete();
} catch (Exception e) {
task.fail();
retryWithNewRequestId();
}
这段代码会把未知结果标成失败,并用新请求 ID 制造重复副作用。
远程写操作的执行时序
ToolConfirmationService 在调用下游前执行:
AgentTask executing = tasks.save(
waiting.beginExecution(clock.instant()),
waiting.version());
任务先进入 EXECUTING,这样服务进程在远程调用中崩溃后,数据库仍保留一个可被恢复程序发现的状态。当前项目尚未实现重启扫描;公司接入时要补上扫描与对账 Worker。若先调用远程、成功后才更新本地,进程在两者之间崩溃会留下“远程已执行、本地仍等待确认”的危险状态。
然后构造稳定 Tool 幂等键。租户和动作 ID 先按字段长度编码,再计算 SHA-256:
tool:v1:SHA-256(
lengthPrefixed("tenantId") + lengthPrefixed(tenantId)
+ lengthPrefixed("actionId") + lengthPrefixed(actionId)
)
长度前缀保留字段边界,避免值中出现冒号或换行时产生相同输入;摘要也不会把租户标识直接暴露在 Header 和网关日志中。v1 为以后调整编码规则保留版本。原始 actionId 仍放在请求体、业务记录和审计里,用于查询动作结果。
actionId 在确认单生成时固定。相同动作的网络重试必须使用同一个 Key;参数变化时应创建新 actionId 和新确认单。Legacy Tool 同样要保存 Key、请求指纹和业务结果。
Agent 与老系统共享同一动作标识
Ticket Agent 的确认接口幂等解决“员工决策是否重复处理”,Legacy Tool 的动作幂等解决“业务副作用是否重复发生”。两层 Key 语义不同,不能只保留一层。
确认 Key 示例:
confirm:v1:<64 位 SHA-256 摘要>
Tool Key 示例:
tool:v1:<64 位 SHA-256 摘要>
确认 Key 由 Java 8 客户端根据 taskId 与 confirmationId 的长度前缀编码生成,员工身份由服务端的受信 principal scope 隔离,不拼进 Header。即使确认接口只执行一次,Ticket Agent 也可能在崩溃恢复后重新调用 Legacy Tool;没有下游幂等,仍会重复退款。反过来,只有 Legacy Tool 幂等而确认接口不幂等,会产生混乱的多个审批回执和并发状态冲突。
最终业务系统应在本地事务中完成:校验幂等键、比较请求指纹、执行业务写、保存结果与 auditId。相同 Key 和相同指纹返回原结果,不同指纹返回冲突。
按结果确定性更新任务状态
HttpLegacyWriteToolExecutor 按确定性映射结果。
2xx 与合法回执。 响应必须能严格解析,actionId 与当前确认动作一致,状态也必须在接口约定的枚举内。然后任务进入 COMPLETED,保存下游 auditId。
明确 4xx。 4xx 表示业务系统明确拒绝当前请求,例如金额非法、状态不允许、权限不足或幂等冲突。适配器读取受限结构化错误,抛出 ToolExecutionRejectedException,任务进入 FAILED。它不自动重试,因为输入或业务状态需要修正。
错误体属于不可信输入。适配器不能把整段 HTML、堆栈或客户数据写入日志和审计,只提取稳定 code/message,并设置长度限制。
5xx、超时与网络断开。 这些结果无法证明业务动作没有发生,因此统一映射为 RemoteExecutionUncertainException。任务进入 EXECUTION_UNCERTAIN,回执中的 toolStatus 为 UNKNOWN。
空响应、actionId 不匹配与 JSON 损坏。 下游可能已经接受请求,只是响应格式不符合约定,因此也属于结果未知。不能因为“解析失败”就按普通客户端异常重试。
本地错误要看发生在什么时点
请求体、访问令牌和 Idempotency-Key 在 HTTP 调用前完成校验与序列化,批准记录也必须在发送请求前写入审计。这个阶段一旦失败,代码会把任务从 EXECUTING 关闭为 FAILED,继续抛出原始异常,并且不会调用远端工具。此时系统知道业务动作没有发出,不需要进入对账流程。
拿到合法的下游成功回执后,判断就不同了。此时如果本地任务保存或必要审计失败,不能再把远程动作改判为 FAILED。
ToolConfirmationService 会重新读取任务。如果 COMPLETED 已经落库,就保留成功状态。如果任务仍是 EXECUTING,则改为 EXECUTION_UNCERTAIN,记录 LOCAL_COMPLETION_FAILED 原因码,后续按原 actionId 对账。
指标与任务状态、必要审计不同。指标只用于观察运行情况,写入失败时可以丢弃当次样本并告警,不能阻止任务保存为 COMPLETED。否则监控系统的短暂异常会被误写成业务结果不明。
这里的 UNKNOWN 表示 Ticket Agent 还没有形成可靠的本地最终记录,不是否认已经收到下游成功回执。恢复程序仍然要用同一 actionId 查询老系统,再补齐任务状态和审计。
对未知结果执行查询与对账
EXECUTION_UNCERTAIN 不能停留在终态文案。当前代码能够保存这个状态和稳定原因,但不会自动查询老系统或启动对账。生产接入时至少要实现一种恢复方式:
- 用 actionId 或 Tool Key 查询 Legacy Tool 幂等结果;
- 用业务对象状态判断动作是否已经生效;
- 无法自动判断时进入人工对账队列;
- 查明后把任务更新为 COMPLETED 或 FAILED,并追加审计。
恢复请求不能重新发起一个新 actionId。最理想的下游接口同时提供:
POST /tool-actions 使用 Idempotency-Key 执行
GET /tool-actions/{actionId} 查询最终结果
如果老系统暂时没有查询接口,可以先利用现有订单或工单状态查询,但要明确判定规则。仅凭“当前订单看起来已退款”可能无法区分本次动作和其他渠道动作,最好让最终系统保存 actionId。
生产 Worker 应扫描长时间停留在 EXECUTING 的任务,将其转入查询恢复,而不是直接重试 POST。扫描过程需要租约和 fencing,防止多个实例同时恢复同一动作。
用版本和唯一约束处理并发
同一任务可能同时收到员工确认、后台恢复和重复 HTTP 请求。任务版本保证状态迁移只基于最新快照;幂等表唯一约束保证同一业务键只有一个结果;下游 actionId 唯一约束保证副作用只归属于一个动作。
生产数据库可以把 task 状态和确认决策放在一个本地事务中,但远程 HTTP 仍在事务外。常见做法是:
- 本地事务校验确认并将任务改为 EXECUTING;
- 提交事务;
- 调用远程 Tool;
- 新本地事务按 expected version 写入最终状态;
- 冲突时重新读取,不覆盖更新后的恢复结果。
若希望调用与恢复更可靠,可以增加 execution attempt 或 outbox 风格的待执行记录,但不要为了“企业级”先引入复杂中间件。只要公司已有可靠任务队列,可以让确认事务写入执行任务,由 Worker 调用下游。选用哪种组件并不关键,actionId、状态、幂等和恢复语义必须保持一致。
对外承诺可重试和可查询
分布式网络下很难保证请求只到达一次。可靠系统通常实现的是:请求可以重复到达,但相同业务意图产生同一个可观察结果。
这依赖端到端幂等,而不是一句“接口保证 exactly-once”。如果 Ticket Agent 复用 Key,但网关丢失 Header;或者 Legacy Tool 保存 Key,却在写业务状态后、写幂等结果前崩溃,承诺仍会失效。
最终系统要尽量在同一数据库事务中提交业务状态和幂等结果。跨多个不可事务资源时,需要业务补偿或对账,不能用术语掩盖不一致窗口。
审计重建动作时间线
当前 Agent Audit 记录:
AGENT_PLAN_RECORDED
CONFIRMATION_REQUESTED
CONFIRMATION_APPROVED
TOOL_EXECUTION_SUCCEEDED / REJECTED / UNCERTAIN
成功事件保存 actionId 和 Legacy auditId,使研发能够从 Agent 任务追到最终业务系统。如果成功回执后的本地收尾失败,时间线中还会追加 TOOL_EXECUTION_UNCERTAIN reasonCode=LOCAL_COMPLETION_FAILED。它表示需要补齐本地记录,不会用一条 FAILED 覆盖已发生的远程动作。拒绝和未知结果只记录稳定分类,不复制原始响应体。
生产审计至少需要:
- taskId、actionId、confirmationId;
- tenant、业务 subject、actor 与确认人 subject;
- 工具名、风险、参数指纹;
- 决策、原因、任务版本;
- 下游业务 auditId、结果分类;
- trace/correlation 与时间;
- 恢复事件和最终处理人。
敏感参数可以加密保存或只保留指纹与受控业务引用。审计可追溯不等于把完整客户数据复制到每条日志。
只重试结果可判断的操作
对 Legacy 写 Tool,客户端关闭自动重试。调用方只有在能确认请求未发送,或者下游以同一 Key 保证幂等且恢复策略明确时,才可以在总预算内重试。
建议按结果处理:
- 400/403/409:确定拒绝,不重试;
- 429:保留原 Key,按 Retry-After 或退避;
- 5xx/超时:先标 UNKNOWN,优先查询结果;
- 明确查询“未执行”:才可以使用原 actionId 和原 Key 再执行;
- 查询“已执行”:更新为成功,不再 POST。
不要把 Resilience4j Retry 注解直接套在写 Tool 方法上。通用重试不知道业务 Key、结果查询和未知状态。
故障结果必须稳定分类
仓库回归检查成功、明确拒绝、远程结果未知、重复确认,以及远端调用前审计失败、拿到成功回执后本地保存或审计失败的状态转换。它也检查指标上报失败不会改写已落库的业务结果。这里检查的是 HTTP 协议与状态映射,公司真实老系统仍要单独联调。
上线前补齐未知结果恢复
Ticket Agent 已提供 JDBC 状态与乐观锁,将 java-ai.persistence.mode 改为 jdbc 后即可启用;默认使用内存状态。两种实现都不会让请求自动接管过期确认。公司上线前还要补三项恢复能力:
- Legacy Tool 的持久幂等记录和 actionId 查询。
EXECUTING超时扫描与EXECUTION_UNCERTAIN恢复队列。- 区分确定失败与未知结果的告警,高风险
UNKNOWN使用更高优先级。
上线前还要演练四个故障窗口:远程执行前 Agent 崩溃、执行后响应前断连、收到成功后本地更新失败、恢复 Worker 重复运行。每个窗口都要依靠同一 actionId 和幂等记录得到唯一结果。
Agent 能否进入公司项目,模型效果只决定一部分。一次网络超时后系统会不会重复写,通常更接近上线底线。JDK8 老系统不升级框架,仍然可以通过稳定 OpenAPI、Java 8 客户端和明确的错误处理规则参与查询、确认与恢复。