2527 字
约 8 分钟
16
S07-A15 · 一条请求的耗时预算:它能用多久

一个接口的超时设为 30 秒,数据库连接池超时 30 秒,HTTP 客户端超时 30 秒,网关超时 60 秒。某天下游变慢,请求全部挂在 29 秒才失败——而用户早在 5 秒时就关掉了页面。这 30 秒里,服务器什么都没为用户做到,只是占着连接、线程与内存。

这个视角换掉了评价标准:从"这个超时值合不合理"变成"这份预算有没有被某一层吃光"。后者才能解释"为什么总在 29 秒失败"这类现象。

超时不是一个数字,而是一份预算:从入口到出口,每一段只能分到它应得的那一份。而多数系统的超时是"各处独立拍一个数",于是预算永远对不上。


1. 预算的基本约束

约束 说明
下游 < 上游 否则等待无意义
总和 ≤ 入口 各段之和不能超总预算
留出返工余量 序列化、日志、重试
与用户耐心对齐 而非与后端能力对齐

第二项与第一项常一起被违反:每段单独看都合理,加起来却超过总预算。比如鉴权 200ms + 主查询 2s + 两个下游各 1.5s = 5.2s,而入口预算是 3s——预算在纸面上就已经超了。

第一项是硬规则:如果下游超时等于上游超时,那么上游的超时永远不会先触发,于是上游的兜底逻辑(降级、缓存)全部失效。

第四项最常被忽略:超时的上限应由"用户愿意等多久"决定,而不是由"我们的查询最慢要多久"决定。前者通常是几秒,后者可能是几十秒。

2. 一个可用的预算分配示例

段 预算 说明
入口总预算 3000ms 用户可接受
鉴权 50ms 本地缓存为主
主查询 800ms 带索引
关联查询(2 次) 400ms 批量取
下游调用(3 次,可并行) 600ms 取 max 而非 sum
序列化与写响应 100ms
余量 ~1050ms 重试、抖动、日志

第二项(主查询 800ms)的定法要落到索引:"带索引的点查"与"范围扫描"不在一个量级,所以同一个接口在不同数据量下可能需要不同预算,这件事只有压测能给出。

第五项的关键是并行调用取最大值而不是相加:三个并行调用的总耗时约为最慢的那个,而不是三者之和。预算够不够,取决于"串行层数"而不是"调用个数"。

// 按剩余预算派生下游超时:永远小于上游
var deadline = Deadline.of(requestStart, Duration.ofMillis(3000));
var remaining = deadline.remaining();                     // 已扣除已用时间
var callTimeout = min(remaining.multipliedBy(6).dividedBy(10), Duration.ofMillis(800));
// 取剩余的 60%:给后续步骤(序列化、其它调用)留出余地

3. 超时的四种行为

行为 适用
快速失败 用户可重试、无副作用
降级返回 有缓存或兜底数据
重试一次 幂等且失败是瞬时
排队等 几乎不适用

第二项(降级返回)要求系统里"有一份可退的旧数据",而这通常在缓存层:降级路径的存在与否,决定了超时是"用户体验问题"还是"数据可用性问题"。

第三项必须带前提:幂等 + 剩余预算足够。一次重试至少要占用"原调用耗时 + 一次完整调用",所以剩余预算不足时重试只会把超时推后。

第四项在同步接口里几乎总是错的:排队让"晚到但成功"与"早到但失败"混在一起,而用户早已离开。

// 三类操作的超时与重试策略
clients:
  read_cached:      { timeout: 200ms,  retries: 0, fallback: stale_cache }
  read_critical:    { timeout: 800ms,  retries: 1, backoff: 50ms }
  write_idempotent: { timeout: 1500ms, retries: 1, idempotency_key: true }
  write_unsafe:     { timeout: 1500ms, retries: 0 }

4. 超时错误该怎么返回

情况 返回
主查询超时、有缓存 200 + 降级标记
主查询超时、无缓存 504 + 可重试提示
下游超时、结果可缺 200 + 部分数据标记
客户端已断开 不返回(放弃)

第二项(部分数据标记)在实践中容易被做成"静默填空":缺失字段返回 null 或空数组,调用方按 0 处理,于是"少了一部分"变成了"没有了"。这比超时更危险,因为它不报错。

第四项常被忽略:客户端断开后继续算完并写日志,是纯粹的浪费。而它也能被观测到——"请求已取消但仍执行的 goroutine/线程数"是个很有用的健康度指标。

第三项要求接口能表达"部分成功",而不是全有全无。这需要一个 partial: true 之类的标记,否则调用方会把缺失当成 0。

预算还有一个"重试预算"的概念:每次重试都要从总预算里再扣一份。所以"总预算 3s,下游单次 800ms,允许重试 2 次"在数学上就不成立——3 次调用已经 2.4s,加上其它段必然超支。

// 重试预算的算法
总预算 3000ms
非下游段(鉴权+序列化+余量)   ≈ 600ms
可用于下游的总量               = 2400ms
单次下游 800ms → 最多 3 次(含首次)→ 允许 2 次重试,且必须带退避
若单次 1500ms → 最多 1 次 → 不允许重试,改用降级
// 结论:重试次数不是策略选择,是预算算术的结果

5. 观测:超时率与耗时分布

指标 用途
超时率(按下游) 定位谁在拖
耗时 P99 与超时阈值的关系 阈值是否合理
剩余预算的分布 预算是否够
取消率 用户是否已放弃

第一项要按下游分别统计:总超时率 3% 无法告诉你是因为 A 慢还是 B 慢,而它们的修法不同。

第三项最有用:在调用前打印"剩余预算",若大量请求的剩余预算已经接近 0,说明前面的段吃掉了预算。这比看总耗时更容易定位到哪一段在超支。

// 一条请求的预算账(日志里可加,平时可关)
t=0     entry                 remaining=3000
t=45    auth.ok               remaining=2955
t=112   cache.miss            remaining=2888
t=903   main_query.ok         remaining=2097   ← 主查询用了 791ms
t=1502  assoc_batch.ok        remaining=1498
t=2103  downstream.parallel   remaining=897    ← 三次并行取 max=601ms
t=2160  serialize+write       remaining=840    ✓ 有富余

还有一个"跨服务"的问题:下游服务的超时值往往由它自己定,而它的预算来自你的契约。所以调用方应当在契约里写清"我最多等你 800ms",否则下游会按自己的最慢查询来设超时,两边永远对不上。

// 契约里写清的两行(比口头约定有效)
接口:GET /internal/user/{id}
SLA: P99 ≤ 300ms,调用方超时建议 500ms
降级:超时返回 503,调用方应使用本地缓存

6. 排错:五种"超时设置不对"

现象 成因 处理方向
全部请求都在同一秒失败 超时值相同,同时触发 分层设置
上游超时了但下游还在跑 下游超时更大 下游必须更小
重试后总数翻倍失败 重试未看剩余预算 带预算判断
超时但错误率不变 超时被兜底成正常 加降级标记
用户已放弃但服务仍在算 未处理客户端断开 检测取消

第四项最危险:超时被静默兜底会让"可用性"看起来很好,而用户在等或已放弃。所以降级必须留下可观测标记。

7. 边界:什么情况不必精细分配

场景 为什么 做法
纯内存计算接口 无下游 一个总超时即可
后台批处理 无用户等待 用软超时与检查点
内部工具 无 SLA 简单设置
已有网关统一管理 平台负责 跟随平台

最后一条与"部署"有关的边界:超时值应当与实例数量一起考虑。实例少时,单个请求挂得久会占住更多比例的资源;实例多时,同样的挂起比例影响更小但总量更大。所以"扩缩容后超时该不该改"是个真问题。

判断口径一句话:这条请求的失败,用户会等多久才发现。用户几秒就放弃,超时就必须在几秒内;用户会一直等(如导出任务),超时可以放宽但要有进度。


8. 自测

  1. 预算的四条约束里,哪一条是被违反得最多的?下游超时等于上游会导致什么后果?
  2. 并行调用为什么取最大值而不是相加?预算够不够取决于什么?
  3. 重试的前提是什么?剩余预算不足时重试会造成什么?
  4. 客户端断开后继续计算为什么是浪费?它可以用什么指标观测?
  5. "剩余预算的分布"为什么比总耗时更容易定位问题?
  6. 判断"是否需要精细分配预算"的那个问题是什么?
S07-A15 · 一条请求的耗时预算:它能用多久
https://www.clxhxhhr.top/posts/6484/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。