重试模式:让系统在失败面前多撑一会儿

说实话,做分布式系统这些年,我最怕的不是业务逻辑写错,而是网络抖动、服务暂时不可用这类「软错误」。你想想看,一个请求发出去,对方可能只是打了个盹儿,几毫秒后就恢复了。这时候如果直接放弃,用户看到的就是报错——这体验太差了。

重试模式(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_;
};

重试模式的核心流程

下面这张图把整个重试逻辑串起来了。你可以看到,从发起请求到最终返回结果,每一步都有判断和决策。

发起请求 执行 HTTP 请求 请求成功? 返回结果 可重试? 报错 已达最大次数? 最终失败 退避等待 delay ms

避坑指南:我曾经踩过的坑

做重试模式这么多年,我总结了几条血泪教训:

  • 不要重试所有错误。 我曾经把400 Bad Request也加入重试列表,结果客户端疯狂重试,服务端日志里全是同样的错误。后来加了错误分类,才消停。
  • 重试要带上请求ID。 否则你分不清哪次是原始请求,哪次是重试。排查问题时会非常痛苦。
  • 考虑熔断。 如果连续重试都失败,说明服务可能真的挂了。这时候应该触发熔断,暂时停止重试,而不是继续浪费资源。
  • 记录重试日志。 我习惯在每次重试时打印一条WARN级别的日志,包含请求ID、当前重试次数、退避时间。这样线上出问题时,能快速定位。

一个小技巧: 在重试逻辑中加入「快速失败」机制。比如连续3次重试都失败,且每次失败时间都在100ms以内,那说明问题不是网络抖动,而是服务端已经彻底挂了。这时候直接放弃重试,比继续等指数退避更明智。

总结

重试模式不是什么高深的技术,但用好了能极大提升系统的鲁棒性。记住三个核心点:只重试可恢复的错误、用指数退避加随机抖动、设置合理的最大重试次数。代码实现上,把重试逻辑封装成一个独立的组件,不要散落在业务代码里——这样维护起来也方便。

嗯,关于重试模式就聊这么多。如果你在实际项目中遇到什么奇葩的重试问题,欢迎一起交流。

公众号:蓝海资料掘金营,微信 deep3321