一、C#微服务调用里的“隐形坑”:乱设重试参数的后果

你有没有遇到过这种情况:线上某个核心服务突然变慢,紧接着一堆关联服务全挂了,最后整个系统瘫成一片?很多人会觉得是服务本身扛不住压力,或者数据库卡了,但背后的罪魁祸首,可能只是一个没设对的重试参数。

1.1 什么是重试参数?

简单说,重试就是当A服务调用B服务失败时,A会自动再打一次请求,试图拿到正确结果。比如你买东西时付款失败,页面会提示“再试一次”,系统内部的重试就是这个逻辑的自动化版本。

1.2 乱设重试参数的具体危害

举个真实的场景:某电商平台的订单服务(A)调用库存服务(B)扣减库存,开发时给重试参数设成了“失败后立刻重试,最多重试10次”。结果某天库存服务因为数据量突增变慢,100个订单请求过来,每个请求都重试10次,相当于原本100个请求瞬间变成了1100个,库存服务的CPU、网络直接被打满,彻底挂掉。紧接着,依赖库存服务的订单、支付、物流服务也跟着出问题,整个交易链路全断,损失了几百万。

这里的核心问题是:不合理的重试(比如立刻重试、次数太多)会放大请求量,把原本只是“慢”的问题,变成“服务死”的问题,这就是加剧资源竞争;而一个核心服务死了,依赖它的所有服务都受影响,进而导致整个系统瘫痪,就是所谓的“雪崩”。

二、解决思路:用熔断和退避搭起“弹性管道”

既然乱设重试会出大问题,那是不是直接关了重试?也不行,偶尔的网络波动、临时卡顿是正常的,合理的重试能帮系统自动恢复。正确的做法是,给系统加一套“弹性机制”——当服务出问题时,自动调整调用策略,不让小问题变成大灾难。

2.1 熔断:给服务装个“保险丝”

熔断的原理和家里的保险丝一样:当电路过载(服务出问题),保险丝会自动断开(熔断),保护整个电路(系统)。比如库存服务连续失败了N次,订单服务就暂时不再调用它,等过一会儿再尝试,给库存服务留出恢复的时间。

2.2 退避:让重试变得“聪明”

退避就是不让重试立刻发生,而是每次重试的间隔越来越长。比如第一次失败后等1秒再试,第二次等2秒,第三次等4秒,以此类推。这样可以避免短时间内大量重试请求打垮服务,给服务足够的缓冲时间。

三、实战:用Polly在C#里实现弹性管道

现在我们用C#的Polly库来实现这套机制,Polly是C#生态里最常用的弹性处理库,专门用来做重试、熔断、退避这些逻辑。

3.1 技术栈说明

本次实战的技术栈为:C# 10.0 + .NET 6 + Polly 7.2.4(版本号要明确,确保代码可运行)。

3.2 完整代码示例

首先,我们模拟两个服务:一个是订单服务(调用方),一个是库存服务(被调用方),库存服务会模拟失败的情况。

第一步:安装Polly包 先在订单服务的项目里安装Polly,打开NuGet包管理器,搜索并安装PollyPolly.Extensions.Http(如果是调用HTTP服务的话,我们这里用HTTP模拟服务调用)。

第二步:模拟库存服务 先写一个简单的库存服务,用ASP.NET Core做一个API,代码如下:

// 库存服务的Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// 模拟库存服务的失败逻辑:当请求次数超过5次时,连续失败
int requestCount = 0;
app.MapGet("/api/stock/decrease", () =>
{
    requestCount++;
    // 前5次请求正常,之后连续失败
    if (requestCount > 5)
    {
        // 返回500错误,模拟服务故障
        return Results.Problem(statusCode: StatusCodes.Status500InternalServerError);
    }
    return Results.Ok("库存扣减成功");
});

app.Run("http://localhost:5001"); // 库存服务运行在5001端口

第三步:订单服务实现弹性管道 接下来是订单服务,我们用Polly实现熔断和退避的重试逻辑,代码如下:

// 订单服务的Program.cs
using Polly;
using Polly.Retry;
using Polly.CircuitBreaker;
using System.Net.Http;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// 第一步:定义退避重试策略
// 退避规则:最多重试3次,每次重试的间隔是1秒、2秒、4秒(指数退避)
AsyncRetryPolicy<HttpResponseMessage> retryPolicy = Policy
    .HandleResult<HttpResponseMessage>(r => !r.IsSuccessStatusCode) // 只对失败的响应重试
    .WaitAndRetryAsync(
        retryCount: 3, // 最多重试3次
        sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), // 退避间隔:2^(尝试次数-1)秒
        onRetry: (result, delay, attempt) =>
        {
            // 重试时的日志,方便观察
            Console.WriteLine($"库存服务调用失败,第{attempt}次重试,等待{delay.TotalSeconds}秒后重试");
        }
    );

// 第二步:定义熔断策略
// 熔断规则:10秒内连续失败3次,触发熔断;熔断后5秒内不再调用服务,5秒后尝试恢复
AsyncCircuitBreakerPolicy<HttpResponseMessage> circuitBreakerPolicy = Policy
    .HandleResult<HttpResponseMessage>(r => !r.IsSuccessStatusCode) // 对失败的响应触发熔断
    .CircuitBreakerAsync(
        exceptionsAllowedBeforeBreaking: 3, // 连续失败3次触发熔断
        durationOfBreak: TimeSpan.FromSeconds(5), // 熔断持续5秒
        onBreak: (result, breakDuration) =>
        {
            // 熔断触发时的日志
            Console.WriteLine($"库存服务连续失败,触发熔断,熔断持续{breakDuration.TotalSeconds}秒");
        },
        onReset: () =>
        {
            // 熔断恢复时的日志
            Console.WriteLine("库存服务熔断恢复,重新开始调用");
        }
    );

// 第三步:组合重试和熔断策略,形成弹性管道
// 先执行重试策略,再执行熔断策略(顺序很重要:先重试,重试失败才触发熔断)
AsyncPolicyWrap<HttpResponseMessage> resiliencePolicy = retryPolicy.WrapAsync(circuitBreakerPolicy);

// 第四步:创建HTTP客户端,调用库存服务
HttpClient httpClient = new HttpClient();

// 模拟订单服务的调用逻辑:连续发送10次请求
for (int i = 1; i <= 10; i++)
{
    Console.WriteLine($"第{i}次调用库存服务");
    try
    {
        // 用弹性管道包裹HTTP调用
        HttpResponseMessage response = await resiliencePolicy.ExecuteAsync(() =>
            httpClient.GetAsync("http://localhost:5001/api/stock/decrease")
        );

        if (response.IsSuccessStatusCode)
        {
            Console.WriteLine($"第{i}次调用成功:{await response.Content.ReadAsStringAsync()}");
        }
        else
        {
            Console.WriteLine($"第{i}次调用最终失败:状态码{response.StatusCode}");
        }
    }
    catch (BrokenCircuitException)
    {
        // 捕获熔断异常,熔断时直接返回,不调用服务
        Console.WriteLine($"第{i}次调用失败:库存服务已熔断,暂不调用");
    }
    // 每次调用间隔1秒,方便观察日志
    await Task.Delay(TimeSpan.FromSeconds(1));
}

app.Run("http://localhost:5002"); // 订单服务运行在5002端口

3.3 代码逻辑详解

  1. 退避重试:我们用指数退避,每次重试的间隔是前一次的2倍,这样可以避免短时间内大量请求冲击服务。
  2. 熔断策略:连续失败3次就触发熔断,熔断5秒后再尝试,给服务足够的恢复时间。
  3. 策略组合:先重试再熔断,因为偶尔的失败可以通过重试解决,只有连续的失败才说明服务真的有问题,需要熔断。

四、实际效果验证

我们运行上面的代码,先启动库存服务,再启动订单服务,会看到以下日志:

  • 第1-5次调用:库存服务正常,调用成功。
  • 第6次调用:库存服务失败,开始第1次重试(间隔1秒),重试失败;第2次重试(间隔2秒),重试失败;第3次重试(间隔4秒),重试失败。此时连续失败3次,触发熔断。
  • 第7-10次调用:因为库存服务已熔断,直接返回“暂不调用”,不会再发送请求。
  • 5秒后,熔断恢复,再调用就会重新尝试。

这个效果就是我们想要的:当服务出问题时,不会因为大量重试打垮服务,也不会因为服务故障导致所有调用都失败,实现了故障隔离和服务自愈。

五、应用场景、优缺点与注意事项

5.1 应用场景

这套弹性机制适用于所有依赖外部服务的场景,比如:

  • 微服务之间的调用(比如订单调库存、支付调银行)。
  • 调用第三方API(比如短信、推送、地图服务)。
  • 访问数据库、缓存等中间件。

5.2 技术优缺点

  • 优点:
    1. 避免雪崩:熔断和退避可以限制故障的传播范围,不会一个服务挂了整个系统瘫。
    2. 自动恢复:熔断会在一段时间后自动尝试恢复,不需要人工干预。
    3. 保护服务:退避可以减少对故障服务的请求量,给服务恢复的时间。
  • 缺点:
    1. 配置复杂:重试次数、退避间隔、熔断阈值需要根据实际场景调整,配置不当可能反而影响系统(比如熔断时间太长,导致用户长时间无法使用)。
    2. 可能出现不一致:比如库存服务恢复后,之前的重试请求可能会导致重复扣减库存,需要配合分布式锁等机制解决。

5.3 注意事项

  1. 区分可重试和不可重试的错误:比如调用银行付款失败,如果是余额不足,就不能重试;如果是网络超时,才可以重试。
  2. 避免重复请求:比如扣减库存、付款等操作,重试时要保证请求的幂等性(即同一个请求多次调用,结果和调用一次一样)。
  3. 合理配置参数:根据服务的响应时间、错误率调整重试次数、退避间隔、熔断阈值,比如响应时间长的服务,退避间隔可以设大一点。

六、文章总结

C#微服务中,不合理的重试参数会放大故障,导致资源竞争和雪崩,而通过熔断和退避构建的弹性管道,可以有效解决这个问题。Polly库为我们提供了简单易用的实现方式,通过合理配置重试、熔断策略,可以实现故障隔离和服务自愈,提高系统的稳定性。

最后,需要注意的是,弹性机制不是万能的,它只是系统稳定性的一道防线,还需要配合服务本身的优化、监控、降级等机制,才能构建出真正可靠的系统。