编写重试逻辑的最清晰方式是什么?

543

偶尔我需要在放弃前重试某个操作几次。我的代码如下:

int retries = 3;
while(true) {
  try {
    DoSomething();
    break; // success!
  } catch {
    if(--retries == 0) throw;
    else Thread.Sleep(1000);
  }
}

我想把这个重试函数改写成一个通用的重试函数:

TryThreeTimes(DoSomething);

在C#中是否可能?TryThreeTimes()方法的代码将是什么?


1
一个简单的循环不够吗?为什么不迭代并执行逻辑多次呢? - Restuta
13
就我个人而言,对于这种辅助方法,我会非常谨慎。虽然可以使用lambda表达式来实现,但该模式本身存在极大的问题,因此引入一个辅助方法(这意味着该模式经常重复出现)本身就是高度可疑的,并且强烈暗示整体设计存在问题。 - Pavel Minaev
14
就我而言,我的DoSomething()函数会在远程机器上执行诸如删除文件或尝试连接网络端口等操作。在这两种情况下,DoSomething成功的时间非常关键,由于远程性质,我无法监听任何事件。所以说,这是一个有问题的地方。欢迎提出建议。 - noctonura
27
为什么使用重试会暗示整体设计不佳?如果你编写了大量连接集成点的代码,那么使用重试肯定是一个你应该认真考虑的模式。 - bytedev
30个回答

645

如果将简单重试相同调用的毯子捕获语句用作一般异常处理机制,可能会很危险。话虽如此,这里有一个基于lambda的重试包装器,您可以将其用于任何方法。我选择将重试次数和重试超时作为参数进行因素分解,以获得更多的灵活性:

public static class Retry
{
    public static void Do(
        Action action,
        TimeSpan retryInterval,
        int maxAttemptCount = 3)
    {
        Do<object>(() =>
        {
            action();
            return null;
        }, retryInterval, maxAttemptCount);
    }

    public static T Do<T>(
        Func<T> action,
        TimeSpan retryInterval,
        int maxAttemptCount = 3)
    {
        var exceptions = new List<Exception>();

        for (int attempted = 0; attempted < maxAttemptCount; attempted++)
        {
            try
            {
                if (attempted > 0)
                {
                    Thread.Sleep(retryInterval);
                }
                return action();
            }
            catch (Exception ex)
            {
                exceptions.Add(ex);
            }
        }
        throw new AggregateException(exceptions);
    }
}

现在您可以使用此实用程序方法执行重试逻辑:

Retry.Do(() => SomeFunctionThatCanFail(), TimeSpan.FromSeconds(1));

或者:

Retry.Do(SomeFunctionThatCanFail, TimeSpan.FromSeconds(1));

或者:

int result = Retry.Do(SomeFunctionWhichReturnsInt, TimeSpan.FromSeconds(1), 4);

或者你甚至可以创建一个 async 重载。


7
+1,特别是对于警告和错误检查。但如果可以通过将异常类型作为泛型参数传递(其中T:Exception)来捕获异常,我会更加放心。 - TrueWill
2
我的意图是,“retries”实际上是指重试。但是将其更改为表示“尝试”并不太困难,只要名称保持有意义即可。还有其他改善代码的机会,比如检查负重试或负超时等。我大多数情况下省略了这些,只是为了保持示例简单...但在实践中,这些可能是实现的良好增强。 - LBushkin
59
我们在高并发的Biztalk应用中使用类似的模式来访问数据库,但有两个改进:我们对不应重试的异常设置了黑名单,并且存储第一个异常,并在最终重试失败时抛出该异常。原因在于第二个及之后的异常通常与第一个异常不同。在这种情况下,如果只重新抛出最后一个异常,那么就会隐藏最初的问题。 - TToni
3
我们使用原异常作为内部异常抛出一个新的异常。原始堆栈跟踪可作为内部异常的属性进行访问。 - TToni
10
你也可以尝试使用开源库,比如 Polly 来处理这个问题。它有更多的重试等待时间的灵活性,并且已经被许多其他人验证过了。例如:Policy.Handle<DivideByZeroException>().WaitAndRetry(new[] { TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(3) }); - Todd Meinershagen
显示剩余18条评论

279

你应该尝试Polly。它是由我编写的.NET库,使开发人员可以流畅地表达临时异常处理策略,如Retry、Retry Forever、Wait and Retry或Circuit Breaker。

示例

Policy
    .Handle<SqlException>(ex => ex.Number == 1205)
    .Or<ArgumentException>(ex => ex.ParamName == "example")
    .WaitAndRetry(3, _ => TimeSpan.FromSeconds(3))
    .Execute(DoSomething);

3
OnRetry委托实际上是什么?我认为它是我们需要在出现异常时执行的操作。因此,在发生异常时,OnRetry委托将被调用,然后调用Execute委托。是这样吗? - user6395764
这段代码应该在哪里使用?如果答案是Startup.cs,那么如何注册策略? - Sina Riani
问:OnRetry委托实际上是什么? 答:它只允许您在执行重试时执行某些操作(例如,记录某些内容)。您不需要在其中调用Execute,这会自动发生。 - D.R.
@SinaRiani 你可以使用 Polly 像这样做。https://dev59.com/h3I_5IYBdhLWcg3wBuNs#68013076 - Keith Banner

77
public void TryThreeTimes(Action action)
{
    var tries = 3;
    while (true) {
        try {
            action();
            break; // success!
        } catch {
            if (--tries == 0)
                throw;
            Thread.Sleep(1000);
        }
    }
}

然后您将调用:

TryThreeTimes(DoSomething);

...或者...

TryThreeTimes(() => DoSomethingElse(withLocalVariable));
一个更加灵活的选项:
public void DoWithRetry(Action action, TimeSpan sleepPeriod, int tryCount = 3)
{
    if (tryCount <= 0)
        throw new ArgumentOutOfRangeException(nameof(tryCount));

    while (true) {
        try {
            action();
            break; // success!
        } catch {
            if (--tryCount == 0)
                throw;
            Thread.Sleep(sleepPeriod);
        }
   }
}

用作:

DoWithRetry(DoSomething, TimeSpan.FromSeconds(2), tryCount: 10);

一个更现代化的版本,支持async/await:

public async Task DoWithRetryAsync(Func<Task> action, TimeSpan sleepPeriod, int tryCount = 3)
{
    if (tryCount <= 0)
        throw new ArgumentOutOfRangeException(nameof(tryCount));

    while (true) {
        try {
            await action();
            return; // success!
        } catch {
            if (--tryCount == 0)
                throw;
            await Task.Delay(sleepPeriod);
        }
   }
}

应该这样使用:

await DoWithRetryAsync(DoSomethingAsync, TimeSpan.FromSeconds(2), tryCount: 10);

2
最好将if改为:--retryCount <= 0,因为如果您将其设置为0以禁用重试,它将永远继续。从技术上讲,“retryCount”这个术语并不是一个非常好的名称,因为如果将其设置为1,则不会重试。要么将其重命名为“tryCount”,要么在其后面加上“--”。 - Stefanvds
2
@saille 我同意。然而,原帖(以及所有其他答案)都在使用 Thread.Sleep。替代方案是使用计时器,或者更可能的是现在使用 async 进行重试,使用 Task.Delay - Drew Noakes
4
我已经添加了一个异步版本。 - Drew Noakes
只有当操作“返回true”时才使用break吗?Func<bool> - Kiquenet
@DrewNoakes 使用“async”版本与其他版本相比有什么好处吗? - ibda
1
@ibda 只有在你想要释放线程以执行其他任务时,才会使用异步版本,而不是休眠。例如,如果在线程池上运行工作,则不应将这些线程置于睡眠状态。 - Drew Noakes

57

这可能是一个不好的想法。首先,它代表了“疯狂就是做同样的事情两次,并期待每次都有不同的结果”的格言。其次,这种编码模式本身不太适合组合。例如:

假设您的网络硬件层在失败时重新发送数据包三次,等待一秒钟后再重试。

现在假设软件层在数据包失败时三次重新发送关于失败的通知。

接着,假设通知层在通知交付失败时重新激活通知三次。

再假设错误报告层在通知失败时重新激活通知层三次。

然后,假设 Web 服务器在出现错误时重新激活错误报告三次。

最后,假设 Web 客户端在从服务器获取错误后重新发送请求三次。

现在假设路由通知到管理员的网络交换机上的线路已被拔掉。用户最终何时才能收到他们的错误消息?我的估计是大约需要十二分钟。

不要以为这只是一个愚蠢的例子:我们在客户代码中看到过此类 bug,尽管比我在这里描述的要严重得多。在特定的客户代码中,错误条件发生和最终向用户报告之间的时间差是几个星期,因为有许多层自动重试并等待。如果有十次重试而不是三次,想象一下会发生什么。

通常处理错误条件的正确方法是“立即报告并让用户决定如何处理”。如果用户想创建自动重试策略,在软件抽象的适当级别上创建该策略。


19
Raymond在这里分享了一个真实的例子,http://blogs.msdn.com/oldnewthing/archive/2005/11/07/489807.aspx - SolutionYogi
245
这个建议对自动批处理系统遇到的短暂网络故障没有用处。 - nohat
17
不确定这是否意味着 "不要做" 接着是 "做它"。大多数问这个问题的人可能是从事软件抽象工作的人。 - Jim L
56
当你运行长时间批处理作业时,使用网络资源(如Web服务),不能期望网络始终是100%可靠的。网络可能会偶尔发生超时、套接字断开连接,甚至出现偶发的路由故障或服务器故障。一种选择是失败,但这可能意味着稍后需要重新启动一个耗时的作业。另一种选择是在适当延迟后重试几次,以查看它是否是暂时性问题,然后失败。我同意关于组合的观点,你必须注意到它,但有时这是最好的选择。 - Erik Funkenbusch
24
我认为你在回答开头使用的那句引语很有意思。如果之前的经验总是给你同样的结果,那么“期待不同结果”就只是疯狂行为。虽然软件建立在一种稳定性的承诺上,但肯定有情况需要我们与无法控制的不可靠力量进行交互。 - Michael Richardson
显示剩余17条评论

34

瞬态错误处理应用程序块提供了一组可扩展的重试策略,包括:

  • 递增
  • 固定间隔
  • 指数级退避

它还包括一组云服务的错误检测策略。

有关更多信息,请参见开发人员指南中的本章节。

可通过NuGet获得(搜索'topaz')。


1
有趣。你能在Windows Azure之外使用它吗?比如在Winforms应用程序中? - Matthew Lock
7
当然。使用核心重试机制并提供自己的检测策略。我们有意将它们解耦。在这里找到核心 NuGet 包:http://nuget.org/packages/TransientFaultHandling.Core - Grigori Melnik
更新的文档可以在这里找到:http://msdn.microsoft.com/en-us/library/dn440719(v=pandp.60).aspx - Grigori Melnik
2
此外,该项目现在已经采用Apache 2.0协议,并接受社区贡献。http://aka.ms/entlibopen - Grigori Melnik
1
@Alex。它的各个部分正在被整合到平台中。 - Grigori Melnik
3
这个现在已经被弃用了,我最后一次使用它时发现了一些错误,据我所知这些错误没有被修复,也永远不会被修复:https://github.com/MicrosoftArchive/transient-fault-handling-application-block。 - Ohad Schneider

18

我是递归和扩展方法的粉丝,所以这是我的一点建议:

public static void InvokeWithRetries(this Action @this, ushort numberOfRetries)
{
    try
    {
        @this();
    }
    catch
    {
        if (numberOfRetries == 0)
            throw;

        InvokeWithRetries(@this, --numberOfRetries);
    }
}

15

允许功能和重试消息

public static T RetryMethod<T>(Func<T> method, int numRetries, int retryTimeout, Action onFailureAction)
{
 Guard.IsNotNull(method, "method");            
 T retval = default(T);
 do
 {
   try
   {
     retval = method();
     return retval;
   }
   catch
   {
     onFailureAction();
      if (numRetries <= 0) throw; // improved to avoid silent failure
      Thread.Sleep(retryTimeout);
   }
} while (numRetries-- > 0);
  return retval;
}

“RetryMethod” 重试方法返回 retval 为 True,或者达到“最大重试次数”? - Kiquenet
如果需要更多的重试,则需要更长的重试超时时间。或者可以结合使用 https://github.com/David-Desmaisons/RateLimiter。 - Kiquenet

14

您还可以考虑添加您想要重试的异常类型。例如,这是一个超时异常需要重试吗?还是一个数据库异常?

RetryForExcpetionType(DoSomething, typeof(TimeoutException), 5, 1000);

public static void RetryForExcpetionType(Action action, Type retryOnExceptionType, int numRetries, int retryTimeout)
{
    if (action == null)
        throw new ArgumentNullException("action");
    if (retryOnExceptionType == null)
        throw new ArgumentNullException("retryOnExceptionType");
    while (true)
    {
        try
        {
            action();
            return;
        }
        catch(Exception e)
        {
            if (--numRetries <= 0 || !retryOnExceptionType.IsAssignableFrom(e.GetType()))
                throw;

            if (retryTimeout > 0)
                System.Threading.Thread.Sleep(retryTimeout);
        }
    }
}
您也可能会注意到,所有其他示例在测试重试次数是否为0时存在类似的问题,如果给定负值,则要么重试无限次,要么不会引发异常。 此外,Sleep(-1000)将在上面的catch块中失败。 这取决于您期望人们有多“愚蠢”,但防御性编程从来没有错。

10
+1,为什么不使用RetryForException<T>(...),其中T:Exception,然后捕获(T e)?我刚试过,它完美地运行了。 - TrueWill
既然我不需要对提供的类型进行任何操作,因此我认为一个普通的参数就足够了。 - csharptest.net
@TrueWill 显然,根据这篇文章https://dev59.com/PnI-5IYBdhLWcg3w-96b,catch(T ex)存在一些错误。 - csharptest.net
3
更新:实际上,我一直在使用更好的实现,它采用了一个 Predicate<Exception> 委托,如果需要重试则返回 true。这样可以使用异常的本机错误代码或其他属性来确定是否适合重试。例如 HTTP 503 错误代码。 - csharptest.net
@csharptest.net:你发布的SO链接实际上得出结论,该错误只在VS调试器(使用.NET 3.5)下才会显现。我已经测试了catch(T ex),在VS 2010下无论是在调试器中还是其他情况下都可以完美运行。 - Sudhanshu Mishra
1
"同时Sleep(-1000)将在上面的catch块中失败。使用TimeSpan,您就不会遇到这个问题。此外,TimeSpan更加灵活和自我描述。从您的“int retryTimeout”的签名中,我怎么知道retryTimeout是毫秒、秒、分钟还是年?;-)" - bytedev

9

我使用最新的方法实现了LBushkin的答案:

    public static async Task Do(Func<Task> task, TimeSpan retryInterval, int maxAttemptCount = 3)
    {
        var exceptions = new List<Exception>();
        for (int attempted = 0; attempted < maxAttemptCount; attempted++)
        {
            try
            {
                if (attempted > 0)
                {
                    await Task.Delay(retryInterval);
                }

                await task();
                return;
            }
            catch (Exception ex)
            {
                exceptions.Add(ex);
            }
        }
        throw new AggregateException(exceptions);
    }

    public static async Task<T> Do<T>(Func<Task<T>> task, TimeSpan retryInterval, int maxAttemptCount = 3)
    {
        var exceptions = new List<Exception>();
        for (int attempted = 0; attempted < maxAttemptCount; attempted++)
        {
            try
            {
                if (attempted > 0)
                {
                    await Task.Delay(retryInterval);
                }
                return await task();
            }
            catch (Exception ex)
            {
                exceptions.Add(ex);
            }
        }
        throw new AggregateException(exceptions);
    }  

并且使用它:

await Retry.Do([TaskFunction], retryInterval, retryAttempts);

函数[TaskFunction] 可以是 Task<T> 或者只是 Task


1
谢谢,Fabian!这应该被投票到顶部! - JamesHoux
1
@MarkLauter 简短的回答是是的。;-) - Fabian Bigler

9

使用Polly

https://github.com/App-vNext/Polly-Samples

这是我在Polly中使用的retry-generic。

public T Retry<T>(Func<T> action, int retryCount = 0)
{
    PolicyResult<T> policyResult = Policy
     .Handle<Exception>()
     .Retry(retryCount)
     .ExecuteAndCapture<T>(action);

    if (policyResult.Outcome == OutcomeType.Failure)
    {
        throw policyResult.FinalException;
    }

    return policyResult.Result;
}

使用方法如下

var result = Retry(() => MyFunction()), 3);

需要异步版本的人可以参考以下链接: https://dev59.com/h3I_5IYBdhLWcg3wBuNs#68013076 - Keith Banner

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接