一个截止时间,而不是许多超时
每层独立设置固定超时表面安全。五层调用各 10 秒,可能让请求存活 50 秒,远超用户早已关闭页面的时间。上层先超时,下层却继续工作,占用连接和容量,产出无人读取的结果。
超时预算反过来计算。边缘设置截止时间,例如 2 秒。处理器调用依赖前先减去已经花费的时间,并传递剩余预算。只剩 1.6 秒时,下一次调用获得 1.6 秒减去安全余量。预算耗尽时,处理器停止并返回超时响应,不再启动新工作。
export function remainingMs(deadline: number): number {
return Math.max(0, deadline - Date.now());
}
export async function callWithinBudget<T>(
deadline: number,
operation: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const budget = remainingMs(deadline);
if (budget < 50) throw new Error('request budget exhausted');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), budget);
try {
return await operation(controller.signal);
} finally {
clearTimeout(timer);
}
}
截止时间应放在请求上下文中传递。Node.js 中接受调用方的 AbortSignal,再与本地定时器组合;Go 中使用派生自入站 context 的 context.WithDeadline。不要在中间层新建后台 context,否则截止时间会丢失。
重试也要消耗预算
重试不是免费的。三次重试、每次两秒,加上退避,最坏情况远超六秒。只有剩余预算能容纳时,这种策略才成立。只剩 80 毫秒时开始重试,无法在合理时间内成功,也不应执行。
预算感知的重试在每次尝试前检查截止时间,并把单次尝试超时限制在剩余时间内。它还要区分可重试失败和调用方取消。如果调用方已经取消,重试只是浪费,并可能掩盖取消。
传播取消
只让调用方停止等待不是取消。远程服务可能继续处理,客户端可能仍持有连接。应把取消信号传播到 HTTP 请求、数据库驱动和消息消费者。记录工作停止的原因:截止时间、调用方取消或关机。不同原因对应不同指标和响应。
权衡
较短预算保护延迟,但增加部分结果和重试的概率;过长预算掩盖依赖问题并占用资源。测量端到端延迟分布,从产品容忍度设定边缘预算,再为每一层分配比例并留出序列化和网络抖动余量。调用图变化时重新审视预算;新增一跳却没有传播预算,会静默破坏这个不变量。
何时使用
当请求跨多个服务或启用重试时,使用超时预算。优点是端到端延迟可预测:调用方截止时间是唯一需要关心的限制。缺点是每一层都必须接受并转发截止时间,需要共享上下文管道。与独立的逐跳超时相比,预算实现更复杂,但避免了隐藏的累加延迟。