延迟预算与尾部放大:完成条件决定延迟模型
一条聚合接口 300ms 的预算,会被"等待全部还是首个成功"的完成条件、分位的不可加性、冗余请求的放大一起重写。本页把调用图变成可实施的超时、排队和取消策略。预算与截止时间在 RPC 层如何落地与传递,见超时重试、熔断限流降级;本页讲方法与算例。
案例是商品聚合接口:网关鉴权后并行查询价格、库存、配送,最后组装返回。入口总 deadline 为 300ms,业务要求价格与库存必须正确,配送可以显示"稍后确认"。
分位不可加与完成条件
单请求的串行阶段实际耗时可以相加,并行等待全部时取最慢分支;等待首个成功、法定数量或可降级子集时,完成条件不同。P99 是总体的顺序统计量,各阶段的慢样本未必属于同一请求,所以相加阶段 P99 不能声称得到精确端到端 P99。即使各阶段测量准确,忽略排队、序列化、重试与客户端网络仍会少算。
设每个分支独立地以 99% 概率在阈值内返回,等待全部 n 个分支时,全体按时的概率是 0.99^n。三分支约 97.03%,100 分支约 36.60%;这说明单分支 P99 在大扇出下可能成为常态尾部。独立、同分布是演算假设,真实共享 GC、同区网络或热点会破坏它;不能把这个公式当成生产预测。The Tail at Scale讨论规模下的尾延迟与缓解策略,以下时间与费用均是自建教学场景。
逐步分配并传递剩余预算
分配网关 20ms、应用排队 30ms、核心依赖窗口 180ms、组装与返回 30ms、余量 40ms,总计 300ms。依赖并行窗口中,价格最多 150ms,库存最多 180ms,配送最多 100ms,不是把三者相加。价格与库存是否会竞争同一数据库连接池也必须验证;如果只提供一个连接,它们在代码中并发提交却可能实际串行。
预算应随已经消耗的时间递减。入口 t0 建立 deadline,t35 应用收到请求,剩余 265ms;此时若预算预留后处理 70ms,可给下游不超过 195ms,而不是重启一个新的 300ms。进程内用单调时钟比较经过时间,跨机器传播应使用协议支持的相对超时或明确时钟误差,不能把本机 nanoTime 数值交给另一台机器直接比较。
以下为 Java 教学伪代码,非 HTTP 客户端源码:
long deadline = System.nanoTime() + budgetNanos;
long remaining = deadline - System.nanoTime() - reserveNanos;
if (remaining <= 0) throw new DeadlineExceeded();
// 客户端还须分别配置连接、请求时限和取消后的资源回收。
return dependency.callWithTimeout(remaining);远程调用超时不意味着远端停止,Future.cancel 也不保证业务副作用撤销。应让服务端检查剩余时间、限制新工作、释放连接;对于写操作,还要用稳定业务键查询未知结果。AWS 超时、重试与抖动明确提醒 DNS、TLS 或连接建立可能不在某些请求超时覆盖范围内,实际客户端需要逐项确认。
失败时序和错误答案的反例
t0 聚合开始;t20 发出价格查询;t170 价格请求超时,但远端仍持有锁;t175 聚合层重试;t290 第二次超时;t300 上游已放弃;t340 第一次成功返回。若把两次尝试视为两个完整预算,既损害用户延迟又增加后端工作。对纯读可以丢弃迟到结果,但资源已支付;对"预占库存"的写操作,两个请求可能产生两次预占,必须用相同操作身份与可查询状态约束。
另一个常见错误是"超时设为下游平均值乘二"。假设 98% 请求 20ms,2% 请求 500ms,平均 29.6ms,乘二只给 59.2ms,正常长尾会大量被误判。应从允许的误超时比例、端到端期限、网络与冷连接成本共同选择,而非任意倍数。最终目标是用户成功完成率与资源稳定,不是让下游报表看起来更快。
两个候选方案:可选降级与延迟冗余
方案 A 将配送设为可选,100ms 未返回就降级;价格库存仍受总期限约束。额外流量小,语义清楚,代价是配送体验变差。必须让产品接受"稍后确认"并追踪配送降级率,不能返回编造的确定日期。
方案 B 对慢的只读库存查询在 80ms 后发第二份请求,首个成功即返回并尝试取消另一份。若 5% 请求触发,逻辑 2,000 QPS 增加约 100 QPS,约 5% 放大;如果共享瓶颈导致 60% 都触发,负载变为约 3,200 QPS,冗余会加重故障。需限制并发、总重试/冗余预算、选择相互独立副本,并禁止把这种策略直接用于无幂等保护的写操作。
当库存读取容忍陈旧而预占必须权威裁决,可以把浏览和结算拆成两种契约。两者有不同 deadline 和缓存策略,不能让浏览缓存的高可用证明结算正确。架构师应明确每种用户动作在超时后进入什么状态、由谁恢复,以及用户如何查询。
可执行推演与验收产物
执行 python3 labs/quality_models.py latency。输入分支成功率 0.99、分支数 3 和 100、总预算 300ms;预期分别得到 0.970299 与 0.366032 的全体成功概率并检查预算合计 300ms。脚本是概率与预算演算,不是真实调用链压测;完整脚本与主题其他算例共用。
进一步在纸上构造 100 个请求:A 在前两个请求中各耗 200ms,其余 10ms;B 在另两个请求各耗 200ms,其余 10ms。按最近秩定义,两个阶段 P99 均 200ms,但串行总耗时最大 210ms,P99 也为 210ms,证明 400ms 不是实际端到端 P99。然后让 A/B 的慢请求重合,端到端尾部可达 400ms,说明相关性改变结论。不要混用不同分位插值算法。
验收提交带完成条件的调用图、每条边的超时与重试责任、时间轴、可选功能降级率、取消后占用资源的观测方案。运行时要采集计划发出、实际发出、排队、完成和超时的时间;只记录成功请求会系统性漏掉最慢样本。
重试与冗余的预算账本
超时和重试最容易在评审里被当成两个独立参数,实际必须记在同一本账上:入口 deadline 是总预算,每次尝试、排队、冗余请求都从同一账户扣款。规则很简单——发出下一次尝试前,用剩余时间减掉返回预留,若不足以覆盖一次有意义的尝试,就不该发。
沿用本页失败时序:入口 300ms、返回预留 30ms,t0 建立 deadline,t20 发出价格查询,价格窗口配置 150ms 超时,t170 超时失败。此时可用预算为 300−170−30=100ms,第二次尝试最多只能拿 100ms,而不是重新获得 300ms。若客户端仍按同样的 150ms 超时重试,请求会越过用户期限继续占用连接、线程与后端资源,写操作还可能产生第二次副作用。账本要同时记录"计划发出、实际发出、完成、超时、放弃"五个时刻,否则只统计成功请求会系统性漏掉最慢样本。这里的 t170 是时刻不是时长;把时间戳当毫秒数会算出错误的剩余预算。
重试放大要按尝试次数算:单层首次失败后重试一次,期望尝试数为 1+p(p 为首次失败率);三层各自最多三次,最坏 3³=27 次。冗余请求的放大则是 1+触发比例:5% 触发约 1.05 倍,60% 触发约 1.6 倍——后者说明冗余在共享瓶颈下会自我加重。两者都要用全局令牌与总 deadline 封顶,不能每层各配一套完整超时。
执行 python3 labs/quality_models.py retry:输入入口 300ms、返回预留 30ms、t20 发出、150ms 超时于 t170,冗余触发 5% 与 60%。预期输出剩余 100ms、放大 1.05、1.6,以及"重试仍配同一 150ms 超时则超预算"的判定 OVER_BUDGET。脚本只做算术,不模拟真实客户端调度。
两个合理选择:A 每层独立重试,实现简单、局部可调,但最坏放大相乘且各层互相看不见 deadline;B 只在有业务上下文的单一入口重试,按剩余预算与令牌发放,放大可控、恢复动作集中,但要求该入口知道操作是否幂等、并能查询未知结果。本例选 B 并保留每层短超时;对无幂等保护的写操作,B 也必须先查询再决定,而不是盲目重发。
架构师变化条件
把完成条件从"等待全部"改为"价格+库存任一先到即返回、另一路丢弃":整体按时概率变为 1−(1−0.99)²≈0.9999 的上界(独立假设下),代价是丢弃路已支付的资源和一致性核对。把配送从可选改为关键(生鲜时效),方案 A 不可行,需在预算、冗余与扩容间重算。若下游共享同一 Redis 热 key,冗余请求不再独立,0.99^n 的演算前提被破坏——先消除共享瓶颈再谈冗余。每次变化都重画完成条件与责任边界,不沿用原图。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。