数字人口播的「分析」功能从视频提取口播文稿、分镜脚本、结构卖点,需要下载视频、压缩、调 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: 任务 IDstatus: 进度(running / completed / failed)operationId: 幂等 IDchargedPoints: 预扣费用resultPayload: 分析结果的 JSONerror: 失败原因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 同时操作,只有一方会成功。
具体实现模式
数字人口播的分析异步化采用了这样的模式:
- 提交阶段(同步):
- 校验用户和资源
- 判重:是否已有进行中的任务(409)
getObject拉视频,检测时长(若≤0 返 400 不扣费)chargeResource预扣费用,获得 operationId- 创建
SkyhumanTask{ status:running, operationId, sourceObjectKey }记录 scheduleTask丢到后台队列- 返回
{ taskId }
- 后台运行阶段:
- 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 定时器
- 查询阶段(前端轮询):
GET /api/workflow/dub/tasks/:id返回{ status, resultPayload, error }- 若 status=completed 或 failed,停止轮询
- 若查询接口返回 5xx,则后续重试
- 清理阶段(reaper):
- 后台定期扫表
- 对于
status=running && updatedAt > 90s的任务,判定 worker 已死 - 若 providerTaskId(这里没有)存在,调用上游的 finalize API
- 若不存在,直接
refundResource(operationId)+ 置 failed - 本设计无 providerTaskId,所以 reaper 自动走退款分支,无需改动 reaper 代码
这个模式的关键是三道防线:提交时判重(409)、后台心跳(定期 touch)、reaper 兜底(自动退款)。任何一个环节出问题,都有后续环节来补救。
防线缺一不可
长耗时任务不能挂在 HTTP 连接上不只是超时问题,而是连接脆弱性与重复处理的共谋。异步化表面是"返回 ID、后台跑"这一步,真正的难点在三个维度的同时保证:幂等(不重复扣费)、持久化(不丢数据)、失败语义(不破坏重试逻辑)。缺一就会在某个场景暴露。
■