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 对齐。

关联