一个接口的超时设为 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. 自测
- 预算的四条约束里,哪一条是被违反得最多的?下游超时等于上游会导致什么后果?
- 并行调用为什么取最大值而不是相加?预算够不够取决于什么?
- 重试的前提是什么?剩余预算不足时重试会造成什么?
- 客户端断开后继续计算为什么是浪费?它可以用什么指标观测?
- "剩余预算的分布"为什么比总耗时更容易定位问题?
- 判断"是否需要精细分配预算"的那个问题是什么?