微服务的配置中心里,timeout 和 retry 大概是出现频率最高、也最容易被随手复制的两个参数。它们通常来自某个项目的模板,在新服务初始化时被一并继承,然后在一次线上抖动之后被集体调大或调小。问题在于,大多数人把这两个参数当成互不相关的调优旋钮,而它们实际上描述的是一份需要整条调用链共同遵守的预算:入口允许多等多久,以及在这段时间里同一次业务意图允许被尝试几次。
把这两个参数分开看,几乎必然出问题。超时设得短、重试次数多,会在下游最脆弱的时候放大流量;超时设得长、重试次数少,会把上游的线程、连接和内存长时间占住,故障从下游蔓延到上游。真正决定系统在故障中表现的,不是某一个服务的参数,而是这些参数在链路上叠加之后的结果。
关于信息边界:本次没有可引用的站内原帖,下文不涉及对具体帖子的复述,讨论基于通用工程实践与常见故障模式,涉及框架能力的地方只做机制层面的描述。
先给结论
超时应该自上而下分配,重试应该自下而上收敛。 前者保证任何一层都不会比它的调用方等得更久,后者保证重试只发生在真正理解业务语义的那一层。
核心判断:一次用户请求的总耗时预算由入口决定并向下分配;任何一层擅自重试,都是在向下一层借债,而债务最终由数据库和线程池偿还。
这条结论有边界。对纯读、天然幂等、QPS 很低的内部接口,多一层重试的收益往往大于风险;对有强一致要求、涉及扣减或状态流转的写链路,重试的安全性完全取决于幂等设计,而不是取决于重试次数。脱离幂等谈重试,等于在讨论“多试几次会不会多扣一笔钱”。
一次请求是怎么被放大的
先看放大效应。假设一条链路有 k 层,第 i 层在失败后额外重试 次,那么最底层承受的请求数大致是:
网关、订单服务、库存服务三层各重试 1 次,。也就是说,数据库在最坏情况下要承接 8 倍的写入压力。平时这 8 倍可能毫无感觉,因为 QPS 基数低;但重试恰恰是在下游已经变慢、超时开始出现的时刻被触发的,此时被放大的流量正好打在已经过载的节点上,慢查询变成锁等待,连接池被打满,故障从单点扩散成面。
比放大更隐蔽的是超时倒挂。如果 A 调用 B 设 3 秒,B 调用 C 也设 3 秒,C 一旦卡满 3 秒,B 会一直等到最后一刻,而 A 的超时几乎同时触发并直接断开连接。结果是 B 用掉了完整的 3 秒和一条线程,换回一个已经被丢弃的结果。在链路追踪里这种形态很典型:父 span 的时长约等于子 span 的时长,说明父级先放弃,子级在做无用功。线程池里堆积的正是这类“结果没人要”的请求。
为什么“每层都设 3 秒”是错的
一个更合理的做法是从入口倒推。假设产品允许用户等待 1000ms,网关自身的鉴权、序列化、日志留 50ms,服务 A 还剩 950ms,A 调用 B 给 400ms,B 调用 C 给 200ms,C 访问数据库给 100ms。每层都要留出余量,是因为超时之外还有排队、序列化和网络抖动的时间,不能让超时值恰好等于下游的预期耗时。
流程图
渲染中...
这样分配之后,最坏情况下 C 在 200ms 就放弃,B 在 400ms 内一定能拿到确定结果,A 不会因为 B 而耗尽预算。超时倒挂被结构性地消除了,而不是靠调参运气。
怎么验证这套预算是否生效?不要只看配置,要看观测数据。用 SkyWalking 或 Jaeger 观察核心链路各 span 的 P99 时长,如果某一层父 span 明显短于子 span,说明父级先放弃;再用 JMeter 或 Locust 打入口,配合延迟注入,确认超时是按预期顺序触发的,而不是所有层同时超时。
重试该放在哪一层
重试的位置比次数更重要,因为不同位置对“这次操作能否安全重放”的理解程度完全不同。
| 位置 | 典型实现 | 优点 | 风险 |
|---|---|---|---|
| API 网关 | Nginx、Spring Cloud Gateway | 集中可控,改动成本低 | 放大流量,难以判断幂等 |
| 服务内 RPC | Dubbo、gRPC、Resilience4j | 贴近业务语义 | 每层都配就变成乘法 |
| 消息队列 | Kafka、RocketMQ 重投 | 异步削峰,天然解耦 | 需要消费幂等与死信处理 |
| 定时补偿 | 对账任务 | 最终一致兜底 | 延迟高,不适合强实时 |
结论是:重试应该尽量下沉到“最了解这次操作能否安全重放”的那一层。如果业务侧没有幂等键,网关层的自动重试就是在制造重复订单;反过来,如果消费端已经用唯一键做了去重,消息队列的重投反而是成本最低的选择。
消息队列重投是重试的另一种形态,代价从“占用线程”变成“需要幂等存储”。Kafka 的 offset 提交时机、RocketMQ 的重试主题与最大重试次数,都会影响“至少一次”语义下的重复消费概率。在事件驱动架构里,这几乎是默认要面对的问题,而不是异常路径。
什么样的重试是安全的
第一是错误分类。只重试连接超时、连接重置、502/503/504、429 这类“可能下次就好”的错误;不要重试 400/401/403/404 和业务异常,那些重试一万次也是同样结果,只是把错误日志刷满。
第二是退避加抖动。固定间隔重试会让所有失败实例在同一时刻再次涌向下游,形成二次尖峰。指数退避加随机抖动能把重试打散,这是把同步放大转成平滑负载的最低成本手段。
第三是重试预算。Envoy 的 retry budget、gRPC 的 retry throttling 都提供了“重试请求占比不超过某个百分比”的机制,防止重试在全局范围内失控。相比逐服务限制次数,全局比例限制更贴近真实风险。
Go
ctx, cancel := context.WithTimeout(parent, 400*time.Millisecond)
defer cancel()
resp, err := client.Do(ctx, req)
if err != nil && isRetryable(err) {
// 退避后重试,仍然复用同一个 ctx 预算
}
这段代码的关键不在语法,而在最后一行注释:重试必须复用同一个 ctx,而不是新建一个。新建 ctx 等于给这次请求追加预算,前面算过的放大效应就是这么产生的。
落地步骤
从现状到可控,可以按下面的顺序推进,每一步都能独立验证:
- 画出核心链路的调用拓扑,标注每一跳的 P99 耗时
- 从入口预算倒推每层超时,写入配置中心,禁止各服务自行设定
- 盘点所有自动重试点,逐个确认幂等键是否存在
- 对写链路关闭网关层自动重试,改为消息队列加重投加对账兜底
- 加入退避抖动和全局重试比例上限
- 用延迟注入验证降级路径,确认熔断器能按预期打开
也要说清楚什么情况下这套方法不适用。低流量的内部系统、批处理任务、离线 ETL,重试成本极低而失败代价很高,可以放宽次数;强一致的金融链路,宁可快速失败也不自动重试,把选择权交给上层业务或人工介入。方法本身不是教条,关键是先明确“这一次操作重复执行会怎样”。
总结与讨论
三条结论值得记住:超时是一份预算而不是经验值,只有自上而下分配才能消除倒挂;重试是乘法而不是加法,位置比次数更重要;退避、抖动、全局比例上限,是让重试在故障中不失控的三个必要约束。
最后留一个具体的问题:如果必须在你的系统里砍掉一层重试,你会砍掉网关层还是服务内的 RPC 层?判断依据是幂等键的覆盖情况,还是故障时的流量形态?这个选择没有标准答案,但想清楚它的过程,本身就是对链路预算的一次梳理。