ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Node.js 服务超时重试:用上限和幂等挡住连锁反应

2026/8/11 15:52:54 拓冰建站 浏览量
Node.js 服务超时重试:用上限和幂等挡住连锁反应 Node.js 服务超时重试用上限和幂等挡住连锁反应1. 慢响应下重试会怎样放大下游压力下游数据库或者微服务稍微慢个两秒微服务的调用方很容易就急了。例如若客户端在超时后额外重试三次最坏情况下的尝试次数可接近原请求量的四倍。实际放大程度还受退避、抖动和成功率影响应通过压测确认。重试机制本意是为了提高服务的最终成功率消除网络瞬时丢包带来的偶发错误。然而在盲目重试面前每一次重试都是在给瘫痪的下游落井下石。flowchart TD Req[Incoming Gateway Request] -- BudgetCheck{Retry Budget Token Available?} BudgetCheck -- No -- Reject[Reject Retry / Return Fast Fail] BudgetCheck -- Yes -- CircuitCheck{Circuit Breaker State} CircuitCheck -- OPEN -- FastFail[Fast Fail / Fallback] CircuitCheck -- CLOSED / HALF-OPEN -- DoRequest[Execute Downstream Call] DoRequest -- Result{Is Response OK?} Result -- Success -- ReplenishBudget[Replenish Token Pool] Result -- Fail/Timeout -- CalculateBackoff[Calculate Backoff with Jitter] CalculateBackoff -- WaitDelay[Wait Randomized Delay] WaitDelay -- Req2. 避免风暴的核心带随机抖动的指数退避与全局 Retry Budget解决重试放大的核心思路有两个一是拉开重试的时间间隔二是限制全局的重试总量。单纯使用固定时间间隔重试比如每次隔 100ms 再试没有任何效果。因为高并发场景下所有超时的请求会在同一个时间点集体发起重试在时间轴上形成高耸的流量峰值也就是所谓的“惊群效应”Thundering Herd Problem。必须引入带随机抖动的指数退避算法Exponential Backoff with Jitter。每次重试的等待时间随重试次数成倍增加同时乘以一个随机因子。WaitTime Min(MaxBackoff, BaseInterval * (2 ^ attempt)) * Random(0.8, 1.2)除了随机退避更关键的防线是Retry Budget重试预算。所谓 Retry Budget是指在 Node.js 服务实例级别维护一个令牌桶。比如规定过去 10 秒内所有发起重试的请求数量不能超过总请求数量的 10%。一旦重试消耗光了配额后续即便请求失败也严禁再发重试直接向上一层返回错误。3. 熔断与 Request Hedging什么时候该果断切断下游当下游故障持续存在时光靠重试配额还不够。如果持续向下游发送必败的请求Node.js 进程内部会积压大量处于 Pending 状态的 Promise极其消耗内存和句柄。这时必须触发断路器Circuit Breaker。断路器维护三种状态CLOSED关闭正常状态流量自由通过。OPEN打开当失败率突破阈值如过去 1 分钟内 50% 失败断路器打开后续所有请求直接返回 Fail-Fast 报错根本不向网络发包。HALF-OPEN半开经过探针等待时间如 10 秒后放行极少数试探请求。如果成功则恢复到 CLOSED失败则重新退回 OPEN。对于某些对延迟敏感但幂等的读请求还可以采用Request Hedging对冲请求策略在发出第一个请求后如果 P95 响应时间内未返回不等超时直接并行发出第二个请求哪个先到就用哪个并取消另一个。但该策略必须严格受限于 Retry Budget。4. 生产级 Node.js 具备 Retry Budget 与断路器的 HTTP 客户端实现下面是基于 Node.js TypeScript 实现的高并发安全 HTTP 客户端。集成了 Retry Budget 令牌桶、带有 Jitter 的指数退避以及简易断路器。import http from http import https from https export interface ClientOptions { timeoutMs: number maxRetries: number baseDelayMs: number maxDelayMs: number budgetRatio: number // 重试预算比例如 0.1 表示重试不可超过总请求数 10% } export class HeavyDutyHttpClient { private totalRequests 0 private totalRetries 0 private circuitState: CLOSED | OPEN | HALF-OPEN CLOSED private failureCount 0 private lastStateChange Date.now() constructor(private options: ClientOptions) { // 定时清理计数器维持滑窗窗口 setInterval(() this.resetBudgetWindow(), 10000) } private resetBudgetWindow(): void { // 衰减计数维持滑动时间窗口 this.totalRequests Math.floor(this.totalRequests * 0.5) this.totalRetries Math.floor(this.totalRetries * 0.5) } private canRetry(): boolean { if (this.totalRequests 0) return true return (this.totalRetries / this.totalRequests) this.options.budgetRatio } private updateCircuit(success: boolean): void { if (success) { if (this.circuitState HALF-OPEN) { this.circuitState CLOSED this.failureCount 0 } } else { this.failureCount if (this.failureCount 10 this.circuitState CLOSED) { this.circuitState OPEN this.lastStateChange Date.now() } } } public async executeRequest(url: string): Promisestring { // 检查断路器 if (this.circuitState OPEN) { if (Date.now() - this.lastStateChange 10000) { this.circuitState HALF-OPEN } else { throw new Error([CircuitBreaker] Circuit is OPEN. Fast fail executed.) } } this.totalRequests let attempt 0 while (true) { try { const data await this.httpGet(url, this.options.timeoutMs) this.updateCircuit(true) return data } catch (err) { this.updateCircuit(false) attempt // 判断是否允许继续重试 if (attempt this.options.maxRetries || !this.canRetry()) { throw new Error([RequestFailed] Max retries reached or budget exhausted. Attempt: ${attempt}, Error: ${(err as Error).message}) } this.totalRetries // 计算带 Jitter 的指数退避延时 const expDelay Math.min( this.options.maxDelayMs, this.options.baseDelayMs * Math.pow(2, attempt - 1) ) // 加上 0.8 ~ 1.2 随机抖动 const jitter 0.8 Math.random() * 0.4 const finalDelay Math.floor(expDelay * jitter) await new Promise((resolve) setTimeout(resolve, finalDelay)) } } } private httpGet(urlStr: string, timeoutMs: number): Promisestring { return new Promise((resolve, reject) { const url new URL(urlStr) const lib url.protocol https: ? https : http const req lib.get(urlStr, { timeout: timeoutMs }, (res) { if (res.statusCode res.statusCode 500) { reject(new Error(Server Error HTTP ${res.statusCode})) return } let body res.on(data, (chunk) (body chunk)) res.on(end, () resolve(body)) }) req.on(timeout, () { req.destroy() reject(new Error(Request Timeout after ${timeoutMs}ms)) }) req.on(error, (err) reject(err)) }) } }5. 压测防线在链路入口挂上可观测指标防线写完之后必须通过压测来检验。在模拟网络丢包 20% 和下游延迟 3 秒的测试环境下缺乏 Retry Budget 的客户端会导致服务端句柄数量直线飙升最终抛出EMFILE: too many open files异常崩溃。而引入了 Retry Budget 和带有 Jitter 退避的客户端在下游故障发生时重试率紧紧被锚定在 10% 以内触发断路器后系统整体 P99 延迟迅速回落。千万别把 Timeout 和 Retry 的配置散落在各自模块的 fetch 调用里。在架构层面必须收敛 HTTP Client 的底层入口统一注入 Retry Budget 和指标打点。唯有如此高并发服务才不会在下一次网络抖动时变成故障放大器。