Sentinel 集群限流深度解析(下):源码实现与核心算法

图片[1]-Sentinel 集群限流深度解析(下):源码实现与核心算法 - 速优课-速优课

本文导读

在上一篇文章中,我们介绍了 Sentinel 集群限流的架构设计、部署模式和配置方式。本文作为集群限流系列的下篇,将深入源码层面,剖析集群限流的实现细节。

我们将从以下几个维度展开:

  1. 核心接口与类结构:TokenService、ClusterTokenClient、ClusterTokenServer
  2. 客户端实现原理:如何发起 Token 请求,如何处理响应
  3. 服务端实现原理:如何分配 Token,优先级请求如何处理
  4. 指标数据统计:集群限流的滑动窗口实现
  5. 常见问题与注意事项:namespace、阈值计算、回退机制

通过本文的学习,你将彻底理解 Sentinel 集群限流的工作原理,遇到问题时能够快速定位和排查。


一、核心接口与类结构

1.1 设计思想

理解集群限流,我们可以结合令牌桶算法来思考:

  • 服务端:负责”生产令牌”和统一管理令牌总数
  • 客户端:向服务端”申请令牌”,申请到了才能放行请求
  • 申请失败:要么等待,要么被拒绝,要么回退到本地限流

集群限流也支持热点参数限流,实现原理大致相同。本文重点分析普通的集群限流,热点参数的集群限流留给大家自行研究。

1.2 核心接口定义

sentinel-core 模块的 cluster 包下定义了集群限流的核心接口:

接口 作用
TokenService 定义申请 Token 的接口,由 FlowRuleChecker 调用
ClusterTokenClient 客户端接口,继承 TokenService,增加启动/停止方法
ClusterTokenServer 服务端接口,定义启动/停止方法
EmbeddedClusterTokenServer 嵌入模式接口,同时继承 TokenService 和 ClusterTokenServer

TokenService 接口:

TokenService 是最核心的接口,定义了申请令牌的方法:

public interface TokenService {
    // 申请普通令牌
    TokenResult requestToken(Long ruleId, int acquireCount, boolean prioritized);
    // 申请热点参数令牌
    TokenResult requestParamToken(Long ruleId, int acquireCount, Collection<Object> params);
}

方法参数说明:

参数 说明
ruleId 集群限流规则的全局唯一 ID
acquireCount 申请的令牌数量
prioritized 请求是否具有优先级
params 热点参数值(参数限流专用)

TokenResult 响应实体:

服务端的响应结果封装在 TokenResult 中:

public class TokenResult {
    private Integer status;          // 响应状态码
    private int remaining;           // 剩余令牌数
    private int waitInMs;            // 等待时间(毫秒)
    private Map<String, String> attachments; // 附加属性
}

各字段含义:

字段 说明
status 响应状态码(见下表)
remaining 当前时间窗口剩余的令牌数
waitInMs 需要休眠等待的时间,用于优先级请求预占下一个窗口
attachments 附加属性,暂未使用

TokenResultStatus 状态码:

状态 含义
OK 申请成功,放行
SHOULD_WAIT 需要等待一段时间后放行
BLOCKED 申请失败,拒绝请求
NO_RULE_EXISTS 规则不存在
BAD_REQUEST 请求参数错误
FAIL 服务端处理失败
TOO_MANY_REQUEST 服务端请求过多,限流

ClusterTokenClient 接口:

客户端接口,继承 TokenService,增加了启动和停止方法:

public interface ClusterTokenClient extends TokenService {
    void start() throws Exception;   // 启动客户端
    void stop() throws Exception;    // 停止客户端
}

客户端负责维护与服务端的连接,并实现向远程服务端申请令牌的逻辑。

ClusterTokenServer 接口:

服务端接口,定义启动和停止方法:

public interface ClusterTokenServer {
    void start() throws Exception;   // 启动服务端
    void stop() throws Exception;    // 停止服务端
}

启动后开始监听端口,接收和处理客户端的请求。

EmbeddedClusterTokenServer 接口:

嵌入模式的服务端接口,同时继承了 ClusterTokenServerTokenService

public interface EmbeddedClusterTokenServer 
                 extends ClusterTokenServer, TokenService {
}

为什么要同时继承两个接口?

在嵌入模式下,如果当前节点本身就是服务端,那就没必要再发起网络请求了,直接在本地调用 TokenService 的方法即可。这就是 EmbeddedClusterTokenServer 存在的意义。

1.3 类关系图

接口和实现类的关系如下图所示:

图片[2]-Sentinel 集群限流深度解析(下):源码实现与核心算法 - 速优课-速优课

各实现类说明:

实现类 所属模块 说明
DefaultClusterTokenClient sentinel-cluster-client-default 客户端默认实现,基于 Netty
DefaultTokenService sentinel-cluster-server-default 服务端 Token 分配逻辑
DefaultEmbeddedTokenServer sentinel-cluster-server-default 嵌入模式服务端实现

服务端的具体实现通过 Java SPI 机制加载,因此可以灵活替换。


二、客户端实现原理

2.1 入口:FlowRuleChecker#passClusterCheck

我们从单机限流的流程继续往下看,集群限流的入口在 FlowRuleChecker#passClusterCheck 方法:

private static boolean passClusterCheck(FlowRule rule, Context context, DefaultNode node, 
                                         int acquireCount, boolean prioritized) {
    try {
        // (1) 获取 TokenService
        TokenService clusterService = pickClusterService();
        if (clusterService == null) {
            return fallbackToLocalOrPass(rule, context, node, acquireCount, prioritized);
        }
        // (2) 获取规则 ID
        long flowId = rule.getClusterConfig().getFlowId();
        // (3) 申请令牌
        TokenResult result = clusterService.requestToken(flowId, acquireCount, prioritized);
        // (4) 处理响应结果
        return applyTokenResult(result, rule, context, node, acquireCount, prioritized);
    } catch (Throwable ex) {
        RecordLog.warn("[FlowRuleChecker] Request cluster token unexpected failed", ex);
    }
    // (5) 异常时回退
    return fallbackToLocalOrPass(rule, context, node, acquireCount, prioritized);
}

整体四步走:

  1. 获取 TokenService:根据当前节点角色获取对应的实现
  2. 获取规则 ID:从集群限流配置中获取全局唯一的 flowId
  3. 申请令牌:调用 requestToken 方法向服务端申请
  4. 处理结果:根据响应结果判断是否放行
  5. 异常回退:发生异常时根据配置决定是否回退到本地限流

2.2 pickClusterService:选择 TokenService

pickClusterService 方法根据当前节点的角色选择对应的 TokenService 实现:

private static TokenService pickClusterService() {
    // 客户端角色 → 获取 ClusterTokenClient
    if (ClusterStateManager.isClient()) {
        return TokenClientProvider.getClient();
    }
    // 服务端角色(嵌入模式)→ 获取 EmbeddedClusterTokenServer
    if (ClusterStateManager.isServer()) {
        return EmbeddedClusterTokenServerProvider.getServer();
    }
    return null;
}

巧妙的设计:

  • 客户端角色:通过网络请求远程服务端
  • 服务端角色(嵌入模式):直接本地调用,不走网络
  • 对外统一是 TokenService 接口,上层调用无需关心底层实现

2.3 requestToken:发起 Token 请求

拿到 TokenService 后,调用 requestToken 方法申请令牌。

  • 如果是客户端角色:将方法参数构造为请求数据包,通过 Netty 向服务端发起请求,同步等待响应
  • 如果是嵌入模式服务端角色:直接在本地调用 DefaultTokenService 的方法

关于 Netty 通信的具体实现不是本文重点,我们更关注限流逻辑本身。

2.4 applyTokenResult:处理响应结果

applyTokenResult 方法根据服务端返回的状态码决定后续行为:

private static boolean applyTokenResult(TokenResult result, FlowRule rule, Context context,
                                         DefaultNode node, int acquireCount, boolean prioritized) {
    switch (result.getStatus()) {
        case TokenResultStatus.OK:
            // 申请成功,直接放行
            return true;
        case TokenResultStatus.SHOULD_WAIT:
            // 需要等待,休眠后放行
            try {
                Thread.sleep(result.getWaitInMs());
            } catch (InterruptedException e) {
            }
            return true;
        case TokenResultStatus.NO_RULE_EXISTS:
        case TokenResultStatus.BAD_REQUEST:
        case TokenResultStatus.FAIL:
        case TokenResultStatus.TOO_MANY_REQUEST:
            // 各种失败情况,回退到本地限流
            return fallbackToLocalOrPass(rule, context, node, acquireCount, prioritized);
        case TokenResultStatus.BLOCKED:
        default:
            // 被限流,拒绝请求
            return false;
    }
}

状态处理逻辑总结:

状态 处理方式
OK 直接放行
SHOULD_WAIT 休眠 waitInMs 毫秒后放行(用于优先级请求)
BLOCKED 直接拒绝
其他失败状态 回退到本地限流或直接放行

2.5 fallbackToLocalOrPass:失败回退

当请求失败或服务端异常时,走 fallbackToLocalOrPass 方法:

private static boolean fallbackToLocalOrPass(FlowRule rule, Context context, DefaultNode node, 
                                              int acquireCount, boolean prioritized) {
    if (rule.getClusterConfig().isFallbackToLocalWhenFail()) {
        // 开启回退:走本地限流逻辑
        return passLocalCheck(rule, context, node, acquireCount, prioritized);
    } else {
        // 不开启回退:直接放行(规则不生效)
        return true;
    }
}

两种策略:

  • fallbackToLocalWhenFail = true(默认,推荐):失败时回退到本地限流,保证系统安全
  • fallbackToLocalWhenFail = false:失败时直接放行,相当于限流失效

强烈建议保持默认值 true。服务可用性永远是第一位的,其次才是限流的精确性。

2.6 关于 SHOULD_WAIT

注意:Sentinel 集群限流没有实现匀速排队和冷启动的流控效果,只支持直接拒绝。

SHOULD_WAIT 是做什么用的呢?

它是用于实现优先级请求的:当请求具有优先级且当前窗口令牌不足时,可以尝试”预占”下一个时间窗口的令牌。如果抢占成功,就告诉客户端”你可以通过,但需要等到下一个窗口开始”。

这是一种”提前申请未来时间通过”的方式,用于实现优先级语义。


三、服务端实现原理

3.1 入口:DefaultTokenService#requestToken

无论是客户端发来的请求,还是嵌入模式的本地调用,最终都会交给 DefaultTokenService 处理:

@Override
public TokenResult requestToken(Long ruleId, int acquireCount, boolean prioritized) {
    // 参数校验
    if (notValidRequest(ruleId, acquireCount)) {
        return badRequest();
    }
    // (1) 根据规则 ID 获取限流规则
    FlowRule rule = ClusterFlowRuleManager.getFlowRuleById(ruleId);
    if (rule == null) {
        return new TokenResult(TokenResultStatus.NO_RULE_EXISTS);
    }
    // (2) 判断是否允许通过
    return ClusterFlowChecker.acquireClusterToken(rule, acquireCount, prioritized);
}

流程很清晰:

  1. 根据 ruleId 从 ClusterFlowRuleManager 获取限流规则
  2. 调用 ClusterFlowChecker#acquireClusterToken 进行核心判断

为什么只传 ruleId 而不传整个规则?

因为 ruleId 只需要一个 Long 类型(8 字节),而整个规则对象会大很多。只传 ID 可以大幅减小数据包大小,优化网络通信性能。这也是要求 flowId 全局唯一的原因。

3.2 acquireClusterToken:核心判断逻辑

ClusterFlowChecker#acquireClusterToken 是服务端限流判断的核心方法。代码比较长,我们拆分成四个部分来分析。

第一部分:前置检查与阈值计算

static TokenResult acquireClusterToken(FlowRule rule, int acquireCount, boolean prioritized) {
    Long id = rule.getClusterConfig().getFlowId();
    
    // (1) 全局 QPS 限制检查(按 namespace 级别的总限流)
    if (!allowProceed(id)) {
        return new TokenResult(TokenResultStatus.TOO_MANY_REQUEST);
    }
    
    // (2) 获取该规则的指标统计
    ClusterMetric metric = ClusterMetricStatistics.getMetric(id);
    if (metric == null) {
        return new TokenResult(TokenResultStatus.FAIL);
    }
    
    // (3) 计算阈值和剩余令牌
    double latestQps = metric.getAvg(ClusterFlowEvent.PASS);
    double globalThreshold = calcGlobalThreshold(rule) * ClusterServerConfigManager.getExceedCount();
    double nextRemaining = globalThreshold - latestQps - acquireCount;
    
    if (nextRemaining >= 0) {
        // 第二部分:令牌充足,放行
    } else {
        if (prioritized) {
            // 第三部分:令牌不足但是优先级请求,尝试预占下一个窗口
        }
        // 第四部分:令牌不足,拒绝
    }
}

关键步骤解读:

  1. allowProceed:检查 namespace 级别的全局 QPS 是否超限(可以给每个 namespace 配置一个总 QPS 上限)
  2. 获取 ClusterMetric:获取该规则对应的滑动窗口统计数据
  3. 计算剩余令牌

    • latestQps:当前窗口已通过的 QPS
    • globalThreshold:集群总阈值
    • nextRemaining:剩余可用 = 总阈值 – 已通过 – 当前申请

getExceedCount() 是一个允许的超额比例,默认 1.0,即不允许超额。

关于 namespace 全局限流:

Sentinel 支持按 namespace 配置全局 QPS 限制,无视具体规则,只要是同一 namespace 的请求都先过这一层。配置方式:

ServerFlowConfig serverFlowConfig = new ServerFlowConfig();
serverFlowConfig.setMaxAllowedQps(1000);
ClusterServerConfigManager.loadFlowConfig("serviceA", serverFlowConfig);

这个功能使用场景不多,官方文档也没有详细介绍,了解即可。

3.3 calcGlobalThreshold:计算集群总阈值

calcGlobalThreshold 方法根据阈值类型计算集群总阈值:

private static double calcGlobalThreshold(FlowRule rule) {
    double count = rule.getCount();
    switch (rule.getClusterConfig().getThresholdType()) {
        case ClusterRuleConstant.FLOW_THRESHOLD_GLOBAL:
            // 集群总阈值模式:直接使用配置值
            return count;
        case ClusterRuleConstant.FLOW_THRESHOLD_AVG_LOCAL:
        default:
            // 单机均摊模式:单机阈值 × 连接的客户端数
            int connectedCount = ClusterFlowRuleManager.getConnectedCount(
                rule.getClusterConfig().getFlowId());
            return count * connectedCount;
    }
}

两种阈值类型:

类型 计算方式
集群总阈值(GLOBAL) 直接使用规则配置的 count
单机均摊(AVG_LOCAL) count × 当前连接的客户端数量

为什么客户端要传 namespace?

这就是原因所在!

在单机均摊模式下,服务端需要知道有多少个客户端连接,才能计算总阈值。而客户端连接是按 namespace 分组的,所以客户端连接时必须带上 namespace。

划重点:如果客户端没有正确传递 namespace,单机均摊模式下计算出来的总阈值会是 0,导致所有请求都被限流!这是使用集群限流时最容易踩的坑之一。

3.4 第二部分:令牌充足,放行

nextRemaining >= 0 时,说明令牌充足,执行放行逻辑:

metric.add(ClusterFlowEvent.PASS, acquireCount);
metric.add(ClusterFlowEvent.PASS_REQUEST, 1);
if (prioritized) {
    metric.add(ClusterFlowEvent.OCCUPIED_PASS, acquireCount);
}
return new TokenResult(TokenResultStatus.OK)
    .setRemaining((int) nextRemaining)
    .setWaitInMs(0);

做了三件事:

  1. 记录 PASS(通过的令牌数)
  2. 记录 PASS_REQUEST(通过的请求数)
  3. 如果是优先级请求,额外记录 OCCUPIED_PASS(预占用通过)
  4. 返回 OK 状态和剩余令牌数

3.5 第三部分:优先级请求预占下一个窗口

当令牌不足但请求具有优先级时,尝试预占下一个时间窗口的令牌:

double occupyAvg = metric.getAvg(ClusterFlowEvent.WAITING);
if (occupyAvg <= ClusterServerConfigManager.getMaxOccupyRatio() * globalThreshold) {
    int waitInMs = metric.tryOccupyNext(ClusterFlowEvent.PASS, acquireCount, globalThreshold);
    if (waitInMs > 0) {
        return new TokenResult(TokenResultStatus.SHOULD_WAIT)
            .setRemaining(0)
            .setWaitInMs(waitInMs);
    }
}

逻辑说明:

  1. 检查当前等待队列的大小是否超过限制(maxOccupyRatio 是最大预占比例)
  2. 调用 tryOccupyNext 尝试预占下一个窗口,计算需要等待的时间
  3. 如果可以预占,返回 SHOULD_WAIT 状态,告诉客户端等待多久

预占的含义:

  • 当前窗口的令牌用完了
  • 但我可以提前”预约”下一个窗口的令牌
  • 需要等到下一个窗口开始才能通过
  • 等待时间 = 下一个窗口开始的时间 – 当前时间

3.6 第四部分:令牌不足,拒绝

如果令牌不足,且不是优先级请求(或优先级请求也预占失败),则拒绝请求:

metric.add(ClusterFlowEvent.BLOCK, acquireCount);
metric.add(ClusterFlowEvent.BLOCK_REQUEST, 1);
if (prioritized) {
    metric.add(ClusterFlowEvent.OCCUPIED_BLOCK, acquireCount);
}
return blockedResult();

做了三件事:

  1. 记录 BLOCK(被拒绝的令牌数)
  2. 记录 BLOCK_REQUEST(被拒绝的请求数)
  3. 如果是优先级请求,额外记录 OCCUPIED_BLOCK(预占用拒绝)
  4. 返回 BLOCKED 状态

四、集群限流的指标统计

4.1 独立的滑动窗口实现

集群限流使用的滑动窗口不是 sentinel-core 模块中的实现,而是 sentinel-cluster-server-default 模块自己实现的。

在加载集群限流规则时,会为每条规则创建一个 ClusterMetric

private static void applyClusterFlowRule(List<FlowRule> list, String namespace) {
    // ...
    for (FlowRule rule : list) {
        if (!rule.isClusterMode()) {
            continue;
        }
        // ...
        ClusterFlowConfig clusterConfig = rule.getClusterConfig();
        // 如果不存在,则为规则创建 ClusterMetric
        ClusterMetricStatistics.putMetricIfAbsent(flowId,
            new ClusterMetric(clusterConfig.getSampleCount(), 
                              clusterConfig.getWindowIntervalMs()));
    }
    // 移除不再使用的 ClusterMetric
    clearAndResetRulesConditional(namespace, new Predicate<Long>() {
        @Override
        public boolean test(Long flowId) {
            return !ruleMap.containsKey(flowId);
        }
    });
    // ...
}

创建参数就是我们在 ClusterFlowConfig 中配置的:

  • sampleCount:窗口数量
  • windowIntervalMs:窗口总时长(毫秒)

4.2 统计指标

集群限流收集的指标数据定义在 ClusterFlowEvent 枚举中:

public enum ClusterFlowEvent {
    PASS,               // 已发放的令牌总数
    BLOCK,              // 被驳回的令牌总数
    PASS_REQUEST,       // 被放行的请求总数
    BLOCK_REQUEST,      // 被拒绝的请求总数
    OCCUPIED_PASS,      // 预占用-已发放的令牌数
    OCCUPIED_BLOCK,     // 预占用-被驳回的令牌数
    WAITING             // 等待下一个窗口的请求数
}

各指标含义:

指标 说明
PASS 通过的令牌数(一个请求可能申请多个令牌)
BLOCK 被拒绝的令牌数
PASS_REQUEST 通过的请求数(按请求计数)
BLOCK_REQUEST 被拒绝的请求数
OCCUPIED_PASS 优先级请求预占通过的令牌数
OCCUPIED_BLOCK 优先级请求预占被拒绝的令牌数
WAITING 当前等待下一个窗口的请求数

除了统计的指标项不同,滑动窗口的实现方式和 sentinel-core 中的基本一致,都是基于 LeapArray 的滑动窗口算法。


五、总结与注意事项

5.1 两种部署模式的选择

模式 优点 缺点 适用场景
嵌入模式 无需单独部署 影响应用性能,无自动切换 单服务、小规模
独立模式 性能隔离,统一服务 需要额外部署和运维 多服务、生产环境

5.2 Namespace 的重要性

关于 namespace,再强调几点:

  1. 按应用区分 namespace:不要整个项目所有微服务共用一个 namespace
  2. 客户端必须设置 namespace:否则单机均摊模式会出问题
  3. namespace 在连接时传递:通过 PING 消息携带给服务端
  4. 规则按 namespace 隔离:不同 namespace 的规则互不影响

为什么不能共用 namespace?

如果多个应用共用一个 namespace,在单机均摊模式下,计算客户端总数时会把所有应用的客户端都算进去,导致总阈值被放大,限流就不准了。

5.3 集群限流的局限性

  1. 不是解决请求倾斜的银弹:集群限流只能保证总阈值准确,但某些节点流量过高的问题仍然存在,需要结合负载均衡优化
  2. 只支持快速拒绝:没有实现匀速排队和冷启动的流控效果
  3. 服务端单点问题:独立模式下服务端是单点,需要考虑高可用
  4. 请求倾斜严重时:可能导致某些节点负载过高,需要配合系统自适应限流和熔断降级做兜底

5.4 实践建议

  1. 生产环境使用独立模式:避免影响业务应用性能
  2. 务必开启失败回退fallbackToLocalWhenFail = true
  3. 正确配置 namespace:按应用隔离,避免串扰
  4. 监控集群限流状态:监控服务端健康、连接数、通过率等
  5. 结合其他保护手段:系统自适应限流、熔断降级不能少
  6. 压测验证:上线前充分压测,了解性能瓶颈

总结与思考

本文作为集群限流系列的下篇,深入源码层面剖析了 Sentinel 集群限流的实现原理。

核心要点回顾

1. 核心接口设计

  • TokenService:统一的 Token 申请接口
  • ClusterTokenClient / ClusterTokenServer:客户端和服务端接口
  • EmbeddedClusterTokenServer:嵌入模式,本地调用不走网络

2. 客户端流程

  • FlowRuleChecker → pickClusterService → requestToken → applyTokenResult
  • 失败时可回退到本地限流(默认开启)
  • 优先级请求支持预占下一个窗口

3. 服务端流程

  • DefaultTokenService → ClusterFlowChecker → acquireClusterToken
  • 支持全局阈值和单机均摊两种阈值类型
  • 优先级请求支持预占未来窗口的令牌

4. 指标统计

  • 每条集群限流规则对应一个 ClusterMetric
  • 独立实现滑动窗口,统计 PASS / BLOCK / OCCUPIED 等指标

集群限流是 Sentinel 的高级功能,虽然使用场景不如单机限流广泛,但在需要精确控制总流量的场景下非常有用。理解它的实现原理,能帮助我们更好地使用和排查问题。

下一篇文章我们将探讨一个大家都关心的话题:Sentinel 对应用性能的影响到底有多大?敬请期待。

© 版权声明
THE END
喜欢就支持一下吧
点赞5
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容

温馨提示:
1、本内容转载于网络,版权归原作者所有!
2、本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
3、本内容若侵犯到你的版权利益,请联系我们,会尽快给予删除处理!