learn-tech/专栏/深入理解Sentinel(完)/14黑白名单限流与热点参数限流.md
2024-10-16 09:22:22 +08:00

33 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        14 黑白名单限流与热点参数限流
                        黑白名单限流

黑白名单过滤是使用最为广泛的一种过滤规则,例如,用于实现接口安全的 IP 黑白名单规则过滤,用于防骚扰的短信、来电拦截黑白名单过滤。所以 Sentinel 中的黑白名单限流并不难理解如果配置了黑名单且请求来源存在黑名单中则拦截拒绝请求如果配置了白名单且请求来源存在白名单中则放行。Sentinel 不支持一个黑白名单规则同时配置黑名单和白名单,因此不存优先级的问题。

黑白名单过滤功能更像是一种授权机制它简单的将权限分为有权限和无权限两种情况如果支持冲突可使用优先级策略解决冲突问题。Sentinel 把黑白名作为授权策略实现黑白名单限流即实现授权限流。Sentinel 在命名上也是使用 Authority而非 BlackWhiteList。

一些关键类说明:

AuthoritySlot实现黑白名称授权功能的切入点ProcessorSlot AuthorityRule授权规则类 AuthorityRuleChecker授权检测类 AuthorityRuleManager授权规则管理者提供 loadRuls API AuthorityException授权检测异常继承 BlockException

AuthorityRule

授权规则AuthorityRule是 Sentinel 中最易于理解的一种规则AuthorityRule 的配置项如下:

public class AuthorityRule extends AbstractRule { private int strategy = RuleConstant.AUTHORITY_WHITE; }

resource资源名称从父类继承而来。 limitApp限制的来源名称在 AuthorityRule 中可配置多个,使用‘,’号分隔。 strategy限流策略白名单AUTHORITY_WHITE黑名单AUTHORITY_BLACK。

当 strategy 配置为 AUTHORITY_WHITE 时limitApp 即为白名单;当 strategy 配置为 AUTHORITY_BLACK 时limitApp 即为黑明单。例如:

AuthorityRule rule = new AuthorityRule(); // 资源名称 rule.setResource("GET:/hello"); // 白名单策略 rule.setStrategy(RuleConstant.AUTHORITY_WHITE); // 白名单 rule.setLimitApp("serviceA,serviceC"); AuthorityRuleManager.loadRules(Collections.singletonList(rule));

上述规则用于限制资源 “GET:/hello” 只允许服务 A 和服务 C 访问。

AuthoritySlot

在使用默认的 SlotChainBuilder 情况下AuthoritySlot 被放在 SystemSlot、FlowSlot、DegradeSlot 的前面,其优先级更高。

原因之一是授权限流不需要使用统计的指标数据另一个原因则是提升性能在未授权的情况下没必要判断是否需要熔断、系统负载能否接住这个请求、QPS 是否过高等,这与用户授权功能是一样的道理,未登陆无需判断是否有权限访问某个资源。

AuthoritySlot 的实现源码如下:

public class AuthoritySlot extends AbstractLinkedProcessorSlot {

@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, boolean prioritized, Object... args)
    throws Throwable {
    checkBlackWhiteAuthority(resourceWrapper, context);
    fireEntry(context, resourceWrapper, node, count, prioritized, args);
}

@Override
public void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) {
    fireExit(context, resourceWrapper, count, args);
}

void checkBlackWhiteAuthority(ResourceWrapper resource, Context context) throws AuthorityException {
    // (1)
    Map<String, Set<AuthorityRule>> authorityRules = AuthorityRuleManager.getAuthorityRules();
    if (authorityRules == null) {
        return;
    }
    // (2)
    Set<AuthorityRule> rules = authorityRules.get(resource.getName());
    if (rules == null) {
        return;
    }
    // (3)
    for (AuthorityRule rule : rules) {
        if (!AuthorityRuleChecker.passCheck(rule, context)) {
            throw new AuthorityException(context.getOrigin(), rule);
        }
    }
}

}

从 AuthorityRuleManager 获取当前配置的所有授权规则; 获取为当前资源配置的所有授权规则; 遍历授权规则,调用 AuthorityRuleChecker#passCheck 方法判断是否拒绝当前请求,是则抛出 AuthorityException 异常。

AuthorityRuleChecker

AuthorityRuleChecker 负责实现黑白名单的过滤逻辑,其 passCheck 方法源码如下:

static boolean passCheck(AuthorityRule rule, Context context) {
    // 获取来源
    String requester = context.getOrigin();
    // 来源为空,或者来源等于规则配置的 limitApp 则拦截请求
    if (StringUtil.isEmpty(requester) || StringUtil.isEmpty(rule.getLimitApp())) {
        return true;
    }
    // 字符串查找,这一步起到快速过滤的作用,提升性能
    int pos = rule.getLimitApp().indexOf(requester);
    boolean contain = pos > -1;
    // 存在才精确匹配
    if (contain) {
        boolean exactlyMatch = false;
        // 分隔数组
        String[] appArray = rule.getLimitApp().split(",");
        for (String app : appArray) {
            if (requester.equals(app)) {
                // 标志设置为 true
                exactlyMatch = true;
                break;
            }
        }
        contain = exactlyMatch;
    }
    // 策略
    int strategy = rule.getStrategy();
    // 如果是黑名单,且来源存在规则配置的黑名单中
    if (strategy == RuleConstant.AUTHORITY_BLACK && contain) {
        return false;
    }
    // 如果是白名单,且来源不存在规则配置的白名单中
    if (strategy == RuleConstant.AUTHORITY_WHITE && !contain) {
        return false;
    }
    return true;
}

整个方法都比较简单,首先是从当前 Context 获取调用来源的名称,只有在调用来源不为空且规则配置了黑名单或者白名单的情况下,才会走黑白名单的过滤逻辑,这也说明,要实现黑白名单限流的前提是,每个服务消费端在发起请求时都必须要携带自身服务的名称,这取决于 Sentinel 主流框架适配器其次Sentinel 通过使用 indexOf 先简单匹配一次黑名单或白名单,再切割黑名单或白名单数组实现精确匹配,这有助于提升性能;如果当前请求来源存在名单中,则根据策略判断这份名称是黑名单还是白名单,再决定是否需要拒绝请求。

热点参数限流

热点参数限流并非在 Sentinel 的 core 模块中实现的但也是非常实用的一种限流方式。并且Sentinel 支持 API Gateway 网关限流也是基于参数限流实现的,了解热点参数限流的实现原理,也有助于我们更好地理解网关限流。

参数限流,即根据方法调用传递的参数实现限流,又或者说是根据接口的请求参数限流;热点参数限流,即针对访问频繁的参数限流。

例如,都是调用一个下单接口,但购买的商品不同,比如主播带货的商品下单流量较大,而一般商品购买量很少,同时因为商品数量有限,不太可能每个下单请求都能购买成功,如果能实现根据客户端请求传递的商品 ID 实现限流,将流量控制在商品的库存总量左右,并且使用 QPS 限流等兜底,这种有针对性的限流将接口通过的有效流量最大化。

热点参数限流功能在 Sentinel 源码的扩展功能模块为 sentinel-extension子模块为 sentinel-parameter-flow-control。

基于滑动窗口的热点参数指标数据统计

热点参数限流使用的指标数据不再是 core 模块中统计的指标数据,而是重新实现了一套指标数据统计功能,依旧是基于滑动窗口。

ParamMapBucket实现参数指标数据统计的 Bucket用于统计某个参数对应不同取值的被限流总数、被放行的总数。 HotParameterLeapArray实现滑动窗口持有 WindowWrap 数组WindowWrap 包装 ParamMapBucket。

与 core 模块的 MetricBucket 实现不同MetricBucket 只统计每个指标的数值,而 ParamMapBucket 需要统计每个指标、参数的每种取值的数值MetricBucket 更像是 Redis 中的 String 结构,而 ParamMapBucket 更像 Redis 中的 Hash 结构。

ParamMapBucket 的源码如下:

public class ParamMapBucket {

// 数组类型为 CacheMap<Object, AtomicInteger>
private final CacheMap<Object, AtomicInteger>[] data;

public ParamMapBucket() {
    this(DEFAULT_MAX_CAPACITY);
}

public ParamMapBucket(int capacity) {
    RollingParamEvent[] events = RollingParamEvent.values();
    // 根据需要统计的指标数据创建数组
    this.data = new CacheMap[events.length];
    // RollingParamEvent 可取值为 REQUEST_PASSED、REQUEST_BLOCKED
    for (RollingParamEvent event : events) {
        data[event.ordinal()] = new ConcurrentLinkedHashMapWrapper<Object, AtomicInteger>(capacity);
    }
}

}

data数组元素类型为 CacheMap<Object, AtomicInteger>,下标为 0 存储的是统计请求通过的指标数据,下标为 1 统计的是请求被拒绝的指标数据。 CacheMap<Object, AtomicInteger>key 为参数的取值,例如商品的 IDvalue 才是指标数值。

HotParameterLeapArray 继承 LeapArray即实现滑动窗口。ParamMapBucket 不存储窗口时间信息,窗口时间信息依然由 WindowWrap 存储HotParameterLeapArray 使用 WindowWrap 包装 ParamMapBucket。

笔者也是看了 HotParameterLeapArray 之后才明白为什么 Sentienl 将滑动窗口抽象为 LeapArray这为扩展实现收集自定义指标数据的滑动窗口提供了支持。

HotParameterLeapArray 的提供的几个 API 如下:

public class HotParameterLeapArray extends LeapArray { //..... public void addValue(RollingParamEvent event, int count, Object value) { // .... }

public Map<Object, Double> getTopValues(RollingParamEvent event, int number) {
   // .....
}

public long getRollingSum(RollingParamEvent event, Object value) {
    // .....
}

public double getRollingAvg(RollingParamEvent event, Object value) {
    // ....
}

}

addValue添加参数的指标数值例如给 REQUEST_PASSED 指标且参数取值为 4343433 的指标数值加上 count假设这个滑动窗口是用于统计商品 ID 参数的4343433 表示商品 IDcount 为 1调用该方法表示给商品 ID 为 4343433 的请求通过总数加 1。 getTopValues获取热点参数的 QPS即获取某个指标排名前 number 的参数取值与指标数据。例如,获取 REQUEST_PASSED 指标排名前 10 的 QPS方法返回值类型为 Mapkey 为参数的取值value 为 QPS。 getRollingSum计算某个指标、参数的某个取值的总请求数。例如获取 REQUEST_PASSED 且商品 ID 为 4343433 的请求总数。 getRollingAvg获取某个指标、参数的某个取值的平均 QPS。例如获取 REQUEST_PASSED 且商品 ID 为 4343433 的平均 QPS。

可见,如果是分钟级的滑动窗口,一分内参数的取值越多,其占用的内存就越多。

参数限流中的 Node

两个需要重点关注的类:

ParameterMetric用于实现类似 ClusterNode 的统计功能。 ParameterMetricStorage用于实现类似 EntranceNode 功能,管理和存储每个资源对应的 ParameterMetric。

ParameterMetric 有三个静态字段,源码如下:

public class ParameterMetric {

private final Map<ParamFlowRule, CacheMap<Object, AtomicLong>> ruleTimeCounters = new HashMap<>();
private final Map<ParamFlowRule, CacheMap<Object, AtomicLong>> ruleTokenCounter = new HashMap<>();
private final Map<Integer, CacheMap<Object, AtomicInteger>> threadCountMap = new HashMap<>();

}

ruleTimeCounters用于实现匀速流量控制效果key 为参数限流规则ParamFlowRule值为参数不同取值对应的上次生产令牌的时间。 ruleTokenCounter用于实现匀速流量控制效果key 为参数限流规则ParamFlowRule值为参数不同取值对应的当前令牌桶中的令牌数。 threadCountMapkey 为参数索引,值为参数不同取值对应的当前并行占用的线程总数。

ParameterMetricStorage 使用 ConcurrentHashMap 缓存每个资源对应的 ParameterMetric只会为配置了参数限流规则的资源创建 ParameterMetric。其部份源码如下所示

public final class ParameterMetricStorage { private static final Map<String, ParameterMetric> metricsMap = new ConcurrentHashMap<>(); private static final Object LOCK = new Object();

public static void initParamMetricsFor(ResourceWrapper resourceWrapper,ParamFlowRule rule) {
    if (resourceWrapper == null || resourceWrapper.getName() == null) {
        return;
    }
    String resourceName = resourceWrapper.getName();
    ParameterMetric metric;
    // 双重检测,线程安全,为资源创建全局唯一的 ParameterMetric
    if ((metric = metricsMap.get(resourceName)) == null) {
        synchronized (LOCK) {
            if ((metric = metricsMap.get(resourceName)) == null) {
                metric = new ParameterMetric();
                metricsMap.put(resourceWrapper.getName(), metric);
            }
        }
    }
    // 初始化 ParameterMetric
    metric.initialize(rule);
}

}

initParamMetricsFor 方法用于为资源创建 ParameterMetric 并初始化,该方法在资源被访问时由 ParamFlowSlot 调用,并且该方法只在为资源配置了参数限流规则的情况下被调用。

热点参数限流功能的实现

sentinel-parameter-flow-control 模块通过 Java SPI 注册自定义的 SlotChainBuilder即注册 HotParamSlotChainBuilder将 ParamFlowSlot 放置在 StatisticSlot 的后面,这个 ParamFlowSlot 就是实现热点参数限流功能的切入点。

public class ParamFlowSlot extends AbstractLinkedProcessorSlot {

@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
                  boolean prioritized, Object... args) throws Throwable {
    if (!ParamFlowRuleManager.hasRules(resourceWrapper.getName())) {
        fireEntry(context, resourceWrapper, node, count, prioritized, args);
        return;
    }
    checkFlow(resourceWrapper, count, args);
    fireEntry(context, resourceWrapper, node, count, prioritized, args);
}

@Override
public void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) {
    fireExit(context, resourceWrapper, count, args);
}

}

既然是参数限流,那么肯定是需要能够拿到参数了,而 ProcessorSlot#entry 方法的最后一个参数就是请求传递过来的参数,通过 SphU#entry 方法一层层往下传递。例如:

@GetMapping("/hello")
public String apiHello(String name) throws BlockException {
    ContextUtil.enter("my_context");
    Entry entry = null;
    try {
        entry = SphU.entry("GET:/hello", EntryType.IN,1,name);
        doBusiness();
        return "Hello!";
    } catch (Exception e) {
        if (!(e instanceof BlockException)) {
            Tracer.trace(e);
        }
        throw e;
    } finally {
        if (entry != null) {
            entry.exit(1);
        }
        ContextUtil.exit();
    }
}

当 SphU#entry 调用到 ParamFlowSlot#entry 方法时ParamFlowSlot 调用 checkFlow 方法判断是否需要限流。checkFlow 方法的实现如下:

void checkFlow(ResourceWrapper resourceWrapper, int count, Object... args) throws BlockException { //1 if (args == null) { return; } if (!ParamFlowRuleManager.hasRules(resourceWrapper.getName())) { return; } List rules = ParamFlowRuleManager.getRulesOfResource(resourceWrapper.getName()); //2 for (ParamFlowRule rule : rules) { applyRealParamIdx(rule, args.length);

        // Initialize the parameter metrics.
        ParameterMetricStorage.initParamMetricsFor(resourceWrapper, rule);

        if (!ParamFlowChecker.passCheck(resourceWrapper, rule, count, args)) {
            String triggeredParam = "";
            if (args.length > rule.getParamIdx()) {
                Object value = args[rule.getParamIdx()];
                triggeredParam = String.valueOf(value);
            }
            throw new ParamFlowException(resourceWrapper.getName(), triggeredParam, rule);
        }
    }
}

checkFlow 方法的最后一个参数是请求参数,也就是调用 SphU#entry 方法传递进来的参数。 checkFlow 方法首先调用 ParamFlowRuleManager 的 API 判断当前资源有没有配置参数限流规则,如果有,则获取为当前资源配置的所有参数限流规则。 遍历参数限流规则,调用 ParameterMetricStorage#initParamMetricsFor 方法判断是否需要为当前资源初始化创建 ParameterMetric然后调用 ParamFlowChecker#passCheck 方法判断当前请求是否可以放行,如果需要拒绝请求,则抛出 ParamFlowException 异常。

在阅读 ParamFlowChecker#passCheck 方法的源码之前,我们需要先了解参数限流规则的配置,了解每个配置项的作用。

参数限流规则 ParamFlowRule 的源码如下(有删减):

public class ParamFlowRule extends AbstractRule { private int grade = RuleConstant.FLOW_GRADE_QPS; private double count; private Integer paramIdx; private int controlBehavior = RuleConstant.CONTROL_BEHAVIOR_DEFAULT; private int maxQueueingTimeMs = 0; private long durationInSec = 1; private int burstCount = 0; }

grade限流规则的阈值类型支持的类型同 FlowRule。 count阈值同 FlowRule。 paramIdx参数索引ParamFlowChecker 根据限流规则的参数索引获取参数的值,下标从 0 开始,例如方法 public String apiHello(String name),该方法只有一个参数,索引为 0 对应 name 参数。 controlBehavior流量控制效果同 FlowRule但只支持快速失败和匀速排队。 maxQueueingTimeMs实现匀速排队流量控制效果的虚拟队列最大等待时间超过该值的请求被抛弃同 FlowRule durationInSec统计指标数据的窗口时间大小单位为秒。 burstCount支持的突发流量总数。

假设需要针对资源“GET:/hello”的 name 参数限流,当 name 取值为“jackson”时限流 QPS 阈值为 5则配置如下

ParamFlowRule rule = new ParamFlowRule(); // 资源为/hello rule.setResource("GET:/hello"); // 索引 0 对应的参数为 name rule.setParamIdx(0); // qps 限流 rule.setGrade(RuleConstant.FLOW_GRADE_QPS); // 阈值为 5 rule.setCount(5); ParamFlowRuleManager.loadRules(Collections.singletonList(rule));

以此为例,我们分析 ParamFlowChecker#passCheck 方法源码passCheck 返回 true 表示放行,返回 false 表示拒绝。

ParamFlowChecker#passCheck 方法源码如下:

public static boolean passCheck(ResourceWrapper resourceWrapper, ParamFlowRule rule, int count,
                         Object... args) {
    if (args == null) {
        return true;
    }
    // 判断参数索引是否合法
    int paramIdx = rule.getParamIdx();
    if (args.length <= paramIdx) {
        return true;
    }
    // 获取参数值,如果值为空则允许通过
    Object value = args[paramIdx];
    if (value == null) {
        return true;
    }
    // 集群限流
    if (rule.isClusterMode() && rule.getGrade() == RuleConstant.FLOW_GRADE_QPS) {
        return passClusterCheck(resourceWrapper, rule, count, value);
    }
    // 单机限流
    return passLocalCheck(resourceWrapper, rule, count, value);
}

如果参数为空、或者参数的总数小于等于规则配置的参数索引值、或者参数索引对应的参数的值为空,则放行请求; 如果是集群限流模式,则调用 passClusterCheck 方法,否则调用 passLocalCheck 方法。

我们先不讨论集群限流情况仅看单机本地限流情况。passLocalCheck 方法的源码如下:

private static boolean passLocalCheck(ResourceWrapper resourceWrapper, ParamFlowRule rule, int count, Object value) { try { // 基本数据类型 if (Collection.class.isAssignableFrom(value.getClass())) { for (Object param : ((Collection)value)) { if (!passSingleValueCheck(resourceWrapper, rule, count, param)) { return false; } } } // 数组类 else if (value.getClass().isArray()) { int length = Array.getLength(value); for (int i = 0; i < length; i++) { Object param = Array.get(value, i); if (!passSingleValueCheck(resourceWrapper, rule, count, param)) { return false; } } } // 引用类型 else { return passSingleValueCheck(resourceWrapper, rule, count, value); } } catch (Throwable e) { } return true; }

由于参数可能是基本数据类型,也可能是数组类型,或者引用类型,所以 passLocalCheck 方法分三种情况处理。我们只讨论其中一种情况,其它情况的实现类似。

以资源“GET:/hello”为例其方法 apiHello 的 name 参数为 String 类型,因此会调用 passSingleValueCheck 方法,该方法源码如下:

static boolean passSingleValueCheck(ResourceWrapper resourceWrapper, ParamFlowRule rule, int acquireCount,Object value) { //1 if (rule.getGrade() == RuleConstant.FLOW_GRADE_QPS) { if (rule.getControlBehavior() == RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER) { return passThrottleLocalCheck(resourceWrapper, rule, acquireCount, value); } else { return passDefaultLocalCheck(resourceWrapper, rule, acquireCount, value); } } // 2 else if (rule.getGrade() == RuleConstant.FLOW_GRADE_THREAD) { Set