网络错误包括连接超时、TCP重置、ICMP错误、端口不可用等,我们将会在“想做的更多事情”中支持网络错误的识别,初期的设计我们将只会考虑业务错误。业务错误对于HTTP服务而言是指错误代码为5XX的响应,对于RPC服务而言可能有专属的字段标记Internal Server Error。值得注意的是,业务错误一定是在Client成功连接到服务节点后才由服务节点生成的。
应用场景举例:如果设置RecoveryInterval为30000毫秒,设置MaxRecoveryAttempts为20,那么在第20次检测后,发现异常节点仍未恢复,则会以10分钟(20 x 30000 ms)为间隔继续执行后续的检测,且该间隔不再累加。如果确定该节点已经恢复,则会将检测间隔重置为RecoveryInterval初始值。
Issue Description
Type: feature request
Describe what feature you want
目前Sentinel Go (后文均用 Sentinel 表示 Sentinel Go) 主要提供分布式场景下的流量治理能力,但是当发生网络或节点异常时,服务可能会调用到失败的节点。所以本issue希望在 Sentinel 中增加服务实例的离群摘除功能。离群检测和摘除是动态确定上游集群中的一些异常节点并将它们从健康的负载均衡集中移除的过程。离群的规则需要考虑不同方面,例如连续失败次数、成功率、时间延迟等。
这里通过具体的示例阐述离群摘除功能,比如服务A会调用服务B,服务B由节点B1、B2、B3组成。当B1、B2、B3中的某些实例异常时,如果服务A无法感知到,会导致部分调用失败。甚至如果触发了熔断,所有对服务B的调用都会失败,这会极大影响服务A的可用性。所以为了保护服务A的性能和可用性,本issue希望为服务A配置离群实例摘除,从而让其负载均衡器感知到失效节点,从而避免调用到B1、B2、B3中的异常节点。
预期提供的API
本issue预期会提供离群检测和摘除功能相关的如下成果:
从用户的角度,接入Sentinel提供的离群摘除功能,需要以下几步:
对 Sentinel 的运行环境进行相关配置并初始化。
埋点(定义资源),该步骤主要是将客户端发起RPC的函数调用和sentinelApi.Entry实现的资源埋点采用Wrapper的方式组合起来。如下例所示,我们将只会提供
ResourceType=RPC
且TrafficType=Outbound
的资源类型。需要注意的是,用户现有代码中的resourceName无需修改,我们的adapter会自动将离群摘除的resourceName映射到具体的服务,而不是映射到具体的节点或者方法。此处adapter的操作对用户而言是无感的,并不会影响到Sentinel中其他规则的resourceName。
配置规则,规则配置项可能包括:资源名称、错误率上限、异常类型、摘除实例比例上限、恢复检测单位时间、未恢复累计次数上限。该步骤可以通过
LoadRules(rules)
函数加载离群摘除的静态规则,本issue后期实现基于注册中心的动态配置加载。如下是配置规则的示例,具体字段含义在后文阐述。创建客户端实例时嵌入我们提供的adapter。注意adapter可能在不同的微服务框架中有不同的名字,比如在go-micro框架中被称为Wrapper。总之,我们将会针对1~2个微服务框架提供
NewClientWrapper
函数,用于给客户端对象添加Wrapper。由于adapter中已经封装好了离群检测和摘除的相关逻辑,所以用户不必关注adapter实现的更多细节,旨在最小化代码迁移的成本。Sentinel现有的adapter大多是将resourceName设置为method名,显然离群摘除并不能直接使用这样的埋点,但是我们又不能修改这个resourceName,否则会导致用户必须修改现有规则中的resourceName。因此我们拟采取的方案是在现有的adapter中新增一个专用于离群摘除的埋点,这个埋点的resourceName会映射到具体的服务,比如服务名称。
对比离群摘除和熔断
为了帮助用户在合适的场景选择熔断或者离群摘除,下面对比了二者的联系和区别。
联系
区别
离群摘除方案设计
定义资源
在Sentinel中,使用
Entry
函数将业务逻辑封装起来,这一过程被称作“埋点”。每个埋点都关联一个资源名称,它标识了触发该资源的调用或访问。埋点API定义在api
包中,如下所示:具体API可以参考Sentinel的官方文档,特别地,离群摘除方案要求流量类型
TrafficType
必须设置为Outbound
,因为离群摘除配置总是在客户端进行,Inbound
代表入口流量,而Outbound
代表出口流量。此外,resourceName必须映射到具体的服务,而不是映射到具体的节点或者方法。由于Entry
函数一般由Sentinel提供的adapter调用,用户不会直接使用埋点函数,所以用户不必修改现有规则中的resourceName,我们的adapter会自动处理好这个映射过程。如果服务调用被拒绝,
Entry
函数将返回非空的BlockError
,表示调用被Sentinel限流。BlockError
提供了限流的原因和触发规则等详细信息,帮助开发者进行记录和处理。但是比较特别的是,离群摘除方案不会使用也不应该使用BlockError
(保证BlockError
为空),因为离群摘除的初衷仅仅是排除被调用服务中的离群节点,而不是阻止对整个服务的调用。定义错误
一般而言,离群摘除需要区分网络错误和业务错误。事实上在Envoy中,错误检测类型被分为externally originated errors 和 locally originated errors,但是为了便于理解,我们在后文的陈述中将二者改名为业务错误和网络错误。
网络错误包括连接超时、TCP重置、ICMP错误、端口不可用等,我们将会在“想做的更多事情”中支持网络错误的识别,初期的设计我们将只会考虑业务错误。业务错误对于HTTP服务而言是指错误代码为5XX的响应,对于RPC服务而言可能有专属的字段标记
Internal Server Error
。值得注意的是,业务错误一定是在Client成功连接到服务节点后才由服务节点生成的。定义规则
我们需要精心设计埋点资源的规则,以便为离群摘除算法的实现提供必要的信息。在规则配置上,我们将其分为两大类:一类是基于Sentinel熔断器 的现有基础规则,另一类则是专为离群摘除设计的扩展规则。在前文中,我们已经对比了熔断和离群摘除的联系和区别。进一步地,我们可以将服务的离群摘除功能理解为对各个具体节点的熔断机制的组合应用。当然完整的离群摘除实现还需要我们引入一些额外的扩展功能,例如每个节点对应熔断器的动态管理和垃圾回收等。
为了提升代码的复用率并减少重复劳动,我们计划利用现有的熔断器代码来实现离群摘除的基础规则部分。这样,当服务中的某个节点满足熔断条件时,我们可以通过熔断器提供的
TryPass
接口判断节点是否需要被摘除,而具体的摘除策略和机制则由离群摘除定义的扩展规则来实现,比如对规则“摘除比例上限”而言,它保证摘除的异常节点不能超过预设的比例上限,即达到阈值后,不再摘除异常节点。下面我们将分别介绍离群摘除的基础规则和扩展规则。基础规则
离群摘除的基础规则主要由节点熔断器实现,其主要目的是评估某个服务节点是否存在异常。在评估过程中,我们会综合考虑多种可能影响服务稳定性的因素。基础规则参考了Sentinel现有的熔断器模块,下面将重点介绍一些关键的熔断器规则,用以全面评估服务节点是否出现离群现象。
Strategy
: 熔断策略,目前支持SlowRequestRatio、ErrorRatio、ErrorCount 三种。Threshold
字段设置触发熔断的慢调用比例,取值范围为 [0.0, 1.0]。通过设置允许的最大响应时间(MaxAllowedRtMs),如果请求的响应时间大于该值则统计为慢调用。规则配置后,在单位统计时长内请求数目大于设置的最小请求数目,并且慢调用的比例大于阈值,则接下来的熔断时长内请求会自动被熔断。经过熔断时长后熔断器会进入探测恢复状态,若接下来的一个请求响应时间小于设置的最大 RT 则结束熔断,否则会再次被熔断。Threshold
字段设置触发熔断的错误比例,取值范围为 [0.0, 1.0]。规则配置后,在单位统计时长内请求数目大于设置的最小请求数目,并且错误的比例大于阈值,则接下来的熔断时长内请求会自动被熔断。经过熔断时长后熔断器会进入探测恢复状态,若接下来的一个请求没有错误则结束熔断,否则会再次被熔断。RetryTimeoutMs
: 熔断触发后持续的时间(单位为 ms)。资源进入熔断状态后,在该时长内请求都会快速失败,熔断结束后进入探测恢复模式(HALF-OPEN)。用户需要根据实际情况设置探测周期,一般情况下设置10秒左右即可。StatIntervalMs
: 统计周期的时间窗口长度(单位为 ms)。用户需要根据实际情况设计统计周期的长度,一般情况下设置10秒左右即可。MinRequestAmount
: 触发熔断的最小请求数量。如果当前统计周期内对资源的请求数量小于该值,即使达到条件也不会触发熔断,此时熔断器处于静默期。只有当统计时间窗口内的请求数量达到该值后,才会考虑是否触发熔断。MaxAllowedRtMs
: 最大响应时间是判断请求是否是慢调用的临界值,仅对慢调用比例熔断策略有效。如果请求的响应时间大于设置的最大响应时间,那么当前请求就属于慢调用。Threshold
: 阈值对于不同的熔断策略有不同的含义。对于慢调用比例的熔断策略,Threshold表示慢调用比例的阈值,取值范围为 [0.0, 1.0],如果当前资源的慢调用比例超过该值,那么断开熔断器,否则保持闭合状态。 对于错误比例的熔断策略,Threshold表示错误请求比例的阈值,取值范围为 [0.0, 1.0]。对于错误数量的熔断策略,Threshold表示错误请求计数的阈值,取值范围为正整数。扩展规则
除了基础规则外,我们还设计了离群摘除的扩展规则。在第一阶段,我们通过每个节点的熔断器已经评估了各个节点是否存在异常,进入第二阶段,我们将利用扩展规则来有效地摘除那些被识别为异常的节点。下面将详细介绍专为离群摘除而设计的扩展规则,它们有助于更精确地控制离群摘除过程,确保服务的稳定性和效率,从而更好地适应多样化的业务场景。
MaxEjectionPercent
: 服务节点的摘除比例的上限,它定义了服务的负载均衡池中允许被排除的节点的最大比例。一旦摘除的异常节点比例达到这个阈值,将不再摘除更多的异常节点。如果由maxEjectionPercent
计算出的允许摘除的节点数量不是整数,则向下取整。maxEjectionPercent
设置为40%,那么理论上允许摘除的节点数为6 * 40% = 2.4,但是需要向下取整,即最多摘除2个异常节点。maxEjectionPercent
设置得太大,那么可能会排除过多的服务节点,导致负载均衡池中剩余可用节点偏少,这会影响服务的可用性。如果设置得太小又会使得异常节点无法及时摘除,这会导致负载均衡池中混入过多异常节点。如果maxEjectionPercent
乘以节点总数小于1,甚至不会有任何异常节点被摘除,这显然违背了离群摘除的初衷。RecoveryInterval
: 恢复检测的时间间隔初始值(单位为 ms)。当某个异常节点被摘除后,将以RecoveryInterval
的线性值为时间间隔,定期地对该节点进行健康检查,以判断它是否已经恢复到正常状态。此时恢复检测的时间间隔会因恢复尝试次数的增加而线性累加。RecoveryInterval
为30000毫秒,也就是30秒,一旦某个异常节点被摘除,那么会在30秒后开始执行第一次恢复检测,此后的每次恢复检测的时间间隔累加30秒,直到确定该节点已经恢复正常或者达到其他预设的条件为止。MaxRecoveryAttempts
: 恢复检测累计次数上限,即恢复检测期间允许的最大恢复尝试次数。在对异常节点进行持续检测时,每次的时间间隔随检测次数按RecoveryInterval
的值线性累加,当达到MaxRecoveryAttempts
设置的检测次数后,会按最长时间间隔持续检测。RecoveryInterval
为30000毫秒,设置MaxRecoveryAttempts
为20,那么在第20次检测后,发现异常节点仍未恢复,则会以10分钟(20 x 30000 ms)为间隔继续执行后续的检测,且该间隔不再累加。如果确定该节点已经恢复,则会将检测间隔重置为RecoveryInterval
初始值。RecoveryInterval
和MaxRecoveryAttempts
的值设置对恢复检测的时间间隔都有影响。如果二者设置得过大,则会导致恢复检测的间隔时间变得过长。这意味着即使节点在检测间隔的早期阶段已经恢复,仍然需要等待间隔时间结束后才能进行下一次健康检查。由于节点已经恢复但未能及时收到业务调用请求,导致了节点资源被闲置。相反,如果二者被设置得过小,则会导致频繁的恢复检测,这会显著增加客户端的负载,甚至可能干扰到正常的业务流程。因此需要根据实际的业务需求合理设置RecoveryInterval
和MaxRecoveryAttempts
的值。摘除算法
服务的离群摘除其实是其众多节点的熔断的组合,因此在实现离群摘除功能时,可以复用 Sentinel 现有的熔断器代码。我们将在 Sentinel 内部维护具体节点的状态转移,从而为每个服务节点独立进行离群的判定和摘除。为此我们定义了一组
map
类型的全局变量,用于维护服务资源对应的熔断和离群摘除功能的规则和状态,下面是对每个全局变量的详细介绍:nodeCount
: 它将资源名称映射到服务中节点的数量,每个服务的节点规模在摘除算法的逻辑中会被使用到。outlierRules
: 此映射存储了每个资源名称的离群摘除规则,即上文所述的扩展规则,这些规则用于更精确和有效地摘除已经被识别为异常的节点。breakerRules
: 此映射存储了每个资源名称的熔断规则,即上文所述的基础规则,这些规则指定了触发节点熔断的条件,同时也用于动态生成节点的熔断器对象。nodeBreakers
: 这是一个嵌套映射,外层映射的键是资源名称,内层映射的键是节点ID。它维护了从资源名称到所有服务节点的映射,并且每个节点ID对应一个或多个熔断器对象。熔断器对象是根据节点所属资源的熔断规则动态生成的。在定义了上述数据结构后,我们开始正式介绍离群摘除算法的实现。我们将在
SentinelEntry
结构体中新增表示离群节点集合的新字段,摘除算法的主要目标正是基于当前记录的状态填充这个新字段。这样微服务框架的Filter
就可以通过调用entry.Context().FilterNodes()
获取这个集合的内容,然后从负载均衡集中过滤掉这些异常节点即可。对于客户端的每次调用,将进入Sentinel的
Entry
函数,并在内部遍历所有已注册的 RuleCheckSlot,同时调用Slot的Check方法。当然其中也包含了离群摘除注册的 Slot,所以摘除算法应该实现在该Slot的Check方法中。摘除算法依赖于熔断器规则和离群摘除的扩展规则,算法流程分为两个主要步骤:首先通过每个节点的熔断器评估该节点是否存在异常;其次通过扩展规则来有效摘除那些被识别为异常的节点。下面的代码简单实现了摘除算法,其中在Slot的Check方法中调用了checkAllNodes函数。checkAllNodes函数的作用是:遍历并评估该资源名称的所有节点的异常情况,返回需要过滤的节点ID列表。下面是checkAllNodes函数按步骤的解释:resource := ctx.Resource.Name()
: 获取当前上下文的资源名称。nodeBreaks := getNodeBreakersOfResource(resource)
: 根据资源名称从全局变量nodeBreakers
中获取该资源下所有节点的熔断器对象集合。outlierRules := getOutlierRulesOfResource(resource)
: 从全局变量outlierRules
中获取资源名称对应的离群摘除规则。nodeCount := getNodeCountOfResource(resource)
: 从全局变量nodeCount
中获取资源名称对应的节点总数。for
循环,外层循环遍历每个节点ID和对应的熔断器列表,内层循环遍历列表中的每个熔断器。if breaker.TryPass(ctx)
: 尝试通过熔断器的检查,如果节点未被熔断器拦截(即没有异常),则continue
到下一次循环。if len(nodeRes) < int(float64(nodeCount)*rule.MaxEjectionPercent)
: 检查当前已记录的异常节点数量是否小于允许的最大摘除比例上限(通过MaxEjectionPercent
规则指定)。如果len(nodeRes)
已经等于最大可摘除节点数,则更多的异常节点不会被摘除,即仅按照设置的比例摘除。nodeRes = append(nodeRes, nodeID)
: 如果满足条件,将当前异常节点的ID添加到结果列表中。需要注意的是在上面的代码中,对于“通过扩展规则来有效摘除那些被识别为异常的节点”这一步骤,我们只使用了
MaxEjectionPercent
这个扩展规则。然而,单一的规则可能无法满足所有复杂场景的需求,因此在后续的代码开发和功能迭代中,我们计划引入更多的扩展规则来丰富我们的离群摘除策略。恢复检测
离群摘除功能需要具备两项关键能力:一是能够迅速识别并摘除那些异常的服务节点,二是能够在节点恢复正常后自动将其重新纳入负载均衡池,从而最大化服务的可用性。本小节我们将重点关注对异常节点的恢复检测。根据异常节点恢复检测的时机,我们设计的恢复检测机制分为主动模式和被动模式两部分。
RecoveryInterval
。如果节点持续检测失败,其时间间隔将线性延长。恢复检测的时间间隔等于RecoveryInterval
乘以节点连续探测失败的次数,但不会超过最大时间间隔MaxRecoveryAttempts * RecoveryInterval
。Adapter设计
我们计划在变量 entry(也就是
Entry
函数的返回值)中携带一个节点集合的字段,这些节点是需要在服务调用前被摘除的。接下来,在针对具体框架实现的 adapter 中,我们会从负载均衡的候选列表中排除这些实例。然后由框架自身来处理具体的服务调用,此时框架可能会从负载均衡列表中选择一个存活节点并正式发起服务调用。在调用结束后,我们会利用TraceError
方法来记录错误信息,并新增TraceCallee
函数来记录被调用的节点ID。最后在entry.Exit()
函数中更新被调用节点的状态信息。许多开源的微服务框架都允许用户自定义 Middleware(中间件)或 Wrapper(包装器)。这为离群摘除的实现提供了机会,使其能够在服务调用之前过滤异常节点,并在服务调用之后更新被调用节点的状态。我们已经基于 go-micro 框架实现了原型,下面这段代码展示了其中的客户端包装器(
clientWrapper
)的Call
方法实现,当然这种方法同样适用于其他的微服务框架。以下是对adapter中离群摘除相关部分代码的详细说明:entry, blockErr := sentinelApi.Entry(
:尝试进入 Sentinel 的流量控制入口。我们会在返回值entry中携带需要被过滤的实例集合。if blockErr != nil {
:处理资源被阻塞的错误。如果该服务名称下仅配置了离群摘除规则,那么不会出现服务调用被阻止的情况,即blockErr == nil
。在离群摘除的场景中,blockErr
将始终为nil
,因为我们的目的是识别并摘除表现不佳的节点,而不是阻止服务调用。defer entry.Exit()
:通过defer
确保 Sentinel 的流量控制出口一定会被执行到。在这个退出过程中,我们嵌入了离群摘除的StatSlot
实现,目前暂定名称为outlier.DefaultMetricStatSlot
。在其OnCompleted(ctx *EntryContext)
方法中,我们将会实现节点状态的更新机制,这不仅包括对新发现的服务节点添加熔断器对象,还需要对现有熔断器对象的状态进行更新。此外,长时间未更新状态的熔断器对象将被回收,以避免发生内存泄漏。opt1 := client.WithSelectOption(selector.WithFilter(...))
:定义一个Filter选项用于服务调用前过滤异常节点。具体来说,我们首先通过entry.Context().FilterNodes()
获取待摘除的节点集合,然后从负载均衡的候选列表中排除这些节点。opt2 := client.WithCallWrapper(func(f1 client.CallFunc) client.CallFunc {...})
:定义一个调用包装器选项用于服务调用之后记录结果信息。具体来说,首先我们调用用户的f1
函数,当这个服务调用返回后,我们会通过TraceError
方法来记录可能的错误,以及通过TraceCallee
方法来记录被调用节点的 ID。这两点信息对于entry.Exit()
阶段的节点状态更新而言是必要的。return c.Client.Call(ctx, req, rsp, opts...)
:最后调用go-micro
定义的客户端的Call
方法,用于发起实际的服务调用。传入的调用选项CallOption
包括了在之前代码中定义的SelectOption
和CallWrapper
,这样我们就在Call
方法内部完整地嵌入了离群检测和摘除的逻辑。想做的更多事情
开源框架适配
我们计划开发一系列适配器,以实现离群摘除方案与特定微服务框架的无缝集成。我们会参考
pkg/adapters
目录下现有的众多适配器的实现。目前我们规划适配的开源框架包括:NewUnaryClientInterceptor
函数实现离群摘除功能,该函数将完全封装离群摘除的相关操作,提供给用户一个即插即用的解决方案。NewClientWrapper
函数暴露离群摘除能力,使得用户能够轻松集成到现有的服务逻辑中。我们会尽可能考虑到适配器实现的通用性,这具体表现在两个方面:
WithXXX
的设置函数,例如WithClientBlockFallback
允许用户设置客户端请求被阻塞时的回调函数。通过这一系列设置函数满足不同用户在特定场景下的需求。最后对于每种适配器,我们将会提供示例代码(Example)以及相应的测试程序(xxx_test.go),确保用户能够理解并验证适配器的功能。
动态配置加载
我们计划实现离群摘除规则的动态加载功能,允许用户通过配置中心灵活地调整离群摘除的规则,无需静态修改配置文件,无需重新部署服务,这一特性旨在提升系统灵活性和可维护性。
目前Sentinel已经提供了一些动态数据源接口,使用户能够从多种配置中心动态加载和更新规则,如etcd、consul、nacos、apollo等。Sentinel的动态数据源接口的相关代码可以在
pkg/datasource/
和ext/datasource
目录下找到。这一功能对于实现实时的离群摘除功能至关重要。我们的主要工作是实现针对离群摘除规则的更新器,并与Sentinel提供的动态数据源接口进行适配。如下所示,Sentinel的
DefaultPropertyHandler
结构体封装了属性的转换器和更新器,详见ext/datasource/property.go
文件。DefaultPropertyHandler
会检查当前属性是否与上次更新的属性一致。如果不一致,其中PropertyConverter
会将消息转换为特定的属性,然后PropertyUpdater
会将特定的属性更新到下游。由于每个DefaultPropertyHandler
实例用于处理一种属性类型,所以我们应该实现一个适用于离群摘除规则的更新器,该函数预期的签名为:func OutlierRulesUpdater(data interface{}) error
网络错误识别
目前无论是网络错误(如连接超时)还是业务错误(如HTTP返回5XX错误),我们都笼统地将其视为“一次失败”。但是从用户的角度来看,更加细粒度的错误区分有利于服务的可维护性。错误区分的设计可以参考Envoy的离群摘除机制 Detection types 小节。因此我们计划在未来的版本中引入错误类型的区分,本阶段的任务可以分为两部分,一是能够识别错误类型,二是根据错误的类型进行不同的处理。
为了能够区分错误类型,未来我们将在Sentinel中引入网络错误的识别机制。因为业务错误独特于HTTP协议或者RPC调用,比较容易区分,所以关键在于网络错误的识别。具体来说,我们需要从现有的笼统的错误返回信息中挖掘关于网络连接本身的信息,这要么需要微服务框架提供更多的支持,要么需要Sentinel主动进行检测和区分,最终使得Sentinel能够捕捉到网络错误(例如超时、重置等)。
当我们能够区分业务错误和网络错误后,我们需要将二者通过单独的计数器进行跟踪并分开处理。值得注意的是,无论是网络连接问题还是服务返回的错误,二者都会被计入到离群检测的总错误数中。但是针对不同的错误类型,离群检测也可以有不同的处理方式,从而实现更加细粒度的错误记录和状态更新。比如设置不同的最大错误请求次数,针对业务错误阈值设置为10次,针对网络错误阈值设置为3次。