# 后台任务与外部调用

> 进程崩溃、任务重试之后，该做的事不丢；调用外部服务时不卡死、不刷爆、不重复。

工程防护 · /zh/jobs/ · 2026-11-05

任务可能在数据库提交和入队之间丢失，也可能在超时后把外部操作再做一遍；一个慢的服务商还能拖住所有调用它的 worker。导出、通知和履约都要知道哪些工作还没做完，哪些已经做过。

## 让 AI agent 追踪任务和交付

**Prompt**

```text
审查 [Jira 编号、PR 链接或 diff] 里的排队任务、重试和外部操作。
如果是 Jira ticket，通过已连接的 Jira MCP 或已认证的 acli 读取描述、验收标准和评论；无法访问时，让我粘贴详情。
把每个操作从工作记录开始，经过队列和外部调用，一路追到完成。检查每个外部调用：有没有显式超时、外面是不是包着数据库事务、怎么重试、有没有守住服务商的限流。用合成数据和测试服务测试重复执行、进程崩溃和超时；涉及资金或奖励时，加上占用和账本检查。
逐条报告重复执行或工作丢失的风险，附上证据。
说明本页列出的测试哪些已在项目测试套件里。能自动化的补上，其余的列出来。
不要重发、退款或修改生产记录。
```

## 保住工作，并认出重复执行

- **JOB-1** (高风险; 由测试检查): **每个操作都有稳定的标识，重复投递时返回已有结果。** 队列的 attempt ID 每次重试都会变，标识不了业务操作。同一个标识带着不同输入就拒绝，并防止两个 worker 做同一个外部操作。
- **JOB-2** (高风险; 由代码评审检查): **工作记录和需要它的数据变更一起提交，提交后再入队，** 这样中间崩溃也不会丢活。
- **JOB-3** (中风险; 由代码评审检查): **任务参数要让上一个版本也读得懂。** 滚动部署时，旧 worker 会接到新任务；回滚之后，新任务会在旧代码上跑。新任务或新参数先发布一个版本，下一个版本再开始排队。

* 外部调用超时后，[结果是未知的](/zh/value-abuse/#r-val-6)：用原来的引用号去查结果。对方查不到、也不能去重时，就标为结果未知，留给[核对](/zh/incidents/)处理，不要新建一个操作。
* 限制重试次数和退避时间。重试耗尽的工作进入可见的失败状态，并指定负责人。

> **示例: ENG-123 · 导出任务在生成文件后中断**
>
> 导出 E-42 还是待处理状态，worker 的 attempt J-42 超时了，存储里已有文件 F-42，通知服务商受理了消息 N-42，但还没有投递结果。
>
> - 核对 F-42 和 E-42 的商户、筛选条件和输入版本是否一致，并检查文件内容；文件名对得上不算证据。
> - 把文件、完成记录、下载和通知当作四个独立阶段。服务商受理了，只说明它收到了，不说明已经送达。
> - 提议只补做缺的那个阶段，生产环境要先经过[批准](/zh/incidents/#2-stop-the-harm)。N-42 标为结果未知并指定负责人；不要因为 J-42 被重试过，就重新生成文件或重发通知。

## 安全地调用外部服务

- **EXT-1** (高风险; 由代码评审检查): **每个外部调用都显式设置超时，并且不放在数据库事务里。** 客户端库的默认超时可能是几分钟，甚至没有；放在事务里的调用会一直占着锁，而且事务回滚时撤不回它。

* 连接超时和读取超时分开设，按服务商平时的延迟来定：比如用户在等的调用，连接 1–2 秒、读取 2–5 秒。
* 先记下意图并提交，再调用或把调用放进队列。Rails 里用 `after_commit`，不要用 `after_save`。[Rails](https://guides.rubyonrails.org/active_record_callbacks.html#transaction-callbacks)。
* 只重试可以安全重复的请求：读请求，或者带了服务商幂等键的写请求。退避时加随机抖动；遇到 429 或 503，按 `Retry-After` 给的时间等。
* 主动守住每个服务商的限流：在所有 worker 和服务器之间统一限速，用共享的限流器，或者固定并发的队列。按进程设的限额，每多一个 worker 就多一份。
* 批量发送、同步和回填放在单独的队列里匀速跑，不能把登录或下单要用的额度用光。
* 服务商挂了，降级的是那一个功能，而不是整个应用：关掉它或者把工作排队，给用户显示处理中，并告警。一个服务商的重试不能把所有功能共用的队列堵满。
* webhook 会重复、会乱序。[验证来源](/zh/security/#check-every-path-to-the-record)之后，先快速响应，再放到任务里处理：响应慢了，服务商会再发一次。记下事件 ID、忽略重复，旧事件绝不能覆盖新状态。
* 生产环境之外用沙箱凭据和测试账号；生产环境的 key 不能出现在 staging。
* 日志里记服务商的请求 ID、结果和我们自己的操作 ID，不记请求内容。结果不明时，靠这些来查清楚。

- [ ] **EXT-T1**: 用模拟的服务商，先让它变慢，再让它出错，再让它返回 429。变慢的调用在时限内超时；出错和 429 只按允许的方式重试；没有 worker 卡住，没有重复发送，用户看到的是处理中的状态。
- [ ] **EXT-T2**: 用户在发请求时跑一个批量任务。面向用户的调用保持在延迟目标之内，服务商收到的请求从不超过它的限流。

## 给批量工作和 fan-out 设上限

- 按项目实测的限制，给结果大小、批次大小、并发数和运行时长设上限。分页只限制返回的数据，限制不了查询背后的工作量。
- 耗时的导出放进队列，数据用流式处理，避免一个请求就耗尽内存。任务和文件的访问范围限定在发起请求的用户和租户内。
- 按条目和按任务限制下游调用次数：一个很小的输入也可能 fan-out 成大量查询或外部调用。
- 开销大的接口，比如搜索、导出和报表生成，要在应用里按调用方限流，做法和[短信、OTP 与付费接口](/zh/otp/#check-requests-before-sending)对付费发送的一样。否则一个调用方就能把它们耗尽，影响所有人。

## 测试中断和重复的工作

- [ ] **JOB-T1**: 并发投递同一个任务，完成后再重放一次：只有一个结果，没有多出来的调用、文件或通知。
- [ ] **JOB-T2**: 分别在入队之前、外部服务受理之后、记录完成之前让进程崩溃：能找到待处理的工作，没有任何重复。
- [ ] **JOB-T3**: 发送延迟和重复的回调：每个回调都对应到原来的操作，不会重复它的效果，也不会覆盖更新的状态。
- [ ] **JOB-T4**: 把重试次数用完：失败记录保留原来的标识、错误和下一步动作。
- [ ] **JOB-T5**: 在正常流量中发起允许范围内最大的导出，再发起一个超限的：前者不超出内存和队列限制，后者被拒绝。
