前言

调用第三方 API 看起来只是一次 HTTP 请求,但当它需要几十秒、几分钟甚至几小时才能完成时,问题就不再是“怎么发请求”,而是:进程重启以后从哪里继续?超时后是否会重复创建任务?回调早于等待逻辑到达怎么办?重试会不会重复扣费?我们又该怎样知道一个任务此刻是在运行、等待、重试,还是已经卡住?

传统队列可以把工作放到后台,却通常还需要我们自己组合状态表、重试次数、延迟任务、分布式锁和补偿逻辑。Restate 提供了另一种方式:仍然使用普通的 Go 或 TypeScript 函数编写业务流程,但由 Restate 持久化执行日志,并在故障后重放已经确认的结果。

这篇文章重点讨论一个具体问题:如果系统需要等待长耗时的 AI、视频生成、支付、物流或其他第三方 API,应该怎样调度,以及应该选择 Go 还是 TypeScript + Bun。

Restate 解决的不是“后台运行”,而是“可靠地继续”

Restate 位于调用方与业务服务之间。每次调用会形成一个 Invocation,服务通过 SDK Context 执行有状态操作、调用其他服务、设置 Timer,或者运行普通的副作用代码。

Restate 的关键抽象是 Journal。已经完成的 ctx.run、服务调用、Timer 和外部事件都会被写入执行日志。当服务崩溃或被重新调度时,处理函数可能从头执行,但已经完成的操作会直接从 Journal 重放结果,而不是再次访问第三方系统。

因此它提供的更准确语义是 durable execution,而不是简单地让一个进程永远活着。

这也意味着业务代码必须遵守一个边界:数据库访问、HTTP 请求、随机数和当前时间等非确定性操作,需要放入 Restate 提供的 durable action 中。对于第三方 API,最常见的入口就是 ctx.run

两种完全不同的长耗时任务

实践中,应该先区分第三方 API 的交互模型。

同步长请求

第一种 API 会一直保持 HTTP 连接,几十秒或几分钟后直接返回结果。例如某些 LLM 推理、报表导出或批量识别接口。

这类调用可以放进一个 durable step:

result, err := restate.Run(ctx, func(runCtx restate.RunContext) (Result, error) {
    return provider.Generate(runCtx, input)
}, restate.WithName("generate"))
const result = await ctx.run("generate", () =>
  provider.generate(input),
);

Restate 会保存成功结果,并根据策略重试失败的 step。不过,ctx.run 并不能让第三方副作用自动变成 exactly-once。请求可能已经被对方接收,响应却在返回途中丢失,所以仍然应该把 Invocation ID、业务请求 ID 或确定性 UUID 作为 Idempotency Key 发送给对方。

还有一个容易忽略的限制:Restate 默认在一段时间没有收到新 Journal Entry 后,会认为服务可能失去响应并尝试挂起。官方文档当前给出的默认 inactivity timeout 是一分钟,之后还受 abort timeout 约束。对于三分钟的 LLM 请求,要么调整服务配置,要么更合理地改用异步 API;同时 HTTP Client 自己也必须有明确的连接和总超时。

异步提交与回调

第二种 API 会立即返回一个 Job ID,几分钟或几小时后通过 Webhook 通知结果。视频生成、批量渲染、物流处理和人工审核通常属于这一类。

不要用一个永久连接、Go goroutine 或 Bun Promise 一直轮询。更合适的流程是:

  1. 创建一个 Awakeable,并得到不可猜测的 ID;
  2. ctx.run 中向第三方提交任务,把回调地址和 Awakeable ID 一起发送;
  3. 当前 Invocation 挂起,不占用业务服务的计算资源;
  4. Webhook 到达后校验签名,再通过 SDK 或 Restate HTTP API resolve/reject;
  5. Restate 恢复原来的 Invocation,并从等待位置继续。

Go 的核心代码可以写成:

callback := restate.Awakeable[ProviderResult](ctx)

_, err := restate.Run(ctx, func(runCtx restate.RunContext) (restate.Void, error) {
    return restate.Void{}, provider.Submit(runCtx, input, callback.Id())
}, restate.WithName("submit-provider-job"))
if err != nil {
    return Result{}, err
}

result, err := callback.Result()

在 TypeScript SDK 与 Bun 中,同一个模型更接近普通 Promise:

const callback = ctx.awakeable<ProviderResult>();

await ctx.run("submit-provider-job", () =>
  provider.submit(input, callback.id),
);

const result = await callback.promise;

如果使用 Workflow,也可以用带名称的 Durable Promise,让另一个 shared handler 接收 Webhook 并完成它。Awakeable 更适合通用 Service 或 Virtual Object;Durable Promise 则与某次 Workflow Execution 绑定,业务语义通常更清楚。

调度:等待不等于占用 Worker

Restate 的价值在长等待阶段最明显。Invocation 等待 durable timer、Awakeable 或 Durable Promise 时可以被挂起;状态由 Restate 保存,事件到达后再恢复服务。应用不需要为每个等待中的任务保留一个 goroutine、Promise 或容器。

对于轮询型 API,可以在每次查询之间使用 durable sleep:

while (true) {
  const status = await ctx.run("query-status", () => provider.status(jobId));
  if (status.done) return status.result;
  await ctx.sleep({ seconds: 30 });
}

Timer 会跨服务和 Restate 重启继续计时。但如果只是安排未来的一次动作,官方更推荐 delayed message,而不是 sleep + send:前者能让当前 Invocation 立即结束,不会阻塞 Virtual Object,也减少长时间保留旧 Deployment Version 的压力。

并发同样需要使用 Restate 的 durable concurrency primitives。TypeScript 应使用 RestatePromise.all/race/any,Go 则使用 SDK Future 与 Wait/WaitFirst。特别是在 Go 中,不能用 goroutine、channel 和 select 去组合阻塞式 Restate 操作,因为完成顺序必须写入 Journal,才能在重放时保持确定性。

如果同一租户或同一个第三方账号必须串行处理,可以把账号 ID 作为 Virtual Object Key。Exclusive Handler 会按 Key 排队,天然形成“每个账号一条队列”。但不要在 Exclusive Handler 内等待数小时,否则这个 Key 的后续调用也会全部排队;更好的设计是把长等待拆成回调驱动的多个 Handler。

超时、重试与限流应该分层

一个可靠的第三方集成至少有四层时间边界:

  • HTTP Client timeout:单次网络请求最多等待多久;
  • durable step retry:哪些错误重试、间隔多久、最多几次;
  • 业务 deadline:整个任务允许在第三方系统中存在多久;
  • inactivity/abort timeout:Restate 与 Service Deployment 之间如何判断挂起或中止。

429503 和网络抖动通常可以重试,TypeScript SDK 还能通过 RetryableError 使用第三方返回的 Retry-After。参数错误、鉴权失败或不支持的输入应该转为 Terminal Error,避免无限重试。

Restate 负责的是工作流的可靠推进,但不应该把它误解成第三方配额管理器。全局 QPS、租户公平性、供应商并发槽位和成本预算,仍然需要明确的流控策略。可以通过按 Key 的 Virtual Object 聚合请求,或在调用第三方前经过专门的限流服务。无论哪种方式,都应该把“已提交任务数”与“正在等待回调数”分开统计,因为后者并不等于正在消耗 Worker。

Go 与 Bun 应该怎样选择

从 Restate 的可靠性语义看,两者没有本质差异:Journal、Timer、重试、挂起和恢复由 Restate Server 与 SDK 协议提供。真正的差异在工程体验和部署边界。

选择 Go

Go 更适合固定边界的基础设施服务,例如统一的第三方 API Gateway、Webhook Receiver、Kafka/Redpanda Bridge 或高并发 I/O 服务。

它的优势是静态类型、成熟的 context.Context 取消传播、可控的 HTTP Transport、较低且稳定的资源开销,以及单文件部署。Restate Go SDK 还可以包装外部 Context,把 OpenTelemetry 和自定义值传入 RunContext

需要注意的是,Restate 的并发不是普通 Go 并发。业务开发者必须接受 Future/Wait 模型,不能随手用 goroutine 组合 durable operation。否则代码表面上并发,恢复语义却可能不确定。

选择 TypeScript + Bun

Restate TypeScript SDK 官方支持 Node.js、Bun 和 Deno。Bun 很适合快速变化的 AI Workflow:TypeScript 类型、原生 fetch、Promise 组合和 npm 生态,可以让 SDK/API 集成代码保持很短。

它的表达方式也更接近工作流本身,ctx.runctx.awakeableRestatePromise.race 都容易阅读。对于需要频繁替换模型供应商、Prompt、工具调用和 Schema 的团队,这通常比 Go 少很多胶水代码。

代价是必须锁定 Bun、Restate SDK 和第三方客户端版本,并在真实环境测试 HTTP/2、TLS、连接池、AbortSignal、内存峰值与 SDK 兼容性。Bun 能运行 TypeScript SDK,不等于所有 Node.js 依赖在边缘行为上都完全一致。

我的选择会是:变化快、AI SDK 密集的编排层优先 Bun;协议稳定、吞吐敏感、承担 Webhook/Kafka/网络边界的服务优先 Go。它们也不必二选一,Restate 的 Service Invocation 可以让 Bun Workflow 调用 Go Service,并保留同一条 durable execution chain。

可观测性:从“进程日志”转向“Invocation 时间线”

长耗时任务最难排查的地方,是它的大部分时间可能什么都没有执行。因此观测对象不能只是一段进程日志,而应该是 Invocation 的完整生命周期。

Restate Web UI 可以查看 Service、Invocation 状态和 Journal。对于一次第三方任务,应该能区分:

  • 请求是否已被 Restate 接收;
  • submit-provider-job 是否完成,还是正在重试;
  • 当前是在 Timer、Awakeable 还是 Durable Promise 上等待;
  • Webhook 是否成功 resolve;
  • 恢复后执行了哪些 step;
  • 是否因为 Terminal Error、取消或重试耗尽而结束。

Restate 还能为 Invocation 与 Context Action 导出 OpenTelemetry Trace,并通过 W3C Trace Context 关联入口请求。生产环境应该继续在 Go HTTP Client 或 Bun 的第三方 SDK 中创建子 Span,并至少记录 provideroperationjob_idattemptresult 等低基数字段。API Key、完整 Prompt、回调 Token 和用户隐私数据不应该进入 Span。

系统层则使用 Prometheus Metrics。Restate Server 默认在 NodeCtl 的 5122/metrics 暴露指标,并提供官方 Grafana Dashboard。除了服务器吞吐、P99、存储和 Invocation Task 指标,我还会增加业务指标:提交延迟、完成延迟、回调失败、重试次数、Terminal Error、等待任务数量,以及按 Provider 分类的成功率。

日志、Trace 和 Metric 最终都应该用同一个 Invocation ID 与业务 Request ID 关联。这样一次任务即使跨越数次重启、一次 Webhook 和多个服务,也仍然能从业务订单一路追到具体 Journal Entry。

一个推荐的生产架构

对于需要等待三方 API 的系统,我会采用下面的职责划分:

Business API -> Restate Workflow -> Provider Adapter -> Third-party API

Third-party Webhook -> Signature Verification -> Resolve Promise -> Workflow resumes

Workflow 保存业务状态和步骤顺序;Provider Adapter 负责超时、错误映射、Idempotency Key 与供应商差异;Webhook Handler 只做认证、去重和唤醒;最终结果通过可靠调用或事件返回业务系统。

每一个外部副作用都拥有稳定的业务 ID,每一个等待都拥有 deadline,每一个重试都能解释,每一个状态转换都可以从 Invocation、Trace 或 Metric 中找到证据。

总结

Restate 最吸引我的地方,不是又提供了一个 Workflow DSL,而是让普通的 Go 和 TypeScript 代码拥有了可恢复的执行历史。进程可以重启,等待可以持续数月,已经完成的 step 不需要重新执行,而业务流程仍然保持接近普通函数的写法。

对于长耗时第三方 API,同步请求应该使用有界的 ctx.run;真正长时间的任务应该采用提交 + 回调,再使用 Awakeable 或 Durable Promise 挂起。Timer 负责可靠的 deadline 与延迟调度,Journal 负责恢复,OpenTelemetry 与 Prometheus 则负责解释系统现在究竟发生了什么。

Go 和 Bun 的选择最终不是“谁更耐久”,而是谁更适合服务边界。Go 适合稳定、基础设施化和高吞吐的适配层;Bun 适合快速迭代、SDK 密集的 AI 编排层。把两者放在 Restate 后面组合使用,往往比强迫整个系统只使用一种语言更自然。

I hope this is helpful, Happy hacking…