1. 先从"能不能集群"这个问题切进去
我经常在技术交流群里被问到一句话:Spring Cloud Gateway 能做集群吗?
问这个问题的人往往刚把网关搭起来,压测一跑发现单机撑不住,或者担心它是"有状态"的中间件,不敢随便横向扩容。我一般会先反问一句:你的网关里存了什么状态?
如果你只是用 Gateway 做路由转发、统一鉴权、灰度分流、限流,那它本质上就是一套无状态的响应式代理层,完全可以做集群。只要前面挂一个负载均衡器(Nginx、云上 SLB 或者干脆走 K8s Service),后面起多个 Gateway 节点,配置保持一致性,流量分过去就行。
但这句话背后有一个前提——你得理解 Gateway 的微内核架构到底是怎么回事。如果你不理解它的"内核"在哪、"扩展点"在哪,就容易把集群做出来,却把状态塞进了不该塞的地方,比如把用户 Session 放本地内存、把限流计数器存在单机 Memory 里、把灰度配置写死在各实例的 YAML 中。然后集群一上线就翻车。
所以这篇文章不打算讲那些"三步启动路由配置"的入门内容,而是围绕微内核架构和可插拔过滤器来拆解,把 Spring Cloud Gateway 从"能跑路由"到"能在生产环境横向扩展"之间需要想清楚的事,一次讲透。
2. 微内核架构在 Gateway 里到底指什么
2.1 内核和插件的边界怎么划分
很多人第一次听到"微内核"这个词是在操作系统领域——内核只管进程调度、内存管理、通信等基础能力,文件系统、网络协议栈、驱动全都可以做成模块,按需加载。Eclipse 也是这么设计 IDE 的,核心引擎很小,各种语言支持、版本控制全靠插件。
Spring Cloud Gateway 的设计思路和我上面说的非常像。它的"内核"只做三件事:
- 接收 HTTP 请求,解析协议和参数;
- 根据配置的路由规则,把请求匹配到对应的目标服务;
- 构建过滤器链,在请求转发前后按顺序执行一系列逻辑。
真正的业务扩展能力,全部交给了过滤器体系。你不需要改内核源码,不需要去动 WebFlux 的底层调度逻辑,只需要实现 Gateway 规定的过滤器接口,把新增能力注册进过滤器链,新的功能就生效了。这就是"可插拔"。
2.2 GlobalFilter、GatewayFilter、GatewayFilterFactory 三者的关系
Gateway 的拦截逻辑分了三个层次,初学者很容易混:
| 类型 | 作用范围 | 配置方式 | 典型场景 |
|---|---|---|---|
| GlobalFilter | 对所有路由全局生效 | 代码注册为 Bean | 全局鉴权、全局日志、链路 TraceId |
| GatewayFilter | 只对指定路由生效 | 在路由配置里用 Filter 名声明 | 路径重写、灰度分流、局部限流 |
| GatewayFilterFactory | 生成 GatewayFilter 的工厂 | 通过配置传参 | 让过滤器可配置化,而不是写死参数 |
GlobalFilter 好理解,相当于 Servlet 里的 Filter,所有经过网关的请求都会被它过一遍。GatewayFilter 则具备了"路由级"的粒度,你可以让 /api/order/ 走一个过滤器,让 /api/user/ 走另一个过滤器。
GatewayFilterFactory 是这两者之间最关键的桥梁。因为 Gateway 的设计哲学是"配置驱动"——我们不希望每次调整参数都改代码重新发版,而是希望直接在 application.yml 里写一段配置,过滤器就能按配置生成并生效。Factory 就是这个桥梁:它读取配置参数,生成一个带具体配置的 GatewayFilter 实例。
这三者配合起来,才真正体现出微内核的威力:内核不动,插件(过滤器)通过配置动态组合。新增一个功能,大多数情况下只写一个新 Factory 类,再在配置里声明,既不需要改核心,也不需要重启所有节点。
2.3 过滤器链的一次完整调用过程
Gateway 的建议使用方式是基于 WebFlux 的响应式模型,所以整个调用链都是异步的。一次请求从进入网关到返回响应,大致经过下面这段路径:
- 请求到达 DispatcherHandler,Gateway 内部通过 RoutePredicateHandlerMapping 找到匹配的 Route;
- 根据 Route 里的过滤器定义 + 全局的 GlobalFilter,组装出一个 GatewayFilterChain;
- 过滤器链中的每个过滤器依次执行
filter(exchange, chain),在方法体内你选择放行(调用chain.filter(exchange))或者短路(直接返回响应); - 请求最终通过 Netty HttpClient 转发到下游服务;
- 下游响应再逆序经过过滤器链返回到客户端。
Spring Cloud Gateway 参考了这种"责任链模式",DefaultGatewayFilterChain就是一个纯函数式的递归调用。每一层过滤器有权决定要不要继续往下传,这也是很多权限校验、限流、熔断操作能够"短路拦截"的原因所在。
理解了这个调用链,你再去写过滤器心里就清楚——你在chain.filter(exchange)之前写的代码是前置逻辑,之后的代码是后置逻辑。而且因为底层是响应式的,后置逻辑往往要依赖then()或者响应式的回调来处理。
3. 生产级网关的功能边界与扩展点设计
3.1 企业网关通常要承载哪些职能
如果在生产环境只拿 Gateway 当转发工具用,其实是浪费。现实中一个中大型微服务系统的入口网关注册下来,通常要承担这么几类能力:
- 安全相关:签名校验、Token 鉴权、IP 白名单、敏感接口防刷;
- 流量治理:限流、熔断、超时控制、并发控制、全局限流;
- 流量调度:灰度发布、按版本路由、按地域路由、蓝绿切换;
- 协议转换:外部 HTTP 转内部 RPC、请求响应字段映射、日志脱敏;
- 可观测性:TraceId 生成与透传、请求日志、耗时统计、异常告警。
这些功能如果全堆在业务系统里,场景一变就要改服务代码,成本太高。放在网关层统一处理,既独立又灵活。但它也带来一个工程管理问题:网关层的代码很容易腐化。
3.2 微内核思想的落地:把功能拆成独立过滤器插件
我在实际团队里带网关项目时,定过一条规矩:网关的"主工程"只保留路由配置、框架启动、基础 Bean 装配,所有具体业务逻辑一律放进独立的过滤器插件工程里,通过自动装配或 SPI 机制加载到主工程。
具体落地上分两类:
一类是和业务强相关的过滤器,比如某个特定业务的签名校验,我们做成一个独立 Maven 模块,命名类似gateway-filter-auth,里面包含完整的 Filter、Config、AutoConfiguration。主工程引入这个依赖后,只要配置里声明路由过滤参数,功能就生效。要下线某个功能时,把依赖注释掉,重新构建主工程即可。
另一类是和业务无关的通用过滤器,比如 TraceId 透传、统一异常处理、耗时上报。我们把它做成一个gateway-filter-common基础包,所有网关实例都会加载。
这样的好处是清晰了:内核(主工程)保持精简,新增功能极少去动主工程代码,团队里不同小组可以同时开发不同过滤器模块,互不干扰,各自测试。这其实就是微内核架构在高并发中间件里的工程化翻译。
3.3 为什么选择"可插拔过滤器"而不是 AOP
我经常被问到:Gateway 这种集中处理逻辑的场景,用 Spring AOP 做切面不也一样吗,为什么要费劲去写 GatewayFilterFactory?
这个问题我确实认真想过。AOP 适合在现有方法调用链上做横切,比如记录 Service 层方法耗时、做事务拦截。但网关的切入点不在方法上,而在"网络请求的处理管线"上。一个请求从进入到返回,要经历路由匹配、多重过滤器、响应映射等多个阶段,AOP 无法优雅地对"过滤器链中某个阶段"做细粒度控制,尤其当你需要"短路拦截"时——直接跳过后续过滤器,AOP 的实现会很别扭。
更重要的是 GatewayFilterFactory 具有配置化能力。AOP pointcut 的表达式写死逻辑边界,而 Factory 可以在 YAML 里用一行配置来声明过滤器的具体参数。生产环境中,我经常通过 Nacos 或 Apollo 动态调整某个过滤器的开关和阈值,这在 AOP 模式下是做不到的。
所以我认为,只要你的扩展逻辑是和"请求经过网关的某一阶段"相关,就应该走过滤器。只有当你要侵入的是 Spring 容器内部的 Bean 生命周期,才值得考虑 AOP。
4. 手把手开发一个生产级可插拔过滤器
4.1 三个真实场景:灰度、签名校验、动态限流
这部分直接进入实战。我拿三个典型的过滤器开发案例来讲,这三个案例我在生产环境都落地过,绝不是网上那种 Hello World 演示。
第一个是灰度发布过滤器:请求带特定 Header(比如X-User-Id)时,按用户 ID 的哈希值把流量路由到灰度版本服务;不带 Header 时,按固定比例随机灰度。第二个是签名校验过滤器:某些开放接口需要校验请求签名,校验失败直接返回 401,不转发到下游。第三个是动态限流过滤器:基于 Redis 做分布式限流,集群环境下所有实例共享同一套限流状态。
落地的过程中,你会真正体会到 Gateway 的可插拔机制要怎么写才规范。
4.2 实战基础:抽象类怎么选
Spring Cloud Gateway 提供了几个抽象基类,大部分场景都不用直接实现GatewayFilterFactory接口:
AbstractGatewayFilterFactory<C>:最常用,配置类 C 定义参数结构;AbstractNameValueGatewayFilterFactory:适合从配置里读"键值对"参数的过滤器;AbstractChangeRequestUriGatewayFilterFactory:用于重写请求 URI。
先看灰度发布过滤器的实现。配置类有两个参数:headerName(灰度标记 Header 名)、grayTarget(灰度目标服务名或者权重值)。
public class GrayReleaseGatewayFilterFactory extends AbstractGatewayFilterFactory<GrayReleaseGatewayFilterFactory.Config> { public GrayReleaseGatewayFilterFactory() { super(Config.class); } @Override public List<String> shortcutFieldOrder() { return Arrays.asList("headerName", "grayTarget"); } @Override public GatewayFilter apply(Config config) { return (exchange, chain) -> { ServerHttpRequest request = exchange.getRequest(); String headerValue = request.getHeaders().getFirst(config.getHeaderName()); boolean isGray = false; if (StringUtils.hasText(headerValue)) { // 按 Header 值精确匹配指定灰度用户 isGray = config.getGrayUsers().contains(headerValue); } else { // 没有指定用户时,按照用户 ID 或者 IP 哈希决策 String userId = request.getQueryParams().getFirst("userId"); if (StringUtils.hasText(userId)) { int hash = Math.abs(userId.hashCode() % 100); isGray = hash < config.getGrayPercent(); } } if (isGray) { ServerHttpRequest mutatedRequest = request.mutate() .header("X-Target-Group", "gray") .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } return chain.filter(exchange); }; } public static class Config { private String headerName; private String grayTarget; private int grayPercent = 10; private List<String> grayUsers = new ArrayList<>(); // getter/setter 略 } }这段代码的关键点是shortcutFieldOrder()方法。它决定了在 YAML 中可以用简化写法配置过滤器参数:
filters: - GrayRelease=headerName=X-User-Id, grayTarget=gray-service如果没有声明shortcutFieldOrder(),就只能用完整写法- name: GrayRelease args: ...。这个细节很多人写过滤器时漏掉,导致配出来怎么都不生效。
4.3 完整配置类与 YAML 注册解析
再看签名校验过滤器的完整实现。这个过滤器我加了一个"白名单路径"参数,和路由前缀不匹配的请求不校验,这样某些公开接口或回调接口可以绕过签名检查。
public class SignatureAuthGatewayFilterFactory extends AbstractGatewayFilterFactory<SignatureAuthGatewayFilterFactory.Config> { public SignatureAuthGatewayFilterFactory() { super(Config.class); } @Override public GatewayFilter apply(Config config) { return (exchange, chain) -> { String path = exchange.getRequest().getURI().getPath(); // 跳过白名单中的路径 if (config.getSkipPaths().stream().anyMatch(path::startsWith)) { return chain.filter(exchange); } String timestamp = exchange.getRequest().getHeaders().getFirst("X-Timestamp"); String sign = exchange.getRequest().getHeaders().getFirst("X-Sign"); if (!StringUtils.hasText(timestamp) || !StringUtils.hasText(sign)) { return unauthorized(exchange); } String encrypted = md5(timestamp + config.getSecretKey()); if (!encrypted.equalsIgnoreCase(sign)) { return unauthorized(exchange); } return chain.filter(exchange); }; } private Mono<Void> unauthorized(ServerWebExchange exchange) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().writeWith( Mono.just(exchange.getResponse().bufferFactory() .wrap("{\"code\":401,\"message\":\"signature invalid\"}".getBytes(StandardCharsets.UTF_8)))); } public static class Config { private String secretKey; private List<String> skipPaths = new ArrayList<>(); // getter/setter 略 } }对应的 YAML 配置长这样:
spring: cloud: gateway: routes: - id: order-service-route uri: lb://order-service predicates: - Path=/api/order/** filters: - SignatureAuth=secretKey=abc123, skipPaths=/api/order/public,/api/order/callback这里有个容易踩的坑:Gateway 的过滤器名称解析规则是"类名去掉 GatewayFilterFactory 后缀后,首字母小写"。也就是说SignatureAuthGatewayFilterFactory对应的过滤器名是SignatureAuth。如果你把类命名为SignatureFilterGatewayFilterFactory,那配置名就变成了SignatureFilter,非常容易在 YAML 里写错导致启动不报错、请求不经过过滤器。
4.4 过滤器注册为 Bean 与自动装配的细节
写好 Factory 类之后,还需要把它注册进 Spring 容器。如果主工程直接能扫描到这些类,用@Component注解即可。但如果你是像我说的那样把过滤器拆分到独立模块里,主工程默认是扫描不到这个 Bean 的,这时候需要用到 Spring Boot 的自动装配能力。
在过滤器的 Maven 模块的resources/META-INF目录下创建spring.factories文件:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.gateway.filter.SignatureAuthAutoConfiguration再写对应的自动配置类:
@Configuration public class SignatureAuthAutoConfiguration { @Bean public SignatureAuthGatewayFilterFactory signatureAuthGatewayFilterFactory() { return new SignatureAuthGatewayFilterFactory(); } }注意,不同 Spring Boot 版本的自动装配机制名称有些差异。新版 Spring Boot 更推荐用AutoConfiguration.imports文件来声明自动配置类,文件名是META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports。如果是历史项目还在用 Spring Boot 2.3 之前,才建议沿用spring.factories。这个细节建议根据你项目实际版本选择,别盲目照抄网上的模板。
4.5 Order 排序规则:为什么有的过滤器先执行有的后执行
过滤器之间的执行顺序,决定了你的灰度过滤器能不能在鉴权过滤器之前看到用户信息,也决定了限流过滤器能不能拦截掉绝大多数非法的请求。
Spring Cloud Gateway 执行过滤器的排序依据是每个过滤器的getOrder()方法返回值。数字越小越先执行,负数优先于正数。GlobalFilter默认 order 是 0,GatewayFilter(路由级过滤器)的排序则是在路由定义里按顺序决定的——不过它会被包装成GatewayFilterAdapter以Ordered的方式加入链中。
在实际项目中我通常会做这样一版 order 规划:
| Order 区间 | 过滤器职责 | 示例 |
|---|---|---|
| -200 ~ -100 | 最基础的上下文处理 | TraceId 生成透传、HTTP 请求头规范化 |
| -100 ~ 0 | 安全类前置校验 | 签名校验、Token 鉴权、IP 白名单 |
| 0 ~ 100 | 流量治理类 | 限流、熔断、并发控制 |
| 100 ~ 200 | 路由相关改写 | 灰度路由、路径重写、加解密 |
| 200 以上 | 响应后处理 | 响应日志、异常响应包装 |
写 GlobalFilter 时我习惯不使用@Component而通过@Bean方法返回,这样可以在 Bean 方法上直接@Order(...)指定顺序,代码更清晰。路由级过滤器如果需要在全局过滤器之前或之后执行,要靠DefaultGatewayFilterChain内部对 route filter 和 global filter 统一排序来实现语义上的相对顺序,所以如果你要精细控制顺序,可以在 GlobalFilter 的 order 上都设计好区间,全局和路由混合执行时按排序算法统一处理。
5. 集群部署的关键设计与常见坑
5.1 无状态是集群的前提
回到文章开头的问题——Spring Cloud Gateway 能不能做集群?答案很明确:能。但前提是"无状态"。
有的开发者在写网关时,顺手把用户的登录 Token 对应的用户信息放进了网关本地ConcurrentHashMap,美其名曰"缓存用户信息,减少下游查询"。这套逻辑在单机环境下没问题,一旦横向扩容成多实例,用户第一次请求落到节点 A,第二次请求负载均衡器把请求转发到节点 B,节点 B 查不到用户信息,直接 401。这就是典型的有状态设计对集群的破坏。
网关层如果要缓存用户信息,正确的做法是放 Redis,或者干脆通过请求头把用户信息透传给下游服务,让下游自己处理。所有跨实例共享的状态必须外置到 Redis、数据库或者配置中心。
5.2 内置限流过滤器在集群环境中的问题
Spring Cloud Gateway 自带的RequestRateLimiter过滤器默认用的是InMemoryRateLimiter,这在单机压测时候表现还行,但上了集群之后立刻失效——每个实例各自统计自己的请求数,假设你配置了"每秒最多 100 个请求",4 个实例在 1 秒内各自放过 100 个请求,总共 400 个请求被打到后端服务,限流形同虚设。
解决这个问题不复杂,但需要额外的工作。RequestRateLimiter内部通过RateLimiter接口抽象了限流器,默认的InMemoryRateLimiter换成RedisRateLimiter就能做到分布式一致。配置:
filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 10 redis-rate-limiter.burstCapacity: 20 key-resolver: "#{@userKeyResolver}"对应的 KeyResolver Bean 需要你自己定义,常见的有按 IP、按用户 ID、按接口路径等方案。
@Bean public KeyResolver userKeyResolver() { return exchange -> { String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id"); return Mono.just(StringUtils.hasText(userId) ? userId : exchange.getRequest().getRemoteAddress().getHostString()); }; }这里有个容易被忽略的问题:RedisRateLimiter 底层是基于 Redis + Lua 脚本实现的令牌桶,它依赖于系统时钟的稳定性。如果 Redis 和网关实例之间的时间不同步,或者 Redis 集群存在主从切换时的时间回拨,限流结果会产生偏差。生产环境最好把 NTP 时钟同步做扎实。
5.3 配置一致性:集群节点行为一致的前提
集群环境中每个 Gateway 实例的路由配置、过滤器配置必须保持严格一致。否则同一个请求打到不同节点,行为和结果完全不一样,这在线上是灾难级的故障。
我见过最典型的反面案例是:运维在某个实例上手动修改了 application.yml,想着"只在这个节点上改一下测试效果"。结果这个节点被负载均衡器选中处理了线上流量,行为和其他节点不一致,引发了半个小时的诡异故障。
正确做法是把配置外置到配置中心(Nacos / Apollo / Consul),所有实例从同一个配置中心拉取相同配置。配合配置中心的监听机制,网关实例在配置变更后自动刷新本地路由缓存。这样集群的"一致性"从运维层面就约束住了,而不是依赖人肉保证。
Gateway 自身的路由读取也是支持动态刷新的。配置中心在推送新路由时,Gateway 的RouteDefinitionRepository会感知到变化,重新加载路由。我自己踩过一个坑:当时用 Nacos 做配置中心,网关路由配置是持久化在 Nacos 上的,但某次发布时发现部分节点没有刷新路由。后来排查发现是监听器只监听了特定 dataId,而我把多个路由配置写成了多个 dataId,导致部分节点没收到变更通知。后来统一用了一个 dataId 承载全部路由,再补充一条监听规则,这个问题才解决。
5.4 WebSocket、SSE 这类长连接在集群下的粘性问题
如果你的网关后面有 WebSocket 或者 Server-Sent Events(SSE)的服务,集群部署时要注意连接粘性。
HTTP 请求是无状态的,每次请求可以随机分发到任意节点。但 WebSocket 建立连接之后,客户端和网关节点之间保持一条长连接,如果网关节点重启或者负载均衡策略把后续消息分发到另一个节点,原来的连接就断了,业务可能因此中断。
处理这个问题有几种思路:
- 负载均衡器层配置会话保持(Session Affinity),同一个客户端的连接始终指向同一个网关节点;
- 对网关做优雅停机,在 K8s 滚动发布时配置
preStop钩子,先摘除流量,再等待存量连接处理完或者延迟一定时间再终止 Pod; - 网关节点之间通过网络层方案做连接迁移,但这样做复杂度高,一般场景不需要。
我实际的经验是:中小规模场景用 K8s Service + ServiceAffinity 或者 Nginxip_hash就能满足需求;大规模场景如果对连接粘性容忍度低,建议考虑 tcp 层或者在网关之上再架一层负载均衡器来处理长连接。总之,这不是一个"不能做集群"的理由,而是一个"要做集群必须考虑"的细节。
5.5 响应式过滤器链中不要做阻塞调用
这一点可以说是 Gateway 性能的头号杀手,也是很多压测上不去、CPU 打满、线程池阻塞的根源。
Gateway 底层是 Netty 的 EventLoop 线程模型,它的核心设计哲学就是"线程不能阻塞"。所有 I/O 操作必须是异步非阻塞的。但很多从传统 Spring MVC 转过来的开发者,习惯性地在过滤器里写了这样的代码:
UserInfo user = userService.getUserInfoByRestTemplate(userId); // 同步阻塞调用getUserInfoByRestTemplate 会阻塞当前线程等待 HTTP 响应,一个 EventLoop 线程被阻塞了,后面排队的请求就全部卡住。压测一开,QPS 直接掉到两位数,甚至无响应。
解决方法是:在过滤器链中尽量用WebClient做非阻塞调用,或者把某些逻辑异步化后合入响应式链路里。如果你实在没法绕开一个同步的 SDK 调用(比如某些老旧的 RPC 客户端),至少用subscribeOn(Schedulers.boundedElastic())把它切换到独立线程池去跑,别占用 EventLoop 线程。
网关压测的时候,CPU 负载在 30% 左右的时候往往已经接近吞吐瓶颈,因为 Netty 的 EventLoop 线程数默认是 CPU 核心数的两倍,每个线程能承担的并发连接是有限的。阻塞调用会把那种"看似闲置又能承载新任务"的线程池状态破坏掉。所以我的经验法则是:写网关过滤器时先问自己一句,这里有没有阻塞调用?有,就必须改。
6. 可插拔过滤器工程的工程化管理经验
6.1 过滤器模块的依赖与版本管理
当你把过滤器拆成一个独立 Maven 模块时,依赖管理需要认真规划。过滤器模块只依赖 Gateway 核心 API 和必要的公共库(如 Lombok、Jackson),不要引入业务系统的依赖——否则会带来无意义的依赖纠缠和容量膨胀。
我用的是多模块 Maven 结构:
gateway-app (启动主工程) └── 依赖 ├── gateway-filter-common (公共过滤器) ├── gateway-filter-auth (业务认证过滤器) └── gateway-filter-gray (灰度过滤器)每个过滤器模块独立负责自己的pom.xml,版本统一在父工程里用dependencyManagement管理。这样新增过滤器就是"加一个模块 + 在主工程加一行依赖",旧功能下线就是注释掉依赖,干净利落。
6.2 把过滤器参数配置化的边界
可插拔不仅仅指"过滤器代码可以插进主工程",还包括"参数可以通过配置热调整"。我在生产环境有这样一个原则:
- 一次性、固定的逻辑(比如某个接口固定要加一个响应头)可以直接写在代码里;
- 有可能变化、需要运营调整的参数(比如灰度放量比例、限流阈值、白名单列表)必须抽成配置参数,通过配置中心下发。
以灰度过滤器为例,灰度比例、灰度用户名单、灰度目标版本号都应该是配置项。灰度比例尤其需要在运营过程中动态调整,上午 10%、下午 20%、晚上 50%,这些都不能靠发版来实现。
用 Nacos 做配置中心时,配置变更后还要考虑"当前已经进入过滤器链的请求怎么处理"。我的做法是允许"最终一致"——新配置在下一次请求进来时生效即可,不做请求级的中途切换,因为中途切换会引入额外的上下文复杂度和出错面。
6.3 过滤器性能与可观测性:给每个过滤器打点计时
生产环境中排查网关慢请求,最大的障碍就是不知道慢在哪。我强烈建议在自定义过滤器里统一埋点。
最简单的埋点方式,是写一个包装过滤器(或者嵌入一个基础过滤器里),在执行每个 GatewayFilter 时记录耗时。用 micrometer 的 Timer 或者直接打日志都行。重点是要有 traceId,能够把一次请求在多个过滤器的耗时串起来。
库存过滤器上耗时特别高的,后面要去排查是不是里面有同步调用;耗时波动特别大的,要去看是不是线程池被其他业务占满了;耗时稳定但总量大,要考虑是不是过滤器的顺序不合理导致部分工作白做。
我这里也踩过坑:有一次灰度发布后流量激增,网关的日志量暴涨,TraceId 打了,但没打过滤器名,我看日志完全定位不了是哪一环慢。后来统一规定了日志格式——[traceId] [filterName] [costTime] [routeId],问题瞬间好查多了。这就是工程规范的价值,它不是锦上添花,是救命必备。
7. 最后分享几个我自己常踩的坑和应对方式
写了一长串原理和实战,最后分享一些零散但很实用的经验,都是我在生产和复盘过程中总结出来的,按重要性排一下。
第一,善用"短路"能力,但短路时要写全响应体。前面签名校验里我用exchange.getResponse().writeWith()返回 JSON,但很多人只设置状态码就返回了,下游客户端的排查进度直接归零。生产级过滤器短路返回时,响应体建议包含 code、message、traceId 和 timestamp,方便客户端快速定位自己请求被拦的原因。
第二,谨慎使用@RefreshScope。如果你的过滤器 Bean 用了@RefreshScope,配置中心的参数刷新后 Bean 会重新创建。但 Gateway 的路由配置和过滤器链是基于缓存构建的,Bean 重建之后很久都不生效,甚至可能因为重建过程中的状态丢失引发服务启动后首波请求 500。我一般不让过滤器 Bean 直接配置刷新,而是通过一个独立的 ConfigurationProperties 类来承载动态参数,配置刷新只更新属性对象,不重建过滤器实例。
第三,全局过滤器里尽量少碰HttpServletRequest。很多人把 Spring MVC 的习惯带进来,想直接从RequestContextHolder取当前请求。Gateway 是 WebFlux 模型,没有线程绑定请求的概念,RequestContextHolder根本拿不到东西。所有请求状态都通过ServerWebExchange传递,务必在 filter 参数里传好。
第四,异常处理要放在全局层面。网关层集中处理业务异常,是指网关要把异常兜底成统一的响应结构,但你自己的业务异常尽量抛到过滤器链外,由全局异常处理器统一处理,而不是在过滤器里做 try-catch 散落得到处都是。我之前维护过一段代码,三个过滤器里各写了一份异常包装逻辑,后来改响应结构的时候改了三个地方才改全,完全是给自己挖坑。
第五,把 Gateway 当作独立服务看待,给它独立的监控大盘。不仅要看 JVM、GC、CPU,更要看"过滤器执行次数"、"路由命中次数"、"限流触发次数"、"熔断开启时长"这些业务指标。网关上没有业务指标,故障时你就只能靠猜。
微内核架构的本质不是代码写得多漂亮,而是它给了你一个清晰的、可预测的扩展模型——核心稳定不变,扩展点按规则接入,边界清晰,参数可配置,行为可观测。Spring Cloud Gateway 把这一套设计落到 Java 生态里,落地程度确实高。你只需要尊重它的规则,把过滤器当成真正的"插件"来设计,集群扩展和功能迭代就不再是互相掰扯的问题了。