故事的开头是一张图表:一个下游库存服务只抖动了大约两秒,但我们对它的出站请求数在那段时间里直接翻了四倍。没有突然涌入的用户请求,也没有任何部署变更。多出来的流量全是我们自己的——确切地说,是我几年前写的那段重试循环,忠实地执行着当时我赋予它的逻辑:“试四次,可能就是暂时的故障。”
这起线上事故的根因简单得令人不安,而真正让我后怕的是,它隐藏在一个几乎人人都写过的代码模式里。为了彻底看清发生了什么,我在本地重新搭了一个微缩版现场:一个自建的 endpoint,在模拟故障期间返回 503;20个并发调用者;以及一块秒表。全部跑在本地 Linux 容器的 .NET 10 进程中,延时参数被特意缩小,让整个过程在几秒内走完。我关心的不是毫秒级的精度,而是请求流在时间上的形状。
![]()
来看看那个再熟悉不过的循环。我写过太多次,多到不好意思承认:
for (var attempt = 1; ; attempt++)var resp = await http.GetAsync("/inventory");if (resp.IsSuccessStatusCode) return true;if (attempt == 4) return false;// no delay — "可能就是暂时的故障"20个并发调用,2秒故障。第一组实验的结果残酷而滑稽:
客户端调用 20 次,成功 0,失败 20。
服务端命中 80 次,全部落在故障窗口内,恢复后零命中。
命中窗口:首次 4 毫秒,末次 33 毫秒。
整个过程只用了 0.03 秒。
所有 20 个调用者的全部 4 次尝试,在 29 毫秒内就完成了。而故障持续了整整 2 秒。也就是说,我的重试循环在故障还没真正开始“持续”之前,就已经把子弹打光了。它用四倍的请求量把所有压力砸向一个已经出问题的服务,却没有换来哪怕一次成功。
这就是天真重试无声的缺陷:它总是先于问题结束。一个需要两秒钟才能恢复的依赖,如果你的重试预算在 30 毫秒内就烧完了,那这段重试代码几乎等于宣布这个依赖永远不可用。而且你付出了双倍代价——自己这边全是无谓的计算浪费,另一端,正在努力恢复的服务还要被额外的请求暴揍。
.NET 其实提供了一个扎实的方案,以 NuGet 包的形式直接集成:Microsoft.Extensions.Http.Resilience,底层基于 Polly v8。在客户端注册时只加一行代码:
builder.Services.AddHttpClient("backoff", c => c.BaseAddress = new Uri("http://127.0.0.1:5199")).AddStandardResilienceHandler(o =>o.Retry.Delay = TimeSpan.FromMilliseconds(500); // 生产环境默认是2秒,这里为了演示缩小了调用处缩减为单个 await,handler 替你管理一切:默认三次重试、加入抖动的指数退避、每次尝试和总超时控制,以及一个马上会谈到的熔断器。
用同样的 20 个并发调用,同样的 2 秒故障再来一次:
客户端调用 20 次,成功 13,失败 7。
服务端命中 80 次,其中 67 次在故障期间,13 次在恢复之后。
命中窗口:首次 1 毫秒,末次 2741 毫秒。
全过程耗时 2.74 秒。
这里有一个真正让我觉得优雅的事实:请求预算完全相同。两次实验发出的都是 80 次请求,与天真重试环发起的一模一样。唯一改变的是这些请求落在时间轴上的位置——它们被分散在 2.7 秒的区间
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.