微服务调用失败后简单重试,可能把局部故障扩大成全链路拥塞。本文以 Java HttpClient 为例,说明如何区分可重试错误,传播请求截止时间,并用指数退避、随机抖动和 Retry-After 设计有边界的重试机制。
微服务中最容易被低估的代码之一,是下面这种看起来很合理的重试:
for (int i = 0; i < 3; i++) {
try {
return callRemoteService();
} catch (Exception e) {
// retry
}
}它解决了偶发网络抖动,却没有回答几个更重要的问题:什么错误可以重试?重试等待的时间从哪里来?总耗时是否有上限?多个实例同时失败时,会不会在同一时刻再次打满下游?请求已经被下游处理成功但响应丢失时,重试是否会造成重复业务?
如果这些问题没有明确答案,重试就不是可靠性措施,而是故障放大器。
一、重试风暴是怎样形成的
假设一个服务有 100 个实例,每个实例同时处理 50 个请求。下游数据库或 HTTP 服务出现短暂拥塞,第一批调用在连接超时或读取超时后失败。如果每个请求立即重试 3 次,那么下游接收到的请求量可能从原来的 5000 个增加到 20000 个左右。
更麻烦的是,许多客户端使用相同的固定等待时间,例如每次失败后等待 100 毫秒。所有实例会在相近的时间发起下一批请求,形成有规律的请求波峰。下游刚刚释放出一点资源,又被重试流量重新压垮,最终出现连接池耗尽、线程池排队和更长的响应时间。
因此,重试设计至少需要同时具备四个边界:
- 时间边界:一次请求不能无限等待,所有尝试共享一个总截止时间。
- 次数边界:重试次数必须有限,并且不能因为异常包装或递归调用被重复计算。
- 错误边界:只有暂时性故障才适合重试,业务拒绝和参数错误应立即返回。
- 流量边界:等待时间需要加入随机抖动,避免客户端同步重试。
二、先区分“请求失败”和“业务处理失败”
HTTP 状态码只是判断依据之一,不能机械地把所有 5xx 都重试。
通常可以考虑重试的情况包括:连接建立失败、读取响应时发生暂时性网络异常、429 Too Many Requests、502 Bad Gateway、503 Service Unavailable 和 504 Gateway Timeout。其中 429 和 503 如果带有 Retry-After,客户端应优先尊重服务端给出的等待时间。
一般不应重试的情况包括:400、401、403、404,以及明确表示业务规则不满足的响应。例如库存不足、账户被冻结、订单状态不允许支付,这些错误重试只会增加压力,不会改变结果。
还要区分 HTTP 调用的语义。查询类 GET 通常具有较好的重试基础,但也要确认接口是否真的没有副作用。POST 默认不能因为超时就直接重试:客户端可能没有收到响应,但服务端已经创建了订单。对于这类接口,应使用业务幂等键,并让服务端持久化和校验该键,而不能只依赖客户端的重试代码。
三、让所有尝试共享一个截止时间
常见错误是给每次尝试都设置 2 秒超时,再尝试 3 次。这样一次调用最坏可能阻塞 6 秒,还没有计算排队和退避时间。上游接口可能只允许 3 秒,但下游客户端却自行消耗了更长时间。
更可靠的方式是先确定一个总截止时间。每次真正发起请求前,计算剩余时间,并把剩余时间设置为本次 HTTP 请求的超时。如果没有剩余时间,就不再发起新的尝试。
下面是一个基于 Java 11 HttpClient 的同步示例。代码只依赖 JDK,重点展示重试策略本身;生产环境还应补充指标、日志采样和连接池配置。
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;
public final class RetryingHttpCaller {
private final HttpClient client;
private final int maxAttempts;
private final Duration baseDelay;
private final Duration maxDelay;
public RetryingHttpCaller(HttpClient client,
int maxAttempts,
Duration baseDelay,
Duration maxDelay) {
if (maxAttempts < 1) {
throw new IllegalArgumentException("maxAttempts must be positive");
}
this.client = client;
this.maxAttempts = maxAttempts;
this.baseDelay = baseDelay;
this.maxDelay = maxDelay;
}
public HttpResponse<String> get(URI uri, Duration totalTimeout)
throws IOException, InterruptedException {
long deadline = System.nanoTime() + totalTimeout.toNanos();
IOException lastIOException = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
long remainingNanos = deadline - System.nanoTime();
if (remainingNanos <= 0) {
throw new IOException("request deadline exceeded");
}
Duration requestTimeout = Duration.ofNanos(remainingNanos);
HttpRequest request = HttpRequest.newBuilder(uri)
.timeout(requestTimeout)
.header("Accept", "application/json")
.GET()
.build();
try {
HttpResponse<String> response = client.send(
request, HttpResponse.BodyHandlers.ofString());
if (!isRetryableStatus(response.statusCode())
|| attempt == maxAttempts) {
return response;
}
long serverDelayMillis = retryAfterMillis(response);
long clientDelayMillis = backoffMillis(attempt);
long delayMillis = serverDelayMillis >= 0
? serverDelayMillis
: clientDelayMillis;
if (!sleepWithinDeadline(delayMillis, deadline)) {
return response;
}
} catch (IOException e) {
lastIOException = e;
if (attempt == maxAttempts
|| !sleepWithinDeadline(backoffMillis(attempt), deadline)) {
throw e;
}
}
}
throw lastIOException == null
? new IOException("request failed")
: lastIOException;
}
private boolean isRetryableStatus(int status) {
return status == 429 || status == 502
|| status == 503 || status == 504;
}
private long backoffMillis(int attempt) {
long exponential = baseDelay.toMillis() * (1L << Math.min(attempt - 1, 20));
long capped = Math.min(exponential, maxDelay.toMillis());
return ThreadLocalRandom.current().nextLong(capped + 1);
}
private long retryAfterMillis(HttpResponse<String> response) {
String value = response.headers().firstValue("Retry-After").orElse(null);
if (value == null) {
return -1;
}
try {
long seconds = Long.parseLong(value.trim());
return Math.max(0, seconds) * 1000L;
} catch (NumberFormatException ignored) {
// 示例只处理秒数格式;HTTP 日期格式应使用 HTTP-date 解析器处理。
return -1;
}
}
private boolean sleepWithinDeadline(long requestedMillis, long deadline)
throws InterruptedException {
long remainingMillis = Duration.ofNanos(
Math.max(0, deadline - System.nanoTime())).toMillis();
long sleepMillis = Math.min(requestedMillis, remainingMillis);
if (sleepMillis <= 0) {
return false;
}
Thread.sleep(sleepMillis);
return System.nanoTime() < deadline;
}
}这段代码中有几个值得注意的细节。
backoffMillis 使用的是带抖动的指数退避。第 1 次失败后,等待时间在 0 到基础延迟之间随机取值;后续尝试的上限逐步增长,但不会超过最大延迟。随机化会把大量客户端的重试时间分散开,避免它们形成同一批请求。
代码使用 System.nanoTime() 计算耗时,而不是使用当前墙上时间。墙上时间可能因为系统校时发生跳变,nanoTime 更适合测量同一进程内的时间间隔。
Retry-After 的秒数格式被优先采用,但示例没有完整实现 HTTP 日期格式解析。正式实现中应使用 DateTimeFormatter.RFC_1123_DATE_TIME 等方式解析合法的 HTTP 日期,并对异常值设置合理上限,不能让下游返回一个很大的数字就让业务线程长时间睡眠。
四、超时不等于服务端没有执行
读取响应超时,只能说明客户端在规定时间内没有拿到完整响应,不能证明服务端没有处理请求。
例如客户端发送创建订单请求后,服务端已经完成数据库提交,但响应在网络中丢失。客户端感知到的是超时,如果直接重试,就可能创建第二个订单。这个场景不能用“重试次数少一点”解决,需要改变接口契约:
POST /orders
Idempotency-Key: 7f4a2d2e-...
Content-Type: application/json服务端需要在事务中保存幂等键、请求摘要和业务结果。相同幂等键再次到达时,如果请求内容一致,应返回第一次处理的结果;如果内容不一致,应明确拒绝。幂等记录还需要设置合理的保留时间,过早清理会让网络重试重新产生副作用。
对于不能安全重试的请求,与其在通用 HTTP 客户端层面强行重试,不如让上层通过查询接口确认结果。例如支付提交超时后,先根据商户订单号查询状态,再决定是否继续处理。
五、常见实现陷阱
1. 只限制重试次数,不限制总耗时
三次重试并不代表耗时可控。每次请求的连接超时、读取超时和退避时间相加后,仍可能超过上游允许的请求生命周期。总截止时间应由调用方传入,或者从入口请求的剩余时间中计算出来。
2. 在每一层都重试
网关重试一次,业务服务重试三次,SDK 再重试三次,最终一次用户请求可能产生九次甚至更多下游请求。重试责任应尽量集中在一个明确层级,并通过请求头或上下文记录当前尝试次数,避免层层叠加。
3. 把线程中断当成普通异常吞掉
如果调用线程在退避等待时被中断,应恢复中断标记并尽快结束,而不是继续循环。上面的同步示例直接声明 InterruptedException,由上层决定如何返回和记录。如果在封装层捕获它,应调用 Thread.currentThread().interrupt()。
4. 重试日志写成完整堆栈
网络抖动期间,重试本身可能产生大量日志。建议记录接口、目标主机、尝试次数、状态码、等待时间、最终结果和耗时,并通过采样控制异常堆栈。更重要的是把这些信息变成指标:重试率、按原因分类的失败率、Retry-After 命中次数和截止时间耗尽次数。
5. 没有并发隔离
即使重试策略正确,下游持续不可用时,请求仍可能占满本服务的工作线程和连接池。实际系统通常还需要并发上限、舱壁隔离和熔断。熔断器负责在明显失败时暂时停止调用,重试负责处理有限的暂时性失败,两者不能互相替代。
六、落地时如何制定规则
可以先为每类接口写出明确策略,而不是从一个全局重试开关开始:
- 查询接口:允许有限次重试,只处理网络异常和指定的 5xx,使用总截止时间和带抖动退避。
- 创建或扣款接口:必须先定义幂等键和结果查询机制,再决定是否允许自动重试。
429:优先遵守Retry-After,同时设置本地最大等待时间。503:可以短暂重试,但应配合熔断和并发限制。- 参数、权限和业务状态错误:直接返回,不重试。
- 消息消费或定时任务:将重试次数、延迟和死信策略放到任务系统中管理,避免在线 HTTP 线程长期等待。
最后,压测和故障演练不能只验证“服务最终能成功”。还要观察下游变慢时,本服务的线程数、连接数、队列长度和重试请求量是否受控;恢复后是否出现集中补偿流量;请求截止时间耗尽后,是否仍有后台任务继续占用资源。
总结
可靠的 HTTP 重试不是简单地把失败请求再发一次。它需要先判断请求是否具备重试语义,再区分暂时性错误和确定性错误;需要让所有尝试共享一个总截止时间,并用指数退避和随机抖动降低同步冲击;对于有副作用的请求,还必须依靠服务端幂等和结果查询消除重复执行风险。
当系统进入故障状态时,最重要的不是让每个请求都坚持到最后,而是让每个请求尽早、明确、可观测地结束,同时给下游留下恢复空间。