42.5 超时、重试与幂等
一句话
RPC 层的超时、重试、幂等是同一个问题的三面:预算是有限的(deadline)、重试会成倍放大请求、幂等决定放大是否安全——deadline 必须随调用链传播,否则下游在替一个已经被放弃的请求白干活。
三种“超时”,别混着配
| 超时 | 含义 | 典型默认 | 配错的后果 |
|---|---|---|---|
| 连接超时 | 建连最多等多久 | 几百 ms | 太长:故障实例拖住调用方;太短:网络抖动时连不上 |
| 读超时(socket) | 一次读写最多等多久 | 与 RPC 超时混用 | 流式响应会被“两次数据间隔”误杀 |
| 整次调用 deadline | 从发起到放弃的总预算 | 由业务 SLO 决定 | 不传播 → 下游白干;不设 → 调用方线程无限期占用 |
🔑 只有第三种能表达“这次调用还能等多久”。前两种是传输层参数,无法回答“上游还剩多少预算”。
超时值定多少:取 P999,不要取平均
拍一个 “500ms” 是最常见的做法,也是最容易两头错的:
| 取什么当超时 | 后果 |
|---|---|
| 平均值 | 一半请求会被你主动掐断 |
| P99 | 1% 被误杀,可接受,但对核心接口仍偏激进 |
| P999 / P9999 | 误杀降到万分之一 / 十万分之一;代价是超时值偏大 |
两个方向的代价不对称:超时太短是“明明下游还在算,你已经放弃并重试”,下游白干还可能被重试压垮;超时太长是“线程被占住”,故障时拖垮调用方。所以取 下游真实水平的 P999,并满足一条硬约束:单次超时 < 上游 deadline 的一半,否则重试没有预算空间。
⚠️ P999 要用下游自己上报的真实耗时统计,不能拿调用方的“成功请求耗时”算——调用方看到的永远是截断后的值(超过超时的请求根本没被记录成 1 秒以上)。这就是“超时越紧、P999 越好看”的自欺循环。
备份请求:用 15% 的额外压力换长尾
长尾请求的常见处理是 “重试”,但重试是串行的:等完超时才重发,用户已经多等了一个超时。更聪明的做法是并行发两份、取先返回的:
| 做法 | 额外压力 | 问题 |
|---|---|---|
| 双发(所有请求都发两次) | ×2 | 后端压力翻倍,代价太大 |
| 备份请求(hedged) | 约 +15% | 只在“超过 P99 还没回”时才补发第二份 |
| 串行重试 | +超时时间的延迟 | 用户多等一个超时 |
可抄的参数(微博口径的实例,用于理解量级):
实例耗时:P90 = 50ms,P99 = 200ms,P999 = 1s
备份触发:超过 P99(200ms)才发第二份
最大备份比例:15%(超过就退回串行重试)
前提:被备份的请求必须幂等——两份都可能落地,写接口不能这么玩。所以备份请求只用于读接口或幂等写(带幂等键)。
熔断的三个数:窗口、阈值、睡眠期
| 参数 | 微博配置口径 | 作用 |
|---|---|---|
| 滑动窗口 | 10 桶 × 1s(共 10s) | 统计粒度过粗会把尖峰抹平,过细则频繁误开 |
| 错误率阈值 | 60% | 窗口内错误率超过即打开 |
| 睡眠窗口 | 数秒级 | 打开后等多久放一个探测请求(半开) |
| 单次超时 | 3000ms(Hystrix 注解里配的常见值) | 超时本身也算“失败”,计入错误率 |
🔑 熔断阈值要和超时一起看:超时 3s + 错误率 60% 意味着 “一次慢请求要占 3 秒才被算作失败”,窗口里能容纳的样本数很少,容易在低流量时误开或迟开。低流量服务应改用慢请求比例(超过阈值的请求占比)而不是错误率。
deadline 传播:省的是下游的命
gRPC 把 deadline 放在 grpc-timeout 元数据里随请求传递;服务端拿到的 ctx 已经带截止时间,任何下游调用都应该用这个 ctx 派生,而不是新建一个 context.Background()。
package main
import (
"context"
"fmt"
"sync/atomic"
"time"
)
const (
clientBudget = 250 * time.Millisecond // 用户愿意等的总预算
hopWork = 100 * time.Millisecond // 每一跳的处理耗时
)
// hop 模拟一跳 RPC:要么在 work 后完成,要么被 ctx 取消
// wasted 记录“客户端已经放弃之后,这一跳还干了多久”
func hop(ctx context.Context, clientDeadline time.Time, wasted *int64) (bool, error) {
start := time.Now()
select {
case <-time.After(hopWork):
done := time.Now()
if done.After(clientDeadline) {
from := start
if clientDeadline.After(from) {
from = clientDeadline
}
atomic.AddInt64(wasted, int64(done.Sub(from)/time.Millisecond))
}
return true, nil
case <-ctx.Done():
return false, ctx.Err()
}
}
func main() {
// ---------- 场景 1:每跳各配 300ms 超时,不传播 deadline ----------
fmt.Println("=== 场景 1:每跳独立超时 300ms(不传播 deadline)===")
clientDeadline := time.Now().Add(clientBudget)
var wasted1 int64
ok := true
for i := 1; i <= 3; i++ {
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
done, err := hop(ctx, clientDeadline, &wasted1)
cancel()
if !done {
fmt.Printf(" 第 %d 跳被取消: %v\n", i, err)
ok = false
break
}
fmt.Printf(" 第 %d 跳完成\n", i)
}
fmt.Printf(" 结果:客户端 %.0fms 已放弃,下游仍跑完 → 白做 %d ms\n\n", clientBudget.Seconds()*1000, atomic.LoadInt64(&wasted1))
// ---------- 场景 2:deadline 随 ctx 传播 ----------
fmt.Println("=== 场景 2:deadline 传播(ctx 派生)===")
ctx, cancel := context.WithTimeout(context.Background(), clientBudget)
defer cancel()
clientDeadline2 := time.Now().Add(clientBudget)
var wasted2 int64
for i := 1; i <= 3; i++ {
done, err := hop(ctx, clientDeadline2, &wasted2)
if !done {
fmt.Printf(" 第 %d 跳被取消: %v\n", i, err)
break
}
fmt.Printf(" 第 %d 跳完成(剩余预算 %v)\n", i, time.Until(clientDeadline2).Round(time.Millisecond))
}
fmt.Printf(" 结果:最后一跳在截止时刻被取消 → 白做 %d ms\n\n", atomic.LoadInt64(&wasted2))
// ---------- 场景 3:每跳重试 1 次,最坏放大 ----------
fmt.Println("=== 场景 3:每跳重试 1 次(每跳最多 2 次尝试)===")
attempts := 1
for i := 1; i <= 3; i++ {
attempts *= 2
}
fmt.Printf(" 3 跳的最坏执行次数 = 2^3 = %d 次,总工作量 = %d × %v = %v\n",
attempts, attempts, hopWork, time.Duration(attempts)*hopWork)
fmt.Printf(" 而客户端预算只有 %v —— 重试必须在 deadline 内收敛,否则放大的是“白干”\n", clientBudget)
_ = ok
}
本机实测(macOS,Apple M1,Go 1.27):
=== 场景 1:每跳独立超时 300ms(不传播 deadline)===
第 1 跳完成
第 2 跳完成
第 3 跳完成
结果:客户端 250ms 已放弃,下游仍跑完 → 白做 53 ms
=== 场景 2:deadline 传播(ctx 派生)===
第 1 跳完成(剩余预算 149ms)
第 2 跳完成(剩余预算 48ms)
第 3 跳被取消: context deadline exceeded
结果:最后一跳在截止时刻被取消 → 白做 0 ms
=== 场景 3:每跳重试 1 次(每跳最多 2 次尝试)===
3 跳的最坏执行次数 = 2^3 = 8 次,总工作量 = 8 × 100ms = 800ms
而客户端预算只有 250ms —— 重试必须在 deadline 内收敛,否则放大的是“白干”
读数:
| 场景 | 结果 | 含义 |
|---|---|---|
| 独立超时 | 客户端 250ms 放弃,下游跑到 300ms,白做 53ms | 下游在为一个已经失败的请求写库/发消息,用户却看到超时 |
| deadline 传播 | 最后一跳在 250ms 被取消,白做 0ms | 预算在链路上“用完即止”,不会出现“客户端已放弃、服务端还在干活” |
| 每跳重试 1 次 | 最坏 8 次执行、800ms 工作量 | 重试是乘法;3 跳各重试 1 次,就把一次调用放大成 8 次 |
🔑 结论:deadline 传播让“放弃”发生在正确的位置。没有传播时,放弃只发生在客户端(它不等了),下游该干的活一点没少干——这些活不仅浪费资源,还可能产生副作用(比如用户重试后又下了一单)。
重试该放在哪一层
| 位置 | 能做什么 | 风险 |
|---|---|---|
| 客户端 SDK/拦截器 | 统一重试、退避、重试预算 | 不判幂等就重试 = 重复副作用 |
| 代理/Sidecar | 与语言无关,可做连接级重试 | 看不到业务语义,只能对“可安全重试”的方法开 |
| 业务代码 | 知道哪些操作幂等、如何补偿 | 每个调用点各写一遍,容易漏 |
⚠️ 三层同时开重试是最常见的生产事故:
3 × 3 × 3 = 27倍放大,故障时把下游彻底打死(35.5 的“一层的勇气,三层的灾难”)。规则:重试只在一个地方开,其余层只做超时与熔断。
幂等键怎么穿过 RPC
| 步骤 | 做法 |
|---|---|
| 客户端生成 | 每次“业务操作”生成一个幂等键(UUID/业务单号),重试时复用同一个键 |
| 随请求传递 | 放进 metadata/header(如 x-idempotency-key),而不是请求体字段(避免被业务改写) |
| 服务端去重 | 用“键 + 方法”做唯一约束或 Redis SETNX,记录首次结果;重复请求直接返回首次结果 |
| 结果保留窗口 | 至少覆盖“客户端重试窗口 + 网络抖动”,通常 24 小时 |
幂等本身的实现细节(去重表、状态机、并发)在 25.4 幂等设计与去重,这里只强调 RPC 层的一件事:幂等键必须由客户端生成并随重试复用。服务端自己生成的键,在重试时是新的,去重表永远命中不了。
反模式
| 反模式 | 后果 |
|---|---|
下游用 context.Background() 新建 ctx | deadline 断链,下游白干(本节场景 1) |
| 每跳都配“足够大”的独立超时 | 总耗时 = 跳数 × 超时,用户等 3 倍 |
| 重试不判幂等 | 重复下单、重复扣款 |
| 重试不带退避、不带抖动 | 故障时同步重试,形成脉冲打垮下游 |
| 幂等键由服务端生成 | 重试是新键,去重失效 |
| 超时与重试都配在 SDK,业务又配一遍 | 放大 27 倍(三层相乘) |
| 流式接口用“读超时”当总预算 | 长流被误杀(两次数据间隔超时) |
| 用调用方的“成功请求耗时”算 P999 来定超时 | 越紧越好看,形成自欺循环,真实长尾永远超时 |
| 对写接口做备份请求 | 两份都落地,重复下单 |
自检
Q1 为什么“每跳都配 300ms 超时”在 3 跳链路上,用户可能等 900ms?deadline 传播怎么解决?
▸ 答案:每跳独立计时意味着“每跳都有 300ms 的完整预算”,最坏情况是 3 × 300 = 900ms;而用户只愿意等 250ms。deadline 传播把“总预算”放进 ctx:第一跳拿到 250ms,第二跳拿到“剩余 150ms”,第三跳拿到“剩余 50ms”——任何一跳超预算就直接取消,总耗时被钉死在 250ms 以内。代价是:预算不足时,后面的跳会直接失败,需要业务决定“砍掉哪一段”(降级或异步)。
Q2 为什么幂等键必须由客户端生成,且重试时复用?
▸ 答案:因为服务端的去重逻辑是“同一个键 = 同一次操作”。如果键由服务端生成,每次重试都是新请求、新键,去重表里永远查不到,重试就变成真正的重复执行。客户端生成 + 重试复用,才能让“网络超时但服务端已成功”的情况下,第二次请求被识别为重复并返回第一次的结果。
Q3 场景 3 里“3 跳各重试 1 次 = 8 次执行”,为什么实际中往往更糟?
▸ 答案:因为除了乘法放大,重试还会同步发生:下游故障时,所有客户端在同一时刻重试,形成脉冲(thundering herd),而且每次重试都带着相同的负载。加上没有退避/抖动,重试请求会持续叠加在下游已经过载的服务上,直到它彻底不可用。缓解手段是:重试预算(成功率低时自动减少重试)、指数退避 + 抖动、只对幂等操作重试、以及把重试限制在一层。
Q4 deadline 传播之后,下游收到“剩余预算只有 5ms”的请求,应该怎么处理?
▸ 答案:快速失败,而不是硬着头皮做。剩余预算不足以完成正常处理(如需要查库 20ms)时,应立即返回 DEADLINE_EXCEEDED(或降级结果),把资源让给还有预算的请求。继续执行只会产生“客户端已放弃、服务端还在写库”的白干,还可能留下半个事务。工程上可以在 handler 入口检查 ctx.Deadline() 的余量,低于阈值直接短路。
Q5 定超时值时为什么要取下游的 P999,而不是平均值或 P99?
▸ 答案:因为超时是“主动放弃”的阈值,取低了会大量误杀(平均值等于放弃一半请求,P99 等于主动砍掉 1%);取 P999/P9999 把误杀降到万分之一以下。同时有两条约束:超时必须用下游自己上报的真实耗时统计(调用方看到的已被超时截断),并且单次超时 < 上游 deadline 的一半,给重试留预算。
Q6 备份请求和串行重试解决的是同一个问题,为什么前者更划算?
▸ 答案:两者都在处理长尾,但串行重试要先等满一个超时再发第二份,用户多等一个超时;备份请求在“超过 P99 还没回”时就并行补发,取先返回的结果,用户只多等一次网络往返。代价是额外流量(经验上限 15%)和对幂等的要求——写接口不能用。
Q7 为什么说“熔断阈值必须和超时一起看”?
▸ 答案:因为超时的请求也会被计入失败。超时 3 秒 + 错误率 60% 意味着一个慢请求要占 3 秒才被算作失败,窗口内样本很少,低流量服务容易“迟开”或“误开”。所以低流量场景应改用慢请求比例(超阈值请求占比)而不是错误率,并把窗口粒度(如 10 桶 × 1s)与业务 QPS 对齐。