故障档案

三个超时机制,一起把长任务掐死了

长耗时分析请求(几十秒到分钟级)用同步 HTTP 导致网关超时、CDN切断、客户端重试重复扣费。改异步任务需同时解决幂等、持久化、失败语义。

数字人口播的「分析」功能从视频提取口播文稿、分镜脚本、结构卖点,需要下载视频、压缩、调 Gemini 视觉 API,耗时从几十秒到几分钟。最初同步实现:前端调 POST /api/workflow/dub/analyze-parsed,后端直接跑完再返回。这个方案暴露的问题不是单点,而是三个独立的缺陷叠加

网关超时、CDN 空闲切断、客户端重试——这三个没有一个是"bug",每个都是合理的系统设计。但组合在一起就是连锁灾难:客户端看不到结果(超时或连接断),认为失败了重试,后端却已经扣过费了;或者分析其实已跑完,但因为响应发不出去,重试又跑了一遍。退款时无法幂等判断,某些用户最终被多扣了好几倍。

具体来说,三个问题各自是什么:

第一个问题是网关超时。云平台的入口网关(API Gateway)有一个固定的读超时,通常设为几十秒。当后端分析耗时超过这个阈值,网关主动断连接,客户端收到 504 或连接重置,但后端的分析进程并不知道客户端已经走了,继续跑完整个流程。如果分析结果的退款逻辑放在 try-catch 的 finally 里,此时 HTTP 响应已发不出去,客户就看到"分析失败",但款已经扣了。

第二个问题是 CDN 空闲切断。不少 CDN 和负载均衡层有这样的策略:如果一条 HTTP 连接在某个时间段内没有数据往返,就认为它已死而主动关闭。这与 FA-013 那次踩到的问题一样——长连接因为没有心跳数据而被 CDN 中间件切断,导致请求被突然中断。这个机制在分析请求上也会造成麻烦:如果分析用时较长,连接就可能被切,响应发不出去。

第三个问题是客户端重试导致重复扣费。当客户端没有收到响应(网关超时或 CDN 切断),一般会重试这个请求。问题在于这次重试是一条完全独立的请求,后端会把它当作新任务处理,再次扣费。而且如果第一次的分析实际上已经跑完了,就会出现"分析跑了两遍、扣费扣了两遍、用户只看到了一个结果"的情况。如果第一次分析中途被 CDN 切了,第二次重试重新开始,那扣费可能是三倍。而退款的时候,因为业务流程上没有幂等设计,很难追溯哪个 operationId 应该被退,哪个不应该。

这三个问题是叠加的,不是独立的。它们共同指向一个根本的架构问题。

根本原因一个:不该让耗时任务挂在 HTTP 连接上

异步任务是必需的,不是可选的

解决方案很简单:把分析改成异步任务模式。

前端提交分析请求直接返回任务 ID(HTTP 耗时极短:校验参数、建数据库记录、预扣费)。后端后台 worker 跑分析重逻辑,完成后持久化结果。前端轮询查询状态,得到结果后停止。

客户端              后端
  |                 |
  +--提交---------->|
  |             创建任务,返回 ID
  |<--返回 ID-----+
  |                 |(后台运行分析)
  |                 |
  +--轮询--------->|
  |             查询状态
  |<--返回状态---+
  |                 |
  |(重复轮询)
  +--轮询--------->|
  |             查询状态
  |<--返回结果---+
  |                 |

好处显而易见:

  • 单次 HTTP 请求的耗时从分钟级降到秒级,不会触发网关或 CDN 的超时。
  • 后端分析的运行生命周期与 HTTP 连接完全解耦。即使连接在中途被切,已经启动的分析会继续在后台跑,结果最终还是会被保存。
  • 客户端重试不会产生新的分析任务。只要实现幂等判重,同一个请求在短时间内重复提交也只会产生一个任务。

但异步化不是万灵药,它把问题转移到了三个新的维度上:幂等、持久化、失败语义

幂等:同一请求只产生一个任务

假设客户端因为网络抖动,在短时间内连续发了两次"开始分析"请求,后端收到两条独立的 HTTP 请求。如果没有幂等设计,就会创建两个任务、扣两次费。

幂等的实现最直接的办法是在提交阶段就判重:为这个用户、这个资源设置一个"只有一个进行中的分析任务"的约束。素材中的设计是这样做的:

if 该用户已有 status=running && kind=analyze_parsed 的任务:
    return 409 Conflict(分析已在进行中)

这样双击提交同一个视频,第一次返回 201 + taskId,第二次返回 409,客户端看到 409 就知道不要再建新任务,拿之前返回的 ID 去轮询。

另一个幂等层次是计费操作的幂等。预扣费的时候用一个全局唯一的 operationId(比如 dub-analyze:{uuid}),这个 ID 关联了这次预扣。如果扣费 API 被重复调用,因为 operationId 重复,系统只会扣一次。退款时也用同一个 operationId,保证退款与预扣能正确配对。

持久化:进程重启后任务不丢

后台分析 worker 可能因为发版、机器重启、容器被杀等原因在中途退出。如果任务的状态只保存在内存里,进程一死任务就丢了。

持久化的关键是任务状态表。素材中复用了既有的 SkyhumanTask 表,包含字段:

  • id: 任务 ID
  • status: 进度(running / completed / failed)
  • operationId: 幂等 ID
  • chargedPoints: 预扣费用
  • resultPayload: 分析结果的 JSON
  • error: 失败原因
  • updatedAt: 最后更新时间戳
  • sourceObjectKey: 源视频对象路径(用于幂等判重和重试)

任务创建时插一条 status=running 的记录。后台 worker 定期通过 heartbeat(每 30 秒做一次空 update,仅触碰 updatedAt)来证明自己还活着。分析完成时更新为 status=completed, resultPayload=...;失败时更新为 status=failed, error=... 并触发退款。

这样即使 worker 进程突然死了,任务记录还在数据库里。新启起的 worker 可以扫描表里的 status=running && updatedAt 过期 的任务,判断它们已经没有 worker 在跑了,执行清理逻辑。

失败语义:区分"任务失败"和"查询失败"

异步设计引入了一个新的错误维度:客户端查询任务状态时得到的 404,可能是"这个任务 ID 不存在"(用户输错了),也可能是"任务 ID 之前存在但已被清理"(进程清理了过期数据)。这两种情况的含义很不一样。

更重要的是,前端轮询收到的错误需要区分是否应该重试:

  • 任务本身失败status=failed, error="Gemini API 返回错误"):这种情况不该重试,因为重试也会失败。应该直接向用户展示失败信息,并根据 operationId 触发退款。
  • 查询接口失败(500、网络超时):这种情况应该重试查询,因为任务本身可能还在继续跑。

素材中的设计把这两类区分得很清楚:

  • 任务最终的状态(completed 或 failed)持久化到数据库,客户端查询会拿到明确的业务语义。
  • 查询接口的 HTTP 错误(5xx)是技术层故障,客户端应该重试。

同时,后端的 reaper 机制(定期扫表清理超期任务)也遵循这个语义:

  • 如果一个 status=running 的任务超过 90 秒没有 heartbeat 更新,说明 worker 已死,reaper 会强制置为 failed 并退款。
  • 但这个强制置为 failed 不应该抛错,因为此时可能有其他进程也想更新这个任务。更新语句应该带条件:UPDATE SkyhumanTask SET status=failed WHERE id=? AND status=running,这样如果 reaper 和其他 worker 同时操作,只有一方会成功。

具体实现模式

数字人口播的分析异步化采用了这样的模式:

  1. 提交阶段(同步):
    • 校验用户和资源
    • 判重:是否已有进行中的任务(409)
    • getObject 拉视频,检测时长(若≤0 返 400 不扣费)
    • chargeResource 预扣费用,获得 operationId
    • 创建 SkyhumanTask{ status:running, operationId, sourceObjectKey } 记录
    • scheduleTask 丢到后台队列
    • 返回 { taskId }
  2. 后台运行阶段
    • Worker 取出任务记录
    • 开启 heartbeat 定时器(每 30s update 一次 updatedAt)
    • 执行 analyzeDubVisionOnly(下载→压缩→调用 Gemini→解析)
    • 成功:update( status:completed, resultPayload )
    • 失败:update( status:failed, error ) + refundResource(operationId)
    • 所有 update 都带条件 WHERE id=? AND status=running(防被 reaper 覆盖)
    • Finally 清理 heartbeat 定时器
  3. 查询阶段(前端轮询):
    • GET /api/workflow/dub/tasks/:id 返回 { status, resultPayload, error }
    • 若 status=completed 或 failed,停止轮询
    • 若查询接口返回 5xx,则后续重试
  4. 清理阶段(reaper):
    • 后台定期扫表
    • 对于 status=running && updatedAt > 90s 的任务,判定 worker 已死
    • 若 providerTaskId(这里没有)存在,调用上游的 finalize API
    • 若不存在,直接 refundResource(operationId) + 置 failed
    • 本设计无 providerTaskId,所以 reaper 自动走退款分支,无需改动 reaper 代码

这个模式的关键是三道防线:提交时判重(409)、后台心跳(定期 touch)、reaper 兜底(自动退款)。任何一个环节出问题,都有后续环节来补救。

防线缺一不可

长耗时任务不能挂在 HTTP 连接上不只是超时问题,而是连接脆弱性与重复处理的共谋。异步化表面是"返回 ID、后台跑"这一步,真正的难点在三个维度的同时保证:幂等(不重复扣费)、持久化(不丢数据)、失败语义(不破坏重试逻辑)。缺一就会在某个场景暴露。

星野的头像

星野 XINGYE

一个人维护 AI 平台的工程师。这里记录 63 篇复盘:18 份故障档案、OTA、架构演进与工作流。