面板上feed?page=2这条 XHR 耗时 214ms,状态 200,看着很正常。但它的响应头里x-request-id出现过三次,说明这条请求悄悄重试过。我把重试间隔设成 1s、2s、4s 这样翻倍,心想够稳了。跑起来才发现不对——网络一断,页面里 18 个有效请求同时失败,18 个都从第 1 秒开始重试,第 1 秒就有 18 条并发打出去,刚恢复的 Wi-Fi 6 又被压垮,于是一起失败、一起再等 2 秒、一起再打。翻倍没有救场,反而把 18 条请求锁进了同一个节拍。
这就是重试风暴。反直觉的地方在于:指数退避本身没错,错在没有把"同时失败"这件事算进去。请求们共享同一个失败时刻,如果退避序列是确定性的,它们下一次重试也会精确地撞在一起。
退避要带抖动,且抖动要先算好上限
核心思路就一条:在指数退避的基础上加一个随机抖动,把每个请求的下一次重试时刻打散。我用的 full jitter,退避区间取[0, base * factor^(attempt-1)]的均匀随机,而不是在确定值上加减一个固定百分比。
interfaceRetryPolicy{baseMs:number// 首次重试的基础间隔factor:number// 每次退避的乘数maxMs:number// 退避上限maxAttempts:number// 最大重试次数}constdefaultPolicy:RetryPolicy={baseMs:200,factor:2,maxMs:5000,maxAttempts:4}functionnextDelayMs(policy:RetryPolicy,attempt:number):number{constcap=Math.min(policy.maxMs,policy.baseMs*Math.pow(policy.factor,attempt-1))returnMath.floor(Math.random()*cap)}用 full jitter 而不是"确定值 ± 25%"有个具体好处:当 18 个请求的 attempt 都是 1 时,确定值会让它们都落在 200ms 附近一个窄带里,full jitter 让它们均匀铺在 0 到 200ms 之间。18 个请求的第 1 次重试,最坏情况全部集中在 200ms 附近,最好情况撒成一片,期望值就是分散的。
重试循环里要留一个取消口
@ohos.net.http的请求是异步的,重试循环如果只关心次数和延迟,很容易写出一个关不掉的循环。我加了一个取消令牌,组件销毁或者用户切走页面时把令牌置位,让还在等待中的重试立刻退出。
import{http}from'@kit.NetworkKit'interfaceRetryHandle{cancel():void}functionrequestWithRetry(url:string,policy:RetryPolicy):RetryHandle{letcancelled=falseconstrun=async():Promise<void>=>{letattempt=0while(attempt<=policy.maxAttempts){if(cancelled){return}try{constresp=awaithttp.createHttp().request(url,{method:http.RequestMethod.GET,connectTimeout:3000,readTimeout:5000})if(resp.responseCode>=200&&resp.responseCode<300){onSuccess(resp)return}// 状态码失败也走重试,但要区分 4xx 和 5xxif(resp.responseCode>=400&&resp.responseCode<500){onError(resp.responseCode)return}}catch(e){// 网络错误,继续退避}if(attempt===policy.maxAttempts){onExhausted()return}attempt+=1constdelay=nextDelayMs(policy,attempt)awaitsleep(delay)}}run()return{cancel:()=>{cancelled=true}}}functionsleep(ms:number):Promise<void>{returnnewPromise(resolve=>setTimeout(resolve,ms))}这里有两个失败分支需要说清楚。4xx 直接不重试——404、403这种是请求本身错了,重试一百次还是错的,等退避纯属浪费时间;只有网络错误和 5xx 才值得重试。第二个分支是maxAttempts耗尽,这时候返回给上层一个"已放弃"的结果,观察面板把这条请求标成红色,而不是静默吞掉。
attempt === policy.maxAttempts这个判断要放在重试前,不能放到重试后。第一版我写在sleep之后,结果多等了一轮退避才发现没次数了,白睡一个maxMs。
另一个坑在sleep本身。第一版我直接setTimeout睡满退避时长,取消信号要等睡醒才被看到,组件销毁后一个已经取消的请求还会再多发一次。改成可打断的 sleep,每 50 毫秒检查一次取消状态:
functioncancellableSleep(ms:number,isCancelled:()=>boolean):Promise<boolean>{returnnewPromise(resolve=>{conststart=Date.now()constcheck=():void=>{if(isCancelled()){resolve(false)return}if(Date.now()-start>=ms){resolve(true)return}setTimeout(check,50)}check()})}返回值区分"睡够"和"被取消",重试循环里根据它是继续下一轮还是直接退出。
面板怎么呈现退避
观察面板的数据列里,feed?page=2的 214ms 是最终成功那次的时间,它前面还有两次失败的请求被折叠掉了。为了不误导看的人,我把重试次数和退避轨迹单独记下来。
interfaceRetryTrace{url:stringattempts:numberdelays:number[]// 每次重试前的等待时长finalStatus:number}functionnextDelayWithTrace(policy:RetryPolicy,attempt:number,trace:RetryTrace):number{constdelay=nextDelayMs(policy,attempt)trace.attempts=attempt trace.delays.push(delay)returndelay}这样在请求详情里就能看到一串退避数字,比如[86, 310, 1240, 4800]。这串数字比一个孤零零的"耗时 214ms"更能说明问题——它会告诉看面板的人,这条请求是扛过了四次失败才成功的,而不是一次就通。
工程里的样子
NetworkScope/ ├─ entry/src/main/ets/ │ ├─ pages/Index.ets # 请求列表与详情面板 │ ├─ net/RetryScheduler.ets # nextDelayMs / requestWithRetry │ ├─ net/RetryTrace.ets # 退避轨迹记录 │ └─ entryability/EntryAbility.ets ├─ entry/src/main/resources/ └─ build-profile.json5请求详情里那行等待 142ms,其实已经把重试的隐形成本吃进去了。加了退避之后,feed?page=2这类接口在弱网下的最终耗时会被拉长,但观察面板能同时看到传输资源 742 KB、页面完成 1.84s 和缓存命中 91% 这三个数,不至于把重试抖动误判成接口变慢。
边界
这套退避只解决"客户端自己发起的重试"。如果服务端也有重试、或者中间网关在重试,客户端再加指数退避就是重试的重试,风暴照样会起来,那种场景要靠x-request-id做幂等去重,不是本方案能覆盖的。另外maxAttempts和maxMs是一对需要按业务调的参数,短视频 feed 这种轻接口可以设得激进一点,下单、支付这类写操作应该把maxAttempts收到 1~2,宁可放弃也不能让用户重复扣款——重试本身是有副作用的,观测面板只负责把退避过程讲清楚,不替你决定哪些接口该重试。