使用网关代理服务请求是微服务架构下常见的做法,相比于对被网关代理的各个服务做限流服务,在网关进行统一的限流操作似乎是更好的做法,sentinel对java应用常用的两款网关框架spring-cloud-gateway和zuul1都做了适配。本文将以zuul为例,说明zuul网关接入senrtinel流控能力的步骤并对源码做简单解读
sentinel中对应网关限流规则的实体为GatewayFlowRule,他有以下字段
resource:资源名称,可以是网关中的 route 名称或者用户自定义的 API 分组名称。
resourceMode:规则是针对 API Gateway 的 route(RESOURCE_MODE_ROUTE_ID)还是用户在 Sentinel 中定义的 API 分组(RESOURCE_MODE_CUSTOM_API_NAME),默认是 route。
grade:限流指标维度,同限流规则的 grade 字段。
count:限流阈值
intervalSec:统计时间窗口,单位是秒,默认是 1 秒。
controlBehavior:流量整形的控制效果,同限流规则的 controlBehavior 字段,目前支持快速失败和匀速排队两种模式,默认是快速失败。
burst:应对突发请求时额外允许的请求数目。
maxQueueingTimeoutMs:匀速排队模式下的最长排队时间,单位是毫秒,仅在匀速排队模式下生效。
paramItem:参数限流配置。若不提供,则代表不针对参数进行限流,该网关规则将会被转换成普通流控规则;否则会转换成热点规则。其中的字段:
parseStrategy:从请求中提取参数的策略,目前支持提取来源 IP(PARAM_PARSE_STRATEGY_CLIENT_IP)、Host(PARAM_PARSE_STRATEGY_HOST)、任意 Header(PARAM_PARSE_STRATEGY_HEADER)和任意 URL 参数(PARAM_PARSE_STRATEGY_URL_PARAM)四种模式。
fieldName:若提取策略选择 Header 模式或 URL 参数模式,则需要指定对应的 header 名称或 URL 参数名称。
pattern:参数值的匹配模式,只有匹配该模式的请求属性值会纳入统计和流控;若为空则统计该请求属性的所有值。(1.6.2 版本开始支持)
matchStrategy:参数值的匹配策略,目前支持精确匹配(PARAM_MATCH_STRATEGY_EXACT)、子串匹配(PARAM_MATCH_STRATEGY_CONTAINS)和正则匹配(PARAM_MATCH_STRATEGY_REGEX)。(1.6.2 版本开始支持)
以zuul为例,sentinel的网关限流能力可以针对两种维度,网关本身配置的路由和我们自定义的路由分组。
网关本身配置的路由维度
下面是zuul的一段路由配置
#配置hello-service与feign-consumer服务的路由
zuul:
routes:
provider:
serviceId: HELLO-SERVICE
consumer:
serviceId: HELLO-CONSUMER
我们将路由provider和consumer分别分配给了注册在eureka上的HELLO-SERVICE和HELLO-CONSUMER服务,这样就可以通过网关的地址加上路由地址去访问不同微服务上暴露出来的接口,sentinel天然支持根据这种路由规则的限流。通过这种方式配置同一微服务所有的服务都将被同一条规则保护(即所有的服务被视为同一个资源),即所有服务的总qps是共享的,保证同一时间对服务的总请求量是稳定的,至于每个服务承担了多少流量其实并不关心,如果要针对不同的服务设置不同的流控规则,则需要使用下面这种维度
自定义分组的维度
自定义分组在sentinel中对应的实体为ApiDefinition,先看看官方对它的介绍
用户自定义的 API 定义分组,可以看做是一些 URL 匹配的组合。比如我们可以定义一个 API 叫 my_api,请求 path 模式为 /foo/** 和 /baz/** 的都归到 my_api 这个 API 分组下面。限流的时候可以针对这个自定义的 API 分组维度进行限流。
自定义分组支持通配符,我们可以通过这个维度对同一微服务下的不同服务做更细粒度的划分,例如一个题库微服务下可能有考试和练习两个服务模块,我们可以对这两个模块根据实际应用场景配置不同的限流规则(需要业务上保证同一模块的服务拥有相同的前缀)。这样两个模块下的服务将被视为两个不同的被保护的资源。
ApiDefinition有以下字段
apiName; 分组名
predicateItems; 分组规则集合
其中分组规则由url匹配内容和匹配模式两部分组成
pattern; 匹配内容
matchStrategy; 支持完全匹配,前缀匹配和正则匹配三种
和sentinel的其它限流规则相同,gatewayFlowRule由一个对应的GatewayRuleManager加载,而apiDefination由GatewayApiDefinitionManager负责加载。
依赖
<dependency>
<groupId>com.alibaba.cspgroupId>
<artifactId>sentinel-zuul-adapterartifactId>
<version>x.y.zversion>
dependency>
注入zuu过滤器
@Configuration
public class ZuulConfig {
@Bean
public ZuulFilter sentinelZuulPreFilter() {
// We can also provider the filter order in the constructor.
return new SentinelZuulPreFilter();
}
@Bean
public ZuulFilter sentinelZuulPostFilter() {
return new SentinelZuulPostFilter();
}
@Bean
public ZuulFilter sentinelZuulErrorFilter() {
return new SentinelZuulErrorFilter();
}
}
按zuul路由进行限流示例
// 测试按zuul路由限流
Set<GatewayFlowRule> gatewayFlowRuleSet = new HashSet<>();
GatewayFlowRule gatewayFlowRule1 = new GatewayFlowRule();
gatewayFlowRule1.setResource("provider");
gatewayFlowRule1.setCount(2);
gatewayFlowRuleSet.add(gatewayFlowRule1);
GatewayRuleManager.loadRules(gatewayFlowRuleSet);
对provider路由设置限流规则,这样同一时间打到路由对应的服务HELLO-SERVICE上流量将不会超过配置的阀值(2)。
按配置分组进行限流
// 配置分组
Set<ApiDefinition> apiDefinitionSet = new HashSet<>();
ApiDefinition apiDefinition = new ApiDefinition();
apiDefinition.setApiName("sentinel-zuul-test");
Set<ApiPredicateItem> predicateItemSet = new HashSet<>();
ApiPathPredicateItem predicateItem = new ApiPathPredicateItem();
predicateItem.setMatchStrategy(1);
predicateItem.setPattern("/consumer/**");
predicateItemSet.add(predicateItem);
apiDefinition.setPredicateItems(predicateItemSet);
apiDefinitionSet.add(apiDefinition);
GatewayApiDefinitionManager.loadApiDefinitions(apiDefinitionSet);
Set<GatewayFlowRule> gatewayFlowRuleSet = new HashSet<>();
// 测试按配置分组限流
GatewayFlowRule gatewayFlowRule = new GatewayFlowRule();
gatewayFlowRule.setResource("sentinel-zuul-test");
gatewayFlowRule.setCount(2);
gatewayFlowRuleSet.add(gatewayFlowRule);
首先配置分组,我们将所有consumer前缀的请求都配置为了一个叫sentinel-zuul-test的分组(根据之前zuul的配置规则我们知道这其实对应的是所有指向HELLO-CONSUMER的请求)。接下来为这个分组配置了GatewayFlowRule,同样是同一时间的qps不能超过2。
参数限流配置
我们设置的网关流控规则实际上在sentinel内部会被转换为参数流控规则(ParamFlowRule),然后使用和ParamFlowRule相同的流控逻辑,这点我们在后面源码进行详细分析,先看看怎么使用网关流控的参数限流能力
paramItem:参数限流配置。若不提供,则代表不针对参数进行限流,该网关规则将会被转换成普通流控规则;否则会转换成热点规则。其中的字段:
parseStrategy:从请求中提取参数的策略,目前支持提取来源 IP(PARAM_PARSE_STRATEGY_CLIENT_IP)、Host(PARAM_PARSE_STRATEGY_HOST)、任意 Header(PARAM_PARSE_STRATEGY_HEADER)和任意 URL 参数(PARAM_PARSE_STRATEGY_URL_PARAM)四种模式。
fieldName:若提取策略选择 Header 模式或 URL 参数模式,则需要指定对应的 header 名称或 URL 参数名称。
pattern:参数值的匹配模式,只有匹配该模式的请求属性值会纳入统计和流控;若为空则统计该请求属性的所有值。(1.6.2 版本开始支持)
matchStrategy:参数值的匹配策略,目前支持精确匹配(PARAM_MATCH_STRATEGY_EXACT)、子串匹配(PARAM_MATCH_STRATEGY_CONTAINS)和正则匹配(PARAM_MATCH_STRATEGY_REGEX)。(1.6.2 版本开始支持)
GatewayFlowRule gatewayFlowRule1 = new GatewayFlowRule();
gatewayFlowRule1.setResource("provider");
gatewayFlowRule1.setCount(2);
gatewayFlowRuleSet.add(gatewayFlowRule1);
GatewayParamFlowItem paramFlowItem = new GatewayParamFlowItem();
paramFlowItem.setParseStrategy(SentinelGatewayConstants.PARAM_PARSE_STRATEGY_URL_PARAM);
paramFlowItem.setFieldName("name");
gatewayFlowRule1.setParamItem(paramFlowItem);
这样限流规则就从对所有HELLO-SERVICE的服务进行限流变成了只对url中含有name参数的服务进行限流,通过这种方式可以进一步细化流控规则,适应更多的应用场景。
配置降级策略
sentinel集成zuul发生限流降级的默认降级策略是返回一个http状态码为429的json字符串
{
"code":429,
"message":Sentinel block exception
"route":xxx
}
而通常我们的应用都有自定义的返回结构和错误码,默认的返回显然不能适配,所以我们需要自定义降级逻辑,sentinel在这方面自然定义了扩展点:
ZuulBlockFallbackManager.registerProvider(new ZuulBlockFallbackProvider() {
private volatile int count = 0;
@Override
public String getRoute() {
return "sentinel-zuul-test";
}
@Override
public BlockResponse fallbackResponse(String route, Throwable cause) {
count++;
log.info("服务器抗不住了,{}", count);
return new BlockResponse(-1, "服务器抗不住了", getRoute());
}
});
getRoute方法需要对应被保护的资源,如果希望对所有的资源都是有同样的降级逻辑,在默认的DefaultBlockFallbackProvider已经告诉了我们该怎么做:
@Override
public String getRoute() {
return "*";
}
fallbackReponse里可以自定义错误码和错误提示,并且做一些日志记录(sentinel对于网关流控默认是没做日志记录的)。
定义完自定义的降级策略并不够,我们还需要对官方提供的ZuulSentinelPreFilter做一下改造:
catch (BlockException ex) {
ZuulBlockFallbackProvider zuulBlockFallbackProvider = ZuulBlockFallbackManager.getFallbackProvider(
fallBackRoute);
BlockResponse blockResponse = zuulBlockFallbackProvider.fallbackResponse(fallBackRoute, ex);
// Prevent routing from running
ctx.setRouteHost(null);
ctx.set(ZuulConstant.SERVICE_ID_KEY, null);
// Set fallback response.
ctx.setResponseBody(blockResponse.toString());
ctx.setResponseStatusCode(HttpStatus.SC_OK);
// Set Response ContentType
ctx.getResponse().setContentType("application/json; charset=utf-8");
我们的改动在倒数第二句,源filter的逻辑是将blockReponse的错误码同时设置到响应的response里
ctx.setResponseStatusCode(blockResponse.getCode());
这在错误码为标准错误码时没有问题,但当业务定义的错误码是非标准的比如-1时,请求将会无法正常响应。同时这里的response body使用了blockResponse序列化的结构,也可以修改成自己项目中通用的结构。
首先看官方文档对于zuul的集成文档
若使用的是 Spring Cloud Netflix Zuul,我们可以直接在配置类中将三个 filter 注入到 Spring 环境中即可:
对于zuul,框架的扩展点就在于各种自定义的filter,而sentinel正是通过自定义的三个filter与zuul进行的集成,分别是SentinelZuulPreFilter,sentinelZuulPostFilter和sentinelZuulErrorFilter。
这里着重分析SentinelZuulPreFilter的源码
com.alibaba.csp.sentinel.adapter.gateway.zuul.filters.SentinelZuulPreFilter
public class SentinelZuulPreFilter extends ZuulFilter {
private final int order;
private final GatewayParamParser<RequestContext> paramParser = new GatewayParamParser<>(
new RequestContextItemParser());
只持有了两个对象,order是决定zuulFilter过滤顺序的,而paramParser听名字像是从请求中提取参数用于参数限流的,继续阅读源码,开始分析过滤器的核心方法run
com.alibaba.csp.sentinel.adapter.gateway.zuul.filters.SentinelZuulPreFilter#run
@Override
public Object run() throws ZuulException {
RequestContext ctx = RequestContext.getCurrentContext();
String origin = parseOrigin(ctx.getRequest());
// 首先获取到当前请求的context,然后从从context中获取到当前的路由id(ZuulConstant.PROXY_ID_KEY="proxy")
String routeId = (String)ctx.get(ZuulConstant.PROXY_ID_KEY);
Deque<AsyncEntry> asyncEntries = new ArrayDeque<>();
// 并将这个id设置给了fallBackRoute。
String fallBackRoute = routeId;
try {
if (StringUtil.isNotBlank(routeId)) {
// 如果routeId不为空则创建对应的sentinel context,对应第一种策略,按路由限流
ContextUtil.enter(GATEWAY_CONTEXT_ROUTE_PREFIX + routeId, origin);
// 以第一种策略进入资源保护区
doSentinelEntry(routeId, RESOURCE_MODE_ROUTE_ID, ctx, asyncEntries);
}
// 获取到当前请求符合的分组
Set<String> matchingApis = pickMatchingApiDefinitions(ctx);
if (!matchingApis.isEmpty() && ContextUtil.getContext() == null) {
// 当前sentinel context不为空时创建sentinel context,对应第二种策略,按分组限流
// 两种策略同时只有一种起效且路由限流策略优先级更高
ContextUtil.enter(ZuulConstant.ZUUL_DEFAULT_CONTEXT, origin);
}
// 根据可能匹配到的多个分组以第二种策略进入不同的保护区
for (String apiName : matchingApis) {
fallBackRoute = apiName;
doSentinelEntry(apiName, RESOURCE_MODE_CUSTOM_API_NAME, ctx, asyncEntries);
}
} catch (BlockException ex) {
// 捕获到BlockException 做降级 写response
ZuulBlockFallbackProvider zuulBlockFallbackProvider = ZuulBlockFallbackManager.getFallbackProvider(
fallBackRoute);
BlockResponse blockResponse = zuulBlockFallbackProvider.fallbackResponse(fallBackRoute, ex);
// Prevent routing from running
ctx.setRouteHost(null);
ctx.set(ZuulConstant.SERVICE_ID_KEY, null);
// Set fallback response.
ctx.setResponseBody(blockResponse.toString());
ctx.setResponseStatusCode(blockResponse.getCode());
// Set Response ContentType
ctx.getResponse().setContentType("application/json; charset=utf-8");
} finally {
// We don't exit the entry here. We need to exit the entries in post filter to record Rt correctly.
// So here the entries will be carried in the request context.
// pre filter结束但是并没有退出保护区,而是将被保护的资源通过RequestContext传递给后面的post filter
if (!asyncEntries.isEmpty()) {
ctx.put(ZuulConstant.ZUUL_CTX_SENTINEL_ENTRIES_KEY, asyncEntries);
}
}
return null;
}
进入资源保护区:com.alibaba.csp.sentinel.adapter.gateway.zuul.filters.SentinelZuulPreFilter#doSentinelEntry
private void doSentinelEntry(String resourceName, final int resType, RequestContext requestContext,
Deque<AsyncEntry> asyncEntries) throws BlockException {
Object[] params = paramParser.parseParameterFor(resourceName, requestContext,
new Predicate<GatewayFlowRule>() {
@Override
public boolean test(GatewayFlowRule r) {
return r.getResourceMode() == resType;
}
});
AsyncEntry entry = SphU.asyncEntry(resourceName, ResourceTypeConstants.COMMON_API_GATEWAY,
EntryType.IN, params);
通过paramParser提取限流参数(注意没有设置参数限流策略的会返回一个特殊的参数数组,数组只有一个值为$D),然后调用SphU.asyncEntry进入资源保护区,之后资源将会在slotChain中的各个slot间传递。lamda表达式里会判断使用的是哪一种策略,不同策略间只有提取参数的逻辑会有所不同。
slotChain和chain中其它slot的逻辑在系列博客中已经分析过了,我们这里主要针对分析GatewayFlowSlot
com.alibaba.csp.sentinel.adapter.gateway.common.slot.GatewayFlowSlot
@Override
public void entry(Context context, ResourceWrapper resource, DefaultNode node, int count,
boolean prioritized, Object... args) throws Throwable {
checkGatewayParamFlow(resource, count, args);
fireEntry(context, resource, node, count, prioritized, args);
}
private void checkGatewayParamFlow(ResourceWrapper resourceWrapper, int count, Object... args)
throws BlockException {
if (args == null) {
return;
}
// 从GatewayRuleManager中获取到转换成ParamFlowRule的限流规则
List<ParamFlowRule> rules = GatewayRuleManager.getConvertedParamRules(resourceWrapper.getName());
if (rules == null || rules.isEmpty()) {
return;
}
for (ParamFlowRule rule : rules) {
// Initialize the parameter metrics.
// 为每条规则初始化parameter metric
ParameterMetricStorage.initParamMetricsFor(resourceWrapper, rule);
// 校验参数限流规则,不通过则抛出BlockException异常
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);
}
}
}
@Override
public void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) {
fireExit(context, resourceWrapper, count, args);
}
}
参数限流的逻辑ParamFlowChecker.passCheck将会在之后对于参数限流源码的分析中介绍,这里只需要知道我们配置的网关限流规则最终都会转换成参数限流规则,与参数限流规则进行同样的处理即可。