在前面 5 篇文章里,我们已经把"用 Spring Cloud Gateway 做统一网关、把多个大模型供应商接口收敛到一套地址空间"的骨架搭了起来。但有一个问题始终悬在头顶:路由规则写在 `application.yml` 里,每次新增一个大模型供应商、或者调整某个接口的限流前缀,都得重新打包、重启网关。对于大模型这一类"今天接文心、明天接通义、后天接本地 Ollama"的业务来说,重启网关的成本不可接受。

本篇我们要解决的就是这件事:**让网关的路由规则彻底脱离代码与重启,全部下沉到 Nacos 配置中心,改一条路由,全集群秒级生效**。这正是"网关统一路由 + Nacos 配置治理"组合拳的第一记重拳。

  1. 为什么需要动态路由:网关重启之痛
  2. Spring Cloud Gateway 路由模型原理
  3. Nacos 配置中心实时推送原理
  4. 实战:基于 Nacos 的动态路由实现
  5. 配置示例:bootstrap 与 Nacos dataId
  6. 最佳实践与踩坑点
  7. 本篇小结

1. 为什么需要动态路由:网关重启之痛

传统网关的路由是"静态"的——写在 `application.yml` 的 `spring.cloud.gateway.routes` 列表里。Gateway 启动时由 `PropertiesRouteDefinitionLocator` 一次性读入内存,之后除非重启,否则改不动。

当你的网关只服务三五个稳定后端时,这没什么问题。但大模型场景有三个特征,让静态路由立刻捉襟见肘:

  • **供应商多且变动频繁**:OpenAI、Azure、智谱、通义、百川、本地 vLLM,每个供应商都要一条路由;新签一个供应商就要加一条。
  • **灰度与切流是常态**:某个供应商限流了,要临时把 `/v1/chat` 切到备用供应商;某个模型下线了,要立刻摘掉对应路由。
  • **多环境差异化**:开发、测试、生产三套环境的路由目标(host、端口)完全不同,但网关代码只有一份。

把这三类需求全部通过"改 yml + 重启"来满足,结果就是:**网关成了发布瓶颈,每次变更都要走一遍上线流程,且重启期间所有大模型请求全部 502**。

动态路由的核心诉求只有一句话——**路由定义(RouteDefinition)的来源从"本地配置文件"换成"可热更新的外部配置源(Nacos)",并且配置变更时能主动推送到网关、不重启即生效**。

2. Spring Cloud Gateway 路由模型原理

要动路由,先得搞清楚 Gateway 脑子里"路由"长什么样、从哪来、怎么被加载。

2.1 Route、RouteDefinition 与 RouteDefinitionLocator

Gateway 在运行期真正用的是 `Route` 对象,但配置层、存储层打交道的是 `RouteDefinition`(路由定义)。两者关系是:`RouteDefinition` 是"配方",`Route` 是"按配方现做出来的成品"。

// RouteDefinition 的关键字段(简化)
public class RouteDefinition {
    private String id;                       // 路由唯一标识,如 llm-openai
    private List<PredicateDefinition> predicates;   // 断言:匹配条件
    private List<FilterDefinition> filters;         // 过滤器链
    private URI uri;                         // 转发目标,如 lb://llm-openai-service
    private int order;                       // 优先级,越小越先匹配
}

 

Gateway 不直接读你的 yml,而是通过一个叫 `RouteDefinitionLocator` 的接口去"找"所有 `RouteDefinition`:

public interface RouteDefinitionLocator {
    Flux<RouteDefinition> getRouteDefinitions();
}

 

Spring Cloud Gateway 内置了多个实现,它们会**合并**成最终路由表:

实现类

数据来源

是否可热更新

---

---

---

`PropertiesRouteDefinitionLocator`

`application.yml` 的 `spring.cloud.gateway.routes`

`InMemoryRouteDefinitionRepository`

内存(API 写入)

是,但重启即丢

`DiscoveryClientRouteDefinitionLocator`

服务注册中心自动生成

是(随服务上下线)

`CompositeRouteDefinitionLocator`

上面所有实现的聚合

取决于各子实现

我们要做的关键动作,就是**自己实现一个 `RouteDefinitionRepository`(它继承自 `RouteDefinitionLocator` 且支持 save/delete),把数据来源换成 Nacos,并在 Nacos 配置变更时主动刷新**。

2.2 路由加载的优先级与刷新机制

Gateway 启动时,`RouteLocator` 会把所有 `RouteDefinition` 转成 `Route` 并缓存为 `Flux<Route>`。默认情况下这个 `Route` 缓存是"构建一次、长期复用"的——这意味着即便你更新了 `RouteDefinition`,只要 `Route` 缓存没失效,请求仍然走旧路由。

因此动态路由的工程难点不在"读 Nacos",而在两件事:

  1. **数据来源要可替换**:用一个自定义 `RouteDefinitionRepository` 从 Nacos 读。
  2. **变更要能触发 Route 缓存重建**:要么让 `CachingRouteLocator` 感知到定义变化(`onRefresh`),要么直接发 `RefreshRoutesEvent` 事件。

// 手动触发路由刷新的标准姿势
@Autowired
private ApplicationEventPublisher publisher;
public void refresh() {
    publisher.publishEvent(new RefreshRoutesEvent(this));
}

 

只要发布了 `RefreshRoutesEvent`,`CachingRouteLocator` 就会清空缓存、重新调用各 `RouteDefinitionLocator` 拉取最新定义,把"新配方"重新编译成"新成品 Route"。

3. Nacos 配置中心实时推送原理

Nacos 作为配置中心,核心能力是**配置变更的实时推送**。它不像早期配置中心只能靠客户端定时拉取,而是用"长轮询(long polling)+ 服务端变更通知"的组合,做到秒级生效。

3.1 @RefreshScope 的工作机制

Spring Cloud 的 `@RefreshScope` 配合 `ContextRefresher`,能在收到 `/actuator/refresh` 或 Nacos 推送后,**销毁并重建被该注解标记的 Bean**,从而让 `@Value`、`@ConfigurationProperties` 拿到新值。

但这里有个坑:**`@RefreshScope` 解决的是"普通配置项"的热更新,并不直接解决 Gateway 路由**。因为路由不是简单的 `@Value` 字段,而是一条由 `RouteDefinitionLocator` 组装、再经 `CachingRouteLocator` 缓存的链路。所以纯粹靠 `@RefreshScope` 刷新 `PropertiesRouteDefinitionLocator` 用的 `GatewayProperties`,虽然能刷新"yml 里的路由",但:

  • 我们的路由根本不在 yml 里;
  • 即便在 yml 里,`@RefreshScope` 刷新后还需 `RefreshRoutesEvent` 才能真正重建 Route。

所以**本篇的主方案是"自定义 RouteDefinitionRepository + 主动监听 Nacos + 发布 RefreshRoutesEvent"**,把 `@RefreshScope` 作为辅助手段(用于刷新网关自身的一些开关参数),而不是路由刷新的唯一依赖。

3.2 Nacos 长轮询与监听

Nacos 客户端对每个关注的 `dataId` 发起一个"长轮询"请求:服务端 hold 住请求,最长 30s;期间若该 `dataId` 内容变化,服务端立刻返回"有变更"的 dataId 列表;客户端收到后真正去拉取最新配置,并回调所有注册的 `Listener`(`addListener`)。

// 注册监听的标准写法
configService.addListener(dataId, group, new Listener() {
    @Override
    public void receiveConfigInfo(String configInfo) {
        // configInfo 就是 Nacos 里最新的整段路由 JSON
        handleRouteChanged(configInfo);
    }
    @Override
    public Executor getExecutor() { return routeExecutor; }
});

 

我们正是利用这个 `receiveConfigInfo` 回调,把"最新路由 JSON"重新解析成 `RouteDefinition` 列表,并发布 `RefreshRoutesEvent`。

4. 实战:基于 Nacos 的动态路由实现

下面给一套可直接落地的实现。整体思路:

  1. 路由全部以 **JSON 数组**形式存在 Nacos 的 `dataId = gateway-routes.json` 里;
  2. 自定义 `NacosRouteDefinitionRepository` 实现 `RouteDefinitionRepository`,启动时从 Nacos 拉全量,并注册监听;
  3. 监听回调里更新内存缓存并发布 `RefreshRoutesEvent`;
  4. 额外暴露一个管理接口,支持对某条路由做增删改(写回 Nacos,保证"治理"可审计)。

4.1 自定义 RouteDefinitionRepository

@Component
public class NacosRouteDefinitionRepository implements RouteDefinitionRepository {
    private final ConfigService configService;
    private final String dataId = "gateway-routes.json";
    private final String group = "GATEWAY_GROUP";
    private final ObjectMapper objectMapper = new ObjectMapper();
    // 内存中的路由定义缓存(Nacos 推送时更新)
    private volatile List<RouteDefinition> routeCache = new ArrayList<>();
    public NacosRouteDefinitionRepository(ConfigService configService) throws Exception {
        this.configService = configService;
        // 1. 首次加载全量
        String init = configService.getConfig(dataId, group, 3000);
        if (StringUtils.hasText(init)) {
            routeCache = objectMapper.readValue(init,
                new TypeReference<List<RouteDefinition>>() {});
        }
        // 2. 注册监听,后续变更实时刷新
        configService.addListener(dataId, group, new AbstractListener() {
            @Override
            public void receiveConfigInfo(String configInfo) {
                try {
                    List<RouteDefinition> latest = objectMapper.readValue(configInfo,
                        new TypeReference<List<RouteDefinition>>() {});
                    routeCache = latest;
                    // 触发 Gateway 重建 Route 缓存
                    publishRefresh();
                } catch (Exception e) {
                    log.error("解析 Nacos 路由配置失败", e);
                }
            }
        });
    }
    @Override
    public Flux<RouteDefinition> getRouteDefinitions() {
        return Flux.fromIterable(routeCache);
    }
    @Override
    public Mono<Void> save(Mono<RouteDefinition> route) {
        return route.flatMap(r -> {
            List<RouteDefinition> list = new ArrayList<>(routeCache);
            list.removeIf(x -> x.getId().equals(r.getId()));
            list.add(r);
            routeCache = list;
            publishRefresh();
            return Mono.empty();
        });
    }
    @Override
    public Mono<Void> delete(Mono<String> routeId) {
        return routeId.flatMap(id -> {
            routeCache = routeCache.stream()
                .filter(x -> !x.getId().equals(id)).collect(Collectors.toList());
            publishRefresh();
            return Mono.empty();
        });
    }
}

 

这里 `AbstractListener` 是一个自定义基类,仅实现 `getExecutor()` 返回一个独立线程池,避免阻塞 Nacos 长轮询线程。关键在于:**它本身不写 Nacos**,只负责"读 + 监听"。写回 Nacos 的职责交给下面的管理服务,符合"配置治理"需要统一出口的诉求。

4.2 路由变更发布与 Nacos 写回

@Service
public class GatewayRouteAdminService {
    private final ConfigService configService;
    private final NacosRouteDefinitionRepository repository;
    private final ObjectMapper objectMapper = new ObjectMapper();
    private final String dataId = "gateway-routes.json";
    private final String group = "GATEWAY_GROUP";
    // 新增或覆盖一条路由,并持久化到 Nacos
    public void upsertRoute(RouteDefinition definition) throws Exception {
        List<RouteDefinition> all = repository.snapshot();      // 当前全量
        all.removeIf(x -> x.getId().equals(definition.getId()));
        all.add(definition);
        String json = objectMapper.writerWithDefaultPrettyPrinter()
                                   .writeValueAsString(all);
        // 第三个参数 true 表示自动创建 dataId
        configService.publishConfig(dataId, group, json, "json", true);
        // publishConfig 会触发监听回调,进而 RefreshRoutesEvent,无需手动发
    }
    public void removeRoute(String id) throws Exception {
        List<RouteDefinition> all = repository.snapshot();
        all.removeIf(x -> x.getId().equals(id));
        String json = objectMapper.writeValueAsString(all);
        configService.publishConfig(dataId, group, json, "json", true);
    }
}

 

注意 `publishConfig` 之后,Nacos 会把新配置推给所有监听该 dataId 的网关实例,监听回调里已经完成 `RefreshRoutesEvent` 发布——**一次写、全集群生效、无需逐个重启**。

4.3 暴露管理接口(带鉴权)

@RestController
@RequestMapping("/_gateway/admin/routes")
public class RouteAdminController {
    @Autowired
    private GatewayRouteAdminService adminService;
    @PostMapping
    public ResponseEntity<Void> add(@RequestBody RouteDefinition definition) throws Exception {
        adminService.upsertRoute(definition);
        return ResponseEntity.ok().build();
    }
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> remove(@PathVariable String id) throws Exception {
        adminService.removeRoute(id);
        return ResponseEntity.ok().build();
    }
}

 

这条管理接口本身也要被网关保护(第 08 篇会专门讲鉴权)。生产环境务必加白名单或独立内网端口,避免被外部直接改路由。

5. 配置示例:bootstrap 与 Nacos dataId

**bootstrap.yml**(网关自身启动配置,决定去哪个 Nacos 拉配置):

spring:
  application:
    name: llm-gateway
  cloud:
    nacos:
      config:
        server-addr: 127.0.0.1:8848
        file-extension: yaml
        group: GATEWAY_GROUP
        namespace: llm-prod
      discovery:
        server-addr: 127.0.0.1:8848
        namespace: llm-prod
    gateway:
      # 关闭从 yml 读取静态路由,避免与 Nacos 路由冲突
      discovery:
        locator:
          enabled: false

 

**Nacos 中 `gateway-routes.json` 的内容示例**(一条把 `/v1/llm/openai/**` 路由到 OpenAI 代理服务的规则):

[
  {
    "id": "llm-openai",
    "order": 1,
    "uri": "lb://llm-openai-proxy",
    "predicates": [
      { "name": "Path", "args": { "_genkey_0": "/v1/llm/openai/**" } }
    ],
    "filters": [
      { "name": "StripPrefix", "args": { "_genkey_0": "2" } },
      { "name": "RequestRateLimiter", "args": { "redis-rate-limiter.replenishRate": "10" } }
    ]
  },
  {
    "id": "llm-local-ollama",
    "order": 2,
    "uri": "http://127.0.0.1:11434",
    "predicates": [
      { "name": "Path", "args": { "_genkey_0": "/v1/llm/ollama/**" } }
    ],
    "filters": [
      { "name": "StripPrefix", "args": { "_genkey_0": "2" } }
    ]
  }
]

 

这里把"大模型接口"统一收敛到 `/v1/llm/*` 前缀下,再按供应商子路径分流到不同 `uri`。这正是"网关统一路由大模型接口"的落地形态。

6. 最佳实践与踩坑点

动态路由听起来很美,落地时却有不少暗坑,逐一列给你:

踩坑点

现象

正确做法

---

---

---

路由定义格式写错

Nacos 推送后网关报 JSON 解析异常,整段路由丢失

用 `RouteDefinition` 的官方 JSON 结构,`_genkey_0` 不能省;上线前用管理接口回读校验

监听回调阻塞长轮询线程

Nacos 推送延迟、甚至连接被断

`getExecutor()` 必须返回独立线程池,回调里只做"解析 + 发事件",重活异步化

`RefreshRoutesEvent` 没发

Nacos 更新了但请求仍走旧路由

监听回调里必须发布该事件,或依赖 `publishConfig` 触发回调链

多个 Locator 路由 id 冲突

同 id 路由被覆盖、行为诡异

给 Nacos 路由统一加 `llm-` 前缀命名规范,与静态/服务发现路由隔离

配置未持久化

网关重启后路由变空

路由必须先 `publishConfig` 落 Nacos,再在内存生效,不能只改内存

热更新时正在进行的请求

偶发 404/500

路由切换是原子的(整体替换 `routeCache`),配合重试过滤器(`Retry`)兜底

另外几条工程经验:

  • **把路由配置的写权限收口**:只允许管理后台/CI 通过 `publishConfig` 写 Nacos,网关实例本身只用 `getConfig` + `addListener` 只读,避免"谁都能改路由"带来的混乱。
  • **给每条路由打 metadata**:在 `RouteDefinition.metadata` 里放 `owner`、`supplier`、`sla` 等字段,方便治理大盘展示和告警。
  • **变更要有审计**:Nacos 自带配置历史,配合"写回必须带说明",出事故能秒级回滚到任意历史版本。
  • **灰度发布路由**:Nacos 支持按 IP 灰度推送配置,新路由可先推给一台网关验证,再全量。

7. 本篇小结

本篇我们打通了"网关统一路由大模型接口 + Nacos 配置治理"的第一环——**动态路由**:

  1. Gateway 路由的本质是 `RouteDefinition` 经 `RouteDefinitionLocator` 组装、再被 `CachingRouteLocator` 缓存为 `Route`;
  2. 自定义 `RouteDefinitionRepository` 把数据来源换成 Nacos,并注册监听;
  3. Nacos 长轮询推送变更 → 回调解析最新 JSON → 发布 `RefreshRoutesEvent` → 全网关节点秒级重建路由;
  4. 写回统一走 `publishConfig`,保证可审计、可回滚。

至此,新增一个大模型供应商,你只需要在 Nacos 里加一条 JSON,无需改代码、无需重启。下一篇(第 07 篇),我们进一步让网关"认识"后端微服务实例——**服务发现联动:Nacos 注册中心 + Gateway 路由自动同步微服务实例**,把 `lb://` 背后的实例列表也交给 Nacos 动态维护。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐