教程
hiapi /v1/tasks 任务挂起或超时:诊断与重试手册
面向开发者的实战手册:调试卡住的异步任务、调整轮询节奏,以及决定何时重试、何时重新提交
异步任务为何会挂起和超时
/v1/tasks 上的异步任务是三方之间的契约:你的客户端、HiAPI 平台,以及真正执行该工作的计算资源。挂起几乎总是其中某一层在等待另一层,而调试的第一件事就是弄清是哪一层——因为每一层的修法都不同,盲目重试只会让情况更糟。
你脑海中应牢记的生命周期如下:
生命周期,逐阶段说明
- 提交(Submit)——你的客户端发出请求,平台接受并返回一个任务标识符。
- 入队(Enqueue)——任务被调度。这一阶段在高负载下最容易变慢,也最不为你所见。
- 运行(Run)——计算资源处理该任务。此处耗时完全取决于工作负载,与你的 HTTP 客户端无关。
- 完成(Complete)——记录一个终态,并可通过同一个任务标识符读取。
“挂起”很少是单一事件,通常属于以下几种情况之一:
- 任务确实仍在运行。 长输入、大输出或繁忙的队列都会拉长第 3 阶段。什么都没坏,你只是太心急了。
- 任务已入队但尚未开始。 第 2 阶段可能拖得很长,而在你这边看起来和挂起一模一样。
- 你的客户端过早放弃了。 你把 HTTP 超时设在了状态查询上,而不是任务上,于是平台一切正常,你的进程却中止了。
- 任务已进入某个终态,但你没检测到。 你的轮询循环在拿到答案之前就停止了、崩溃了或退出了。
- 真正的平台侧故障。 更少见,但确实存在,也是唯一应当想到平台级重试的情况。
核心误区
最常见的自找超时,是把两个时钟混为一谈。你用来查询状态的 HTTP 请求应当很短——它只是一次读取。任务则有自己的、长得多的截止时间。如果你对两者使用同一个超时值,就会取消本该健康的任务,然后再重试它们,把即将完成的工作重复一遍。
在动手改任何东西之前,先判定你实际处于上面五种情况中的哪一种。本手册其余内容都取决于这个答案。
第一步:收集任务证据
克制住在第一分钟就改代码或发起重试的冲动。挂起是一种只有在你记录了足够多信息之后才能事后调试的状态。在介入之前先抓取以下内容。
每个任务应记录什么
- 提交时返回的任务标识符。 这是通往一切的关键;没有它你只能靠猜。
- 你最后观察到的状态字符串,逐字记录,不要转述。
- 时间戳:你何时提交、每次状态查询何时发出、每次查询何时返回。
- 你执行的状态查询次数及其间隔。
- 请求负载的形态——未必是完整内容,但要足以复现:输入规模、参数,以及任何影响运行时长的东西。
- 你设置的客户端侧截止时间(如果有),以及它是否触发。
对同一标识符手动再查一次
在得出任何结论之前,对同一任务标识符发起一次全新的状态查询。会有两种结果:
- 状态有推进。 任务还活着;你遇到的是轮询或超时 bug,而不是任务 bug。请转到轮询一节。
- 状态未变且任务已很旧。 现在你有了一个真正疑似卡住的任务。记下它在该状态中停留了多久,并与你的预期作对比。
把它写下来
每次尝试保留一行简短日志:标识符、状态、已耗时、所做的决定。反复出现的挂起几乎总是靠历史记录诊断出来的,而不是靠现场事件。如果你无法还原某个任务一小时前做了什么,你就会不断重复调试同一个故障。
还有一项容易被忘记的证据:一天中的时间和工作负载类型。如果挂起集中在某一类输入上,你发现的就只是一个值得上报的模式,而不是值得重试的偶发故障。
正确解读任务状态
状态是每个重试决策的事实依据。把它当作一个小型状态机,让每个状态精确对应一个动作,这样你就绝不会重试仍在运行的任务。
你会看到的状态及其含义
- Queued / pending(已排队/待处理)——已接受,未开始。在这里停留很久意味着调度压力。不要重试;任务已经存在。
- Running / processing(运行中/处理中)——计算资源正在工作。没有任何问题。等待就是正确的动作。
- Succeeded / completed(成功/已完成)——终态。读取结果,收工。
- Failed / error(失败/错误)——终态,其错误负载是你最好的调试材料。做任何决定之前先读它。
- Cancelled / expired(已取消/已过期)——因策略或你自己的操作而进入的终态。是否重试是一个全新的决定。
- Unknown 或缺失——标识符无法识别。这不是失败状态,而是证据状态。重读标识符,确认你看的是正确的那一个。
面对任何状态都要问的两个问题
- 这是终态吗? 如果不是,唯一正确的动作就是在你的预算内继续等待。
- 如果是终态,它可重试吗? 成功不可重试。失败或许可重试,或许不可——先读错误。未知标识符需要调查,而不是重试。
不要用已耗时推断状态
“它已经跑了十分钟,所以肯定卡住了”是猜测,不是诊断。已耗时只告诉你何时去查询,从不告诉你状态是什么。一个任务可以在队列中待上很长一段时间,然后完美完成。让状态字符串去做结论;你的时钟只决定你多久问一次。
设计合理的轮询节奏
大多数“挂起”都是伪装起来的轮询 bug。主要有两种失效模式:轮询过于激进,反而损害了自己的可靠性;以及轮询过于天真,永远察觉不到终态。有界节奏能同时解决这两者。
起步要慢,退避要温和
不要在紧密循环中轮询。先用较短的间隔,让快任务迅速有结果,然后随着任务变老而增大间隔:
import time
def poll(job_id, check_status, deadline_s=600):
start = time.monotonic()
interval = 1.0
while True:
status = check_status(job_id)
if status in ("succeeded", "failed", "cancelled", "expired"):
return status
if time.monotonic() - start > deadline_s:
return "client_deadline_exceeded"
time.sleep(interval)
interval = min(interval * 1.5, 30.0) # 设上限
关键性质:初始间隔短到能捕获快任务,增长因子让长任务保持低成本,以及一个上限,使你绝不会睡上一个小时而错过结果。
始终设置客户端侧截止时间
永远不要假定平台会替你发出完成信号。设好你自己的截止时间——你愿意等待的最长时间——并把超出它视作一种独立结果,而不是失败。这个区分很重要:client_deadline_exceeded 结果的意思是“我不再看了”,而不是“任务死了”。请按此记录。
两条实用规则
- 限制查询总次数。 没有最大迭代次数的循环就是一次等待发生的挂起。根据你的截止时间和间隔上限把它算出来。
- 把读取超时与任务截止时间分开。 获取状态的 HTTP 调用应有自己的短超时。如果状态读取超时,重试这次读取——绝不要重试任务。
尊重端点
轮询便宜,但并非免费。如果你要检查数百个任务,请把它们错开,并复用同一个客户端。拿不准时,更慢几乎总是更安全的方向:一个值得等六分钟的任务,也值得等六分钟零三十秒。
在重试与重新提交之间做决定
这是正确性与成本相互碰撞的决策。重试同一个任务与提交一个新任务不可互换,选错要么重复工作,要么让一个健康任务被搁置。
决策规则
按顺序逐条处理:
- 任务是终态吗? 如果不是,不要重试,也不要重新提交。等待。对仍在运行的任务重试会重复工作。
- 如果是终态,状态说了什么? 成功意味着你已完成。失败带有错误负载——先读它。
- 该失败可能是暂时性的吗? 高负载下的超时、短暂不可用、限流——这些都是重试候选。
- 该失败可能是永久性的吗? 无效输入、不支持的参数、格式错误的负载——无论你试多少次,它们都会以同样方式失败。请修正请求。
在以下情况重试同一任务……
任务存在,且因某个可能不会复现的原因进入终态失败,并且平台对该任务的语义允许再试一次。你复用标识符,保留一条历史记录,并避免创建一个做同样事情的第二个任务。
在以下情况作为新任务重新提交……
- 标识符未知或已无法获取——没有任何东西可重试了。
- 原任务已按策略明确过期,重试无处附着。
- 你需要更改输入。 重试是同一个请求;修正则是新请求。绝不要让重试的含义与原始请求不同。
成本的不对称性
一次重复仍在运行任务的重试,会让一个结果的工作量翻倍。重新提交则是一个明确的全新任务——新标识符、新生命周期,以及一笔新的开销。两者都是正当工具;都应当审慎使用。经验法则:对基础设施不确定时重试,对输入确定时重新提交。
让重试变得安全
悄悄重复工作的重试比挂起更糟,因为它静默失败并且花你的钱。要在需要之前就把安全性内建进去。
用幂等性的思路思考
对你运行的每一种任务都问一句:如果它运行两次,结果仍然正确吗? 如果答案是否定的——比如该任务会追加一行记录或触发下游副作用——那么你的重试逻辑就必须以状态为门槛,绝不能用定时器。只重试终态失败,并且只在一次全新的状态查询确认该状态之后。
限制尝试次数
每个重试循环都需要最大尝试次数和最大总耗时,并且两者都要显式检查。无界重试循环不过是一次更慢、还多花钱的挂起。预算耗尽时,要把失败暴露出来——不要悄悄吞掉它。
在尝试之间加入退避
重试尝试本身也应拉开间隔,并随每次失败而增长。对已出故障的依赖立即重试,通常只会再次失败,并给本已吃力的系统增加负载。
记录尝试,而不仅是错误
每次尝试都要记录:任务标识符、做决定时的状态、尝试序号、使用的间隔以及结果。这就是“它有时会挂起”和“它在周二下午处理大输入时会挂起”之间的差别。后者可修,前者不可修。
永远不要掩盖真正的失败
如果一个任务失败了三次,而你的包装层只返回一个笼统错误,你就丢弃了说明原因的那些错误负载。要把最后一次的状态和错误详情一路保留到调用方。重试是恢复机制,不是遮掩机制——它应当减少底层问题浮现的机会,而不是减少它被看见的机会。
反复挂起的事后复盘清单
挂起发生一次,修好当下那个任务即可。发生两次,就跑一遍这份清单——否则你只是在永远为重试付费。
清单
- 给事件分类。 是你停止观察的仍在运行任务、真正卡住的任务、客户端超时,还是你没能读取的状态?能说清它,就已经解决了一半。
- 检查轮询计算。 你的间隔上限、截止时间和尝试上限真的生效了吗?有没有哪个循环是无界的?
- 确认超时是分开的。 你的状态读取超时是否曾与任务截止时间混淆?
- 复查重试决策。 有没有哪次重试是对非终态任务发起的?有没有哪次重新提交重复了仍在运行的任务?
- 检查失败负载。 错误是被保留了,还是被包装层吞掉了?
- 寻找输入模式。 失败是否按负载大小、参数或工作负载类型聚集?
- 重新审视预期。 任务超过你的截止时间,是不是因为工作确实就有那么大?如果是,那 bug 就在截止时间上。
把发现转化为默认配置
上面每一项都应转化为你客户端里的默认做法:一个有界轮询器、一种独立的截止时间结果、一套由状态驱动的重试策略,以及以任务标识符为键的结构化日志。做到这些,下一次挂起就只是日志里的一行,而不是一个下午。
异步任务是一份契约,只要你尊重它的状态,契约就成立。把仍在运行与永远无法完成区分开,以有界的节奏等待,只重试那些可以安全重试的东西——这样挂起就仍是小麻烦,而不会变成故障。