Skip to content

超时与重试:RPC 调用的生死线

你有没有遇到过这种情况?

用户反馈:「订单创建成功了,但页面一直转圈,最后报错。」

你查日志:服务端明明处理完了,返回了响应。

问题在哪?

可能是超时和重试配置不当,导致客户端「等不到结果,提前放弃了」。


超时:时间的艺术

超时是什么?

超时(Timeout)是指客户端等待服务端响应的最大时间。超过这个时间,客户端就认为调用失败。

客户端                              服务端
  │                                   │
  │  发起调用                         │
  ├──────────────────────────────────▶│
  │                                   │
  │  等待响应(超时计时开始)           │
  │  ⏱️ 3 秒...                       │
  │  ⏱️ 2 秒...                       │
  │  ⏱️ 1 秒...                       │
  │                                   │
  │  ╳ 超时!                         │
  │  认为调用失败                       │
  │  抛出 TimeoutException            │
  │                                   │
  │  ←───────── 服务端还在处理 ──────── │
  │                                   │
  │  服务端返回成功                     │
  │  但客户端已经不等了                │
  │                                   │

超时设置的原则

超时太短: 服务端还没处理完,客户端就放弃了 超时太长: 失败响应慢,系统可用性下降

最佳实践:
├─ 读操作:200-500ms(用户对延迟敏感)
├─ 写操作:1-3s(需要等待数据落地)
└─ 批量操作:视数据量而定,通常 30s 内

Dubbo 超时配置

java
// 接口级别超时
@DubboReference(
    timeout = 3000  // 3 秒超时
)
private OrderService orderService;

// 方法级别超时
@DubboReference(
    methods = {
        @Method(name = "getOrder", timeout = 2000),      // 2 秒
        @Method(name = "createOrder", timeout = 5000),  // 5 秒
        @Method(name = "batchCreate", timeout = 30000)  // 30 秒
    }
)
private OrderService orderService;

// 全局默认超时
dubbo:
  consumer:
    timeout: 3000  # 默认 3

gRPC 超时配置

java
// Java gRPC 超时配置
ManagedChannel channel = ManagedChannelBuilder
    .forAddress("localhost", 8080)
    .build();

// 方式一:每次调用指定超时
Order order = blockingStub
    .withDeadlineAfter(3, TimeUnit.SECONDS)
    .getOrder(request);

// 方式二:使用 Context 控制
try (ManualResetTaskableSourceFuture<Order> future = 
        futureStub.withDeadlineAfter(3, TimeUnit.SECONDS)
                  .getOrderAsync(request)) {
    Order order = future.get();
} catch (ExecutionException e) {
    if (e.getCause() instanceof StatusRuntimeException) {
        StatusRuntimeException sre = (StatusRuntimeException) e.getCause();
        if (sre.getStatus() == Status.DEADLINE_EXCEEDED) {
            // 超时处理
        }
    }
}

重试:失败的补救

重试是什么?

重试(Retry)是指调用失败后,客户端自动重新发起调用。

调用 1 ──────────────────────────▶ │ 服务端
       │ 超时/失败                   │
       ◀─────────────────────────────│
       │                             │
       │ 重试 2                      │
       ├──────────────────────────▶   │ 成功!
       │ ◀──────────────────────────┤
       ✓ 返回结果

重试的前提

不是所有失败都适合重试:

场景是否重试原因
网络抖动✅ 重试临时性问题
服务端正重启✅ 重试短暂不可用
超时✅ 重试可能只是慢
业务校验失败❌ 不重试重试也不会成功
资源不足(内存/连接池)❌ 不重试只会雪上加霜
非幂等操作⚠️ 谨慎重试可能产生副作用

幂等性:重试的关键

幂等操作: 多次执行和一次执行的结果相同

java
// 幂等操作:可以安全重试
public Order getOrderById(Long id) {
    // 查询,多次执行结果相同
    return orderRepository.findById(id);
}

// 非幂等操作:不能随意重试
public void createOrder(Order order) {
    // 创建,每次执行都会创建新订单
    // 重试会导致重复订单
    return orderRepository.save(order);
}

// 解决方案:使用唯一订单号
public void createOrder(Order order, String idempotencyKey) {
    // 幂等化:先检查是否已存在
    if (orderRepository.existsByIdempotencyKey(idempotencyKey)) {
        return orderRepository.findByIdempotencyKey(idempotencyKey);
    }
    return orderRepository.save(order);
}

Dubbo 重试配置

java
@DubboReference(
    retries = 3,  // 重试次数(不包含首次调用)
    timeout = 3000
)
private OrderService orderService;

Dubbo 内置的重试策略:

策略说明使用场景
failover失败自动切换到其他实例读操作,幂等
failfast快速失败,不重试非幂等写操作
failsafe失败忽略,返回空日志、监控
failback失败后台重试非核心服务
forking并发调用多个实例需要多个结果
java
// 配置重试策略
@DubboReference(
    cluster = "failover",  // 失败切换
    retries = 2,          // 重试 2 次(最多 3 次调用)
    methods = {
        @Method(
            name = "getOrder",
            timeout = 2000,
            retries = 3  // 单独配置方法级别重试
        )
    }
)
private OrderService orderService;

超时与重试的配合

黄金公式

总超时时间 = 单次超时时间 × (重试次数 + 1) + 间隔时间

配置不当的后果

场景一:超时设置过长

java
// 超时 30 秒,重试 3 次
// 最坏情况:30s × 4 = 120 秒
// 用户体验极差
@DubboReference(
    timeout = 30000,
    retries = 3
)
private OrderService orderService;

场景二:超时设置过短

java
// 超时 100ms,重试 3 次
// 服务端正常处理需要 500ms
// 结果:调用了 4 次,全部超时
@DubboReference(
    timeout = 100,
    retries = 3
)
private OrderService orderService;

正确的配置示例

java
// 推荐配置:超时短,重试少
@DubboReference(
    timeout = 3000,    // 单次调用超时 3 秒
    retries = 2,       // 最多重试 2 次
    // 最坏情况:3s × 3 = 9 秒,用户可接受
    cluster = "failover"
)
private OrderService orderService;

高级配置

1. 指数退避重试

每次重试的间隔时间逐渐增加,避免对服务端造成压力:

java
// 指数退避实现
public class ExponentialBackoffRetry {
    
    public static final int MAX_RETRIES = 3;
    public static final long BASE_DELAY_MS = 100;
    public static final long MAX_DELAY_MS = 5000;
    
    public <T> T execute(Callable<T> operation) throws Exception {
        Exception lastException = null;
        
        for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
            try {
                return operation.call();
            } catch (Exception e) {
                lastException = e;
                
                if (attempt < MAX_RETRIES - 1) {
                    // 计算退避时间:100ms, 200ms, 400ms...
                    long delay = Math.min(
                        BASE_DELAY_MS * (1L << attempt), 
                        MAX_DELAY_MS
                    );
                    Thread.sleep(delay);
                }
            }
        }
        
        throw lastException;
    }
}

2. 熔断器模式

当失败率超过阈值时,快速失败,不再重试:

java
// 简化熔断器实现
public class CircuitBreaker {
    
    private final AtomicInteger failureCount = new AtomicInteger(0);
    private final AtomicInteger successCount = new AtomicInteger(0);
    
    private volatile State state = State.CLOSED;
    private volatile long lastFailureTime = 0;
    
    private static final int THRESHOLD = 5;      // 失败阈值
    private static final long TIMEOUT = 60000;   // 熔断恢复时间
    
    public void recordFailure() {
        failureCount.incrementAndGet();
        lastFailureTime = System.currentTimeMillis();
        
        if (failureCount.get() >= THRESHOLD) {
            state = State.OPEN;
        }
    }
    
    public void recordSuccess() {
        successCount.incrementAndGet();
        failureCount.set(0);
        
        if (successCount.get() >= THRESHOLD && state == State.HALF_OPEN) {
            state = State.CLOSED;
            successCount.set(0);
        }
    }
    
    public boolean allowRequest() {
        if (state == State.CLOSED) {
            return true;
        }
        
        if (state == State.OPEN) {
            // 检查是否超时,可以尝试恢复
            if (System.currentTimeMillis() - lastFailureTime > TIMEOUT) {
                state = State.HALF_OPEN;
                return true;
            }
            return false;
        }
        
        // HALF_OPEN 状态允许部分请求通过
        return true;
    }
    
    enum State {
        CLOSED,     // 正常状态
        OPEN,       // 熔断状态,拒绝请求
        HALF_OPEN   // 半开状态,尝试恢复
    }
}

3. 服务端超时设置

服务端也需要设置超时,防止客户端无限等待:

java
// Dubbo 服务端超时
@DubboService(
    timeout = 5000,  // 服务端处理超时
    executes = 10    // 最大并发执行数
)
public class OrderServiceImpl implements OrderService {
    // ...
}

常见问题与排查

问题一:超时异常怎么排查?

java
// 捕获超时异常
try {
    orderService.createOrder(request);
} catch (RpcException e) {
    if (e.isTimeout()) {
        // 是超时异常
        // 记录上下文信息
        log.error("RPC 调用超时", e);
        log.error("  服务: {}", e.getServiceName());
        log.error("  方法: {}", e.getMethodName());
        log.error("  超时时间: {}ms", e.getTimeout());
        
        // 上报监控
        metrics.increment("rpc.timeout", 
            Tags.of("service", serviceName, "method", methodName));
    }
}

问题二:重试导致重复数据?

java
// 问题场景
public void createOrder(Order order) {
    // 没有幂等控制的重试场景
    // 第 1 次:创建订单 A(成功,但响应丢失)
    // 第 2 次:重试,又创建订单 B(重复!)
    
    orderRepository.save(order);
}

// 解决方案:数据库唯一约束
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    idempotency_key VARCHAR(64) UNIQUE NOT NULL,  -- 幂等键
    customer_name VARCHAR(100),
    total_amount DECIMAL(10,2)
);

// 解决方案:先查询再插入
@Transactional
public Order createOrder(String idempotencyKey, Order order) {
    Order existing = orderRepository.findByIdempotencyKey(idempotencyKey);
    if (existing != null) {
        return existing;  // 返回已有的订单
    }
    return orderRepository.save(order);
}

问题三:重试风暴

当大量请求同时失败时,同时发起重试,可能把服务端打垮:

java
// 问题:所有客户端同时重试
// 失败 ──▶ 重试 ──▶ 更多请求 ──▶ 更多失败 ──▶ 更多重试
//                    ↑                              │
//                    └──────────────────────────────┘

// 解决方案:添加随机抖动
public long calculateDelayWithJitter(int attempt) {
    long baseDelay = BASE_DELAY_MS * (1L << attempt);
    // 随机增加 0-100% 的延迟
    long jitter = (long) (baseDelay * Math.random());
    return baseDelay + jitter;
}

总结

配置建议值说明
简单查询超时1-3 秒快速失败,快速切换
复杂查询超时3-10 秒需要多表 join
写入操作超时3-5 秒需要等待数据落盘
批量操作超时30-60 秒视数据量调整
重试次数1-3 次避免雪崩
重试间隔指数退避100ms → 200ms → 400ms

超时和重试是 RPC 调用可靠性的基础,配合熔断、限流等机制,才能构建健壮的分布式系统。


留给你的问题

假设你的系统有以下特点:

  • 服务 A 调用服务 B,B 调用服务 C
  • 链路:A → B → C
  • 每个服务都配置了 timeout=1s, retries=2

在最坏情况下,用户等待多久才能收到错误响应?如果要优化这个链路,你会从哪些方面入手?

这个问题,可以结合 RPC 链路追踪 来思考如何定位超时问题。

基于 VitePress 构建