Spring cloud gateway 升级带来的一些思考

624 阅读8分钟

背景

最近公司内对组件做一些升级优化,spring-cloud-gateway 也在其中,

旧版的版本

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
    <groupId>2.2.9.RELEASE</groupId>
</dependency>

新版的

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
    <groupId>3.1.8</groupId>
</dependency>

再升级的过程踩了些坑,这篇文章用于记录来时的路。

坑1:Feign 组件无法使用,程序假死卡在启动中

因为有组件需要鉴权,所以引入了鉴权的中心类

原先的引入方式是

    @Autowired
    private AuthClient authClient;

经过 debug 发现代码在进入 WeightCalculatorWebFilter.java的时候会触发RefreshRoutesEvent事件

img_v3_02hh_a9d685be-b371-4200-989a-68aaec2ad60g.jpg 新版的源码这里有个 blockLast

而旧版的是subscribe

image.png

这两者的区别是

subscribe

订阅(consume)一个 Flux 流以触发数据的处理。

  1. 非阻塞: 调用后不会阻塞当前线程,订阅操作会异步执行。
  2. 触发流: Flux 中的数据只有在 subscribe 调用后才会开始流动。
  3. 回调处理: 支持通过参数指定数据、错误和完成信号的处理逻辑。

blockLast

阻塞当前线程,直到 Flux 流中的最后一个元素被处理完。

  1. 阻塞: 当前线程会等待直到流结束或出现错误。
  2. 同步返回: 返回 Flux 的最后一个元素(如果存在),或者抛出异常。
  3. 触发流: 同样会触发 Flux 的流动,但是在阻塞模式下执行。

这里的区别就能发现两者本质的差别,一个是调用线程异步执行(默认主线程),一个是阻塞当前线程(主线程),交由boundedElastic 线程执行,那么这种组合有没有问题呢,本质上来说,没有问题,再来看看新版的代码

else if (event instanceof RefreshRoutesEvent && routeLocator != null) {
    // forces initialization
    if (routeLocatorInitialized.compareAndSet(false, true)) {
        // on first time, block so that app fails to start if there are errors in
        // routes
        // see gh-1574
        // 如果是首次注册则应该要强制等待路由刷新完毕,为了保证路由能够正常加载
        routeLocator.ifAvailable(locator -> locator.getRoutes().blockLast());
    }
    else {
        // this preserves previous behaviour on refresh, this could likely go away
        // 如果是后续的刷新注册则可以异步加载刷新
        routeLocator.ifAvailable(locator -> locator.getRoutes().subscribe());
    }
}

根据这个注释我找到了 github 的 issue 同时看到有个讨论问题WeightCalculatorWebFilter.onApplicationEvent method causes a deadlock 那不是正是现在遇到的问题吗

  1. FeignClientFactoryBean 在初始化对应的客户端的时候通过工厂创建出 FeignClient 对象,并会触发 refresh,发出 RefreshRoutesEvent 事件
  2. 主线程在实例化的过程中 DefaultSingletonBeanRegistry.getSingleton 方法中持有锁。
  3. 如果WeightCalculatorWebFilter首次收到RefreshRoutesEvent,它会调用blockLast(),而blockLast()实际上调用的是CountDownLatch.wait(),所以主线程会挂断并解除锁(LockSupport.Park)。
  4. 另一个线程(boundedElastic)尝试获取主线程 DefaultSingletonBeanRegistry.getSingleton 锁定的锁。
    调用链如下:RouteDefinitionRouteLocator.convertToRoute() -> CombinePredicates() -> Lookup() -> ConfigurationService.bind() -> DoBind() -> ObjectProvider.getIfAvailable
  5. 这个时候两个线程互相持有此锁导致的死锁以至于应用僵死在这

image.png

有啥解法呢

image.png

既然是加载顺序的问题,于是乎,我把所有 Feign 的引用方式改成了懒加载

    @Autowired
    @Lazy
    private AuthClient authClient;

程序能够正常启动,似乎问题得到了解决。。。

坑2:Feign 请求导致同步阻塞

接着上面说到的问题,这里把所有的 Feign改成了 Lazy Load 的方式加载,但是通过网关去请求其他接口的时候带来了新的问题

java.lang.IllegalStateException: block()/blockFirst()/blockLast() are blocking, which is not supported in thread reactor-http-nio-4
	at reactor.core.publisher.BlockingSingleSubscriber.blockingGet(BlockingSingleSubscriber.java:83)
	Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 

这里主要描述了在响应式编程中不使用 非响应式的请求逻辑 断点请求进去发现这段逻辑

image.png block 这几个字符见如其名,那就是这里堵了,在feign进行负载均衡选择路由实例,是个同步等待的过程

这里也看到几个有意思的 issue

总结下各位大佬的意见就是在响应式编程中不应该使用同步的请求或者方法来实现接口调用,Feign这种同步的请求客户端那指定是不能使用了,在一番调研之后选择了官方开发者都主推的 WebClient

坑3:Get请求中带+号(加号)的字符转发到下游会被当成空格处理

这里发现的情况是Get请求的过程中带了加密串,加密串中有+号和=号,传递到下游的时候只有等号会被正常传递,加号会被当成空格

正常请求:cipherText=kvZopjbXFA1q++YBlVPhxQ==
下游请求:cipherText=kvZopjbXFA1q  YBlVPhxQ==

webClient和webflux都有在请求到下游前对url先做decoded +号就会被转换成空格,再传递到下游的时候就会缺失对应的信息。正解是实现自己的url编码格式


@Slf4j
public class CustomUrlEncodeFilter implements WebFilter, Ordered {

    @Override
    public int getOrder() {
        // 定义过滤器的优先级,值越小优先级越高
        return Ordered.LOWEST_PRECEDENCE - 4; // 在AuthFilter之前执行
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        ServerHttpRequest originalRequest = exchange.getRequest();
        if (!needEncode(originalRequest)) {
            return chain.filter(exchange);
        }

        // 重写路径逻辑
        String newUri = rewriteUri(originalRequest);

        // 创建新的请求对象
        ServerHttpRequest modifiedRequest = originalRequest.mutate()
            .uri(URI.create(newUri))// 替换为新的 URI
            .build();

        // 将修改后的请求传递到后续过滤器
        return chain.filter(exchange.mutate().request(modifiedRequest).build());
    }

    /**
     * 重写uri
     *
     * @param request
     * @return
     */
    private String rewriteUri(ServerHttpRequest request) {

        // 获取当前请求 URI
        String originalUri = request.getURI().toString();
        Map<String, String> map = extractQueryParams(originalUri);
        log.info("Original URI:" + originalUri);
        log.info("Original param:" + map);
        Map<String, String> encodeQueryParams = encodeQueryParams(map);
        log.info("Encode param:" + encodeQueryParams);
        String newUrl = request.getURI().getScheme() + "://"
            + request.getURI().getAuthority() + request.getURI().getPath() + "?" + mapToQueryString(encodeQueryParams);
        log.info("NewUrl:" + newUrl);
        return newUrl;
    }

    public static String mapToQueryString(Map<String, String> params) {
        if (params == null || params.isEmpty()) {
            return "";
        }

        return params.entrySet().stream()
            .map(entry -> {
                String key = entry.getKey();
                String value = entry.getValue() != null ? entry.getValue() : "";
                return key + "=" + value;
            })
            .collect(Collectors.joining("&"));
    }

    public static Map<String, String> extractQueryParams(String url) {
        Map<String, String> queryParams = new HashMap<>();

        if (url == null || !url.contains("?")) {
            return queryParams;
        }

        try {
            String queryString = url.substring(url.indexOf('?') + 1);
            String[] pairs = queryString.split("&");

            for (String pair : pairs) {
                String[] keyValue = pair.split("=", 2);
                String key = decodeIfNeededPreservePlus(keyValue[0]);
                String value = keyValue.length > 1 ? decodeIfNeededPreservePlus(keyValue[1]) : "";
                queryParams.put(key, value);
            }
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException("Failed to decode query parameters", e);
        }

        return queryParams;
    }

    /**
     * 加密查询参数
     *
     * @param map
     * @return
     */
    public static Map<String, String> encodeQueryParams(Map<String, String> map) {
        Map<String, String> newMap = new HashMap<>();
        map.forEach((k, v) -> {
            newMap.put(k, URLEncodeUtil.encodeAll(v));
        });
        return newMap;

    }

    /**
     * 解密如果是需要解密的,支持带加号
     *
     * @param value
     * @return
     * @throws UnsupportedEncodingException
     */
    private static String decodeIfNeededPreservePlus(String value) throws UnsupportedEncodingException {
        if (value.contains("%")) {
            value = value.replaceAll("\+", "%2B");
            value = URLDecoder.decode(value, StandardCharsets.UTF_8);
            return value.replaceAll("%2B", "+");
        }
        return value;
    }


    /**
     * 判断是否需要加密
     *
     * @param request
     * @return
     */
    private boolean needEncode(ServerHttpRequest request) {
        // get请求 且包含关键字
        MultiValueMap<String, String> queryParams = request.getQueryParams();
        HttpHeaders headers = request.getHeaders();
        return Objects.equals(request.getMethod(), HttpMethod.GET)
            && !headers.containsKey("skipEncode")
            && (queryParams.containsKey("cipherText") || queryParams.containsKey("sign"));
    }

}


以webflux转发为例子,这段代码是核心代码,只需要在请求之前将url的参数encoding再发出去就好了

image.png

坑4:QPS上不去

填好上面的坑,又发现了一个新坑,上线后测试发现QPS压不上去。之前一度怀疑是网关配置的问题,怀疑是连接数不够的问题导致的,后来调大对应的连接数和工作线程发现都提升不大,直到对比了另一个网关 发现此网关拥有好多路由断言的配置信息

image.png

后来本地调试的过程中把断言去掉后发现果然并发上去了,但是这些路由是之前就有的,能否直接去掉呢,去掉是否会带来一系列的兼容性问题呢,带着这些疑问,又重新研究了下gateway的源码。发现路由相关的代码由org.springframework.cloud.gateway.handler.RoutePredicateHandlerMapping#lookupRoute

image.png

这里的this.routeLocator.getRoutes() 获取到的是所有的路由列表,每次都会对其做路由的过滤、断言的校验,当路由数量达到一定规模之后这里也会成为现在性能的瓶颈所在。 同时这里也发现一个博主也有相同的经历QPS无法突破,但他是直接操刀修改gateway源码,对于我们不太适用,

我结合公司的业务场景,定制了一套获取路由的规则的方法,我们大多数的路由地址其实是知道要路由到哪个服务的,且有对应的服务发现地址 例如:

那么可以解析出url的第一个名称做为服务名当成Map-Key,对应的路由断言作为Map-Value,构造出一个路由缓存,就能实现O(1)的时间复杂度获取到对应的路由信息

首先定义出一个缓存路由的缓存器,并在路由变更的时候能够触发更新缓存信息

@Slf4j
public class CustomerRouteCache {

    private final RouteLocator routeLocator;
    private final Map<String, Route> routeMap = new ConcurrentHashMap<>();

    public CustomerRouteCache(RouteLocator routeLocator) {
        this.routeLocator = routeLocator;
        refreshRoutes();
        log.info("输出 routeMap {}", routeMap);
    }

    @EventListener
    public void handleRouteUpdateEvent(RefreshRoutesEvent event) {
        refreshRoutes(); // 路由更新时刷新缓存
    }

    private void refreshRoutes() {
        this.routeLocator.getRoutes()
                .collectMap(Route::getId, route -> route)
                .doOnNext(routeMap::putAll)
                .subscribe();
    }

    public Route getRouteById(String routeId) {
        return routeMap.get(routeId);
    }
}

然后继承RoutePredicateHandlerMapping并重写部分逻辑,核心逻辑是getRoteByCache

@Slf4j
public class CustomerRoutePredicateHandlerMapping extends RoutePredicateHandlerMapping {

    private final FilteringWebHandler webHandler;

    private final RouteLocator routeLocator;

    private final Integer managementPort;

    private final ManagementPortType managementPortType;
    private final CustomerRouteCache routeCache;
    public CustomerRoutePredicateHandlerMapping(CustomerRouteCache routeCache,FilteringWebHandler webHandler, RouteLocator routeLocator, GlobalCorsProperties globalCorsProperties, Environment environment) {
        super(webHandler, routeLocator, globalCorsProperties, environment);
        this.routeCache = routeCache;
        this.webHandler = webHandler;
        this.routeLocator = routeLocator;

        this.managementPort = getPortProperty(environment, "management.server.");
        this.managementPortType = getManagementPortType(environment);
        log.info("初始化自定义的路由断言处理器");
    }

    @Override
    protected Mono<?> getHandlerInternal(ServerWebExchange exchange) {
//        // don't handle requests on management port if set and different than server port
//        if (this.managementPortType == DIFFERENT && this.managementPort != null
//                && exchange.getRequest().getLocalAddress() != null
//                && exchange.getRequest().getLocalAddress().getPort() == this.managementPort) {
//            return Mono.empty();
//        }
        exchange.getAttributes().put(GATEWAY_HANDLER_MAPPER_ATTR, getSimpleName());

        return lookupRoute(exchange)
                .flatMap((Function<Route, Mono<?>>) r -> {
                    exchange.getAttributes().remove(GATEWAY_PREDICATE_ROUTE_ATTR);
                    logger.debug("Mapping [" + getExchangeDesc(exchange) + "] to " + r);
                    exchange.getAttributes().put(GATEWAY_ROUTE_ATTR, r);
                    return Mono.just(webHandler);
                }).switchIfEmpty(Mono.empty().then(Mono.fromRunnable(() -> {
                    exchange.getAttributes().remove(GATEWAY_PREDICATE_ROUTE_ATTR);
                    if (logger.isTraceEnabled()) {
                        logger.trace("No RouteDefinition found for [" + getExchangeDesc(exchange) + "]");
                    }
                })));
    }

    @Override
    protected Mono<Route> lookupRoute(ServerWebExchange exchange) {
        return getRoteByCache(exchange);
    }

    private Mono<Route> getRoteByCache(ServerWebExchange exchange) {
        // 获取到对应的服务名
        String serviceName = extractServiceName(exchange);
        // 根据服务发现拼接获取对应的route
        Route route = routeCache.getRouteById("ReactiveCompositeDiscoveryClient_" + serviceName);
        if (route != null) {
            log.debug("输出 route: " + route);
            // 校验 Predicate,通过就用服务发现的route,反之使用父类的路由选择规则方式
            return Mono.just(route)
                    .filterWhen(r -> r.getPredicate().apply(exchange))
                    .switchIfEmpty(Mono.defer(() -> {
                        log.warn("Predicate did not pass for route: " + route.getId());
                        return super.lookupRoute(exchange);
                    }));
        }
        return super.lookupRoute(exchange);
    }

    public String extractServiceName(ServerWebExchange exchange) {
        String path = exchange.getRequest().getURI().getPath();
        int firstSlash = path.indexOf('/');
        int secondSlash = path.indexOf('/', firstSlash + 1);
        if (secondSlash > firstSlash) {
            return path.substring(firstSlash + 1, secondSlash);
        }
        return path.substring(firstSlash + 1); // 没有第二个斜杠时返回剩余部分
    }


    private String getExchangeDesc(ServerWebExchange exchange) {
        StringBuilder out = new StringBuilder();
        out.append("Exchange: ");
        out.append(exchange.getRequest().getMethod());
        out.append(" ");
        out.append(exchange.getRequest().getURI());
        return out.toString();
    }

    private static Integer getPortProperty(Environment environment, String prefix) {
        return environment.getProperty(prefix + "port", Integer.class);
    }

    private ManagementPortType getManagementPortType(Environment environment) {
        Integer serverPort = getPortProperty(environment, "server.");
        if (this.managementPort != null && this.managementPort < 0) {
            return DISABLED;
        }
        return ((this.managementPort == null || (serverPort == null && this.managementPort.equals(8080))
                || (this.managementPort != 0 && this.managementPort.equals(serverPort))) ? SAME : DIFFERENT);
    }
}

修改后重新测试后,相同接口QPS从1KQps提升至5K+Qps,提升了至少有5倍

总结

每次升级和变动组件都是一次新的挑战,在不停迭代的过程中也是对自身经历的一次磨练,每次解决问题对源码的理解也有了不一样的感觉,会思考到作者写这段代码的一些用意。