Gateway 网关坑我! 被这个404 问题折腾了一年?

简介: 小富分享了一个困扰团队一年多的 SpringCloud Gateway 路由 404 问题。通过日志追踪和源码分析,发现是网关在 Nacos 配置更新后未能正确清理旧的路由权重缓存,导致负载均衡时仍使用已删除的路由数据。最终通过监听路由刷新事件并手动更新缓存,成功解决了问题。

大家好,我是小富~

最近同事找我帮忙排查一个"诡异"的 Bug,说困扰了他们一年多一直没解决。我接手后花了一些时间定位到了问题根源,今天就来跟大家分享一下这个问题的排查过程和解决方案。

问题描述

同事使用的是 SpringCloud Gateway 3.0.1 + JDK8,整合了 Nacos 做动态路由配置。问题是:每次修改 Nacos 的路由配置后,网关的 API 请求就会出现 404 错误,但重启网关后又能恢复正常。

听到这个问题,我的第一反应是:Nacos 配置更新后,网关的缓存数据可能没有及时更新。带着这个猜想,我开始深入排查。

环境准备

首先准备了 3 个后端服务实例,端口分别为 81031204012041,在 Nacos 中配置了对应的网关路由:xiaofu-8103xiaofu-12040xiaofu-12041,并将它们放在同一个权重组 xiaofu-group 中,实现基于权重的负载均衡。

- id: xiaofu-8103
  uri: http://127.0.0.1:8103/
  predicates:
    - Weight=xiaofu-group, 2
    - Path=/test/version1/**
  filters:
    - RewritePath=/test/version1/(?<segment>.*),/$\{
   segment}
- id: xiaofu-12040
  uri: http://127.0.0.1:12040/
  predicates:
    - Weight=xiaofu-group, 1
    - Path=/test/version1/**
  filters:
    - RewritePath=/test/version1/(?<segment>.*),/$\{
   segment}
- id: xiaofu-12041
  uri: http://127.0.0.1:12041/
  predicates:
    - Weight=xiaofu-group, 2
    - Path=/test/version1/**
  filters:
    - RewritePath=/test/version1/(?<segment>.*),/$\{
   segment}

使用 JMeter 进行持续请求测试,为了便于日志追踪,给每个请求参数都添加了随机数。

20250909180759

准备完成后启动 JMeter 循环请求,观察到三个实例都有日志输出,说明网关的负载均衡功能正常。

20250909180814

问题排查

为了获取更详细的日志信息,我将网关的日志级别调整为 TRACE

启动 JMeter 后,随机修改三个实例的路由属性(uri、port、predicates、filters),请求没有出现报错,网关控制台也显示了更新后的路由属性,说明 Nacos 配置变更已成功同步到网关。

20250909180836

接下来尝试去掉一个实例 xiaofu-12041,这时发现 JMeter 请求开始出现 404 错误,成功复现问题!

20250909180846

查看网关控制台日志时,惊奇地发现已删除的实例 xiaofu-12041 的路由配置仍然存在,甚至还被选中(chosen)处理请求。问题根源找到了:虽然 Nacos 中删除了实例路由配置,但网关在实际负载均衡时仍然使用旧的路由数据。

20250909180857

继续深入排查,发现在路由的权重信息(Weights attr)中也存在旧的路由数据。至此基本确定问题:在计算实例权重和负载均衡时,网关使用了陈旧的缓存数据。

20250909180907

源码分析

通过分析源码,发现了一个专门计算权重的过滤器 WeightCalculatorWebFilter。它内部维护了一个 groupWeights 变量来存储路由权重信息。当配置变更事件发生时,会执行 addWeightConfig(WeightConfig weightConfig) 方法来更新权重配置。

@Override
public void onApplicationEvent(ApplicationEvent event) {
   
    if (event instanceof PredicateArgsEvent) {
   
        handle((PredicateArgsEvent) event);
    }
    else if (event instanceof WeightDefinedEvent) {
   
        addWeightConfig(((WeightDefinedEvent) event).getWeightConfig());
    }
    else if (event instanceof RefreshRoutesEvent && routeLocator != null) {
   
        if (routeLocatorInitialized.compareAndSet(false, true)) {
   
            routeLocator.ifAvailable(locator -> locator.getRoutes().blockLast());
        }
        else {
   
            routeLocator.ifAvailable(locator -> locator.getRoutes().subscribe());
        }
    }

}

addWeightConfig 方法的注释明确说明:该方法仅创建新的 GroupWeightConfig,而不进行修改。这意味着它只能新建或覆盖路由权重,无法清理已删除的路由权重信息。

void addWeightConfig(WeightConfig weightConfig) {
   
        String group = weightConfig.getGroup();
        GroupWeightConfig config;
        // only create new GroupWeightConfig rather than modify
        // and put at end of calculations. This avoids concurency problems
        // later during filter execution.
        if (groupWeights.containsKey(group)) {
   
            config = new GroupWeightConfig(groupWeights.get(group));
        }
        else {
   
            config = new GroupWeightConfig(group);
        }


        final AtomicInteger index = new AtomicInteger(0);
  ....省略.....

        if (log.isTraceEnabled()) {
   
            log.trace("Recalculated group weight config " + config);
        }
        // only update after all calculations
        groupWeights.put(group, config);
    }

解决方案

找到问题根源后,解决方案就清晰了

开始我怀疑可能是springcloud gateway 版本问题,将版本升级到了4.1.0,但结果还是存在这个问题。

20250909180923

看来只能手动更新缓存,需要监听 Nacos 路由配置变更事件,获取最新路由配置,并更新 groupWeights 中的权重数据。

以下是实现的解决方案代码:

@Slf4j
@Configuration
public class WeightCacheRefresher {
   

    @Autowired
    private WeightCalculatorWebFilter weightCalculatorWebFilter;

    @Autowired
    private RouteDefinitionLocator routeDefinitionLocator;

    @Autowired
    private ApplicationEventPublisher publisher;

    /**
     * 监听路由刷新事件,同步更新权重缓存
     */
    @EventListener(RefreshRoutesEvent.class)
    public void onRefreshRoutes() {
   
        log.info("检测到路由刷新事件,准备同步更新权重缓存");
        syncWeightCache();
    }

    /**
     * 同步权重缓存与当前路由配置
     */
    public void syncWeightCache() {
   
        try {
   
            // 获取 groupWeights 字段
            Field groupWeightsField = WeightCalculatorWebFilter.class.getDeclaredField("groupWeights");
            groupWeightsField.setAccessible(true);

            // 获取当前的 groupWeights 值
            @SuppressWarnings("unchecked")
            Map<String, Object> groupWeights = (Map<String, Object>) groupWeightsField.get(weightCalculatorWebFilter);

            if (groupWeights == null) {
   
                log.warn("未找到 groupWeights 缓存");
                return;
            }

            log.info("当前 groupWeights 缓存: {}", groupWeights.keySet());

            // 获取当前所有路由的权重组和路由ID
            final Set<String> currentRouteIds = new HashSet<>();
            final Map<String, Map<String, Integer>> currentGroupRouteWeights = new HashMap<>();

            routeDefinitionLocator.getRouteDefinitions()
                    .collectList()
                    .subscribe(definitions -> {
   
                        definitions.forEach(def -> {
   
                            currentRouteIds.add(def.getId());

                            def.getPredicates().stream()
                                    .filter(predicate -> predicate.getName().equals("Weight"))
                                    .forEach(predicate -> {
   
                                        Map<String, String> args = predicate.getArgs();
                                        String group = args.getOrDefault("_genkey_0", "unknown");
                                        int weight = Integer.parseInt(args.getOrDefault("_genkey_1", "0"));

                                        // 记录每个组中当前存在的路由及其权重
                                        currentGroupRouteWeights.computeIfAbsent(group, k -> new HashMap<>())
                                                .put(def.getId(), weight);
                                    });
                        });

                        log.info("当前路由配置中的路由ID: {}", currentRouteIds);
                        log.info("当前路由配置中的权重组: {}", currentGroupRouteWeights);

                        // 检查每个权重组,移除不存在的路由,更新权重变化的路由
                        Set<String> groupsToRemove = new HashSet<>();
                        Set<String> groupsToUpdate = new HashSet<>();

                        for (String group : groupWeights.keySet()) {
   
                            if (!currentGroupRouteWeights.containsKey(group)) {
   
                                // 整个权重组不再存在
                                groupsToRemove.add(group);
                                log.info("权重组 [{}] 不再存在于路由配置中,将被移除", group);
                                continue;
                            }

                            // 获取该组中当前配置的路由ID和权重
                            Map<String, Integer> configuredRouteWeights = currentGroupRouteWeights.get(group);

                            // 获取该组中缓存的权重配置
                            Object groupWeightConfig = groupWeights.get(group);

                            try {
   
                                // 获取 weights 字段
                                Field weightsField = groupWeightConfig.getClass().getDeclaredField("weights");
                                weightsField.setAccessible(true);

                                @SuppressWarnings("unchecked")
                                LinkedHashMap<String, Integer> weights = (LinkedHashMap<String, Integer>) weightsField.get(groupWeightConfig);

                                // 找出需要移除的路由ID
                                Set<String> routesToRemove = weights.keySet().stream()
                                        .filter(routeId -> !configuredRouteWeights.containsKey(routeId))
                                        .collect(Collectors.toSet());

                                // 找出权重发生变化的路由ID
                                Set<String> routesWithWeightChange = new HashSet<>();
                                for (Map.Entry<String, Integer> entry : weights.entrySet()) {
   
                                    String routeId = entry.getKey();
                                    Integer cachedWeight = entry.getValue();

                                    if (configuredRouteWeights.containsKey(routeId)) {
   
                                        Integer configuredWeight = configuredRouteWeights.get(routeId);
                                        if (!cachedWeight.equals(configuredWeight)) {
   
                                            routesWithWeightChange.add(routeId);
                                            log.info("路由 [{}] 的权重从 {} 变为 {}", routeId, cachedWeight, configuredWeight);
                                        }
                                    }
                                }

                                // 找出新增的路由ID
                                Set<String> newRoutes = configuredRouteWeights.keySet().stream()
                                        .filter(routeId -> !weights.containsKey(routeId))
                                        .collect(Collectors.toSet());

                                if (!routesToRemove.isEmpty() || !routesWithWeightChange.isEmpty() || !newRoutes.isEmpty()) {
   
                                    log.info("权重组 [{}] 中有变化:删除 {},权重变化 {},新增 {}",
                                            group, routesToRemove, routesWithWeightChange, newRoutes);

                                    // 如果有任何变化,我们将重新计算整个组的权重
                                    groupsToUpdate.add(group);
                                }

                                // 首先,移除需要删除的路由
                                for (String routeId : routesToRemove) {
   
                                    weights.remove(routeId);
                                }

                                // 如果权重组中没有剩余路由,则移除整个组
                                if (weights.isEmpty()) {
   
                                    groupsToRemove.add(group);
                                    log.info("权重组 [{}] 中没有剩余路由,将移除整个组", group);
                                }
                            } catch (Exception e) {
   
                                log.error("处理权重组 [{}] 时出错", group, e);
                            }
                        }

                        // 移除不再需要的权重组
                        for (String group : groupsToRemove) {
   
                            groupWeights.remove(group);
                            log.info("已移除权重组: {}", group);
                        }

                        // 更新需要重新计算的权重组
                        for (String group : groupsToUpdate) {
   
                            try {
   
                                // 获取该组中当前配置的路由ID和权重
                                Map<String, Integer> configuredRouteWeights = currentGroupRouteWeights.get(group);

                                // 移除旧的权重组配置
                                groupWeights.remove(group);
                                log.info("已移除权重组 [{}] 以便重新计算", group);

                                // 为每个路由创建 WeightConfig 并调用 addWeightConfig 方法
                                Method addWeightConfigMethod = WeightCalculatorWebFilter.class.getDeclaredMethod("addWeightConfig", WeightConfig.class);
                                addWeightConfigMethod.setAccessible(true);

                                for (Map.Entry<String, Integer> entry : configuredRouteWeights.entrySet()) {
   
                                    String routeId = entry.getKey();
                                    Integer weight = entry.getValue();

                                    WeightConfig weightConfig = new WeightConfig(routeId);
                                    weightConfig.setGroup(group);
                                    weightConfig.setWeight(weight);

                                    addWeightConfigMethod.invoke(weightCalculatorWebFilter, weightConfig);
                                    log.info("为路由 [{}] 添加权重配置:组 [{}],权重 {}", routeId, group, weight);
                                }
                            } catch (Exception e) {
   
                                log.error("重新计算权重组 [{}] 时出错", group, e);
                            }
                        }

                        log.info("权重缓存同步完成,当前缓存的权重组: {}", groupWeights.keySet());
                    });

        } catch (Exception e) {
   
            log.error("同步权重缓存失败", e);
        }
    }
}

网上找一圈并没发现官方的修改意见,可能是咱们使用方式不对导致的,要不如此明显的BUG早就有人改了吧!

相关文章
|
2月前
|
机器学习/深度学习 Kubernetes API
【Azure APIM】自建网关(self-host gateway)收集请求的Header和Body内容到日志中的办法
在Azure API Management中,通过配置trace策略可完整记录API请求的Header和Body信息。在Inbound和Outbound策略中分别使用context.Request/Response.Headers和Body.As&lt;string&gt;方法捕获数据,并写入Trace日志,便于排查与审计。
118 7
|
4月前
|
安全 虚拟化
Omnissa Secure Email Gateway 2.33 - 电子邮件网关
Omnissa Secure Email Gateway 2.33 - 电子邮件网关
106 0
|
负载均衡 Java 应用服务中间件
Gateway服务网关
Gateway服务网关
325 1
Gateway服务网关
|
负载均衡 Java API
项目中用的网关Gateway及SpringCloud
Spring Cloud Gateway 是一个功能强大、灵活易用的API网关解决方案。通过配置路由、过滤器、熔断器和限流等功能,可以有效地管理和保护微服务。本文详细介绍了Spring Cloud Gateway的基本概念、配置方法和实际应用,希望能帮助开发者更好地理解和使用这一工具。通过合理使用Spring Cloud Gateway,可以显著提升微服务架构的健壮性和可维护性。
664 0
|
负载均衡 Java 网络架构
实现微服务网关:Zuul与Spring Cloud Gateway的比较分析
实现微服务网关:Zuul与Spring Cloud Gateway的比较分析
797 5
|
Java API 微服务
服务网关Gateway
该博客文章详细介绍了Spring Cloud Gateway的使用方法和概念。文章首先阐述了API网关在微服务架构中的重要性,解释了客户端直接与微服务通信可能带来的问题。接着,文章通过具体的示例代码,展示了如何在Spring Cloud Gateway中添加依赖、编写路由规则,并对路由规则中的基本概念如Route、Predicate和Filter进行了详细解释。最后,文章还提供了路由规则的测试方法。
服务网关Gateway
|
负载均衡 Java 应用服务中间件
Gateway服务网关
本节针对微服务中另一重要组件:网关 进行了实战性演练,网关作为分布式架构中的重要中间件,不仅承担着路由分发(重点关注Path规则配置),同时可根据自身负载均衡策略,对多个注册服务实例进行均衡调用。本节我们借助GateWay实现的网关只是技术实现的方案之一,后续大家可能会接触像:Zuul、Kong等,其实现细节或有差异,但整体目标是一致的。
|
JSON 前端开发 Java
SpringCloud怎么搭建GateWay网关&统一登录模块
本文来分享一下,最近我在自己的项目中实现的认证服务,目前比较简单,就是可以提供一个公共的服务,专门来处理登录请求,然后我还在API网关处实现了登录拦截的效果,因为在一个博客系统中,有一些地址是可以不登录的,比方说首页;也有一些是必须登录的,比如发布文章、评论等。所以,在网关处可以支持自定义一些不需要登录的地址,一些需要登录的地址,也可以在网关处进行校验,如果未登录,可以返回JSON格式的出参,前端可以进行相关处理,比如跳转到登录页面等。
695 4
|
安全 API
【Azure API 管理】APIM Self-Host Gateway 自建本地环境中的网关数量超过10个且它们的出口IP为同一个时出现的429错误
【Azure API 管理】APIM Self-Host Gateway 自建本地环境中的网关数量超过10个且它们的出口IP为同一个时出现的429错误
195 0
|
存储 容器
【Azure 事件中心】为应用程序网关(Application Gateway with WAF) 配置诊断日志,发送到事件中心
【Azure 事件中心】为应用程序网关(Application Gateway with WAF) 配置诊断日志,发送到事件中心
197 0