Polly 故障处理策略
如何引入Polly
Aegis推荐使用Polly来处理常见的故障问题,提供了Aegis.Extensions.Polly
的Nuget包,导入到对应项目中就可以使用Polly。
目前Aegis提供的Polly还是v7版本的Polly。
Polly 可以实现重试、断路、超时、限流、降级和缓存策略,下面给出常见策略的应用场景说明和基本使用方法。像是缓存和限流这类策略在之后会陆续给出文档,目前可以自行参考Polly资料。
为什么要引入故障处理策略?
应用场景
在Http请求时,重试请求,多次请求失败后进行降级,N次失败后启动断路 我们在日常开发中需要请求其他服务接口或第三方接口场景非常多见,当遇到其他接口报错的时候讲直接引起自身服务异常
乐观锁重试 在处理数据库更新时,更新数据状态我们经常要引入乐观锁来确保线程安全,但乐观锁并不能确保当前请求成功,如果失败的情况下需要重试更新。
....等等场景
为什么是Polly?
我们日常在处理故障的时候,往往重试都是手动for循环按固定次数重试,降级也是用try catch加验证结果的方式,这样处理没办法重用我们的故障处理策略,还容易出现各种问题。Polly在这方面是.Net下公认最强且完善的故障处理策略库。降低了我们自己编写的风险和难度,同时还能确保稳定性。
策略对象
使用Polly的时候,首要需要考虑的便是什么情况下触发策略。
Policy.Handle<HttpException>() //这里可以传入具体的异常,当遇到指定异常时触发策略
.Or<Exception>() //可以定义多个异常类型,当不确定时可以捕获Exception
.OrResult<Result>(r => r.Status == 200) //这里传入的是自己定义的结果,当符合条件时触发策略
.OrResult<Result2>(r=>r.IsOk) //可以定义多个OrResult
//.ExecuteAndCaptureAsync() //可以获取PolicyResult对象,用于后续程序处理
//.ExecuteAsync() //只返回result
重试策略
当我们与其他资源或服务交互的时候,都可能会遇到失败(异常),这时候我们最常见的做法就是重试。
重试,并不意味着一直尝试,一定是有次数或者终止条件限制的。就像我们买彩票一样,如果一直不中一直买,那不是得破产吗?
在日常的开发中,我们对重试一定会指定一个上限次数,在请求数达到上限后返回,但这样依然会遇到很多问题。
固定次数重试
var retryPolicy = await Policy.Handle<Exception>()//处理所有异常,也可以为自行定义的异常
.OrResult<ServiceResult<App1317Dto>>(r => !r.IsSuccess) //请求不成功(ServiceResult<App1317Dto>为请求方法的返回实体)
.RetryAsync(3, (result, retryCount, context) => //重试3次
{
//建议每一次重试都记录日志
var exception = result.Exception;//异常
var response = result.Result; //对象
var x = retryCount;//当前重试次数
})
.ExecuteAndCaptureAsync(async () =>
{
//执行具体的逻辑
var result = await _medicalInsuranceProvider.Medical1317(request);
return result;
});
//处理返回
if (retryPolicy.Outcome == OutcomeType.Successful)
{
return ServiceResult.Success(MessageType.Ignore, "操作成功", retryPolicy.Result.Data);
}
else
{
return ServiceResult.Failed<App1317Dto>(MessageType.Message, retryPolicy.Result.Message, default);
}
这种方式的问题在于:不带等待的重试,对于请求的服务来说会在失败发生时进一步遇到更多的请求压力,继而导致进一步恶化。
往往短时间内多次的重试对现状来说没有任何帮助,反而加剧了压力;不推荐使用固定次数重试。
延时重试
var retryWaitPolicy = await Policy.Handle<Exception>()//处理所有异常,也可以为自行定义的异常
.OrResult<ServiceResult<App1317Dto>>(r => !r.IsSuccess)//请求不成功(ServiceResult<App1317Dto>为请求方法的返回实体)
//retryAttempt代表当前重试次数,重试等待时间跟随重试次数,2->4->6
.WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(2 * retryAttempt), (result, retryCount, context) =>
{
//建议每一次重试都记录日志
var exception = result.Exception;//异常
var response = result.Result; //对象
var x = retryCount;//当前重试次数
})
.ExecuteAndCaptureAsync(async () =>
{
//执行具体的逻辑
var result = await _medicalInsuranceProvider.Medical1317(request);
return result;
});
//处理返回
if (retryWaitPolicy.Outcome == OutcomeType.Successful)
{
return ServiceResult.Success(MessageType.Ignore, "操作成功", retryWaitPolicy.Result.Data);
}
else
{
return ServiceResult.Failed<App1317Dto>(MessageType.Message, retryWaitPolicy.Result.Message, default);
}
这种方式的问题在于:虽然带了固定间隔的延时,但是间隔的一致性,对于请求资源的冲击将会变成间歇性的脉冲;特别是当集群都遇到类似的问题时,步调一致的脉冲,将会最终对资源造成猛烈的冲击,并陷入失败的循环中。
但是这种方案相比直接重试的成功性大了不少,在业务的请求量级并没有那么高时,是值得尝试的方案。
随机延时重试
和固定间隔的delay不一样,现在采用随机延时时间的方式,即具体的delay时间,采用Random在一个最小值和最大值之间浮动。
var retryRandomPolicy = await Policy.Handle<Exception>()//处理所有异常,也可以为自行定义的异常
.OrResult<ServiceResult<App1317Dto>>(r => !r.IsSuccess)//请求不成功(ServiceResult<App1317Dto>为请求方法的返回实体)
//利用随机值来打乱重试等待时间
.WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Random.Shared.Next(1, 3) * retryAttempt), (result, retryCount, context) =>
{
//建议每一次重试都记录日志
var exception = result.Exception;//异常
var response = result.Result; //对象
var x = retryCount;//当前重试次数
})
.ExecuteAndCaptureAsync(async () =>
{
//执行具体的逻辑
var result = await _medicalInsuranceProvider.Medical1317(request);
return result;
});
//处理返回
if (retryRandomPolicy.Outcome == OutcomeType.Successful)
{
return ServiceResult.Success(MessageType.Ignore, "操作成功", retryRandomPolicy.Result.Data);
}
else
{
return ServiceResult.Failed<App1317Dto>(MessageType.Message, retryRandomPolicy.Result.Message, default);
}
这种方式的问题在于:虽然现在解决了延时时间集中的问题,对时间进行了随机打散,但是依然存在问题,如果依赖的底层服务持续地失败,该方法依然会进行固定次数的尝试,并不能起到很好的保护作用。
比较好的方案是与断路器(熔断)相结合,如果多次重试依然失败就直接启动断路,来防止对请求服务的过量冲击。
断路器策略
断路器,每个家庭中都有的东西,一旦发生电路短路现象,就立马启动断路器来防止出现更大的灾难。
在软件开发中,一般是在请求资源多次失败后,停止请求该资源避免失败蔓延到自身,同时给予对应资源喘息的时间,来防止资源彻底不可用。断路器自身也会不定时的恢复,尝试重新接入该资源。在很多资料中也称之为熔断。
- 断路
一旦触发指定次数的错误后,就触发断路器。 - 半开
到达指定时间后,断路器会尝试恢复资源请求,如果再次触发失败则又会进入断路状态。到达指定时间后再次进入半开测试。 - 恢复
在半开测试成功后,断路器将重置失败次数,恢复对资源的请求直到再次累计到失败次数。
var circuitBreakerPolicy = await Policy.Handle<Exception>()//处理所有异常,也可以为自行定义的异常
.OrResult<ServiceResult<App1317Dto>>(r => !r.IsSuccess)//请求不成功(ServiceResult<App1317Dto>为请求方法的返回实体)
//50次失败后触发断路器,恢复时间为5分钟
.CircuitBreakerAsync(50, TimeSpan.FromMinutes(5), (ex, ts) =>
{
//断路器打开
//ex.Exception 异常情况
//ts.TotalSeconds 开启时间,可自行组装date对象
},
() =>
{
//断路器重置
},
() =>
{
//一会儿开一会儿关
})
.ExecuteAndCaptureAsync(async () =>
{
//执行具体的逻辑
var result = await _medicalInsuranceProvider.Medical1317(request);
return result;
});
//处理返回
if (circuitBreakerPolicy.Outcome == OutcomeType.Successful)
{
return ServiceResult.Success(MessageType.Ignore, "操作成功", circuitBreakerPolicy.Result.Data);
}
else
{
return ServiceResult.Failed<App1317Dto>(MessageType.Message, circuitBreakerPolicy.Result.Message, default);
}
超时策略
当我们请求资源的时候,我们也不希望无限等待它,一旦服务资源过慢,达到了设定的时间期限后,就停止等待资源的响应;这个动作就称之为”超时”。如果日常去吃饭的时候,在餐厅里等待出餐很久都没有响应,我们这时候为了确保能吃上饭的最佳措施肯定是放弃在这家餐厅就餐。
超时分为两种情况
请求超时
当网络不好的时候,发起请求后与服务资源一直无法建立连接,网络在一直尝试连接,这时候的表现往往是长时间的等待。响应超时
当我们的请求发送给了对应服务器,服务器接收到了请求,一直在处理迟迟响应不了,这时候也会遇到超时的问题。
不管哪种情况,我们都希望在请求资源后一定时间内未得到响应都及时触发超时策略,在Polly中写法如下
//悲观策略 Polly.Timeout.TimeoutStrategy.Pessimistic 会抛异常,需捕捉异常进行二次处理
//乐观策略 Polly.Timeout.TimeoutStrategy.Optimistic 不会抛异常
//设置5s超时,需要考虑具体业务方法多长时间未处理完成表示不可接受
var timeoutPolicy = await Policy.TimeoutAsync(5, Polly.Timeout.TimeoutStrategy.Pessimistic, async (context, time, t, ex) =>
{
//超时后处理
await t;
var error = ex;
})
.ExecuteAndCaptureAsync(async () =>
{
//执行具体的逻辑
var result = await _medicalInsuranceProvider.Medical1317(request);
return result;
});
//处理返回
if (timeoutPolicy.Outcome == OutcomeType.Successful)
{
return ServiceResult.Success(MessageType.Ignore, "操作成功", timeoutPolicy.Result.Data);
}
else
{
return ServiceResult.Failed<App1317Dto>(MessageType.Message, timeoutPolicy.Result.Message, default);
}
超时策略不需要任何的结果或者异常,定义就可以使用。
我建议是超时策略可以跟重试策略结合的方式来避免业务失败。
降级策略
在请求资源故障,重试无法解决问题的情况下,我们除了使用断路策略以外,还可以启动降级措施,降级的核心思想就是弃车保帅,保证核心业务的运转。
想象一下,还是日常去吃饭的时候,在餐厅里等待出餐很久都没有响应,催促了好几次依然没有任何进展,后面才得知原来是厨房师傅罢工了,我们肯定得采取退款等补偿措施来保障我们的消费者权益,在之后更换一家餐厅确保能吃上饭。
当前业务依赖的业务出现了故障时,我们应该考虑依赖业务的重要程度,有没有替代措施。依赖的业务如果直接影响了核心业务的情况下,我们应该如何确保业务的正确性。以下四种方案是常见策略,实际中可以根据业务制定更合适的策略。
回滚业务
PS:创建订单后,如果订单详情创建失败,就直接删除该订单,同时告知用户下单失败。
补偿队列
PS:创建订单成功后,需要给用户增加积分,如果增加失败,则将增加积分写入队列中,后续等待再次增加积分。同时可以选择是否告知用户当前部分成功的情况。
通知手动处理
PS:还是加积分的例子,如果没有补偿队列或是补偿失败后,系统发通知给管理员,管理员手动增加积分。
var fallbackPolicy = await Policy.Handle<Exception>() // 处理所有异常,也可以为自行定义的异常
.OrResult<ServiceResult<App1317Dto>>(r => !r.IsSuccess)//请求不成功(ServiceResult<App1317Dto>为请求方法的返回实体)
//当r.IsSuccess不成功的时候触发降级
.FallbackAsync(XmlConfigurationExtensions =>
{
//降级方法
//例如:
//查询接口失败后读取缓存
//回滚当前业务操作
//执行其他业务操作
return Task.FromResult<ServiceResult<App1317Dto>>(default);
}).ExecuteAndCaptureAsync(async () =>
{
//执行具体的逻辑
var result = await _medicalInsuranceProvider.Medical1317(request);
return result;
});
//处理返回
if (fallbackPolicy.Outcome == OutcomeType.Successful)
{
return ServiceResult.Success(MessageType.Ignore, "操作成功", fallbackPolicy.Result.Data);
}
else
{
return ServiceResult.Failed<App1317Dto>(MessageType.Message, fallbackPolicy.Result.Message, default);
}
策略组合
在解释了各种常规策略后,可以发现,往往独立策略很难解决问题,我们需要将各种策略组成起来,发挥各自的优势区间。
我们在文中最开始就提到了HTTP请求的例子,我们希望
- 请求资源不能过慢,如果太慢了就触发超时机制
- 请求资源发生了错误或者异常后(包含超时),我们就进行一定限度的重试
- 当重试一定次数后还不可用,我们就启用降级策略来补偿当前业务,同时通知我们。
- 如果该服务长期不可用,我们就启动断路策略防止错误蔓延导致大面积故障。
这时候我们就可以将之前聊到的四种策略组合起来达成这个目标。
public Result Run(Func<TResult,bool> checkFilter,Func<Task<HttpResponseMessage>> excute)
{
var policyBuilder = PolicyBuild(checkFilter);
var timeoutPolicy = Policy.TimeoutAsync(5);
var waitAndRetryPolicy = policyBuilder.WaitAndRetryAsync(retryTimes,retryAttempt => TimeSpan.FromSeconds(Math.Pow(2,retryAttempt)));
var circuitBreakerPolicy = policyBuilder.CircuitBreakerAsync(3,TimeSpan.FromSeconds(10));
var fallbackPolicy = policyBuilder.FallbackAsync(new Result(400))); //可以定义更多降级思路
//执行从右到左执行,断路器->超时->重试->降级
return Policy.WrapAsync(fallbackPolicy,waitAndRetryPolicy,timeoutPolicy,circuitBreakerPolicy).ExecuteAsync(excute);
}
var result = Run(_apiContract.Login("xxx"));
if(result.Status == 400) //降级结果
{
//...执行补偿业务
}
需要注意的问题
- 如果采取了重试策略,请一定要确保请求资源的幂等性,不然可能会遇到服务资源计算了两次,导致最终数据错误的情况。
- 如果策略处理的异常/结果不同最好分开定义Handle