1. 为什么我们需要自动重试机制
上周排查一个线上问题时,发现日志里大量重复出现"网络请求超时"的错误。开发同学在代码里简单写了个while循环做重试,结果因为没控制好间隔时间,直接把下游服务打挂了。这种场景正是自动重试机制要解决的典型问题——在分布式系统中,临时性故障(网络抖动、服务短暂不可用等)时有发生,但粗暴的手动重试往往会造成更严重的雪崩效应。
1.1 手动重试的三大痛点
我见过太多团队在错误处理上踩坑,总结下来手动重试主要有这些问题:
重试策略不可控:直接在代码里写死循环+Thread.sleep,既无法灵活配置重试次数,也无法实现指数退避等高级策略。就像前面那个案例,每秒重试10次和每隔10秒重试一次,对下游的压力天差地别。
错误处理碎片化:不同开发人员写的重试逻辑风格各异,有的用try-catch嵌套,有的用递归调用,还有的干脆不处理。等出问题时,连定位重试代码都要花半天时间。
监控盲区:手动重试很难统一收集metrics,比如"哪种错误重试最多"、"平均重试几次能成功"这些关键指标,对系统稳定性分析至关重要。
1.2 自动重试的四个核心优势
对比之下,成熟的自动重试方案应该具备这些特性:
- 策略可配置化:通过声明式配置定义重试次数、间隔算法(固定间隔/指数退避/随机抖动)、异常过滤条件等
- 上下文感知:能获取当前重试次数、上次异常信息等上下文,实现条件化重试
- 熔断保护:当失败率超过阈值时自动熔断,避免无效重试
- 可视化监控:提供重试成功率、耗时分布等关键指标
以电商下单场景为例,当支付服务返回"余额不足"时不应该重试,而"支付网关超时"则值得重试3次,这就是业务语义感知的重试策略。
2. 重试机制的技术实现
2.1 基础重试模式对比
先看几种常见的重试实现方式:
// 1. 粗暴版(千万别用) int retries = 0; while(retries < 3) { try { callAPI(); break; } catch (Exception e) { retries++; Thread.sleep(1000); } } // 2. 递归版(栈溢出警告) void retry(int remaining) { try { callAPI(); } catch (Exception e) { if(remaining > 0) { Thread.sleep(1000); retry(remaining - 1); } } } // 3. Spring Retry模板 @Retryable(maxAttempts=3, backoff=@Backoff(delay=1000)) public void callAPI() { // 业务代码 }显然第三种方式最优雅,但实际落地时还要考虑这些细节:
- 需要重试的异常类型(IOException需要重试,IllegalArgumentException不应该重试)
- 重试间的延迟策略(立即重试/固定延迟/指数退避)
- 重试失败后的回调处理
2.2 进阶重试策略实现
对于生产级应用,推荐使用Resilience4j或Spring Retry这类专业库。下面是带指数退避和熔断的完整示例:
// 配置重试策略 RetryConfig config = RetryConfig.custom() .maxAttempts(3) .intervalFunction(IntervalFunction.ofExponentialBackoff(1000, 2)) .retryOnException(e -> e instanceof TimeoutException) .failAfterMaxAttempts(true) .build(); // 配置熔断(10秒内失败率超过50%则熔断) CircuitBreakerConfig cbConfig = CircuitBreakerConfig.custom() .failureRateThreshold(50) .waitDurationInOpenState(Duration.ofSeconds(30)) .slidingWindowType(COUNT_BASED) .slidingWindowSize(10) .build(); // 组合使用 CircuitBreaker circuitBreaker = CircuitBreaker.of("paymentService", cbConfig); Retry retry = Retry.of("paymentRetry", config); Supplier<String> decoratedSupplier = Decorators.ofSupplier(() -> paymentService.call()) .withCircuitBreaker(circuitBreaker) .withRetry(retry) .decorate(); // 执行 Try.ofSupplier(decoratedSupplier) .recover(e -> "fallback");关键参数说明:
intervalFunction:支持指数退避算法,避免重试风暴retryOnException:通过Predicate过滤需要重试的异常failAfterMaxAttempts:超过最大重试次数后抛出RetryException- 与熔断器组合使用,形成双层防护
2.3 分布式场景下的重试
在微服务架构中,还需要特别注意:
- 幂等性设计:重试可能导致重复执行,接口必须支持幂等
- 链路传递:重试上下文(如attemptCount)需要跨服务传递
- 全局策略:通过服务网格(如Istio)实现全局限流和重试
例如在Spring Cloud项目中,可以通过FeignClient统一配置:
feign: client: config: default: retryer: feign.Retryer.Default(500, 2000, 3) errorDecoder: com.example.MyErrorDecoder3. 异常捕获的最佳实践
3.1 精细化异常捕获
很多开发者习惯直接catch(Exception),这会导致业务异常和系统异常混为一谈。推荐的分层捕获策略:
try { // 业务代码 } catch (BusinessException e) { // 已知业务异常,直接转换错误码 return Result.error(ErrorCode.PARAM_ERROR); } catch (RemoteException e) { // 远程调用异常,需要重试 throw new RetryableException(e); } catch (Exception e) { // 其他系统异常,记录日志并告警 log.error("System error", e); throw new SystemException(e); }3.2 上下文信息传递
在捕获异常时,务必保留完整的错误上下文:
catch (HttpClientErrorException e) { throw new RetryableException( "调用支付服务失败", e, Map.of( "url", e.getRequest().getURI(), "status", e.getStatusCode(), "body", e.getResponseBodyAsString() ) ); }这样在监控看板上就能清晰看到:
- 哪些接口最容易超时
- 错误码分布情况
- 请求参数与错误的关联性
3.3 自动捕获工具推荐
对于Java项目,推荐使用这些工具简化异常处理:
Guava的Throwables:解析异常根原因
Throwables.getRootCause(e).getClass();Lombok的@SneakyThrows:简化checked exception处理
@SneakyThrows public void readFile() { Files.readAllBytes(Paths.get("file")); }GlobalExceptionHandler:统一异常拦截
@ControllerAdvice public class ExceptionHandler { @ResponseBody @ExceptionHandler(RetryableException.class) public Result handleRetryable(RetryableException e) { // 触发重试逻辑 } }
4. 生产环境避坑指南
4.1 重试配置的黄金法则
根据多年运维经验,总结出这些配置原则:
- 重试次数:通常3次足够,关键业务不超过5次
- 退避基数:从1秒开始,配合2.0的退避系数
- 熔断阈值:错误率超过50%持续1分钟触发熔断
- 超时设置:单个请求超时 < 重试间隔 < 全局超时
典型错误配置示例:
# 反模式:间隔太短会导致重试风暴 retry: max-attempts: 10 delay: 100ms4.2 必须避免的重试场景
有些错误根本不应该重试:
- 4xx类错误(如400 Bad Request)
- 业务校验失败(如余额不足)
- 幂等冲突(如订单已支付)
- 权限类错误(如403 Forbidden)
可以通过自定义Predicate过滤:
.retryOnException(e -> e instanceof TimeoutException || (e instanceof HttpClientErrorException && ((HttpClientErrorException)e).getStatusCode().is5xxServerError()) )4.3 监控指标设计
完整的重试监控应该包含:
| 指标名称 | 计算方式 | 告警阈值 |
|---|---|---|
| 重试成功率 | 成功次数/(成功+失败) | <95% |
| 平均重试次数 | 总重试次数/总调用次数 | >1.5 |
| 重试耗时P99 | 包含重试的总耗时99分位值 | >500ms |
| 熔断触发次数 | 计数器统计熔断状态切换 | 每分钟>3次 |
在Grafana中可以这样展示:
sum(rate(retry_calls_total[1m])) by (service) / sum(rate(calls_total[1m])) by (service)5. 完整实现示例
最后给出一个Spring Boot项目的完整配置:
- 添加依赖:
<dependency> <groupId>io.github.resilience4j</groupId> <artifactId>resilience4j-spring-boot2</artifactId> <version>2.0.2</version> </dependency>- 配置application.yml:
resilience4j: retry: configs: default: maxAttempts: 3 waitDuration: 1s enableExponentialBackoff: true exponentialBackoffMultiplier: 2 retryExceptions: - org.springframework.web.client.HttpServerErrorException - java.util.concurrent.TimeoutException circuitbreaker: configs: default: failureRateThreshold: 50 minimumNumberOfCalls: 10 slidingWindowSize: 20 waitDurationInOpenState: 30s- 业务代码使用:
@Service public class PaymentService { @Retry(name = "paymentRetry", fallbackMethod = "fallback") @CircuitBreaker(name = "paymentCB") public String processPayment(PaymentRequest request) { return restTemplate.postForObject("/api/pay", request, String.class); } private String fallback(PaymentRequest request, Exception e) { return "支付处理中,结果稍后通知"; } }- 监控集成(Prometheus):
@Bean public MeterRegistryCustomizer<MeterRegistry> metrics() { return registry -> { registry.config().meterFilter( new MeterFilter() { @Override public DistributionStatisticConfig configure( Meter.Id id, DistributionStatisticConfig config ) { if(id.getName().contains("retry")) { return DistributionStatisticConfig.builder() .percentiles(0.5, 0.95, 0.99) .build() .merge(config); } return config; } } ); }; }这套方案在笔者团队处理日均百万级支付请求时,将支付成功率从98.3%提升到了99.7%,同时将因临时故障导致的投诉量降低了82%。关键在于合理控制重试节奏,既不能太激进导致雪崩,也不能太保守影响用户体验。