写点什么

集群限流功能是如何实现的?

  • 2023-06-14
    湖南
  • 本文字数:8048 字

    阅读完需:约 26 分钟

Sentinel 集群限流功能是基于令牌桶算法实现的。集群限流就是让集群限流服务端负责生产令牌,让集群限流客户端向集群限流服务端申请令牌,只有在集群限流客户端申请到令牌时才能放行请求,否则拒绝请求。

集群限流也支持热点参数限流,两者的实现原理大致相同,所以关于热点参数的集群限流将留给读者自己去研究。


sentinel-cluster 包含以下几个重要模块:

  • sentinel-cluster-common-default:公共模块,定义通信协议,包括编码器和解码器接口、请求和响应实体(数据包),与底层使用哪种通信框架无关。

  • sentinel-cluster-client-default:集群限流客户端模块,实现公共模块定义的接口,使用 Netty 进行通信,实现自动连接与掉线重连,提供连接配置 API。

  • sentinel-cluster-server-default:集群限流服务端模块,实现公共模块定义的接口,使用 Netty 进行通信,实现 TokenService 接口。

集群限流规则

集群限流规则即 FlowRule,当 FlowRule 实例的 clusterMode 字段被配置为 true 时,表示这个规则是一个集群限流规则。


如果将一个限流规则配置为集群限流规则,则 FlowRule 实例的 clusterConfig 字段必须被配置,该字段的类型为 ClusterFlowConfig。


ClusterFlowConfig 类的源码如下:

  • flowId:集群限流规则的全局唯一 ID。

  • thresholdType:集群限流阈值类型。

  • fallbackToLocalWhenFail:失败时是否回退为本地限流模式,默认为 true。

  • sampleCount:滑动窗口构造方法的参数之一,指定 WindowWrap 的数组大小。

  • windowIntervalMs:滑动窗口构造方法的参数之一,指定整个滑动窗口的周期(每个 WindowWrap 的时间窗口大小=windowIntervalMs/sampleCount)。


当限流规则配置为集群限流模式时,限流规则的阈值类型(grade)将被弃用,转而使用集群限流配置(ClusterFlowConfig)的阈值类型(thresholdType),支持单机均摊和集群总阈值两种集群限流阈值类型:

  • 单机均摊类型:将当前连接到集群限流服务端的集群限流客户端节点数乘以规则配置的 count 的结果作为集群的 QPS 限流阈值。

  • 集群总阈值类型:将规则配置的 count 作为集群的 QPS 限流阈值。

集群限流规则的动态配置

集群限流规则需要在集群限流客户端配置一份,同时需要在集群限流服务端也配置一份。集群限流客户端需要取得集群限流规则才会走集群限流模式,而集群限流服务端需要取得同样的集群限流规则才能正确地回应集群限流客户端。


为了统一规则配置,我们应当选择动态配置,让集群限流客户端和集群限流服务端从同一数据源中获取同一份数据。


Sentinel 支持使用名称空间(namespace)区分不同应用之间的集群限流规则配置,如服务 A 的集群限流规则配置和服务 B 的集群限流规则配置可以使用名称空间隔离。


我们已经分析了 Sentinel 动态数据源的实现原理,并且基于 Spring Cloud 提供的动态配置功能实现了一个动态数据源。为了便于理解和测试,我们将自行实现一个简单的动态数据源(SimpleLocalDataSource),实现根据名称空间加载集群限流规则。


SimpleLocalDataSource 类继承 AbstractDataSource 抽象类,同时 SimpleLocalDataSource 类的构造方法要求传入名称空间,用于指定一个 SimpleLocalDataSource 实例只负责加载指定名称空间的集群限流规则。


SimpleLocalDataSource 类的代码如下:

我们在 SimpleLocalDataSource 类的构造方法中启动一个线程,用于实现等待动态数据源注册到 ClusterFlowRuleManager 之后模拟加载一次集群限流规则。


由于是测试,因此 SimpleLocalDataSource 类的 readSource 方法并未被实现,我们直接在 SimpleConverter 转换器中虚构一个集群限流规则,代码如下:

接下来,我们将使用这个动态数据源实现集群限流客户端和集群限流服务端的配置。

集群限流客户端配置

在需要使用集群限流功能的微服务项目中添加 sentinel-cluster-client-default 模块的依赖,代码如下:

将身份设置为集群限流客户端(CLUSTER_CLIENT),并且将 ClusterClientConfig 实例注册到 ClusterClientConfigManager 中,代码如下:

在 Spring 项目中,可通过监听 ContextRefreshedEvent 事件,在 Spring 容器启动完成后初始化创建动态数据源并为 FlowRuleManager 注册限流规则动态数据源,代码如下:

注册用于连接到集群限流服务端的配置(ClusterClientAssignConfig),指定集群限流服务端的 IP 地址和端口,代码如下:

提示:当 ClusterClientConfigManager#applyNewAssignConfig 方法被调用时,会触发 Sentinel 初始化或重新连接到集群限流服务端,所以我们看不到启动集群限流客户端的代码。当集群限流客户端与集群限流服务端意外断开连接时,Sentinel 还支持集群限流客户端不断地重试重连。


我们在调用 ClusterClientConfigManager#applyNewAssignConfig 方法之前,先调用了 ConfigSupplierRegistry#setNamespaceSupplier 方法注册名称空间,这是非常重要的一步。当集群限流客户端连接上集群限流服务端时,会立即发送一个 PING 类型的消息给集群限流服务端。Sentinel 会将名称空间携带在 PING 数据包上传递给集群限流服务端,集群限流服务端以此获得每个集群限流客户端连接的名称空间。


在完成以上步骤后,集群限流客户端就已经配置完成,如果集群限流服务端使用嵌入式模式启动,则还需要在同一个项目中添加集群限流服务端的配置。

集群限流服务端配置

如果使用嵌入式模式,则可以直接在微服务项目中添加 sentinel-cluster-server-default 模块的依赖;如果使用独立应用模式,则需要单独创建一个项目,在独立项目中添加 sentinelcluster-server-default 模块的依赖。


在项目的依赖配置文件中添加如下配置。

在独立应用模式下,需要手动创建 ClusterTokenServer 并启动,在启动之前需要指定监听端口和连接最大空闲等待时间等配置,代码如下:

接下来需要为集群限流服务端创建用于加载集群限流规则的动态数据源,在创建动态数据源时,需要指定数据源只加载哪个名称空间下的限流规则配置,代码如下:

从代码中可以看出,我们注册的是一个 Java8 的 Function,这个 Function 的 apply 方法将在注册名称空间时触发调用。


现在,我们为集群限流服务端注册名称空间以触发动态数据源的创建,使得 ClusterFlowRuleManager 拿到动态数据源的 SentinelProperty,将规则缓存更新监听器注册到动态数据源的 SentinelProperty 上。注册名称空间的代码如下:

名称空间可以有多个,但是如果存在多个名称空间,则会多次调用 ClusterFlowRuleManager#setPropertySupplier 方法注册的 Function 对象的 apply 方法,从而创建多个动态数据源。


由于我们在 SimpleLocalDataSource 的构造方法中创建了一个线程并延迟执行,因此当以上步骤完成后,也就是当 SimpleLocalDataSource 的延时任务执行时,SimpleLocalDataSource 会加载一次限流规则配置,并调用 SentinelProperty#updateValue 方法通知 ClusterFlowRuleManager 更新限流规则配置。


在实际项目中,自定义的动态数据源可以通过定时拉取方式从配置中心中拉取规则,也可以结合 Spring Cloud 动态配置使用,通过监听动态配置改变事件,获取最新的规则配置,而规则的初始化加载,可通过监听 Spring 容器刷新完成事件实现。

动态配置为嵌入式模式提供支持

如果采用嵌入式模式启动,则除非一开始就清楚地知道应用会部署多少个节点,这些节点的 IP 地址是什么,并且不会改变,否则无法使用静态配置的方式去指定某个节点的角色。


Sentinel 为此提供了支持动态改变某个节点角色的 API,使用方式如下。其中,{state}为 0 代表集群限流客户端,为 1 代表集群限流服务端。当一个新的节点被选为集群限流服务端后,旧的集群限流服务端节点应该变为集群限流客户端,并且其他的节点都需要做出改变以连接到这个新的集群限流服务端。


Sentinel 提供动态修改 ClusterClientAssignConfig 和 ClusterClientConfig 的 API,使用方式如下,

其中,{body}要求是 JSON 格式的字符串,支持的参数配置如下:

  • serverHost:集群限流服务端的 IP 地址。

  • serverPort:集群限流服务端的端口。

  • requestTimeout:请求的超时时间。


除了使用 API 可以动态修改节点角色、客户端连接到服务端的配置,Sentinel 还支持通过动态配置方式修改,但无论使用哪种方式修改都有一个弊端:需要人工手动配置。


虽然未能实现自动切换,但不得不称赞的是,Sentinel 将动态数据源与 SentinelProperty 结合使用,通过 SentinelProperty 实现的观察者模式,提供了更为灵活的嵌入式模式集群限流角色转换功能,支持以动态修改配置的方式重置嵌入式模式集群中任一节点的集群限流角色。


ClusterClientAssignConfig(客户端连接服务端配置)、ServerTransportConfig(服务端传输层配置,包括监听端口、连接最大空闲时间)、ClusterClientConfig(客户端配置,包括请求超时)、ClusterState(节点状态,包括集群限流客户端、集群限流服务端)都支持使用动态数据源方式配置。

  • 当动态改变 ClusterClientAssignConfig 时,Sentinel 将重新创建集群限流客户端与集群限流服务端的连接。

  • 当动态改变 ServerTransportConfig 时,Sentinel 将重启集群限流服务端。

  • 对于嵌入式模式,当动态改变 ClusterState 时,如果改变前与改变后的状态不同,如从集群限流客户端角色变为集群限流服务端角色,则关闭集群限流客户端与集群限流服务端的连接,并且启动服务监听客户端连接,而其他节点也会监听到动态配置改变,重新连接到这个新的集群限流服务端。

集群限流核心类介绍

sentinel-core 模块的 cluster 包中定义了实现集群限流功能的相关接口:

  • TokenService:定义集群限流客户端向集群限流服务端申请 Token 的接口,由 FlowRuleChecker 调用。

  • ClusterTokenClient:集群限流客户端需要实现的接口,继承 TokenService。

  • ClusterTokenServer:集群限流服务端需要实现的接口。

  • EmbeddedClusterTokenServer:支持嵌入式模式的集群限流服务端需要实现的接口,继承 TokenService 和 ClusterTokenServer。


TokenService 是集群限流客户端与集群限流服务端通信的 RPC 接口,TokenService 接口的定义如下:

  • requestToken:向 server 申请令牌,参数 1 为集群限流规则 ID,参数 2 为申请的令牌数,参数 3 为请求优先级。

  • requestParamToken:用于支持热点参数集群限流向 server 申请令牌,参数 1 为集群限流规则 ID,参数 2 为申请的令牌数,参数 3 为限流参数。


TokenResult 实体类的定义如下:

  • status:请求的响应状态码。

  • remaining:当前时间窗口剩余的令牌数。

  • waitInMs:休眠等待时间,单位为毫秒,用于通知集群限流客户端当前请求可以放行,但需要先休眠指定时间后才能放行。

  • attachments:附带的属性,暂未使用。


ClusterTokenClient 是由集群限流客户端实现的接口,定义如下:

ClusterTokenClient 接口定义了启动和停止集群限流客户端的方法,负责维护集群限流客户端与集群限流服务端的连接。该接口还继承了 TokenService,要求实现类必须实现 requestToken 方法和 requestParamToken 方法,向远程服务端请求获取令牌。


ClusterTokenServer 是由集群限流服务端实现的接口,定义如下:

ClusterTokenServer 接口定义了启动和停止集群限流服务端的方法,负责启动能够接收和响应客户端请求的网络通信服务端,根据接收的消费类型处理客户端的请求。


EmbeddedClusterTokenServer 接口由集群限流服务端实现,定义如下:

EmbeddedClusterTokenServer 接口继承了 ClusterTokenServer 接口和 TokenService 接口,即整合了集群限流客户端和集群限流服务端的功能,为嵌入式模式提供支持。在嵌入式模式下,如果当前节点是集群限流服务端,就没有必要发起网络请求。


TokenService 接口、ClusterTokenClient 接口、ClusterTokenServer 接口和 EmbeddedClusterTokenServer 接口及默认实现类的关系如图所示:

其中,DefaultClusterTokenClient 是 sentinel-cluster-client-default 模块中的 ClusterTokenClient 接口的实现类,DefaultTokenService 与 DefaultEmbeddedTokenServer 分别是 sentinel-cluster-server-default 模块中的 ClusterTokenServer 接口与 EmbeddedClusterTokenServer 接口的实现类。


当使用嵌入式模式启用集群限流服务端时,使用的 TokenService 实现类是 DefaultEmbeddedTokenServer,而当使用独立应用模式启用集群限流服务端时,使用的 TokenService 实现类是 DefaultTokenService。

集群限流客户端的实现

下面继续依据单机限流工作流程分析集群限流功能的实现,且从 FlowRuleChecker#passClusterCheck 方法开始。该方法的源码如下:

  1. 获取 TokenService 实例。

  2. 获取集群限流规则的全局唯一 ID。

  3. 调用 TokenService#requestToken 方法申请令牌。

  4. 调用 applyTokenResult 方法处理响应结果。


pickClusterService 方法可根据节点当前角色获取 TokenService 实例。如果当前节点是集群限流客户端角色,则获取的 TokenService 实例类型为 ClusterTokenClient,如果当前节点是集群限流服务端角色(嵌入式模式),则获取的 TokenService 实例类型为 EmbeddedClusterTokenServer。pickClusterService 方法代码如下:

ClusterTokenClient 接口和 EmbeddedClusterTokenServer 接口都继承了 TokenService 接口,区别在于,ClusterTokenClient 接口的实现类是通过向集群限流服务端发起请求来实现 requestToken 方法的,而 EmbeddedClusterTokenServer 接口的实现类在实现 requestToken 方法时不需要发起远程调用,因为在嵌入式模式下,如果当前节点角色是集群限流服务端,则调用的远程服务就是其自身。


在获取 TokenService 实例后,调用 TokenService 实例的 requestToken 方法请求获取 Token。


如果当前节点角色是集群限流客户端,则这一步骤会先将方法参数构造为请求数据包,再向集群限流服务端发起请求,并同步等待获取集群限流服务端的响应结果。因为网络通信的内容不在本书的讲解范围内,所以这里并没有展开分析。


applyTokenResult 方法的源码如下:

applyTokenResult 方法根据响应状态码决定是否拒绝当前请求:

  • 当响应状态码为 OK 时,放行请求。

  • 当响应状态码为 SHOULD_WAIT 时,休眠指定时间再放行请求。

  • 当响应状态码为 BLOCKED 时,直接拒绝请求。

  • 其他状态码均代表调用失败,根据规则配置的 fallbackToLocalWhenFail 是否为 true 决定是否回退为本地限流,如果需要回退为本地限流,则调用 passLocalCheck 方法重新判断。


在请求异常或集群限流服务端响应异常的情况下,都会调用 fallbackToLocalOrPass 方法。该方法的源码如下:

fallbackToLocalOrPass 方法根据规则配置的 fallbackToLocalWhenFail 决定是否回退为本地限流。如果将 fallbackToLocalWhenFail 配置为 false,则会导致集群限流客户端在与集群限流服务端失联的情况下拒绝所有流量。fallbackToLocalWhenFail 的默认值为 true,建议不要将其修改为 false,我们应当先确保服务的可用性,再确保集群限流的准确性。


由于网络延迟的存在,Sentinel 集群限流并未实现匀速排队流量效果控制,也没有支持冷启动,而只支持直接拒绝请求的流量效果控制。响应状态码 SHOULD_WAIT 并非用于实现匀速限流,而是用于实现具有优先级的请求在达到限流阈值的情况下,可试着抢占下一个时间窗口的 Token,如果抢占成功,则通知集群限流客户端,当前请求需要休眠等待下一个时间窗口的到来才可以放行。Sentinel 使用“提前申请在未来时间通过”的方式实现优先级语意。

集群限流服务端的实现

无论是集群限流服务端接收集群限流客户端发来的 requestToken 请求,还是在嵌入式模式下自己向自己发起请求,最终都会交给 DefaultTokenService 处理。


DefaultTokenService 类实现的 requestToken 方法的源码如下:

  1. 根据限流规则 ID 获取限流规则。

  2. 调用 ClusterFlowChecker#acquireClusterToken 方法继续处理请求。


提示:Sentinel 只使用一个 ID 字段向集群限流服务端传递限流规则,减小了数据包的大小,优化了网络通信的性能。


由于 ClusterFlowChecker#acquireClusterToken 方法的源码太多,因此将 acquireClusterToken 方法拆分为 4 个部分进行分析。


第一部分的代码如下:

计算集群限流阈值需要根据规则配置的阈值类型计算。calcGlobalThreshold 方法的源码如下:

  • 当阈值类型为集群总阈值时,直接使用限流规则的阈值(count)。

  • 当阈值类型为单机均摊时,根据规则 ID 获取当前连接的客户端总数,将当前连接的客户端总数乘以限流规则的阈值(count)的结果作为集群的 QPS 限流阈值。


这正是集群限流客户端在连接上集群限流服务端时,发送 PING 类型消息给集群限流服务端并将名称空间携带在 PING 数据包上传递给集群限流服务端的原因。当限流规则阈值类型为单机均摊时,需要知道哪些连接与限流规则所属名称空间相同,如果集群限流客户端不将名称空间传递给集群限流服务端,则计算出来的集群的 QPS 限流阈值将为 0,导致所有请求都会被限流。这是我们在使用集群限流功能时特别需要注意的。


集群限流阈值根据规则配置的阈值和阈值类型计算得到,每秒平均被放行请求数可从滑动窗口中获取,而剩余可用令牌数(nextRemaining)等于集群的 QPS 限流阈值减去当前时间窗口已经放行的请求数,再减去当前请求预占用的 acquireCount(一般情况下,acquireCount 值为 1)。


第二部分的代码如下:


当 nextRemaining 的计算结果大于或等于 0 时,执行这部分代码,先统计放行指标数据,再将响应状态码 OK 发送给集群限流客户端。


第三部分的代码如下:

当 nextRemaining 的计算结果小于 0 时,如果当前请求具有优先级,则执行这部分代码。


计算是否允许抢占下一个时间窗口的 Token,若允许,则通知集群限流客户端,当前请求可放行,但需要等待 waitInMs(一个窗口时间大小)毫秒之后才可放行。如果请求抢占到下一个时间窗口的 Token,则下一个时间窗口的被放行请求数也需要加上这些提前占用 Token 的请求数,这将会影响下一个时间窗口可用的 Token 总数。


第四部分的代码如下:

当 nextRemaining 的计算结果大于 0 且无优先级权限时,直接拒绝请求,统计拒绝指标数据。

集群限流指标数据统计的实现

集群限流使用的滑动窗口并非 sentinel-core 模块实现的滑动窗口,而是 sentinel-clusterserver-default 模块自己实现的滑动窗口。


ClusterFlowConfig 的 sampleCount 与 windowIntervalMs 这两个配置项用于为集群限流规则创建统计指标数据的滑动窗口,并在加载集群限流规则时创建,源码如下:

实现集群限流需要收集的指标数据有以下几种:

  • PASS:已经发放的令牌总数。

  • BLOCK:令牌申请被驳回的总数。

  • PASS_REQUEST:被放行的请求总数。

  • BLOCK_REQUEST:被拒绝的请求总数。

  • OCCUPIED_PASS:被上一个时间窗口抢占的令牌总数。

  • OCCUPIED_BLOCK:上一个时间窗口抢占令牌失败的总数。

  • WAITING:当前等待下一个时间窗口到来的请求总数。


除统计的指标项与在 sentinel-core 模块下实现的滑动窗口统计的指标项有些区别外,滑动窗口的实现方式都一致。

小结

集群限流服务端允许被嵌入应用服务中启动,也可以作为独立应用服务启动。嵌入式模式适用于单个微服务实现集群内部限流的场景,独立应用模式适用于多个微服务应用共享同一个集群限流服务端的场景。另外,独立应用模式不会影响应用性能,而嵌入式模式对应用性能会有所影响。


集群限流客户端需要指定名称空间,默认使用 main 方法所在类的全类名作为名称空间。


在集群限流客户端连接到集群限流服务端时,集群限流客户端会立即向集群限流服务端发送一条 PING 消息,并将名称空间携带在 PING 数据包上传递给集群限流服务端。


集群限流规则的阈值支持单机均摊和集群总阈值两种类型,如果是单机均摊阈值类型,则集群限流服务端需要根据限流规则的名称空间获取该名称空间当前所有的客户端连接,并将当前连接的客户端总数乘以限流规则的阈值的结果作为集群的 QPS 限流阈值。


集群限流支持按名称空间全局限流,并且无视该名称空间下配置的任何限流规则,只要是同一名称空间的集群限流客户端发来的 requestToken 请求,都先按名称空间阈值过滤。但其并没有特别实用的场景,因此官方文档也并未介绍此特性。


建议按应用区分名称空间,而不是对整个项目的所有微服务项目都使用同一个名称空间,因为在限流规则阈值类型为单机均摊的情况下,将获取与限流规则所属名称空间相同的客户端连接数作为客户端总数,如果不是同一个应用,则会导致获取的客户端总数是整个项目所有微服务应用集群的客户端总数,限流就会出问题。


集群限流并不能完全解决请求倾斜问题,在请求倾斜严重的情况下,集群限流可能会导致某些节点的流量过高,从而导致系统的负载过高,这时就需要使用系统自适应限流和熔断降级作为兜底解决方案。

用户头像

加VX:bjmsb02 凭截图即可获取 2020-06-14 加入

公众号:程序员高级码农

评论

发布
暂无评论
集群限流功能是如何实现的?_Java_互联网架构师小马_InfoQ写作社区