重试模式:让系统在失败面前多撑一会儿
说实话,做分布式系统这些年,我最怕的不是业务逻辑写错,而是网络抖动、服务暂时不可用这类「软错误」。你想想看,一个请求发出去,对方可能只是打了个盹儿,几毫秒后就恢复了。这时候如果直接放弃,用户看到的就是报错——这体验太差了。
重试模式(Retry Pattern)就是专门解决这个问题的。它的核心思想很简单:当操作失败时,不要立刻放弃,而是再试一次。但怎么试、试几次、每次间隔多久,这里面门道可不少。
重试策略:不是所有失败都值得重试
我在项目中遇到过最典型的场景:一个微服务调用另一个服务,返回了500错误。我第一反应是「再发一次」,结果连续重试了5次,全失败了。后来一查,是对方数据库连接池满了,重试只会让情况更糟。
所以,重试之前先问自己三个问题:
- 这个错误是临时的吗? 网络超时、连接被重置——可以重试。认证失败、参数错误——重试一万次也没用。
- 重试会不会造成副作用? 比如支付接口,重试可能导致重复扣款。这时候需要幂等性设计。
- 系统当前负载如何? 如果已经快扛不住了,重试就是火上浇油。
核心原则:只对可恢复的错误进行重试。
常见的可恢复错误:网络超时、503 Service Unavailable、连接池耗尽、临时性锁冲突。
常见的不可恢复错误:400 Bad Request、401 Unauthorized、404 Not Found、500 Internal Server Error(如果确定是代码bug)。
退避算法:别在伤口上撒盐
假设你决定要重试,那每次重试之间等多久?如果每次都等固定时间,比如1秒,那当服务端压力大时,你的重试请求会像潮水一样涌过去——这叫「惊群效应」。
我个人习惯用指数退避。说白了就是:第一次失败后等1秒,第二次等2秒,第三次等4秒,以此类推。给服务端留出恢复的时间。
常见的退避算法有这几种:
| 算法 | 说明 | 适用场景 |
|---|---|---|
| 固定间隔 | 每次等待相同时间 | 对延迟不敏感,且失败率极低 |
| 指数退避 | 等待时间 = base × 2^(attempt-1) | 大多数分布式系统,最常用 |
| 随机退避 | 在指数退避基础上加随机抖动 | 防止多个客户端同时重试 |
| 增量退避 | 每次增加固定时间 | 简单场景,实现容易 |
我的经验:指数退避一定要加随机抖动(jitter)。不加抖动的指数退避,多个客户端会在同一时刻发起重试,效果跟固定间隔差不多。我曾经在一个高并发系统里踩过这个坑,加了随机抖动后,重试成功率从60%提升到了95%。
最大重试次数:适可而止
重试不是无限循环。你想想看,如果服务已经挂了10分钟,你还在每秒重试一次,那不仅浪费资源,还会让用户等到天荒地老。
设置最大重试次数时,我一般遵循两个原则:
- 用户体验优先: 总等待时间不要超过用户能接受的极限。比如用户操作超时是30秒,那你的重试总耗时就不能超过25秒。
- 系统保护优先: 重试次数太多会放大系统负载。假设正常流量是1000 QPS,重试3次就变成4000 QPS,服务端可能直接被冲垮。
我常用的配置是:最多重试3次,指数退避基数为200ms,最大间隔不超过5秒。这样最坏情况下总等待时间大约是 0.2 + 0.4 + 0.8 = 1.4秒,加上随机抖动也不会超过2秒。
注意: 重试次数和超时时间要联动考虑。如果单次请求超时是10秒,重试3次,那最坏情况用户要等40秒。这时候应该缩短单次超时时间,或者减少重试次数。
网络请求的可靠重试:实战代码
说了这么多理论,咱们直接看代码。下面是一个带指数退避和最大重试次数的HTTP请求重试实现。
Java 实现
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Random;
import java.util.function.Predicate;
public class RetryableHttpClient {
private final HttpClient client;
private final int maxRetries;
private final long baseDelayMs;
private final Random random = new Random();
// 可重试的错误条件
private static final Predicate<HttpResponse<String>> RETRYABLE = resp -> {
int status = resp.statusCode();
return status == 503 || status == 502 || status == 429;
};
public RetryableHttpClient(int maxRetries, long baseDelayMs) {
this.client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
this.maxRetries = maxRetries;
this.baseDelayMs = baseDelayMs;
}
public HttpResponse<String> sendWithRetry(HttpRequest request) throws Exception {
HttpResponse<String> response = null;
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (!RETRYABLE.test(response)) {
return response; // 不可重试的错误,直接返回
}
// 最后一次尝试失败,不再重试
if (attempt == maxRetries) {
return response;
}
} catch (java.io.IOException e) {
// 网络异常,可以重试
if (attempt == maxRetries) {
throw e;
}
}
// 计算退避时间:指数 + 随机抖动
long delay = (long) (baseDelayMs * Math.pow(2, attempt));
long jitter = random.nextLong(delay / 2);
Thread.sleep(delay + jitter);
}
return response;
}
}
C++ 实现
#include <iostream>
#include <chrono>
#include <thread>
#include <random>
#include <functional>
class RetryableHttpClient {
public:
RetryableHttpClient(int maxRetries, long baseDelayMs)
: maxRetries_(maxRetries), baseDelayMs_(baseDelayMs) {}
// 模拟HTTP请求,返回状态码
using HttpCall = std::function<int()>;
int sendWithRetry(const HttpCall& call) {
int statusCode = -1;
for (int attempt = 0; attempt <= maxRetries_; ++attempt) {
try {
statusCode = call();
if (!isRetryable(statusCode)) {
return statusCode;
}
if (attempt == maxRetries_) {
return statusCode;
}
} catch (const std::exception& e) {
// 网络异常
if (attempt == maxRetries_) {
throw;
}
}
// 指数退避 + 随机抖动
long delay = baseDelayMs_ * (1L << attempt); // 2^attempt
long jitter = rand() % (delay / 2 + 1);
std::this_thread::sleep_for(
std::chrono::milliseconds(delay + jitter));
}
return statusCode;
}
private:
bool isRetryable(int statusCode) {
return statusCode == 503 || statusCode == 502 || statusCode == 429;
}
int maxRetries_;
long baseDelayMs_;
};
重试模式的核心流程
下面这张图把整个重试逻辑串起来了。你可以看到,从发起请求到最终返回结果,每一步都有判断和决策。
避坑指南:我曾经踩过的坑
做重试模式这么多年,我总结了几条血泪教训:
- 不要重试所有错误。 我曾经把400 Bad Request也加入重试列表,结果客户端疯狂重试,服务端日志里全是同样的错误。后来加了错误分类,才消停。
- 重试要带上请求ID。 否则你分不清哪次是原始请求,哪次是重试。排查问题时会非常痛苦。
- 考虑熔断。 如果连续重试都失败,说明服务可能真的挂了。这时候应该触发熔断,暂时停止重试,而不是继续浪费资源。
- 记录重试日志。 我习惯在每次重试时打印一条WARN级别的日志,包含请求ID、当前重试次数、退避时间。这样线上出问题时,能快速定位。
一个小技巧: 在重试逻辑中加入「快速失败」机制。比如连续3次重试都失败,且每次失败时间都在100ms以内,那说明问题不是网络抖动,而是服务端已经彻底挂了。这时候直接放弃重试,比继续等指数退避更明智。
总结
重试模式不是什么高深的技术,但用好了能极大提升系统的鲁棒性。记住三个核心点:只重试可恢复的错误、用指数退避加随机抖动、设置合理的最大重试次数。代码实现上,把重试逻辑封装成一个独立的组件,不要散落在业务代码里——这样维护起来也方便。
嗯,关于重试模式就聊这么多。如果你在实际项目中遇到什么奇葩的重试问题,欢迎一起交流。