重试模式:构建弹性微服务
在微服务架构中,服务通过网络而不是内存中的调用进行通信。虽然这种解耦可以实现大规模水平扩展和独立部署,但它也引入了一个主要漏洞:网络不可靠。
下游服务随时可能会遇到短暂的网络故障、临时的 CPU 峰值、快速的数据库锁定争用或滚动更新重启。这些临时故障称为“暂时性故障”。
如果您的服务在下游调用失败时立即抛出错误并导致请求失败,则会造成脆弱的用户体验。相反,许多暂时性错误可以通过等待片刻并重试来自动解决。这就是 重试模式 的用武之地。
在本指南中,我们将探讨重试模式、它的幕后工作原理、简单实现的危险,以及如何在 Java (Resilience4j) 和 Go 中正确实现它。
现实世界的类比:重拨占线的线路
想象一下您正在尝试给朋友打电话。您拨打他们的号码,但收到忙音,因为他们当前正在通话。
您是否会立即放弃,删除他们的联系方式,并认为您永远无法再与他们交谈?当然不是。您挂断电话,稍等一下,然后再次拨打他们的号码。如果他们仍然很忙,您可以等待五分钟再重试。
最终,他们的通话结束,您的重试成功。
在微服务中:
- Call 是对下游服务的 API 请求。
- 忙信号是暂时性网络错误或
503 Service Unavailable响应。 - 重拨 是重试尝试。
- 等待时间 是退避持续时间。
危险:天真的重试和“重试风暴”
乍一看,实现重试机制似乎微不足道:只需将 HTTP 调用包装在 for 循环中,然后继续尝试,直到成功为止。然而,天真的重试实现很容易将一个小问题演变成灾难性的系统范围中断。
想象一下下游服务在流量突然激增的情况下陷入困境。其数据库以 99% 的 CPU 利用率运行,并且请求开始超时。
如果 100 个客户端服务都检测到超时并立即重试 3 次而无需等待,它们将突然使已经陷入困境的下游服务的流量增加三倍。这种流量的突然放大被称为“重试风暴”(或“Thundering Herd”问题)。
您的重试不但不会帮助下游服务恢复,反而会不断将其推低,使其无法跟上。
解决方案:退避和抖动
为了防止重试风暴,弹性系统必须利用两个基本策略:退避和抖动。
1. 退避策略
退避指示客户端在进行后续重试尝试之前应等待多长时间。
- 固定退避:客户端在尝试之间等待恒定的时间(例如,正好 200 毫秒)。虽然简单,但它仍然面临同步流量峰值的风险。
- 指数退避:等待时间随着每次失败的尝试呈指数增加(例如,100 毫秒、200 毫秒、400 毫秒、800 毫秒)。当故障持续存在时,这为下游服务提供了更多的时间来恢复。
2. 抖动(随机性)
即使采用指数退避,如果网络故障导致 1,000 个请求在同一时刻失败,则所有 1,000 个客户端都将计算完全相同的退避延迟。因此,它们将同时分批重试,以同步的峰值冲击下游服务。
抖动通过向退避延迟添加随机方差来解决这个问题。
Without Jitter (Synchronized Waves):
Time: 0ms -> [1000 requests fail]
Time: 100ms -> [1000 retries hit simultaneously]
Time: 200ms -> [1000 retries hit simultaneously]
With Jitter (Distributed Traffic):
Time: 0ms -> [1000 requests fail]
Time: 92ms -> [85 retries]
Time: 105ms -> [120 retries]
Time: 118ms -> [95 retries]
... (Traffic is smoothed out over time)
通过将重试分布在随机间隔内,可以消除下游服务上的负载,从而使其能够正常恢复。
重试的黄金法则:幂等性
在对任何 API 端点应用重试之前,您必须问一个关键问题:此操作是否幂等?
幂等操作是发出多个相同请求与发出单个请求具有完全相同效果的操作。
- 幂等:读取用户配置文件 (
GET /users/123)、更新整个电子邮件字段 (PUT /users/123/email) 或删除项目 (DELETE /items/456)。 - 非幂等:创建新订单 (
POST /orders),或处理信用卡收费 (POST /payments)。
假设您调用 POST /payments 向客户收取 50 美元。下游支付网关收到请求,成功向信用卡收费,但在将 200 OK 响应发送回给您之前,出现了网络故障。
您的服务会注册超时,假设调用失败,然后自动重试。如果支付网关的设计不能处理重复的请求,客户将被收取两次费用。
[!警告] 切勿重试非幂等操作,除非下游服务支持幂等密钥(用于检测和丢弃重复事务的唯一请求标识符)。
实现示例
让我们看看如何在 Java 和 Go 中实现重试模式。
1.Java(Resilience4j 和 Spring Boot)
Resilience4j 提供了一个强大的、高度可配置的重试模块。以下是如何为外部计费服务配置它。
配置 (application.yml)
resilience4j.retry:
instances:
billingService:
maxAttempts: 3
waitDuration: 100ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2.0
enableRandomizedWait: true
randomizedWaitFactor: 0.5
retryExceptions:
- org.springframework.web.client.ResourceAccessException
- io.netty.channel.ConnectTimeoutException
ignoreExceptions:
- org.springframework.web.client.HttpClientErrorException # E.g., 400 Bad Request shouldn't be retried
代码实现
import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service
public class PaymentProcessor {
private final RestTemplate restTemplate;
public PaymentProcessor(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
// Apply the configured retry policy with a fallback method
@Retry(name = "billingService", fallbackMethod = "billingFallback")
public String chargeUser(BillingRequest request) {
return restTemplate.postForObject("http://billing-service/charge", request, String.class);
}
// Executed when all retry attempts fail
public String billingFallback(BillingRequest request, Throwable throwable) {
return "Billing service is currently unavailable. Your transaction will be queued.";
}
}
2.Go(Go语言)
在 Go 中,我们可以使用标准库计时器编写一个优雅的重试运行程序,具有指数退避和随机抖动功能。
package main
import (
"context"
"errors"
"fmt"
"math/rand"
"time"
)
// RetryConfig holds the policies for our retry attempts
type RetryConfig struct {
MaxAttempts int
MinBackoff time.Duration
MaxBackoff time.Duration
}
// Execute runs the operation using exponential backoff with full jitter
func Execute(ctx context.Context, config RetryConfig, operation func() error) error {
var err error
for attempt := 1; attempt <= config.MaxAttempts; attempt++ {
err = operation()
if err == nil {
return nil // Success!
}
if attempt == config.MaxAttempts {
break
}
// Calculate exponential backoff
// wait = min(MaxBackoff, MinBackoff * 2^(attempt-1))
backoff := config.MinBackoff * (1 << (attempt - 1))
if backoff > config.MaxBackoff || backoff <= 0 {
backoff = config.MaxBackoff
}
// Apply Full Jitter: wait randomly between 0 and backoff
jitter := time.Duration(rand.Int63n(int64(backoff)))
fmt.Printf("[Attempt %d/%d] Failed: %v. Retrying in %v...\n", attempt, config.MaxAttempts, err, jitter)
select {
case <-time.After(jitter):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("operation failed after %d attempts: %w", config.MaxAttempts, err)
}
func main() {
config := RetryConfig{
MaxAttempts: 4,
MinBackoff: 100 * time.Millisecond,
MaxBackoff: 2000 * time.Millisecond,
}
// Mock function that fails 3 times and succeeds on the 4th
attempts := 0
mockAPI := func() error {
attempts++
if attempts < 4 {
return errors.New("network timeout (503)")
}
return nil
}
ctx := context.Background()
err := Execute(ctx, config, mockAPI)
if err != nil {
fmt.Printf("Final Outcome: %v\n", err)
} else {
fmt.Println("Final Outcome: Successfully connected on attempt", attempts)
}
}
重试与断路器:何时使用哪个?
开发人员经常将重试模式与断路器模式混淆。虽然两者都旨在提高系统弹性,但它们处理的故障类型根本不同:
| 公制 | 重试模式 | 断路器模式 |
|---|---|---|
| 主要目标 | 自动从暂时(短暂的)故障中恢复。 | 防止持久(长期)故障导致调用者崩溃。 |
| 策略 | 立即重试或在短暂的退避等待后重试。 | 立即快速失败而不调用目标服务。 |
| 下游影响 | 暂时增加下游服务的负载。 | 保护下游服务免受流量影响,使其能够恢复。 |
| 典型触发器 | 短暂的网络断开、数据库超时、套接字丢失。 | 下游服务完全宕机,返回连续500s或者超时。 |
权力夫妇:两者结合
在生产系统中,这两种模式被设计为一起使用。
当您发出下游请求时,它应该首先通过 Circuit Breaker,然后通过 Retry 包装器。
如果发生瞬时故障,重试机制会拦截它并重试。但是,如果下游服务死机,故障就会堆积起来。断路器检测到连续故障,“跳闸”打开,并阻止所有未来的呼叫。
现在,当您尝试调用该服务时,断路器会立即快速失败,完全绕过重试循环并节省您的计算资源。
重试模式的最佳实践
- 仅重试暂时错误:检查 HTTP 状态代码。重试
503 Service Unavailable、429 Too Many Requests(考虑Retry-After标头(如果存在))和网络超时。 不要重试400 Bad Request、401 Unauthorized或404 Not Found- 这些重试永远不会成功。 - 应用抖动:在不引入随机抖动的情况下,切勿使用固定重试计时器。
- 限制最大尝试次数:在合理阈值(通常为 3 到 5 次尝试)后停止重试。无限制的重试会消耗资源并降低延迟。
- 小心级联重试:如果服务 A 调用服务 B(重试 3 次),服务 B 调用服务 C(重试 3 次),则 C 中的故障可能会导致 $3 \times 3 = 9$ 级联调用。仅在最有意义的边界处应用重试。
- 始终设置严格的超时:确保网络超时短于退避期,这样线程就不会无限期地保留。
## 结论
重试模式是微服务架构中针对网络不可靠性的强大第一道防线。与指数退避、抖动和断路器配合使用时,您可以保护您的系统免受级联中断的影响,并为用户创建无缝、自我修复的体验。