☰
Spring Cloud Gateway进阶实战:动态路由、自定义过滤器与全链路日志
2026/10/10 22:07:47 网站建设 项目流程

1. 整体架构设计与方案选型

1.1 网关在微服务体系中的核心定位

先聊一个最基础的问题——为什么我们需要网关。微服务架构拆到最后,服务数量可能上百,客户端不可能一个个去记每个服务的地址。如果没有网关统一收口,每个服务都要处理 CORS、鉴权、限流、日志这些横切关注点,代码污染严重,管理也很混乱。

Spring Cloud Gateway 在几个主流网关方案里是比较受青睐的一个。相比 Zuul 1.x 基于 Servlet 的阻塞模型,Gateway 基于 Spring WebFlux 和 Netty 的响应式模型,性能上限高,线程模型更优。而且它和 Spring Cloud 生态的兼容性天然无缝隙,Nacos 注册发现、Sentinel 限流、Sleuth 链路追踪都有直接的整合方案。

我这里想说的"进阶",不是让你去背 API 文档。核心是三个能力的落地:自定义过滤器、动态路由、全链路日志监控。这三个东西在真实的业务里几乎必用,但网上讲得深的资料不多,大多停留在"跑个 demo"的层面。

1.2 为什么需要自定义过滤器和动态路由

我们先想一个问题:网关上要处理的逻辑,除了转发请求之外还有多少?从我这边的经验看,至少包括这些:

  • 鉴权:验证 token、签名,校验权限,无效的直接拦截
  • 灰度:按用户维度分流,部分用户走新版本的 provider 服务
  • 规范化:统一改写请求头、响应头
  • 报文处理:对请求体做验签或加签,在网关层完成 RSA/SM2 这类操作
  • 日志:记录请求耗时、状态码、目标服务,方便问题排查

这些逻辑中,有一部分是全局的,比如日志。有一部分是只针对特定路由生效的,比如某个接口的上游服务需要对某个 header 做特殊处理。Spring Cloud Gateway 自带的过滤器工厂只覆盖常规场景,比如 StripPrefix、AddRequestHeader 这些,真正贴合业务的一定要自己动手写。

动态路由解决的又是另一个问题。网关上路由规则如果写死在配置文件里,每次变更都要重启服务,这在微服务架构里是不可接受的。服务上下线、新接口上线、流量转发策略调整,这些都是高频操作。动态路由就是把路由规则从配置文件搬到中间件(Nacos、Redis 或数据库),运行期可以通过 API 或配置推送更新,不用重启网关。

1.3 全链路日志监控的实际需求

微服务排查问题的痛点在跨服务调用。A 服务调 B 服务,B 服务调 C 服务,任何一个环节慢了,问题出在哪一层?再加个 MQ 异步,线程一换,日志上下文全丢了,根本没法串联。

全链路日志的核心思路是给一个用户请求生成唯一的 TraceId,然后通过 HTTP Header 或者消息队列的附加属性往下传。每个服务的日志里都打上这个 TraceId。排查的时候,拿 TraceId 去日志系统里一搜,整个调用链的所有日志就都出来了。

Spring Cloud Gateway 在链路追踪中的地位很特殊——它是入口。TraceId 在网关生成、路由通过 Header 传给下游服务。如果这一步没做好,后面全是白搭。所以网关的全链路日志,不光是网关自己日志的问题,还决定了整个链路追踪体系能不能打通。

2. 自定义过滤器实战

2.1 Spring Cloud Gateway 过滤器的运行机制

搞清楚 Spring Cloud Gateway 过滤器的运行机制,是所有自定义过滤器开发的底层基础。

先记住一句话:网关而言,过滤器就是处理链上的节点。WebFlux 框架把一次完整的请求封装成一个ServerWebExchange对象,它持有 request、response、attributes 等所有上下文数据。过滤器链上每个过滤器拿到这个exchange后,按顺序处理,最终把请求转发到目标服务。

Spring Cloud Gateway 的过滤器分为两类:

  • GatewayFilter:路由级别的过滤器,通过spring.cloud.gateway.routes.filters配置生效,只作用于该路由
  • GlobalFilter:全局过滤器,作用于所有请求

在源码实现上,GlobalFilter会被适配成GatewayFilter并加入每个路由的过滤器链中。如果你看FilteringWebHandler的源码,会发现它拿到路由的全部GatewayFilterAdapter,然后按Ordered接口的顺序排列成一个有序链表,依次执行。

执行顺序怎么定的?看Ordered接口的getOrder()返回值,值越小优先级越高,越先执行。

一个容易踩的坑是:过滤器的 pre 逻辑(在chain.filter(exchange)之前的部分)按 order 从小到大执行,但 post 逻辑(在chain.filter(exchange)之后、响应返回给客户端之前的逻辑)是逆序的。因为chain.filter(exchange)返回的是一个Mono,后面的过滤器嵌套在里面,响应回来的时候,最先执行的那个过滤器的 post 逻辑最后执行。这类似于 Servlet 里 Filter 的执行模型,但很多人第一次写网关过滤器时不注意,结果响应日志记录的时间点不对。

2.2 自定义全局过滤器:签名校验的完整实现

下面用签名校验这个实战场景来走一遍完整流程。

业务背景:外部商户调用网关开放的 API,网关需要校验签名。签名规则是:md5(appSecret + sortedQueryString) + salt,时间戳超时 5 分钟拒绝。请求头携带x-app-id、x-timestamp、x-sign。

第一步,创建一个类实现GlobalFilter和Ordered接口:

@Component public class SignVerifyFilter implements GlobalFilter, Ordered { private static final Set<String> WHITE_LIST = Set.of("/health", "/internal/"); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path = exchange.getRequest().getPath().value(); // 白名单直接放行 if (WHITE_LIST.stream().anyMatch(path::startsWith)) { return chain.filter(exchange); } // 1. 获取请求头信息 String appId = exchange.getRequest().getHeaders().getFirst("x-app-id"); String timestamp = exchange.getRequest().getHeaders().getFirst("x-timestamp"); String sign = exchange.getRequest().getHeaders().getFirst("x-sign"); // 2. 校验参数完整性 if (appId == null || timestamp == null || sign == null) { return reject(exchange, "MISSING_SIGN_PARAM"); } // 3. 校验时间戳, 拒绝重放攻击 long ts = Long.parseLong(timestamp); if (Math.abs(System.currentTimeMillis() / 1000 - ts) > 300) { return reject(exchange, "SIGN_EXPIRED"); } // 4. 根据 appId 从内存/Redis中取该商户的 appSecret String appSecret = getSecretByAppId(appId); if (appSecret == null) { return reject(exchange, "INVALID_APP_ID"); } // 5. 服务端重算签名 String sortedQuery = sortQueryParams(exchange.getRequest().getURI().getRawQuery()); String serverSign = DigestUtils.md5DigestAsHex( (appSecret + sortedQuery + SALT).getBytes(StandardCharsets.UTF_8)); // 6. 对比签名 if (!serverSign.equalsIgnoreCase(sign)) { return reject(exchange, "SIGN_MISMATCH"); } // 通过校验, 将 appId 和 appSecret 放入 exchange, 方便下游过滤器使用 exchange.getAttributes().put("appId", appId); return chain.filter(exchange); } private Mono<Void> reject(ServerWebExchange exchange, String code) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); DataBuffer buffer = exchange.getResponse().bufferFactory() .wrap(("{\"code\":\"" + code + "\"}").getBytes(StandardCharsets.UTF_8)); return exchange.getResponse().writeWith(Mono.just(buffer)); } @Override public int getOrder() { return -100; } }

这个过滤器逻辑上没什么高级的,但有几个细节值得注意:

关于顺序:order设为 -100,确保它先于大多数过滤器执行。如果网关里还有别的过滤器,比如把x-app-id写入ServerWebExchange的 attributes 让下游服务使用,就要理解好顺序关系——chain.filter(exchange)之后的代码会在下游过滤器和路由转发完成后执行,不要在 post 位置去做需要 pre 阶段数据的操作。

关于sortQueryParams的实现,记住一个原则:去掉sign参数本身,其余参数按 key 自然排序,然后拼key=value用&连接,空 value 保留空字符串。这个逻辑要和客户端约定好,两边算法不一致是最常见的对接失败原因。我的做法是把这部分算法用 Java 和 JS 各写一份单测,对接方直接用我们的测试用例验证他们的实现。

关于时间戳校验:按秒级时间戳做校验,容差 5 分钟既防重放又避免比较大的时钟偏差。另外建议把Math.abs(System.currentTimeMillis() / 1000 - ts) > 300这个 300 秒的容差放配置中心动态下发,线上调整不用重启网关。

2.3 自定义 GatewayFilterFactory:与配置体系结合

GlobalFilter适合全局逻辑,但如果某个逻辑只针对部分路由生效,更优雅的方式是自定义GatewayFilterFactory。

比如有这样的需求:内部系统调用对接服务时,网关上要把上游返回的某个 header 改写掉,只针对/api/private/前缀的路由生效。

编写自定义 GatewayFilterFactory 需要继承AbstractGatewayFilterFactory<NameValueConfig>:

@Component public class RewriteHeaderGatewayFilterFactory extends AbstractGatewayFilterFactory<RewriteHeaderGatewayFilterFactory.Config> { public RewriteHeaderGatewayFilterFactory() { super(Config.class); } @Override public GatewayFilter apply(Config config) { return (exchange, chain) -> { ServerHttpResponse response = exchange.getResponse(); response.getHeaders().add(config.name, config.value); return chain.filter(exchange); }; } @Override public List<String> shortcutFieldOrder() { return List.of("name", "value"); } public static class Config { private String name; private String value; // getter / setter 省略 } }

在配置文件里的写法就非常简洁:

spring: cloud: gateway: routes: - id: private-api uri: lb://private-service predicates: - Path=/api/private/** filters: - RewriteHeader=X-Proxy-Env, pro

配置里的RewriteHeader=X-Proxy-Env, pro就是基于shortcutFieldOrder的快捷写法。如果不提供shortcutFieldOrder,就必须写完整 YAML 结构:

filters: - name: RewriteHeader args: name: X-Proxy-Env value: pro

这个类怎么写是有讲究的。AbstractGatewayFilterFactory<C>的泛型 C 就是这个过滤器工厂的配置类,框架会在配置绑定阶段自动把配置值绑定到 Config 对象的字段上。注意配置类的字段名必须和配置里 key 的名字保持一致(其实是有@ConfigurationProperties的绑定规则在里面,可以通过@Value注解或shortcutFieldOrder控制)。

我在接手一些老项目时经常看到的做法是,不管什么逻辑全塞在GlobalFilter里,然后写一堆 if 判断路由路径。最终结果是过滤器类越来越大,配置体系完全没用起来。正确的姿势是:基于路由级别的过滤器决策树,该用GatewayFilterFactory就用,让路由规则自解释。这样新同学接手维护也容易理解。

2.4 过滤器中的异步操作与线程上下文

自定义过滤器里大概率会遇到异步操作,比如查 Redis、调远程服务获取密钥。

这里必须强调一个 WebFlux 响应式编程的坑:不要用Thread.sleep来等待,不要用Future.get()阻塞。Spring Cloud Gateway 基于 Netty 的工作线程是 NIO 线程,一旦阻塞,吞吐量直接崩盘。

正确的是用响应式操作:

@Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { return userService.findByToken(token) .flatMap(user -> { exchange.getAttributes().put("userId", user.getId()); return chain.filter(exchange); }) .switchIfEmpty(reject(exchange, "INVALID_TOKEN")); }

配合 Redis 响应式客户端ReactiveRedisTemplate,全程无阻塞。

另一个大坑是 MDC 上下文。如果你打算在过滤器里打日志,用MDC.put("traceId", ...),然后chain.filter(exchange)之后的日志想拿到这个值——拿不到。原因很简单:WebFlux 的反应式链路在不同线程间切换时,ThreadLocal 不会跟着传递。这就是我们在后面的全链路日志部分要专门讲reactor.context的原因。在这一节先提一嘴,后面会展开。

3. 动态路由实现

3.1 从配置文件路由到运行时路由

先明确一下动态路由的目标:网关启动时加载路由定义的方式改成从外部数据源读取,运行期间可以动态地新增、修改、删除路由,所有网关节点同时生效,无需重启。

目前常见的动态路由方案有几种:

  • 基于 Nacos 配置中心:路由规则用 JSON/YAML 存储在 Nacos 配置中,网关监听配置变更事件,解析后更新内存中的路由表
  • 基于 Redis:路由定义存 Hash 结构,网关定期拉取或者订阅key的变更消息
  • 基于数据库:路由表在 MySQL 中管理,通过@Scheduled或事件推送更新
  • 基于 Apollo:和 Nacos 类似

选择方案要根据团队基础设施情况。已有 Nacos 就用 Nacos,已有 Apollo 就用 Apollo,不建议在已有配置中心的情况下再引入别的存储。下面以 Nacos 为例完整讲解。

Spring Cloud Gateway 原生提供了RouteDefinitionRepository接口,Gateway 内部通过这个接口加载路由定义:

public interface RouteDefinitionRepository { Flux<RouteDefinition> getRouteDefinitions(); Mono<Void> save(Mono<RouteDefinition> route); Mono<Void> delete(Mono<String> routeId); }

内置的InMemoryRouteDefinitionRepository就是把路由存内存,PropertiesRouteDefinitionRepository从配置文件加载。

我们自己实现一个 Nacos 版本,思路是:从 Nacos 的某个配置项读取路由定义的 JSON 数组,同时启动一个监听器,配置变更时刷新本地。

3.2 基于 Nacos 的动态路由完整代码实现

先写核心类NacosRouteDefinitionRepository:

@Component public class NacosRouteDefinitionRepository implements RouteDefinitionRepository { private static final String DATA_ID = "gateway-routes.json"; private static final String GROUP = "DEFAULT_GROUP"; private final ObjectMapper objectMapper = new ObjectMapper(); private final ConfigService configService; private final List<RouteDefinition> routeDefinitions = new CopyOnWriteArrayList<>(); public NacosRouteDefinitionRepository(ConfigService configService) { this.configService = configService; initAndListen(); } private void initAndListen() { try { // 初始化拉取配置 String config = configService.getConfig(DATA_ID, GROUP, 5000); updateRouteDefinitions(config); // 注册监听器 configService.addListener(DATA_ID, GROUP, new Listener() { @Override public Executor getExecutor() { return null; // 使用 Nacos 的默认线程池 } @Override public void receiveConfigInfo(String configInfo) { updateRouteDefinitions(configInfo); } }); } catch (NacosException e) { throw new RuntimeException(e); } } private void updateRouteDefinitions(String config) { if (config == null || config.trim().isEmpty()) { return; } try { List<RouteDefinition> list = objectMapper.readValue(config, new TypeReference<List<RouteDefinition>>() {}); routeDefinitions.clear(); routeDefinitions.addAll(list); } catch (JsonProcessingException e) { log.error("解析网关路由配置失败", e); } } @Override public Flux<RouteDefinition> getRouteDefinitions() { return Flux.fromIterable(routeDefinitions); } @Override public Mono<Void> save(Mono<RouteDefinition> route) { return route.flatMap(r -> { try { String config = configService.getConfig(DATA_ID, GROUP, 5000); List<RouteDefinition> list = new ArrayList<>(); if (config != null && !config.trim().isEmpty()) { list = objectMapper.readValue(config, new TypeReference<List<RouteDefinition>>() {}); } list.removeIf(d -> d.getId().equals(r.getId())); list.add(r); configService.publishConfig(DATA_ID, GROUP, objectMapper.writeValueAsString(list)); return Mono.empty(); } catch (Exception e) { return Mono.error(e); } }); } @Override public Mono<Void> delete(Mono<String> routeId) { return routeId.flatMap(id -> { try { String config = configService.getConfig(DATA_ID, GROUP, 5000); List<RouteDefinition> list = new ArrayList<>(); if (config != null && !config.trim().isEmpty()) { list = objectMapper.readValue(config, new TypeReference<List<RouteDefinition>>() {}); } list.removeIf(d -> d.getId().equals(id)); configService.publishConfig(DATA_ID, GROUP, objectMapper.writeValueAsString(list)); return Mono.empty(); } catch (Exception e) { return Mono.error(e); } }); } }

这个类实现后,按理说所有新增、修改、删除路由会被 Nacos 的通知机制触发到receiveConfigInfo方法,然后更新routeDefinitions列表。但这里有个关键点:单纯更新这个列表还不够,RouteDefinitionLocator是一个链式缓存器,底层被CachingRouteLocator包了一层,它会在第一次加载时缓存结果。

怎么解决?发布一个RefreshRoutesEvent。Spring Cloud Gateway 在CachingRouteLocator上监听这个事件做缓存刷新。这是我们刷路由的机制。

@Component public class DynamicRouteService implements ApplicationEventPublisherAware { private ApplicationEventPublisher publisher; @Override public void setApplicationEventPublisher(ApplicationEventPublisher publisher) { this.publisher = publisher; } public void refresh() { publisher.publishEvent(new RefreshRoutesEvent(this)); } }

然后再看一下关联关系。其实不用手动触发也是可以的——RouteDefinitionRouteLocator中,getRouteDefinitions()获取到的Flux<RouteDefinition>来自RouteDefinitionLocator的组合。默认情况下CachingRouteLocator实现了RouteLocator接口,并且缓存了路由查询结果,监听RefreshRoutesEvent。

实际的刷新链路是:

  1. RouteDefinitionRepository中的数据更新(Nacos 监听器触发)
  2. 发布RefreshRoutesEvent
  3. CachingRouteLocator.onApplicationEvent清除本地缓存
  4. 下一次请求进来时重新从CompositeRouteDefinitionLocator读取路由

所以完整的代码里,Nacos 的receiveConfigInfo中拿到新配置后不仅更新列表,还要调用publisher.publishEvent(new RefreshRoutesEvent(this))。

3.3 动态路由的性能、一致性与灰度发布细节

动态路由在上线后,有几个藏在细节里的坑必须处理。

第一个坑:路由更新的事务性。发布到 Nacos 的配置是全量 JSON 字符串,如果一个节点解析成功、另一个节点因为网络问题没拿到更新,那不同网关节点之间的路由是不一致的。好在 Nacos 的addListener机制会保证推送最终一致性,但短暂的窗口期内,同一个路由在不同节点上状态可能不同。对于敏感业务,建议在刷新逻辑里做一个"灰度"控制:先刷新一台,观察 10 分钟确认没有异常流量报错后再放量刷新剩余节点。实现上就是在配置里加一个版本号字段,代码里判断版本落差,超过阈值就告警。

第二个坑:路由定义的前缀冲突。动态路由管理界面如果允许不同组的人各自创建路由,很容易出现Path=/api/**这种「大而全」的断言优先级盖过Path=/api/user/**这种精确断言的情况。Spring Cloud Gateway 的断言匹配是按配置顺序从上往下匹配的,前面的命中就不会走下去。所以配置管理要约定规则:精确路由放在前面,通配路由靠后。如果代码层面想兜底,可以在保存路由时做一个检查,禁止间路由之间断言相互包含。

第三个坑:动态路由的谓词重算不生效。路由定义里如果带Weight断言,这个断言比较特殊,它不是路由命中匹配,而是分组路由的权重分配。刷新路由时如果不同节点之间Weight配置更新不是同时完成,流量分配就乱了,严重时会误伤某个集群。建议权重类变更选业务低谷期操作。

第四个坑:路由定义里的uri是注册中心服务名。如果把lb://user-service直接写成http://127.0.0.1:8080,通过 Nacos 动态改路由可以改,但服务地址变了又得改一次。生产环境大部分场景用lb://前缀 + 注册中心的服务发现,服务实例上下线网关自动感知,路由不用变。

最后补充一种更现代化但需要更多依赖的方案:如果你想做的不仅仅是网关侧变化,还希望服务和 API 维度的元数据管理——那可以上API 网关的管理体系,引入 Sentinel 的网关流控规则,把动态路由和动态限流放到同一个配置中心管理。这个方案的好处是路由和流控规则在同一个治理平面,坏处是复杂度上来了,没有一定规模不必强上。我的经验是,先做动态路由,能覆盖掉的场景比想象中多。

4. 全链路日志监控

4.1 TraceId 的生成与 MDC 的传递机制

全链路日志的第一步,是把 TraceId 在网关入口生成,然后传给下游所有服务。

提到日志就绕不开 MDC。MDC 是日志框架提供的一个 ThreadLocal 类型的存储容器,你在代码里MDC.put("traceId", "xxx"),日志配置里用%X{traceId}引用来输出,就能让这条线程上所有日志都带上 traceId。

问题前面已经提到:WebFlux 是响应式的,同一个请求的处理可能发生在多个不同线程上。MDC.put放在一个线程上,chain.filter(exchange)的后续日志在另一个线程上打印,MDC 里的内容就丢了。

社区有常见的两种解法:

解法一:使用 Reactor 的 Context 机制。

WebFlux 提供了contextWrite和deferContextual来在整个响应链路中传递上下文。我们可以写一个过滤器,把 traceId 放入reactor.core.publisher.Context:

@Component public class TraceIdFilter implements GlobalFilter, Ordered { private static final String TRACE_ID_HEADER = "X-Trace-Id"; private static final String TRACE_ID_ATTR = "traceId"; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String traceId = exchange.getRequest().getHeaders().getFirst(TRACE_ID_HEADER); if (StringUtils.hasText(traceId)) { // 如果前端或上游已经传入 traceId, 则复用 exchange.getRequest().mutate().header(TRACE_ID_HEADER, traceId); } else { traceId = UUID.randomUUID().toString().replace("-", ""); exchange.getRequest().mutate().header(TRACE_ID_HEADER, traceId); } exchange.getAttributes().put(TRACE_ID_ATTR, traceId); MDC.put(TRACE_ID_ATTR, traceId); return chain.filter(exchange) .doFinally(signalType -> MDC.remove(TRACE_ID_ATTR)); } @Override public int getOrder() { return Integer.MIN_VALUE; } }

这种做法,在当前线程内打日志没问题。但如果后续的响应式调用发生线程切换,日志里就丢了 traceId。Spring Cloud Gateway 内置的响应式链路日志记录也面临同样的问题。

这就必须要引入一个结合 Reactor 上下文的工具类:在过滤器里contextWrite(ctx -> ctx.put(TRACE_ID_ATTR, traceId)),然后在日志输出时通过deferContextual从Context取出,手动放入 MDC。代码会复杂一些,但这是响应式场景最干净的方式。

解法二:直接用 Micrometer Tracing(原 Sleuth)集成。

spring-cloud-sleuth在 3.x 里被重构成了micrometer-tracing,现在是最推荐的方案。它会自动处理TraceId的生成、传播和日志输出,把 traceId 注入 Slf4J 的 MDC。网关侧不需要自己写代码去管理 MDC,只需要引入依赖和配置:

<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-brave</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency>

然后日志配置里直接使用%X{traceId}就能打出来。它对 HTTP 请求的 Header 传播也做了自动处理。这是我认为在普通规模企业里性价比最高的方案。自己维护一套 TraceId 的生成传递和上下文管理,成本比大家想象中高。因为不只网关,所有下游服务都要配合。

4.2 网关日志的标准化输出

不管用不用 Micrometer Tracing,网关自身的请求日志要标准化。这方面很多项目的做法是拼一个长字符串,格式各自为政,解析起来痛苦不堪。

推荐的做法是用 JSON 格式输出 access log,方便 Logstash 这类工具消费。比如在全局过滤器里记录这些字段:

字段说明
timestamp请求处理完成时间,毫秒时间戳
traceId链路追踪 ID
methodHTTP 方法
path请求路径
query请求查询参数
routeId命中的路由 ID,没命中则为空
targetUri目标服务实际地址
status响应状态码
requestTime请求开始时间
costMs总耗时(毫秒)
clientIp客户端 IP
userId用户 ID(如已鉴权)

实现里有个重要的点:响应状态码和耗时只能在 post 位置拿到,但 post 位置拿到的响应对象已经被消费过一部分了。对普通场景,exchange.getResponse().getStatusCode()在 post 位置取是没问题的,但如果你想记录响应体内容,就麻烦了——响应体是异步流,只能在DecoratingServerHttpResponse里包装后自己收集才能拿到。

这里先说直接可用于生产的 AccessLogFilter:

@Component public class AccessLogFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { long start = System.currentTimeMillis(); String traceId = exchange.getRequest().getHeaders().getFirst("X-Trace-Id"); return chain.filter(exchange).doFinally(signal -> { long cost = System.currentTimeMillis() - start; ServerHttpResponse response = exchange.getResponse(); Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR); String routeId = route == null ? "" : route.getId(); String targetUri = route == null ? "" : route.getUri().toString(); JSONObject logObj = new JSONObject(); logObj.put("timestamp", System.currentTimeMillis()); logObj.put("traceId", traceId); logObj.put("method", exchange.getRequest().getMethodValue()); logObj.put("path", exchange.getRequest().getPath().value()); logObj.put("routeId", routeId); logObj.put("targetUri", targetUri); logObj.put("status", response.getStatusCode() == null ? 0 : response.getStatusCode().value()); logObj.put("costMs", cost); logObj.put("clientIp", getClientIp(exchange)); log.info("{}", logObj.toJSONString()); }); } @Override public int getOrder() { return Integer.MAX_VALUE; } }

注意order设置成Integer.MAX_VALUE,也就是最后执行。这样前面所有过滤器(包括路由转发)都执行完了,日志里能拿到最终的 status 和 route 信息。

另一个需要注意的地方:doFinally里不要做耗时长的操作。有一次我在里面加了一个 Elasticsearch 的同步写入,结果响应变慢,Netty 线程被占用的时间边长,排查了很久才发现是这里的问题。改成异步投递到消息队列就解决了。

4.3 网关关键指标与日志分析

日志打出来了,还要用起来。监控分两个层面:

基础可观测性:网关的 QPS、错误率、P99 耗时。这些指标如果还没有 Prometheus + Grafana,建议从这一版直接配上。Spring Boot 有现成的 actuator 集成,Spring Cloud Gateway 直接暴露/actuator/gateway/metrics类似端点,配合 micrometer-registry-prometheus 聚合。

业务日志分析:通过 ELK 或者 Loki 收集上面的 access log,生成仪表盘。我常用的几个视图:

  • 按 routeId 分组,看每个路由的请求量、错误率、耗时趋势
  • 按 path 分组,找出 TOP N 慢接口
  • 按 clientIp 分组,识别异常流量来源
  • 全局 TraceId 检索

链路追踪可视化这几年大家通常会引入分布式链路追踪系统,像 SkyWalking、Zipkin 等。它们的优势是自动采集调用链信息,可视化展示服务调用拓扑图。网关接入后,可以在服务拓扑图里看到网关作为链路的起点。

引入时有个建议:不要让网关把所有请求细节都上报到追踪系统。每秒几万请求的网关如果每条都上报,追踪系统本身会成为瓶颈。采样率配置成 10%,慢请求单独全量采样的思路更实用。如果用 SkyWalking 有现成的采样配置;用 Zipkin 则是配置spring.sleuth.sampler.rate或者自定义Sampler。

4.4 日志丢失、乱序与排查的实战经验

用了链路日志后,最常见的问题有三个,我逐个说。

问题一:TraceId 在部分服务里是空的。

这个十有八九是下游服务没接入 MDC。排查方法:进到下游服务容器里,直接看它的原始日志有没有 traceId。标准做法是下游服务用同一套spring-cloud-starter-sleuth或micrometer-tracing依赖,日志配置统一加%X{traceId}。要确认某一个叫mall-member的服务是否上链路,看它 received 的 HTTP 请求头里有没有X-B3-TraceId。

这里的背景知识是:Sleuth 或 Micrometer Tracing 的 Header 传播默认用的X-B3-TraceId、X-B3-SpanId这类 B3 格式。如果某个服务不识别这个 header,链路就断了。

问题二:日志乱序。

日志消费端解析时发现一个 traceId 的日志时间线错乱。原因通常是两个:一是多线程输出日志,MDC 中同一个 traceId 像异步编程那样切换线程特别容易发生;二是日志收集端时间戳解析口径不一致。处理办法:日志格式里时间精度到毫秒,并设置日志收集端的采集时间与主机进行 NTP 同步。此外,在浏览器检索时用 traceId 分组后按时间排序,勉强能还原出调用顺序。

问题三:链路中出现了 MQ 消息,TraceId 过不去。

同步 HTTP 调用好解决,消息队列跨线程跨进程就麻烦。Sleuth 对 Kafka 这种有自动的提取注入支持,只要消息头的traceId没有手动覆盖,一般能传过去。真正难搞的是自研 RPC 框架或 WebSocket。我的意见是,自研框架必须对接后置追踪 SDK,官方规范不支持的情况下,两端约定好外卖一个 TraceId Header 或者消息属性,手动穿透。

5. 踩坑记录与性能调优

5.1 ID 生成策略对网关性能的影响

TraceId 的生成方式对性能影响不小。

如果直接用UUID.randomUUID().toString().replace("-", ""),在高 QPS 下会有比较明显的性能开销。不是不能用,但你可以试试用Long.toHexString(System.currentTimeMillis()) + Long.toHexString(随机数)来拼一个更短的 TraceId。当前网络上各厂常用的做法还包括 Snowflake 变种。合理性排序是这样的:

  • 毫秒时间戳 + 随机填充:简单够用,适合一般业务
  • Snowflake 算法:带机器号,分布式下唯一性有保证,适合高并发
  • 第三方同步发号器:保证全局唯一但引入额外依赖

个人推荐:网关这一层没有复杂的分发要求,直接把时间戳和随机因子生成字符,长度压缩到 32 位以内。太长的话,日志文件体积显著增大,ES 索引膨胀。

5.2 网关线程模型与性能调优要点

Spring Cloud Gateway 的性能调优,核心是理解它基于 Netty 的线程模型。总结下来 4 个要点:

调大 Netty 线程数。默认情况可用 CPU 核数的 2 倍作为 eventLoop 线程数。在高并发场景下这个值不够,需要配置spring.webflux.netty.worker-count,一般建议按照 CPU 核数的 4-6 倍配置。这个参数不是越大越好——线程膨胀反而带来上下文切换成本。

配置连接池的大小。Gateway 转发请求到下游服务时,会从 Netty 的 Http 客户端连接池获取连接。默认最大连接数有限,QPS 高了容易等待。Spring Boot 2.4+ 支持用配置调整:

spring: cloud: gateway: httpclient: connect-timeout: 3000 response-timeout: 10s pool: type: elastic max-connections: 1000 max-idle-time: 5s

禁用不必要的组件。Gateway 在实际使用中,Redis 限流RequestRateLimiter是基于spring-boot-starter-data-redis-reactive的,如果你用了实时限流,这个一定保留。但重试机制RetryGatewayFilter,建议只在幂等的 GET 请求上启用,其他请求慎用——重试会给下游造成成倍压力。

注意全局过滤器里的 JSON 序列化,要用ObjectMapper复用,别每次 new。这个虽然是老生常谈,但 Filter 里循环调用高代价方法导致性能下降的情况真的发生过。网关这一层是流量咽喉,每一微秒的损耗乘以海量请求,结果都会被放大。

5.3 内存溢出与 CPU 飙升的实战排查

网关线上问题,我印象比较深刻的一次是 CPU 飙升到 90%,接口大面积超时。

排查过程:先用top -Hp <pid>找到高 CPU 线程,再用jstack导出线程栈,发现大量线程阻塞在 Netty 的连接建立与读取上。进一步看,是某个路由配置的lb://服务下线后,注册中心上没有及时剔除实例,网关一遍又一遍地连接一个已经不可用的服务,造成连接建立风暴。

处理办法:一是动态路由的下线流程要在代码层面确保先从注册中心摘除实例,再删路由;二是给 HttpClient 配置短超时(connect-timeout 和 response-timeout),让失败的请求快速失败,不占用线程资源。

还有一次是内存溢出。原因说出来很蠢——在过滤器里把请求体缓存到内存里:

// 绕过, 不要这样做!! 大的请求体会把堆撑爆 byte[] body = exchange.getRequest().getBody().collectList() .map(list -> ... ).block();

当有大数据量的上传请求进来时,内存灰飞烟灭。如果要读请求体(比如验签需要 body),用DataBufferUtils.join并限制大小,或者改为把 body 缓存到磁盘临时文件。正常场景下,网关层不要尝试读取大的请求体,除非你的过滤器可以证明它必须这么做。

5.4 如何给网关做压测与容量评估

网关上线前压测很重要,但很多人做得不专业。我给的建议是分三步:

第一步:单机性能基准。用 WRK/Locust 做纯路由转发的压测,看单机吞吐量和 P99 延迟。先探底,不要参考任何网上别人的数字,因为业务复杂度、机器配置、下游服务响应速度都不同。

第二步:全链路压测。网关 + 真实下游服务(或者足够逼真的 Mock)。这一步重点看网关对下游服务慢响应的表现——下游服务如果 P99 是 300ms,1000 QPS 请求转发时,网关工作线程耗尽的情况是什么样。此时你要确信你的连接池大小、超时配置、限流策略是匹配的。

第三步:故障演练。模拟下游服务宕机、注册中心抖动、Nacos 配置错误,观察网关是快速失败还是本地宕机。这一条中我最有价值的经验是:一定要配置本地兜底路由缓存。动态路由依赖中心化配置,如果 Nacos 短暂不可用,网关重启后路由加载不出来就是整个系统瘫痪。做法是网关启动时如果拉配置失败,就加载上一次从 Nacos 拉到的序列化到本地文件的缓存路由。这个兜底救过我的命。

6. 动态路由管理 API 与前端对接

6.1 提供路由管理的后端 API

动态路由还包含一个可管理的后台。我们要提供暴露给管理页面(或运维脚本)的 HTTP API,功能至少包括:查询所有路由、新增路由、修改路由、删除路由、刷新路由缓存。

路由实体类重写了 Java 端;管理 API 使用了RouteDefinitionWriter和RouteDefinitionLocator。这里给出一段简化的后端 Controller:

@RestController @RequestMapping("/route") public class RouteController { private final RouteDefinitionWriter routeDefinitionWriter; private final RouteDefinitionLocator routeDefinitionLocator; private final DynamicRouteService dynamicRouteService; public RouteController(RouteDefinitionWriter routeDefinitionWriter, RouteDefinitionLocator routeDefinitionLocator, DynamicRouteService dynamicRouteService) { this.routeDefinitionWriter = routeDefinitionWriter; this.routeDefinitionLocator = routeDefinitionLocator; this.dynamicRouteService = dynamicRouteService; } @GetMapping("/list") public Flux<RouteDefinition> list() { return routeDefinitionLocator.getRouteDefinitions(); } @PostMapping("/save") public Mono<ResponseEntity<Object>> save(@RequestBody RouteDefinition routeDefinition) { return routeDefinitionWriter.save(Mono.just(routeDefinition)) .then(Mono.fromRunnable(dynamicRouteService::refresh)) .thenReturn(ResponseEntity.ok().build()); } @DeleteMapping("/delete/{id}") public Mono<ResponseEntity<Object>> delete(@PathVariable String id) { return routeDefinitionWriter.delete(Mono.just(id)) .then(Mono.fromRunnable(dynamicRouteService::refresh)) .thenReturn(ResponseEntity.ok().build()); } }

动态路由管理 API 配合细粒度的权限校验很重要。网关是核心组件,路由规则一旦被恶意修改,整个系统的流量都可能被打到错误的地方。接口必须走内部管理系统鉴权,并且建议所有增删改操作记录操作审计日志。

6.2 SEO 优化思路:从"vue动态路由"到网关的路由可视化

前面提到热搜词里有"vue动态路由"这个热词。很多人会奇怪为什么它和网关动态路由关联起来。其实道理很简单——后台管理系统中路由权限动态生成,本身就是 Spring Cloud Gateway 网关动态路由的商业场景。前端页面根据当前登录用户动态生成菜单,菜单背后对应的 API 权限网格又反过来需要网关动态路由去控制访问范围和权限。

这块工作做得好,呈现出来的就是一个完整的 API 治理后台:

  • 前端 Vue 菜单,一个菜单对应一个路由 ID
  • 运维添加一条网关路由,生成对应的前端菜单项
  • 用户登录时查询自己的权限集合,后端把可访问的 API 列表动态下发
  • 前端动态渲染菜单

这种前后端配合的场景在真实的政企项目中太常见了。网上关于 vue 动态路由的文章多如牛毛,但大多只讲了前端怎么挂菜单,后端怎么配合做"动态",讲清楚的不多。网关的动态路由,本质上是权限控制的基础设施层:谁可以访问哪个 API,在网关这里先卡一道,前端菜单只是展示层的映射。

6.3 路由配置校验与回滚策略

最后补一个运维向的经验:动态路由变更不能没有回滚机制。

在运营后台做"变更单"的概念:任何一次路由变更都生成一个快照,存储在数据库或者配置中心的另一个 namespace 里。如果做了一次新增路由的变更导致线上故障,可以通过点击"回滚"按钮一键恢复成上一个版本。

路由配置校验分三层:

  • 格式校验:RouteDefinition的 id 不能重复,predicates 不能为空,uri 必须是合法的 URI 或lb://格式
  • 冲突校验:新路由的断言与现有路由是否有重叠和冲突(尤其是Path=/api/**这类白名单兜底路由)
  • 连通性校验:新路由目标lb://service-name在注册中心是否存在,如果连服务都没有,保存时直接给出警告,不允许强制上线

这些校验说起来简单,但实际开发中经常被忽略。我见过最惨的一次事故是运维在后台把一条Path=/的全局兜底删了,不到一分钟,前端所有页面 404,静态资源全部没有转发过去。紧急恢复的代价是十分钟的线上事故——如果当初写校验时拦住"删除兜底路由"这个操作,事故根本不会发生。

写在最后

网关这个组件,平时不起眼,一旦出问题就是全局性的事故。我自己做这几次实践下来最大的感受是:动态路由、自定义过滤器、全链路日志这三块不是孤立的,它们共同构成了网关的可运维性。过滤器负责业务规则,动态路由解决变更效率,日志链路提供可观测性。三件事都做扎实了,网关才真正算得上稳定。

最后再分享一个小细节:如果你要走向生产环境,记得给网关注册一个独立的日志目录,不要跟业务服务混在一起,日志清理策略提前定好。访问日志保留 30 天、链路追踪日志保留 7 天、错误日志保留 90 天,这是我在线上稳定运行下来比较合理的周期配置。希望这篇实战记录能给正在搞网关的人一些参考。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询