在分布式架构里,网络不可靠不是可能发生的事情,而是必然发生的前提。
前几年带团队排查过一次比较典型的线上事故:下游一个核心只读服务因为短暂的 GC 停顿,响应延迟拉高到了 2 秒。前端组件写得比较直接,设置了超时 1 秒,失败后立刻重试 3 次。结果两分钟内,原本 2000 QPS 的接口瞬间被打到了接近 8000 QPS,下游直接被打死,运维不得不切断流量做紧急降级。
那次复盘之后,我们重新梳理了全链路的重试规范。重试是保证可用性的有效手段,但没有节制的重试本质上就是对系统的自残。要兼顾客户端体验与服务端的存活性,需要前端的「指数退避抖动」配合后端的「重试预算(Retry Budget)」。
一、 客户端的自我克制:指数退避与抖动(Jitter)
做前端或者移动端开发,面对网络抖动,最直观的反应是「如果失败了,再发一次」。但如果所有客户端在同一时刻失败,并在固定的延迟(比如 1 秒)后再次发起请求,服务端的流量曲线就会呈现尖刺状的波峰,这种现象被称为「惊群」或「重试风暴」。
1. 指数退避(Exponential Backoff)
退避的思路是:重试次数越多,等待时间呈指数级上升。
公式通常是:
$$Delay = Base \times 2^{attempt}$$
但这依然不够,如果 1000 个用户在同一秒因为服务抖动失败,他们在 2 秒、4 秒、8 秒后的重试时机依然是高度对齐的。
2. 加入随机抖动(Full Jitter)
AWS 在其系统架构论文中系统性地对比过几种 Jitter 策略,生产环境中性价比最高的是 Full Jitter。它不是在固定时间上加一点波动,而是在零到当前指数退避上限之间,取一个随机数:
$$Sleep = \text{random}(0, \min(Cap, Base \times 2^{attempt}))$$
这样可以将重试流量完全均匀地打散在整个时间轴上,填平流量尖峰。
3. 前端实现示例(TypeScript)
在前端做封装时,还要注意一个前提:非网络错误或非服务端 5xx 错误,坚决不重试。
interface RetryConfig {
maxRetries?: number;
baseDelayMs?: number;
maxDelayMs?: number;
}
const defaultRetryConfig: Required<RetryConfig> = {
maxRetries: 3,
baseDelayMs: 300,
maxDelayMs: 3000,
};
// 计算带有 Full Jitter 的退避时间
function getJitterDelay(attempt: number, base: number, cap: number): number {
const exponential = base * Math.pow(2, attempt);
const maxWait = Math.min(cap, exponential);
// 在 0 到 maxWait 之间随机取值
return Math.random() * maxWait;
}
// 判断当前错误是否具备重试价值
function isRetryableError(error: any): boolean {
if (!error.response) {
// 网络中断、超时等无响应情况,可以重试
return true;
}
const status = error.response.status;
// 5xx 服务端错误可以重试,但 4xx 业务错误重试没有意义
return status >= 500 && status < 600;
}
export async function requestWithRetry<T>(
requestFn: () => Promise<T>,
config?: RetryConfig
): Promise<T> {
const { maxRetries, baseDelayMs, maxDelayMs } = {
...defaultRetryConfig,
...config,
};
let attempt = 0;
while (true) {
try {
return await requestFn();
} catch (error) {
attempt++;
if (attempt > maxRetries || !isRetryableError(error)) {
throw error;
}
const delay = getJitterDelay(attempt, baseDelayMs, maxDelayMs);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
二、 服务端的防线:重试预算(Retry Budget)
很多团队把重试策略止步于前端,这在微服务体系下是非常脆弱的。如果用户规模足够大,即便客户端做了 Full Jitter,累积的重试请求依然可能超出服务端处理能力的极限。
服务端必须假设客户端是「不可信」的,它必须有手段主动掐断过量的重试流量。这里引入的核心概念是 Retry Budget(重试预算)。
1. 什么是重试预算?
重试预算的逻辑很朴素:在任意时间窗口内,重试流量占整体正常请求的比例不能超过阈值(通常设定在 10% 到 20%)。
如果一个系统当前处理了 1000 个正常请求,给它 10% 的预算,意味着它最多允许额外处理 100 个重试请求。当系统处于亚健康状态时,由于错误率升高,重试请求会激增;一旦重试流量耗尽了这 10% 的配额,服务端应当直接拒绝后续的重试,快速返回失败,防止自身被彻底拖垮。
2. 后端中间件实现思路(Node.js / Token Bucket 思路)
在后端网关或微服务中间件中,我们可以维护一个滑动窗口或者基于令牌桶改造的预算计算器:
// 简化的服务端重试预算控制器
export class RetryBudget {
private normalRequests = 0;
private retryRequests = 0;
private readonly budgetRatio: number; // 允许的重试比例,例如 0.1 表示 10%
private windowResetTimer: NodeJS.Timeout;
constructor(budgetRatio = 0.1, windowMs = 10000) {
this.budgetRatio = budgetRatio;
// 定时重置统计窗口
this.windowResetTimer = setInterval(() => {
this.normalRequests = 0;
this.retryRequests = 0;
}, windowMs);
}
// 记录一次普通请求
public recordNormalRequest(): void {
this.normalRequests++;
}
// 校验当前重试请求是否在预算内
public canAcceptRetry(): boolean {
const allowedRetries = Math.floor(this.normalRequests * this.budgetRatio);
if (this.retryRequests < allowedRetries) {
this.retryRequests++;
return true;
}
// 超出预算,拒绝重试
return false;
}
}
// 模拟 Express / Koa 中间件
const budget = new RetryBudget(0.1, 5000);
export function retryBudgetMiddleware(req: any, res: any, next: () => void) {
// 依靠客户端透传的 Header 识别是否是重试流量
const isRetry = req.headers['x-is-retry'] === 'true';
if (!isRetry) {
budget.recordNormalRequest();
return next();
}
if (budget.canAcceptRetry()) {
return next();
}
// 超出预算,快速失败,告知客户端当前负载过高
res.setHeader('Retry-After', '5');
return res.status(429).json({
code: 'RETRY_BUDGET_EXCEEDED',
message: 'System is experiencing load, retry is temporarily throttled.',
});
}
三、 全栈协同的工程细节
让前后端的重试逻辑有效衔接,不能单靠两端各自为政,还需要明确几项工程契约:
1. 语义明确的 Header 传递
x-is-retry: true:客户端在重试请求中打上标,让网关与后方链路能够清晰识别。Retry-After: 3:后端在限流(HTTP 429)或过载(HTTP 503)时,可以通过这个标准响应头明确告诉前端「别瞎猜,等 3 秒后再来」,前端捕获后应优先采用后端的指令时间。
2. 写操作的幂等性(Idempotency Key)
GET 请求大多天然幂等,重试风险较小。但对于 POST、PUT 等写操作,直接重试极易造成重复扣款、重复下单。 工程上的权衡通常是:
- 客户端在发起有副作用的写请求前,本地生成唯一的
Idempotency-Key(如 UUID v4),随 Header 提交。 - 后端 Redis 结合数据库防重表校验,如果遇到已处理的 Key,直接返回上一次的处理结果,而非再次执行业务逻辑。
四、 权衡与取舍
架构设计里很少有银弹,重试机制同样是收益与成本的权衡:
-
链路深度与重试放大
微服务链路越长,越要在重试层级上克制。如果网关重试 2 次,服务 A 重试 2 次,服务 B 重试 2 次,底层数据库承受的可能就是 $2 \times 2 \times 2 = 8$ 倍的流量放大。实践中,原则上只在最靠近用户的前端发起一次退避重试,或者仅在链路的最末端依赖项处做局部重试,切忌全链路各层都在重试。 -
降级永远优先于硬抗
重试是为了抹平偶发的网络抖动,如果一个依赖服务已经彻底失去响应,重试就没有任何正面作用了。在重试预算被击穿、熔断器打开时,前端应做好降级 UI 处理,展示友好兜底页或者静默失败,避免给后端施加无意义的压力。
把前端的抖动退避看作一种「自律」,把后端的重试预算看作一道「硬约束」,系统面对网络波动的韧性,大体就是从这些细微的防御性编码里累积出来的。
License: CC BY-NC 4.0
Updated a day ago
Was this article helpful? Give it a like.
0 comments


