下游抖了 300 毫秒。就 300 毫秒,连告警阈值都未必够得着。但这条链路上有三层调用,每一层都"贴心"地配了两次重试——也就是一次请求最多发三次。三层乘起来,3×3×3,最坏情况下最底层那个服务收到的请求量是平时的 27 倍。
这是小学生算术,不是什么高深理论。可线上真崩掉的时候,绝大多数人的第一反应还是那句:"再加一次重试吧。"这正是放大器越拧越大的原因——每一次"再试一次"都是一次新的流量,而上游并不知道下游已经在重试了。
这篇文章要立的判断很直接:重试不是越多越可靠,重试是一台流量放大器。评价一套重试参数好不好,看的不是"成功率提高了几个点",而是"最坏情况下它会把流量放大几倍"。围绕这个判断,下面两条是硬规则:
先把这条链路画清楚。一个典型的读接口:用户请求打到网关层,网关调聚合服务,聚合服务调底层的用户权益服务。三层,每层都配了"重试 2 次"。
平时底层 P99 是 50 毫秒,客户端读超时设的 200 毫秒,余量很足,大家相安无事。某天底层因为一次慢查询或者一次 GC 停顿,响应时间爬到 350 毫秒。于是:
如果抖动的时间足够长、重试的次数足够多,这个乘法会一路跑到理论上限:三层每层 3 次尝试,最坏 27 倍。而任何一个系统的容量规划都不会按 27 倍留余量——常见的是留 2 到 3 倍。所以底层不是"慢了",是"死了"。
真正的杀手其实还不是 27 倍这个数字本身,是正反馈:请求变多 → 排队变长 → 延迟变高 → 更多超时 → 更多重试 → 请求更多。这个环一旦转起来,抖动停止之后它也不会自己停下来,因为队列里已经积压的请求还会继续触发重试。这就是为什么很多事故的恢复时间远远长于故障时间——故障 10 秒,恢复 10 分钟。
人的直觉是加法的:三层各重试两次,那不就是多了 6 次请求吗?错了。重试对上层是完全透明的——上层发出一个请求,它只知道这个请求超时了,于是再发一个。它并不知道它发的第一个请求在下游已经变成了三次尝试。所以放大是乘的,不是加的。
更麻烦的是超时错位,这是最容易被忽略的一项。假设网关层对聚合服务的超时是 800 毫秒,聚合服务对底层的超时是 3 秒(很多框架的默认值就这么离谱)。网关在 800 毫秒时判定失败并重试,可聚合服务那边还挂着 3 秒的调用在跑。结果:
这类重叫我叫它无效重试——上游已经放弃,下游还在拼命。它比"重试次数太多"更隐蔽,因为你在下游的日志里看到的是"请求量涨了、成功率还行",根本想不到一半的活是白干的。
还有一个次生效应:连接池和线程池被打满。重试的请求不会消失,它们占着连接。假设连接池 200,平时并发 40,放大 27 倍后瞬时需求上千,池子瞬间耗尽。这时候连健康请求也进不来了——系统从"部分失败"直接跳到"全面失败"。很多事故里看到的"明明下游机器 CPU 才 30%,接口却全挂了",根子就在这:瓶颈不在 CPU,在池子。
把重试写得能用是一回事,写得不出事是另一回事。下面这七种写法,我在不同团队的代码里见过太多次,每一种单独看都不致命,凑在一起就是雪崩配方。
另外补一个隐蔽的:重试叠重试。HTTP 客户端配了 2 次,RPC 框架配了 2 次,业务代码 catch 后又 for 循环 2 次。三层叠一起就是 8 次尝试,而你可能以为自己只配了 2 次。排查的时候一定要把整条调用路径上所有会发起重试的地方列出来,包括负载均衡器和网关的重试插件。
前面的问题都还是"效率"层面的,接下来这三类是"正确性"层面的——重试它们不是浪费,是出事。
下单、扣款、发券、发短信、扣库存,这些操作执行一次和执行一次以上,结果是不一样的。超时尤其危险:超时不等于失败。你的请求可能已经到达服务端、已经执行成功了,只是响应包在回程路上丢了。这时候重试,等于扣两次款、发两张券。用户投诉过来的时候,你查日志会看到两笔记录都是"成功"的,而你的第一反应往往是不可能。
400、422 这类状态码,是服务端明确告诉你"你的请求有问题,我不会处理"。这类错误的重试成功率是零。把它们纳入重试,唯一的作用就是消耗重试预算并放大下游负载。
401、403 同理,重试不会让它变成通过。而且频繁重试鉴权接口还有额外风险:很多风控系统会把短时间内的多次鉴权失败判定为撞库攻击,直接把账号或 IP 封掉。本来是一次 token 过期,最后变成用户被锁。
那么到底哪些错误值得重试?判断标准只有一条:这次重试有没有可能成功,且成功后不会造成重复副作用。按这个标准,值得重试的通常是:连接被拒(大概率请求还没到达服务端)、连接超时(同上)、503 / 429(服务端明确表示"现在不行,待会儿再来",并且最好遵守响应里的 Retry-After)、网络瞬断类的 IO 异常。而 5xx 里那些"执行到一半挂了"的错误,必须先解决幂等才能重试。
幂等键(idempotency key)说白了就是调用方给每一笔业务操作发一个唯一身份证,服务端拿着这个身份证做去重:见过的直接返回上次的结果,没见过的才真正执行。它的关键点不在"生成一个 UUID",在下面几条。
业务类型:操作:主体:唯一串 这样的结构,比如 order:create:uid_88123:ct_7f3a9c...。带上业务维度和主体 ID,出问题时你能按用户维度去查重复记录。所以顺序必须是先幂等,再重试。这个顺序不能倒过来,原因很实在:没有幂等键就给写操作开重试,等于把一台故障放大器对准了自己的资金账户。平时下游健康、成功率 99.9%,你根本看不出问题;等到抖动那天,放大器才露出真面目——而那时候你已经在赔钱了。
退避解决的是"重试请求在同一时刻对齐"的问题。四种打法,代价各不相同。
最简单,也最危险。完全不破坏同步性,只适合单进程内部、并发量极低的场景。跨服务调用里用固定间隔,等于主动制造尖峰。
1 秒、2 秒、3 秒这样递增。比固定间隔好,能在一定程度上拉开重试时刻。但它的拉开的幅度是固定的,客户端基数一大,依然会有相当一部分重试落在同一小段窗口里。
等待时间按 base×2^n 增长,并且要配一个上限(cap),否则第 5 次重试就要等 32 秒,用户早跑了。指数退避能把重试迅速推到远处,但有个致命弱点:退避后的时刻依然是对齐的。所有客户端在同一秒失败,也就在同一秒重试,指数退避只是把这一秒往后挪了 base×2^n。
抖动的本质是在退避时长上引入随机性,把对齐的重试打散成一片。三种常见做法:
举个例子(以下为示例参数,非推荐值):base 50 毫秒、cap 1 秒、最多重试 2 次。纯指数退避的等待是 50ms → 100ms,最坏 150 毫秒;加 full jitter 后,期望等待大约 75 毫秒,但分布被打散到 0~150 毫秒的区间里。看起来差别不大,可当成千上万个客户端同时这么做时,下游看到的就是一个平缓的小山坡和一个 150 毫秒宽的尖峰的区别。
除了次数上限,还必须配总时长预算:一次调用从发起到最终返回,包含所有重试在内,不能超过一个硬上限。这个上限不靠"次数 × 退避"去算,而是用一个整体 deadline 卡死——次数到了或者时间到了,谁先到算谁。原因很简单:下游抖动时长是不可预测的,如果下游每次都卡到读超时才返回,你那"最多 2 次"实际可能耗掉几秒钟。而这个总时长预算,必须小于上游给你的超时时间,否则又回到无效重试的老问题上。
现在说最关键的一节。前面所有手段——退避、抖动、次数上限——都是在优化"单个客户端的行为"。但雪崩是个群体问题,个体行为的优化管不住群体。
理由很直白:总放大量 = 客户端数量 × 单客户端重试次数。你只锁住了右边那一项。而客户端数量恰恰在故障时会变大:横向扩容的分片更多了、积压的重试队列在放水、用户看到页面转圈会疯狂刷新、上游还有上游。你锁死了"2 次",总量照样可以翻几十倍。
重试预算(retry budget)换了个思路:把重试当成一份全局配额,而不是每个客户端的私有额度。具体做法是给每个被调用方维护一个滑动窗口统计:窗口内的正常请求数 N,重试请求数 R。只有当 R / (N + R) 低于一个比例(示例取 10%)时才允许重试,超了就直接拒绝这次重试——注意,是拒绝重试,不是拒绝请求,正常请求照常放行。
这么做的效果是:无论有多少客户端、无论它们多慌,重试流量在下游眼里最多只增加约 10%。放大器的增益被硬顶在 1.1 倍,而不是理论上的 27 倍。这就是它比"每客户端限次"有效的根本原因——一个是限制每辆车的载重,一个是限制桥的总承重。桥塌不塌,看的是总承重。
实现上有几个要点绕不过去:
这套机制的成本在于:你需要一个共享计数、需要额外的指标、需要有人看懂它。相比写一行"retry=2",它确实麻烦。但雪崩的代价是整条链路不可用,这个麻烦值得付。
重试是"再试一次",是乐观的;熔断是"先别试了",是悲观的。很多人以为有了熔断就不用管重试,或者反过来——其实它们是互补的两半。
只有重试没有熔断:下游已经彻底死了,上游还在尽职尽责地放大,而且放大的全是必死请求。重试预算能压住总量,但压不住"这些请求注定失败"这件事本身,你还是在往尸体上做心肺复苏。
只有熔断没有重试:一次几十毫秒的偶发抖动被误判为故障,链路直接断开,本来能自愈的毛刺被放大成可见的不可用。可用性的抖动变多,用户体感反而变差。
三个状态的标准模型:
半开探测的最小实现,四行就能说清楚(示例值):Open 之后等一个冷却期(5~30 秒),然后放行 N 个探测请求(1~3 个);若连续成功 M 次(示例 3 次)就回到 Closed;任何一个失败立刻回 Open 并重置冷却期。这里的关键是探测量必须极小——如果你一上来放行 50 个探测请求,那跟没熔断区别不大,下游刚缓过来就又被打死了。
熔断阈值怎么定才不误触发?两个条件必须同时满足:窗口内请求数达到一个最小值(示例 20 个),且失败率超过阈值(示例 50%)。为什么非要加最小请求数这一条?因为低流量时段,2 个请求失败 1 个就是 50%,噪声太大。没有最小请求数门槛的熔断,会在凌晨流量低谷时莫名其妙地把链路断掉。
还有一个顺序问题必须讲清楚:熔断器在最外层,重试在熔断器之内。也就是说,一次用户请求无论内部重试了几次,在熔断器眼里都只算一次成功或一次失败。如果把重试放在熔断器外面,熔断器统计的就是尝试次数,失败率会被人为放大,阈值就失真了。
"超时"这个词在大多数配置里其实是一笔糊涂账。它至少是三个不同的东西:
为什么读超时要按 P99 定,而不是按平均值?因为平均值意味着你有一半的正常请求会被判死。按 P99 定,等于承认"会有 1% 的正常慢请求被我误杀"——这是一个有意的取舍:宁可错杀 1%,也不能让超时值过短,导致大量正常抖动被判成失败从而触发重试风暴。重试风暴的破坏力,远大于 1% 的请求被误杀。初值可以按 P99 乘 1.5~2 倍给,然后用实测去收敛。
为什么下游超时必须小于上游超时?前面说过一次,这里给个量化:下游整体超时 ≤ 上游整体超时 − 上游为一次重试预留的时间 − 网络与序列化开销。粗略做全链路对齐时,可以按每层 0.6~0.7 倍向下递减(示例口径,不是定值)。不这么做,你就是在系统性地制造无效重试。
一个三层链路的超时递减草案(以下为示例草案,必须按你自己业务的 P99 实测替换,不要直接抄):
更稳妥的做法是 deadline 透传:上游把自己的剩余时间沿着调用链传下去(HTTP 里可以放在请求头,gRPC 本身就是 deadline 模型),下游看到的不是一个自己拍的固定值,而是"还剩多少时间"。这样无论链路多深、每一跳花了多久,都不会出现下游比上游更有耐心的荒唐事。这是根治无效重试的办法,代价是所有服务都要支持透传协议。
把前面讲的这些做法放在一起看,差别就一目了然了。这张表看的不是"成功率",是最坏情况下的放大倍数和放大是否收敛。
| 做法 | 最坏放大倍数 | 放大是否收敛 | 适合什么请求 | 代价与风险 |
|---|---|---|---|---|
| 不重试,快速失败 | 1 倍 | 完全收敛 | 非幂等写、参数错误、鉴权失败、强实时读 | 单次偶发抖动会直接变成用户可见的失败,需要上游有降级 |
| 固定间隔重试 N 次 | (N+1) 的层数次方 | 不收敛 | 几乎不适合任何跨服务调用 | 重试同步化形成尖峰,是雪崩最经典的起点 |
| 指数退避(无抖动) | 次数上限内收敛 | 次数收敛,时刻不收敛 | 单一客户端对单一服务的内部调用 | 多客户端同时失败时退避后仍对齐,尖峰只是被推迟 |
| 指数退避 + 抖动 | 次数上限内收敛 | 收敛 | 绝大多数幂等的跨服务读请求 | 尾部延迟变长,必须配整体超时预算兜底 |
| 重试预算(全局配额) | 约 1.1 倍(配额 10% 时) | 强收敛 | 所有多层级的调用链路 | 需要共享计数与指标,实现成本高于改一行配置 |
| 熔断先行 + 有限重试 | 熔断期间 1 倍 | 强收敛 | 下游已确认不可用、需要快速止损的场景 | 半开探测参数不当会导致过早恢复或迟迟不恢复 |
| 网关 0 次 + 中间 1 次 + 底层 1 次 | 约 4 倍(示例三层草案) | 收敛 | 三层链路的基线起点 | 单次抖动仍可能失败,客户端侧需要有降级与兜底展示 |
重试放大最阴险的地方是——它在你的常规监控里几乎是隐形的。CPU 没满、内存正常、错误率看上去也就涨了几个点,可系统就是不行了。因为放大最先显形的地方不是 CPU,是队列、连接池和下游的实际请求量。
唯一的真相指标是这个比值:放大系数 = 下游实际 QPS ÷ 入口 QPS。
健康状态下它略大于 1(比如 1.05,多出来的那点就是零星重试)。抖动开始时会飙到 1.5、3、10 甚至更高。这个指标必须常驻大盘,而不是等故障了才临时去查——因为故障那天你没时间现写查询语句。
有几个坑会让这个指标失效,绕开它们:
告警阈值可以这样起(示例,需按业务基线调整):放大系数连续 1 分钟大于 1.5 就报警;总重试率(重试请求数 ÷ 总请求数)超过 10% 报警;出现 attempt 达到上限的请求就报警。第三条尤其重要——它意味着有人已经把重试次数用光了,说明抖动时长超过了你设计的容错范围。
还有一件事不能省:混沌演练。在预发环境里定期注入 300 毫秒的延迟,然后看放大系数这条曲线长什么样。不做演练,你那些参数就只是纸上的数字,你永远不知道真实抖动下它会跑到几倍。演练里最常暴露的问题是"原来这个地方还有一层我忘了的重试"。
前面所有参数都建立在一个前提上:你的网络基线是稳的,你的 P99 是可信的。如果承载链路的机器本身就时不时抖一下——共享带宽被邻居挤占、宿主超售导致 CPU 偷停、网卡队列打满——那你调超时就是在噪声里调参,今天调到 400 毫秒刚好,明天又变成 900 毫秒。放大系数也会被基线噪声污染,你的告警天天在响。
这也是为什么承载环境这件事绕不过去。跑网关接入层和聚合层的机器,重点不是单核多强,而是网络基线稳、延迟可预期、资源不被抢。我给客户配这类链路时,接入层一般用 2 核 4G 起步的反代就够了——它扛的是并发连接和转发,不是计算;真正吃资源的是聚合层和数据层,那两层更适合独享物理资源。
具体到选型,一万网络这类深耕 IDC 19 年(成立于 2007 年)的服务商,在这件事上能提供的是比较实在的东西:深圳南山总部、自营机柜最快 1 分钟上架,意味着链路要扩一层接入节点时不用等;BGP 多线 + CN2 GIA 回国低延迟,加上华南、华东、华北、中国香港以及海外的多节点布局,意味着你可以把三层部署在延迟可控的同一个区域内,而不是让业务层在中国香港、数据层在华北,跨地域 RTT 把你的 P99 拉成一条长尾;7×24 中文工单、平均 5 分钟响应,硬件故障 10 分钟内自动迁移,故障时的恢复窗口能压短——这一点跟重试设计直接相关,因为恢复时间越长,积压队列触发的重试越多。
另外几个点也值得写进部署清单:免费的系统盘每日 3 份快照、30 秒回滚,让你在改错了超时参数之后有后悔药;5~20G 的免费流量防护,避免一次小规模流量攻击和你的重试放大叠在一起;免费备案协助,省掉上线前的流程时间。
配置上给几个真实档位做参考(均为官网公开报价,以官网实时价为准):裸金属标准档从 E5-2620 / 32G / 1T 的 ¥999 元/月起,到 E5-2698v4 双路 / 32G / 1T 的 ¥3999 元/月起,带 50M 大陆优化不限流量,适合跑聚合层和数据层这种需要资源独占的层级;轻量一点的接入层或测试环境可以用一万云,¥25 元/月起;如果链路里有面向中国香港及海外用户的接入节点,中国香港自营的 E3 / 8G / 2T / 10M CN2 档是 ¥1500 元/月起。海外裸金属有买 1 送 1 的活动,做跨地域容灾节点时成本能压下来不少。
最后说回边界,这条必须讲清楚:文中除了"3×3×3 = 27 倍"这个算术是确定的,其余所有数值都是示例草案——300 毫秒抖动、10% 预算、2 次重试、2000/1200/600 毫秒的超时递减、50% 熔断阈值、1.5 倍告警线,全都不是推荐值。它们必须结合你自己业务的 P99 分布和容量实测来重新定。
具体怎么定?三条实操建议:第一,把下游的 P99 拉出来看分布形态,长尾越肥,读超时越要留余量;第二,用压测找出下游的实际承载上限,你的最坏放大倍数乘上平时峰值,必须小于这个上限,否则参数就是不合格的;第三,改完参数一定要做一次注入演练,看放大系数曲线,不演练的参数不算数。
能,但必须先有幂等键。判断标准不是"这是 GET 还是 POST",而是"执行一次和执行多次,结果是否一样"。如果不一样,就必须让服务端能识别出"这两次是同一笔业务"。具体做法是调用方生成幂等键(业务类型 + 操作 + 主体 ID + 唯一串)随请求带上,服务端建一张去重表,撞键就返回首次结果。顺序是先幂等再重试——没有幂等键就对写操作开重试,等于在故障那天等着赔钱,平时你根本发现不了问题。
按层级递减,不要全链路统一。我的基线是:网关层 0 次、中间聚合层 1 次、底层最多 1 次(示例口径,需按实测调整)。理由是重试必须收敛——层级越深,重试的放大效应越强,越靠近用户反而越不该重试,因为网关层重试一次会连带放大下面整条链。另外别只看次数,一定要配总时长预算:次数到了或者时间到了,谁先到算谁。下游如果每次都卡到读超时才返回,"最多 2 次"实际可能耗掉好几秒。
base 建议贴近下游的正常响应时间量级——下游平时 30 毫秒返回,base 给 50 毫秒左右就够(示例);给 1 秒纯属浪费时间。cap 要按你能接受的最长等待来定,通常压在一次整体超时预算的三分之一以内。抖动方式默认选 equal jitter,既打散又不会过早重试;下游明显吃紧时用 full jitter,追求最大程度削峰;需要连续多次重试的长链路用 decorrelated jitter。不管选哪种,cap 必须有,否则后面几次重试会等到天荒地老。
两个条件同时满足才熔断:窗口内请求数达到最小值(示例 20 个),并且失败率超过阈值(示例 50%)。最小请求数这条不能省,否则凌晨流量低谷时两个请求失败一个就是 50%,链路会莫名其妙断掉。还有两个容易错的地方:一是失败要按"请求"统计而不是按"尝试"统计,否则重试会把失败率人为放大好几倍;二是半开探测的放行量必须极小,示例 1~3 个,连续成功 3 次再恢复,失败一次立刻回到断开并重置冷却。
会,而且很常见。下游返回 429 说明它已经在自我保护,你的重试等于往伤口上踩,形成"限流 → 重试 → 更限流"的螺旋。正确做法是把 429 和 503 区分对待:429 带 Retry-After 的,至少要遵守它给的等待时间再重试,不要立刻重试;同时把重试纳入全局预算,让限流和重试看到同一份配额。限流是刹车,重试不能变成油门——这两套机制必须放在同一个视角下设计,各配各的必然打架。
不放。网关是链路最外层,它重试一次的代价是放大下面所有层级的流量,杠杆最大、收益最小。网关该做的是:超时控制、熔断、降级、以及给用户一个确定的响应。重试留给靠近数据源的那一层去做,那里的重试代价最小、成功率最高(很多抖动是单实例级别的,底层重试换一个实例可能就成了)。如果网关确实需要容错,用"换一个后端实例重试"而不是"同一个实例重试",并且次数限制在 1 次以内。
看放大系数:下游实际 QPS 除以入口 QPS。健康时略大于 1,抖动时会飙到 3 甚至更高。这个比值要常驻大盘,别等故障了才去查。另外三个信号比错误率更早出现:成功但 attempt 大于 1 的请求占比上升、连接池等待时间变长、线程池队列堆积。放大最先在这些地方显形,等错误率涨起来时,队列已经堵死了。
四个要点。一是由调用方生成,下游生成无效——只有上游知道这两次是同一笔业务。二是键要带业务维度,做成"业务类型:操作:主体ID:唯一串"的结构,方便事后按用户维度查重复记录。三是服务端要记录"处理中"状态,不能只记"已完成",否则第一笔还在执行时第二笔重试会查不到记录、又执行一次。四是 TTL 必须长于你的重试总时长预算,否则后到的重试会因键已过期被当成新请求。异步消息消费端同样要做,消息重投是常态。
本文涉及的品牌与服务信息来自一万网络官网 https://www.idc10000.net/ 的相关产品与服务页面,包括裸金属服务器标准档(E5-2620 32G/1T ¥999 元/月起、E5-2698v4×2 32G/1T ¥3999 元/月起)、一万云(¥25 元/月起)、中国香港自营服务器(E3 / 8G / 2T / 10M CN2 ¥1500 元/月起)、以及服务基线(7×24 中文工单、平均 5 分钟响应、硬件故障 10 分钟自动迁移、免费系统盘每日 3 份快照、5~20G 免费流量防护、BGP 多线 + CN2 GIA 回国、华南/华东/华北/中国香港/海外多节点)等页面明示内容,以上报价以官网实时价为准。
需要强调的是:文中所有技术参数——300 毫秒抖动、10% 重试预算、各层重试次数、2000/1200/600 毫秒的超时递减草案、50% 熔断阈值、1.5 倍告警线——均为示例草案,不是推荐值,也不是实测数据,必须结合你自己业务的 P99 分布与容量实测重新确定。唯一确定的是那条算术:三层调用、每层两次重试,最坏放大 27 倍。具体配置与商务条款,以签约时最新报价与合同为准。
Copyright © 2013-2020 idc10000.net. All Rights Reserved. 一万网络 科技有限公司 版权所有 深圳市科技有限公司 粤ICP备07026347号
本网站的域名注册业务代理北京新网数码信息技术有限公司的产品