ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Cloud Gateway实战:路由、断言、过滤器与压测调优

Spring Cloud Gateway实战:路由、断言、过滤器与压测调优

1. 为什么最终选了 Spring Cloud Gateway:网关选型背后的现实考量

先交代一下背景。去年我们组在做一个面向多端(小程序、App、Web)的电商业务系统改造,老网关是 Spring Cloud Zuul 1.x,跑在传统 Servlet 容器上。随着服务拆分越来越细,前端调用一个聚合页要发七八次请求,每次都要过网关做鉴权、灰度、限流,Zuul 1.x 的线程模型开始扛不住了——同步阻塞式 IO,每个请求占一个 Tomcat 工作线程,压测到 2000 QPS 时线程池被打满,响应时间直接从 80ms 飙到 1200ms。当时团队内部讨论过三条路:继续用 Zuul 2.x(异步非阻塞)、自研一个轻量网关、或者换 Spring Cloud Gateway。

先说 Zuul 2.x。它确实是基于 Netty 的异步模型,但和 Spring Cloud 生态的整合不算顺滑,社区里有些模块一直停在里程碑版本,我们不敢拿生产环境赌。自研网关听着很美,但考虑到要对接已有的 Nacos 注册中心、配置中心、SkyWalking 链路追踪、Sentinel 限流,短时间内做出一套生产级网关不太现实。所以最后选了 Spring Cloud Gateway。

选它的理由很实在:第一,它是 Spring Cloud 官方主推的网关组件,基于 Spring WebFlux + Reactor + Netty,天然就是响应式非阻塞,线程模型和我们现有系统匹配;第二,它和 Nacos、Sentinel、Sleuth 这些组件的集成方案都是现成的,不用自己造轮子;第三,路由、断言、过滤器这套抽象非常清晰,做灰度、鉴权、限流时扩展成本低。

一句话总结这个组件能干什么:它是一道位于客户端和服务端之间的流量闸门,所有外部请求先经它做路由转发、安全检查、流量控制,再把请求分发到后端的用户服务、商品服务、订单服务。如果你正准备做微服务网关选型,或者已经用 Gateway 但只是照文档配了几个路由、遇到问题不知道怎么调试,这篇文章可以给你一条完整的实战路径。

2. 路由、断言、过滤器:先把三件套的底层逻辑捋清楚

2.1 一个请求在网关里的完整旅程

Spring Cloud Gateway 的文档里总在提"路由(Route)、断言(Predicate)、过滤器(Filter)"这几个词。很多新手一上来就配路由,出了错不知道原因,本质上是没把三者的协作逻辑搞清楚。我用自己的话复盘一遍:

网关启动时会把配置的每个路由组装成一个Route对象,这个对象由三部分组成——id(唯一标识)、predicate(一组匹配条件)、filter(一组过滤器链)。当一个 HTTP 请求到达网关时,执行顺序是这样的:

  1. 请求进入DispatcherHandler,被RoutePredicateHandlerMapping拿去做断言匹配。网关会遍历所有路由,把请求的 URI、Header、Query 参数、Cookie、时间、Method 等信息交给每个路由的断言去判断,如果某个路由的所有断言都通过,就选定这个路由。
  2. 路由确定后,请求进入过滤器链。过滤器链由两部分拼接而成:所有全局过滤器(GlobalFilter)+ 这个路由上配置的过滤器(GatewayFilter)。
  3. 过滤器链按Order顺序依次执行filter(exchange, chain)。前置逻辑(比如鉴权、加请求头)在chain.filter()之前做,转发调用在filter()执行,后置逻辑(比如记录响应耗时)在filter()返回的Mono上做回调。
  4. 请求通过被NettyRoutingFilter转发到目标服务,响应原路返回。

这里有个关键点:断言的匹配结果只决定"走哪个路由",过滤器的执行决定"请求怎么被处理"。断言的逻辑是and关系,一个路由下的多个断言必须全部通过;多个路由之间是or关系,谁先匹配上谁先接管。

2.2 为什么说 Gateway 是"路由优先"而非"拦截器优先"

我把这个特点叫"路由优先模型",因为它和 Zuul 1.x 的设计哲学不同——Zuul 先有 ServletFilter链,再在其中做路由;Gateway 先有路由,路由上绑过滤器。这带来的实际影响是:同一个全局过滤器,不能像 Zuul 那样对"非路由的非法请求"做前置拦截,因为请求只有先被某个路由匹配,才会进入过滤器链。

这就引出一个很多团队踩过的坑:想拦截一个没有配置路由的路径,比如/health或者/metrics,写了全局 Filter 却发现根本没走到。原因不是 Filter 写错了,是请求没有匹配到任何路由,直接返回 404,RoutePredicateHandlerMapping压根没把请求交给 Filter 链。解决方案有两种,要么给这个路径单独配一个不转发的路由,要么用RequestMappingHandlerMapping的处理器先做拦截。我在下面第 6 节会详细演示。

2.3 和 Zuul 1.x 的线程模型差异

这部分对理解 Gateway 的性能上限很重要。Zuul 1.x 是 Servlet 阻塞模型,每个请求从进入到响应结束全程占有一个工作线程。Gateway 基于 Netty 的 EventLoop 线程,请求处理是事件驱动的,线程在等待下游响应时不会被挂起,可以继续处理其他请求。这就是为什么同样 2C4G 的配置,Zuul 最多抗 2000 QPS 左右,Gateway 可以稳定到 6000+ QPS(前提是下游不慢、参数没乱调)。

但要注意,"响应式"不代表可以把上游的所有问题都包住。下游服务如果响应极慢,连接数、堆积的请求还是会占资源的,只是表现形式不一样——Zuul 表现为线程池耗尽,Gateway 表现为connection pool exhausted或者 GC 压力上升。因此网关的超时和连接池参数必须单独调,这个我留到第 7 节讲。

3. 从零搭建一个可运行的路由服务:依赖、配置与第一个断言

3.1 环境与依赖版本选择

我们项目用的是 Spring Boot 2.3.12.RELEASE 搭配 Spring Cloud Hoxton.SR12,Gateway 的 maven 坐标是spring-cloud-starter-gateway。这里提醒一句:不要同时引入spring-boot-starter-web,因为 Gateway 本身是 WebFlux 应用,引入 Web 会触发Spring MVC found on classpath的冲突,导致应用起动后路由不生效,日志里还会出现诡异的端口占用提示。如果项目必须保留一部分 MVC 代码,用spring-boot-starter-webflux替代,并且把原有的@RestController包扫排除掉。

pom 里最小依赖如下:

<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency>

3.2 服务注册发现与自动路由

网关和下游服务都注册到 Nacos 后,可以用lb://前缀做负载均衡路由,这样下游服务多实例扩展时,Gateway 会自动通过负载均衡器选择一个可用实例,不需要写死 IP。

spring: application: name: gateway-server cloud: nacos: discovery: server-addr: 127.0.0.1:8848 gateway: discovery: locator: enabled: true lower-case-service-id: true routes: - id: user-service-route uri: lb://user-service predicates: - Path=/api/user/** filters: - StripPrefix=1

注意discovery.locator.enabled这个配置:开启后,网关会自动为 Nacos 里注册的服务生成路由,路径规则是/{serviceId}/**。这个功能在开发阶段很有用,但生产中建议关掉,因为自动路由意味着每个服务的所有接口都会裸奔到网关,容易绕过后续的权限过滤链。

3.3 第一个断言的完整验证流程

上面配置里我写了Path=/api/user/**和StripPrefix=1,这组合很常见,含义是:所有以/api/user/开头的请求转发到user-service,并且转发前把路径前缀去掉 1 段。举个例子:

  • 原始请求:GET /api/user/info?id=123
  • 经过 StripPrefix=1 后实际转发路径:GET /user/info?id=123
  • 下游 user-service 的 Controller 只需按/user/info定义接口

验证方法不复杂。启动 Nacos、user-service、gateway-server 后,用 curl 发一个请求:

curl -i http://localhost:8080/api/user/info?id=123

正常情况下,返回头里能看到Server: gateway-server之类的标记,返回体是 user-service 的响应。如果路由没生效,最直接的排查方式是开启 Debug 日志:

logging: level: org.springframework.cloud.gateway: TRACE

启动时会打印每个路由的RouteDefinition,请求进来时会打印断言匹配过程。我一般习惯同时打开 Netty 的日志看转发情况:

logging: level: reactor.netty.http.client: DEBUG

前者解决"路由为什么没匹配上",后者解决"转发后响应为什么不对"。

3.4 路由定义的多种写法对比

路由可以在 yml 里写,也可以用RouteLocator的 Bean 方式写,还有一种是动态从配置中心加载RouteDefinition。三者的使用场景不同:

写法优点缺点适合场景
yml 静态配置简单、直观、随手改改路由要重启或依赖配置刷新稳定不变的接口
Java RouteLocator Bean可以用代码做复杂拼接配置分散在代码里,运维不友好路由含强业务规则
动态 RouteDefinition 持久化改完立即生效,不用重启需要自研管理接口或引入现成方案上线频繁、多环境共用网关

我们生产上是"静态 + 动态"混合:通用业务路由写在 Nacos 配置中心的共享配置里,灰度、活动类路由存在数据库里,通过一个管理接口动态增删。动态更新的核心 API 是RouteDefinitionWriter.save(Mono.just(definition)),搭配ApplicationEventPublisher.publishEvent(new RefreshRoutesEvent(this)),刷新后路由立刻生效。

4. 内置断言工厂怎么选:从时间匹配到 Header 校验的实战场景

4.1 我实际用到的 6 个断言工厂

Spring Cloud Gateway 提供了 11 种内置断言工厂,覆盖路径、请求头、查询参数、Cookie、Method、时间、远程地址、Host、权重等维度。我在项目里用得最多的是下面这几个:

Path:按请求路径匹配。可以写多段通配,比如/api/order/**、/api/goods/{id}。/{id}这种占位符模式会在转发时保留,下游可以通过@PathVariable("id")获取,但要注意,如果配合StripPrefix使用,路径参数不会因为前缀剥离而变化,这点好多人混淆。

Method:按 HTTP 方法匹配。可以组合多个方法:- Method=GET,POST。主要用于管理端和 C 端共用网关系数时的分流。

Header:按请求头匹配两种写法:

  • Header=X-Token, \d+,要求 X-Token 存在且值匹配正则。
  • 只传名不传值,要求 Header 存在即可。

我们在灰度场景里会用 Header 判断用户是否命中灰度组:Header=X-Gray-User, vip|test-.*,命中的用户走灰度路由到新版本服务,其余走稳定版。

Query:按查询参数匹配。同样支持正则,典型用法是内部调用必须带签名参数Query=sign, [0-9a-f]{32},不合法参数在网关层直接挡掉。

Cookie:按 Cookie 匹配。用法是Cookie=login_token, .*,等价于"必须携带 login_token 这个 Cookie"。注意 Cookie 匹配的是 Value,不是 Name,这个细节容易看错文档。

Weight:按权重分流到不同路由。- Weight=group1, 10和- Weight=group1, 90搭配使用,实现 10% 流量打到灰度服务、90% 打到正常服务。这比 Header 灰度更随机,适合放量测试。

4.2 时间断言的困惑:为什么我建议少用

Between、After、Before这组时间断言看起来很适合"活动开始后放量",但实际用起来有个痛点:时间格式是基于 Asia/Shanghai 时区的ZonedDateTime,配置里写的时间会随系统时区变化。而且网关是多实例部署的,如果每台实例的系统时间有秒级偏差,流量分配就会出现不一致。活动流量控制我宁可放到业务服务的配置中心去做,也不依赖网关时间断言。

4.3 一个鸡生蛋的问题:自定义断言什么时候需要写

内置的断言覆盖了大部分场景,但有一种内置断言解决不了——需要判断"请求来源渠道"决定路由目标。比如 App 端请求路由到 App 集群,H5 端请求路由到 H5 集群。渠道通常通过X-Client-Type头标识,而且这个头可能会被上游代理改写。标准做法是写一个自定义断言工厂,名字叫ClientTypeRoutePredicateFactory,把它注册到 Spring 容器,然后路由配置写:

predicates: - Path=/api/home/** - Client-Type=app

本质上自定义断言就是实现RoutePredicateFactory接口,返回一个Predicate<ServerWebExchange>。这个ServerWebExchange是请求的上下文对象,可以从里面取 Header、Cookie、属性等。核心代码结构:

@Component public class ClientTypeRoutePredicateFactory extends AbstractRoutePredicateFactory<Config> { public ClientTypeRoutePredicateFactory() { super(Config.class); } @Override public Predicate<ServerWebExchange> apply(Config config) { return exchange -> { String clientType = exchange.getRequest() .getHeaders().getFirst("X-Client-Type"); return config.getExpected().equalsIgnoreCase(clientType); }; } @Validated public static class Config { private String expected; // getter/setter 略 } }

写完之后,配置文件里Client-Type=app这一段的名称就是类名的前缀——ClientType去掉RoutePredicateFactory后缀,首字母转中划线。这个命名规范很容易被忽略,命名不对时配置不报错但也不生效,调试起来非常痛苦。

5. 过滤器工厂的常见组合:跨域、限流、重试与熔断下沉

5.1 跨域配置:我踩过的五分钟异常

前端调网关最常见的报错是 CORS。很多人直接在网关层加一个全局 CORS 配置,却发现某些请求头带上了还是不通过。问题往往出在allowedHeaders和exposedHeaders没有对上。以下是我生产上用的全套配置:

spring: cloud: gateway: globalcors: cors-configurations: '[/**]': allowedOriginPatterns: "*" allowedMethods: - GET - POST - PUT - DELETE - OPTIONS allowedHeaders: "*" exposedHeaders: - X-Trace-Id - X-Request-Id allowCredentials: true maxAge: 3600

allowCredentials: true时,allowedOriginPatterns不能写*,必须写具体域名列表或 pattern。这是浏览器规范对凭证模式 CORS 的强制要求,不是代码能绕过的。很多团队在这卡住半天,最后发现是这个问题。建议exposedHeaders把自定义的请求头(比如 TraceId)放进去,否则前端 JS 读不到。

5.2 RequestRateLimiter 限流:Redis 化改造和参数选择

内置的RequestRateLimiter基于令牌桶,默认用RedisRateLimiter实现,需要引入spring-boot-starter-data-redis-reactive。典型配置:

filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 20 redis-rate-limiter.burstCapacity: 40 redis-rate-limiter.requestedTokens: 1

replenishRate是令牌补充速率(每秒往桶里加几个令牌),burstCapacity是桶容量(峰值允许的瞬时请求数)。比如上面配置,相当于桶里最多攒 40 个令牌,每秒补充 20 个,平均每秒限 20 次请求,允许突发到 40 次。

这个限流有一个比坑还坑的细节:默认按 API Key 维度,即每个客户端 Key 独立一个桶;如果所有用户共享一个 Key(比如没有鉴权头),那整个网关就只成一个桶了,所有人都要被限在一起。所以生产上我建议加一个自定义 KeyResolver,按用户 ID 或来源 IP 区分桶:

@Bean public KeyResolver userKeyResolver() { return exchange -> { String userId = exchange.getRequest() .getHeaders().getFirst("X-User-Id"); if (userId == null || userId.isEmpty()) { return Mono.just("anonymous_" + exchange.getRequest().getRemoteAddress().getHostString()); } return Mono.just(userId); }; }

这样每个用户一个限流桶,单个用户接口被刷爆不会影响其他用户。

5.3 重试过滤器:我劝你谨慎开启

内置Retry过滤器可以配置重试次数、重试状态码、重试序列等。听起来很香,但在 GET 之外的方法上要特别小心,因为它默认会对 POST、PUT 重试——下游如果已经处理成功但响应丢失,重试会造成重复扣款、重复下单。我们的做法是:

  • 只在读接口上开启重试,写接口一律关闭。
  • 重试状态码只配 502 和 504,不防 500,因为 500 往往意味着下游代码 bug,重试没意义还可能放大压力。
  • 配合spring.cloud.gateway.httpclient.retry的其他参数,控制重试间隔,避免瞬间把下游打垮。

5.4 熔断下沉:过滤器只做网关侧兜底

Gateway 的FallbackHeaders过滤器可以在转发失败时往响应头里塞异常原因,CircuitBreaker过滤器可以配合 Resilience4j 做熔断。但我个人建议,网关层的熔断只做兜底,真正要做的熔断还是放到下游服务自身的调用链里。原因很简单:网关是所有流量的汇聚点,如果网关整体熔断了,影响面是全局的。下游服务的某个接口故障,应当在服务间调用的时候就被熔断掉,不要把所有故障风险都压到网关这一层。

6. 自定义全局过滤器:从鉴权到日志的落地实现

6.1 全局过滤器的执行时机与 Order 设计

全局过滤器实现GlobalFilter接口,配合Order决定执行顺序。Order 值越小越先执行。我们生产上依据这套顺序规范(经验值,按场景微调):

Order过滤器职责
-1000灰度标记过滤器解析 Header/Cookie,往 exchange 属性写入灰度标识
-900鉴权过滤器校验是否登录、是否白名单,失败直接返回 401
-800签名校验过滤器校验接口签名
-700TraceId 注入过滤器生成全局 TraceId,放入请求头和响应头
100访问日志过滤器记录请求和响应耗时、状态码

Order 设计的原则很简单:能提前挡掉的请求,绝不放行到后面再挡。鉴权和签名一定要排在 TraceId 和转发的前面,这样才能减少无效请求对下游的压力。

6.2 鉴权过滤器完整代码

先说一个关键点:不要在全局过滤器里做太重的逻辑,比如查数据库、调鉴权服务。每次请求都调一次远端鉴权服务,网关的响应时间会从 5ms 变成 30ms,QPS 掉一半。正确做法是网关做"验签 + 校验 Token 存在性 + 缓存身份信息",把 Token 解密和有效期校验放在本地 JVM 缓存里,做不到的话至少用 Redis,不要把同步调用放在关键路径上。

下面是一个包含白名单跳过、Token 校验的全局过滤器骨架:

@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { private static final Set<String> WHITE_LIST = Set.of( "/login", "/register", "/captcha" ); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String path = request.getURI().getPath(); if (isWhiteList(path)) { return chain.filter(exchange); } String token = request.getHeaders().getFirst("Authorization"); if (token == null || !token.startsWith("Bearer ")) { return unAuth(exchange, "missing token"); } // 这里只做本地格式/缓存校验,细节略 String userId = parseUserIdFromToken(token); if (userId == null) { return unAuth(exchange, "invalid token"); } // 将 userId 放入 exchange 属性,后续过滤器与转发都可以用 ServerWebExchange mutated = exchange.mutate() .request(request.mutate().header("X-User-Id", userId).build()) .build(); return chain.filter(mutated); } private Mono<Void> unAuth(ServerWebExchange exchange, String msg) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().writeWith( Mono.just(exchange.getResponse().bufferFactory() .wrap(msg.getBytes(StandardCharsets.UTF_8)))); } @Override public int getOrder() { return -900; } }

有个工具性建议:exchange.mutate().request(...).build()这种写法在过滤器链里特别常用,凡是需要在转发前修改请求头、请求体时都用它。如果只是改响应头,用exchange.getResponse().getHeaders().add(...)。注意对请求体的修改比较复杂,需要在过滤器里重写 Body,我默认不建议在网关层改请求体,除非是做统一加密解密。

6.3 访问日志过滤器:别把请求体一起打

很多团队的访问日志是把整个请求体打出来,压测的时候日志文件一天能涨到几十 GB,还会拖慢网关。我的做法是只记录以下字段:时间、TraceId、方法、路径、目标路由 id、状态码、耗时。超过 1 秒的慢请求单独打到 WARN 级别,方便排查。

@Component public class AccessLogFilter implements GlobalFilter, Ordered { private static final Logger log = LoggerFactory.getLogger(AccessLogFilter.class); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { long start = System.currentTimeMillis(); return chain.filter(exchange).doFinally(signalType -> { long cost = System.currentTimeMillis() - start; ServerHttpRequest req = exchange.getRequest(); String routeId = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR) == null ? "unknown" : exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR).toString(); if (cost > 1000) { log.warn("[slow-gateway] path={} route={} cost={}ms", req.getURI().getPath(), routeId, cost); } else { log.info("[gateway-access] path={} route={} cost={}ms", req.getURI().getPath(), routeId, cost); } }); } @Override public int getOrder() { return 100; } }

7. 切到生产前必须处理的几个坑:超时、WebSocket 与内存问题

7.1 HttpClient 连接池与全局超时配置

前面讲过,Gateway 的转发是 Netty 客户端干的。默认的 HttpClient 连接池是固定大小(500),连接超时是 45 秒,响应超时没有全局默认值。生产上如果下游偶发慢请求,连接池被占满以后,新请求会一直排队等连接,网关 HTTP 状态看起来就是大量 504。我们当时配的参数如下:

spring: cloud: gateway: httpclient: connect-timeout: 2000 response-timeout: 5s pool: type: elastic max-idle-time: 30s max-life-time: 60s

connect-timeout是 TCP 建连超时,response-timeout是等待响应的总超时(毫秒或 Duration)。pool.type从fixed改成elastic,在连接不够时允许动态创建,避免峰值流量把连接池打爆。但 elastic 也不是毫无代价,动态连接意味着内存使用会上升,GC 压力变大,这两者在调优时要平衡。

7.2 WebSocket 转发:有个必须开的配置

如果网关需要转发 WebSocket 请求,路由配置里的Path断言保持不变,但 URI 要改成ws://或wss://的目标地址,并且必须在全局配置里打开 WebSocket 支持:

spring: cloud: gateway: websocket: auto-flush: true max-frame-payload-length: 65536

max-frame-payload-length不设置的话,默认只允许普通大小的数据帧,大图、音视频流可能直接被截断。另外,网关转发 WebSocket 时,一定要把断线重连和心跳逻辑放在客户端,网关层只做透传,不要在网关层实现心跳保活,否则断线清理非常麻烦。

7.3 大响应体与内存问题:不要默认打开缓存

Gateway 默认不会缓存下游响应体,而是以流式方式转发给客户端,这保证了内存占用很低。如果业务需要做"响应体脱敏""响应体二次处理",很多人直接写ReadBody/ModifyBody过滤器,把整个响应读进内存。这个操作等于把网关从流式模式改成缓冲模式,大文件下载时内存会急剧上涨。

我的处理建议是:非必要不在网关层动响应体。如果要动,必须给这个路由单独配置更小的连接池、加流控,并且完成压测后再上。我见过有团队为了加一个统一签名,把所有响应用ByteArrayInputStream读了一遍,业务高峰期网关直接 OOM。

7.4 自定义异常返回:让调用方拿到结构一致的错误

网关默认的 404、500 错误体是空的或框架默认格式,对前端很不友好。我们统一在全局配了一组错误处理:

@Configuration public class ErrorWebFluxConfig { @Bean public ErrorWebExceptionHandler gatewayExceptionHandler() { return new GatewayErrorWebExceptionHandler(); } }

核心思想是:实现ErrorWebExceptionHandler,捕获异常后统一返回 JSON 结构:{ code: 401, message: "unauthorized", traceId: "..." }。注意这个 Handler 和普通@RestControllerAdvice不一样,WebFlux 下的全局错误处理得走ErrorWebExceptionHandler的路径,很多人从 MVC 迁移过来在这里被卡住。

8. 压测与调优:Reactor 模型下最容易忽略的参数

8.1 我的压测方法:从 100 QPS 起步逐步加压

不要一上来就直接 5000 QPS 压,先跑小流量验证功能,再加压力找瓶颈。压测工具用 wrk 或者 JMeter 都行,我习惯用 wrk 做网关层压测,因为它能模拟大量并发连接,更容易暴露 Netty 连接池和线程调度的瓶颈。

wrk -t8 -c400 -d60s --latency http://localhost:8080/api/user/info?id=1

上面参数表示 8 个线程、400 个并发连接、持续 60 秒。先看 p99 耗时,如果 p99 超过 500ms,就需要检查是下游慢还是网关本身慢。区分方法很简单:压测时在网关日志里打开慢请求日志(第 6.3 节的过滤器),看cost是发生在转发前还是转发后。如果转发前耗时占比高,说明鉴权等过滤器太重;如果转发后占比高,说明下游服务是瓶颈。

8.2 Reactor 和 Netty 参数对 QPS 的影响

几个容易忽略的调优点:

参数默认值建议值作用
reactor.netty.ioWorkerCountCPU 核数保持默认EventLoop 线程数,默认够用,不用强调
spring.cloud.gateway.httpclient.pool.maxConnections500按压测结果调单路由最大连接数
spring.cloud.gateway.httpclient.pool.pendingAcquireTimeout60s5s获取连接的最大等待时间,过大会让请求堆积
spring.cloud.gateway.httpclient.pool.maxIdleTime30s30s~60s空闲连接回收时间
server.netty.connection-timeout30s2s客户端连网关的超时

这里重点说pendingAcquireTimeout。下游服务故障时,请求拿不到连接,默认会等 60 秒才报超时。前端最怕这种超时等待,结果就是网关层明明可以快速失败,却把失败感知拖到了一分钟。我们把它调成 5 秒,加上全局response-timeout: 5s,故障期间网关可以在 5 秒内返回 504,前端立刻可以走降级逻辑。

8.3 我之前压测发现的一个"假瓶颈"

有一次压测,QPS 卡在 3000 左右上不去,CPU 也没跑满,看火焰图发现大量时间花在日志打印上——访问日志用 Logback 同步写文件,每条请求打一行,压测期间 IO 把线程阻塞了。后来我们改成异步日志(Logback AsyncAppender)才把 QPS 推上去。这个经验提醒我,网关这类 IO 密集型组件,日志后端必须异步,所有同步 IO 都是隐形杀手。

8.4 性能劣化的第一排查路径建议

假设你遇到"网关响应变慢、QPS 衰减",按照下面的顺序排查能省很多时间:

  1. 先看 GC 日志,Full GC 频率高不高;
  2. 再看等待连接获取的日志,pendingAcquireTimeout有没有大量触发;
  3. 再看下游服务响应时间,是不是某个接口被拖慢;
  4. 最后看日志打印是否同步阻塞;
  5. 都正常但 QPS 还是低,用jstack抓线程,看线程堆栈卡在哪个环节。

按这套路径,我们大多数问题都能在 30 分钟内定位。

我自己在实际运维中体会最深的一点是:Spring Cloud Gateway 的功能本身不算难学,难的是理解了路由、断言、过滤器的协作之后,还能清醒地意识到——网关是流量入口,它的每一处优化都会被放大千百倍。建议你在自己的项目里先跑通一个最简单的路由,然后逐步叠加鉴权、限流、灰度这些过滤器,每加一个就压测一次对比。这样遇到问题更容易定位,也更能理解每个组件在你业务里的真实代价。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进