跳到正文

服务网关 ​

一句话:Gateway 把鉴权、限流、路由、日志这些「每个服务都要做一遍」的横切逻辑收到入口,用 WebFlux 的非阻塞模型扛连接数;代价是网关内部不能有阻塞调用。

适用版本:Spring Boot 3.2.x / Spring Cloud 2023.0.x / Spring Cloud Gateway 4.x(WebFlux 版)。不要与 spring-cloud-starter-gateway-mvc 混用。

为什么需要 ​

没有网关时:

  • 每个服务各自实现鉴权,规则不一致,改一次要发很多次版。
  • 限流散落在各服务,入口流量无法统一控制。
  • 前端要知道每个服务的地址,跨域要配很多次。
  • 灰度、A/B、路由变更没有统一入口。

网关解决的是「入口的横切面」,不是业务逻辑。把业务编排塞进网关,会让网关变成新的单体。

核心机制 ​

Route、Predicate、Filter ​

  • Route:一个路由 = 断言集合 + 目标 URI + 过滤器集合。
  • Predicate:匹配条件(Path、Header、Query、Weight、时间等),同一路由内是 AND。
  • Filter:请求前后处理;分 GlobalFilter(全局)和 GatewayFilter(单路由)。

请求在网关内部只走一条链:先匹配到唯一一个 Route,再把两类过滤器合成一条有序链,pre 正序、post 逆序。

路由匹配是「按 order 升序,第一个命中即返回」,不是遍历全部取最优。灰度路由要用显式的 order 把更具体的断言排在前面;只靠配置文件里的书写顺序不可靠。

过滤器顺序:一条链,不是两类分开跑 ​

常见误解是「GlobalFilter 先跑完,再跑 GatewayFilter」。实际是:

  1. 把 GlobalFilter 和当前路由的 GatewayFilter 合并成一个列表。
  2. 按 Ordered 排序(数值小的先执行)。
  3. 对每个 filter 调 filter(exchange, chain):先执行 pre 逻辑,再 chain.filter(),返回后执行 post 逻辑。

所以 pre 是升序,post 是同一批 filter 的逆序;不是「Global 全部 pre → Gateway 全部 pre」。

Spring Cloud Gateway 4.x 常用内置顺序(以官方源码为准):

Filterorder作用
RemoveCachedBodyFilterInteger.MIN_VALUE清理缓存的请求体
AdaptCachedBodyGlobalFilterInteger.MIN_VALUE + 1000适配已缓存 body
NettyWriteResponseFilter-1写响应
ForwardPathFilter0forward 场景改路径
RouteToRequestUrlFilter10000把路由 URI 转成请求 URL
ReactiveLoadBalancerClientFilter10150选实例并改写 URL
WebsocketRoutingFilterInteger.MAX_VALUE - 1WebSocket 转发
NettyRoutingFilterInteger.MAX_VALUE实际 HTTP 转发

旧版本的 LoadBalancerClientFilter(order 10100)已废弃,升级时不要照抄旧表。

为什么必须是 WebFlux ​

Gateway 基于 WebFlux/Reactor:少量 EventLoop 线程处理大量连接,转发时不需要「一个请求一个线程」。代价是:

  • Filter 里不能有 JDBC、Thread.sleep、同步 HTTP 这类阻塞调用;一个阻塞会卡住该 EventLoop 上的所有连接。
  • ThreadLocal 不可靠;需要上下文时用 Reactor Context 或显式传参。
  • 异常处理不能用 @ControllerAdvice,要用 ErrorWebExceptionHandler。

请求体上限:键不是 maxRequestSize ​

纯转发时,Gateway 不解码请求体,body 是流式转发的,不存在「默认 1MB 上限」。只有需要读 body 的过滤器(缓存、签名、日志)才会触发 codec 缓冲,受:

yaml
spring:
  codec:
    max-in-memory-size: 256KB   # Spring Boot 默认 256KB

要支持大请求体,调这个键;不存在 spring.cloud.gateway.maxRequestSize。如果只是上传大文件,优先让客户端直传对象存储,不要经过网关缓冲。

实战场景 ​

一、加了灰度路由,一部分请求 404:匹配是“第一个命中即返回” ​

现象:给 order-service 加一条灰度路由(按 Header 分流到 v2),上线后约 8% 的请求 404,另有少量灰度请求打到了稳定版本。两条路由单独测试都正常。

排查:路由匹配按 order 升序,第一个命中就返回,不遍历全部取最优。

定位层取证据判定
实际加载的路由与顺序curl /actuator/gateway/routes,看 order 与断言顺序与预期不同 → 命中了别的路由
断言组合同一路由内多断言是 AND;灰度路由是否同时要求 Path + HeaderHeader 缺失时 AND 不成立,落到兜底路由
通配重叠/api/order/** 与 /api/** 是否都能匹配同一请求都能匹配时由 order 决定,与书写顺序无关
路径改写StripPrefix=2 与后端实际 context-path网关命中但后端 404,表现为“网关没错、下游没路径”
目标服务lb://order-service 名称与注册中心 serviceName名称不一致 → 解析不到实例,日志有 Unable to find instance
命中分布按 route id 统计请求数某条路由命中为 0 → 断言根本没生效

本例命中第 3 行:兜底路由 /api/** 的 order 更小,先于灰度路由命中,灰度 Header 从未被评估。

对症做法:

yaml
spring:
  cloud:
    gateway:
      routes:
        - id: order-gray
          uri: lb://order-service-v2
          order: 10                       # 更具体的断言必须显式排在前面
          predicates:
            - Path=/api/order/**
            - Header=X-Gray, true
          filters:
            - StripPrefix=2
        - id: order-stable
          uri: lb://order-service
          order: 100                      # 兜底路由 order 更大
          predicates:
            - Path=/api/order/**
          filters:
            - StripPrefix=2
java
// 路由装配自检:启动时断言“同一路径前缀下,具体路由的 order 小于兜底路由”
@Test void grayRoutePrecedesStable() {
    List<RouteDefinition> rs = props.getRoutes();
    int gray = indexOf(rs, "order-gray"), stable = indexOf(rs, "order-stable");
    assertThat(rs.get(gray).getOrder()).isLessThan(rs.get(stable).getOrder());
}

配置中心里的路由要能灰度:先发一个网关实例、验证命中分布、再全量;/actuator/gateway/routes 的快照留档做发布前后 diff。

复盘要点:判据不是“配置看起来对”,而是“命中数分布符合预期”。给每条路由打指标(命中数、5xx 数、404 数),命中数为 0 或突然腰斩就告警;这能在用户报障之前发现顺序问题。

二、Filter 里一次同步查询,整站变慢 ​

现象:网关加了“查数据库拿用户权限”的 GlobalFilter。压测正常,上线后全站 RT 从 80ms 涨到 700ms,网关 CPU 只有 25%;某些时刻完全卡住,几秒后又恢复。

排查:WebFlux 的 EventLoop 线程数很少,一个阻塞会带走该线程上所有连接。

定位层取证据判定
EventLoop 线程栈jstack 看 reactor-http-nio-* 是否停在 JDBC/SocketInputStream.read卡在阻塞调用 → 直接确认
是否有 block()开 -Dreactor.debug=true,异常栈会指出在哪个算子阻塞block()/toFuture().get() 出现在 Filter 里
线程数与连接数reactor.netty.ioWorkerCount vs 当前连接数4 个 EventLoop 扛 2 万连接时,一次 20ms 阻塞就是灾难
影响面与故障路由无关的接口是否也变慢全站一起慢 → 不是下游问题,是网关
GC/队列Netty 直接内存与事件循环队列排除内存因素后再定位阻塞

根因就是那一句 permissionDao.findByUser(uid):它是同步 JDBC,跑在 EventLoop 上。数据库 P99 20ms,在 4 个 EventLoop 线程模型下相当于每 20ms 就让出一整个线程的处理能力。

对症做法:

java
// 1. 阻塞调用离开 EventLoop:显式切到弹性线程池,并接受线程切换成本
return Mono.fromCallable(() -> permissionDao.findByUser(uid))
           .subscribeOn(Schedulers.boundedElastic())
           .flatMap(perm -> chain.filter(exchange.mutate()
                   .request(withPermHeader(mutate, perm)).build()));

// 2. 更好的做法:鉴权数据走响应式客户端 + 本地缓存,不在每个请求上查库
private final WebClient authClient;                    // 非阻塞,复用连接池
private final Cache<String, Permission> cache = Caffeine.newBuilder()
        .maximumSize(20_000).expireAfterWrite(Duration.ofSeconds(30)).build();

// 3. 用 Reactor Context 或 exchange attribute 传上下文,不要用 ThreadLocal
return authClient.get().uri("/perm/{u}", uid).retrieve().bodyToMono(Permission.class)
        .contextWrite(Context.of("traceId", traceId));
yaml
# 4. 弹性线程池要有界,并暴露排队指标
spring:
  reactor:
    scheduling:
      pool: { name: bounded-elastic, capacity: 500, queue-capacity: 1000 }

异常处理同理:Gateway 里不能用 @ControllerAdvice,要用 ErrorWebExceptionHandler;否则异常路径又变成一次不可预期的阻塞。

复盘要点:网关的容量口径是“EventLoop 线程上的非阻塞程度”,不是 CPU。判据应固化成一条压测断言:注入 20ms 的下游延迟时,网关 P99 只上升约 20ms 而不是翻倍;再加一条静态检查——Filter 代码里出现 JDBC/block() 即 CI 失败。

三、300KB 请求体报 DataBufferLimitException ​

现象:客服批量导入接口上传 300KB CSV,网关返回 500,日志 io.netty...DataBufferLimitException: Exceeded limit on max bytes to buffer 262144。同样的请求直连后端服务正常。

排查:纯转发时 Gateway 不解码 body,是流式转发;只有需要读 body 的过滤器才会缓冲,上限来自 codec。

定位层取证据判定
报错来源栈里是否有 AdaptCachedBodyGlobalFilter、签名/日志 Filter是 → 缓冲发生在网关,不是后端
上限值spring.codec.max-in-memory-size(Spring Boot 默认 256KB)262144 正是 256KB,直接对上
是否真有 maxRequestSize搜配置项 spring.cloud.gateway.maxRequestSize不存在这个键,调它不会有任何效果
哪些请求受影响按路由统计 body 字节分布只有带 body 缓存的路由报错,其他正常
大文件是否该走网关上传类型统计(CSV、图片、附件)走网关缓冲会占住直接内存与带宽

对症做法:

yaml
# 1. 确有读 body 的需求时,按业务真实上限调 codec,而不是调到无限
spring:
  codec:
    max-in-memory-size: 2MB
java
// 2. 只在需要的路由上缓存 body:用 GatewayFilter 限定作用范围,而不是 GlobalFilter
//    签名校验这类需求优先用 Header 里的摘要,避免读取整个 body
.route("order-import", r -> r.path("/api/import/**")
        .filters(f -> f.requestSize(1_572_864)      // 拒绝超阈值请求,返回 413 而不是 500
                       .modifyRequestBody(...)))
yaml
# 3. 大文件上传走客户端直传对象存储,网关只签发临时凭证
- id: upload-token
  uri: lb://file-service
  predicates: [ Path=/api/file/sts-token ]
# 真实文件不经过网关:网关不做文件服务器

复盘要点:262144 这个数字要能立刻翻译成“256KB 的 codec 默认值”,否则排查会绕到后端服务。返回码也要治理:超过上限应给 413 和明确错误码,而不是 500;网关层的错误要带路由 id,便于定位是哪条链路的缓冲策略造成的。相关超时链路见 全链路超时与自适应限流。

最小上手 ​

依赖:

xml
<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>
yaml
spring:
  cloud:
    gateway:
      routes:
        - id: order
          uri: lb://order-service
          predicates:
            - Path=/api/order/**
          filters:
            - StripPrefix=2
bash
# 启动网关与 order-service 后
curl -i http://127.0.0.1:8080/api/order/1
# 期望 200;若 404,先看 /actuator/gateway/routes
curl -s http://127.0.0.1:8080/actuator/gateway/routes

lb:// 表示走 LoadBalancer;目标服务必须在注册中心里,且 order-service 名称一致。StripPrefix=2 会去掉 /api/order 两段,转发到后端的 /1。

生产动作 ​

  • 路由变更走发布流程:先发 1 个网关实例验证,再全量;核心路由加「命中数为 0」告警。
  • 404 排障:查路由 order、Path 断言、StripPrefix、lb:// 服务名、后端是否注册;不要先怀疑后端。
  • 超时:spring.cloud.gateway.httpclient.response-timeout 要大于下游 Feign 读超时 ×(重试 + 1);连接超时单独设。
  • 重试:Retry filter 只给幂等 GET;默认重试会放大下游故障。
  • 跨域:只在网关配一次 spring.cloud.gateway.globalcors,不要网关和服务各配一套,否则响应头重复。
  • 阻塞检查:Filter 内禁止 JDBC/同步 HTTP;必须调用时用 Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()),并接受线程切换成本。
  • 可观测:按路由统计 P99、5xx、404 比例、请求体拒绝数;路由匹配数骤降通常先于全站故障。
  • 高可用:Gateway 无状态,多实例 + 外层 LB;路由配置放配置中心,但变更仍需灰度和回滚能力。

边界 ​

  • 不是业务编排层:聚合多个服务的业务逻辑放 BFF 或服务内,网关只做横切。
  • 不是文件服务器:大文件走对象存储/直传,网关只做鉴权和重定向。
  • 不能替代 Nginx:TLS 终止、静态资源、极致吞吐仍可用 Nginx;常见是 Nginx → Gateway 两层。
  • WebFlux 生态限制:依赖 Servlet/阻塞驱动的库不能直接放进 Filter;选型前先确认。
  • 路由不是越多越好:几百条路由的匹配和排障成本会上升,按业务域收敛。

讲清楚 ​

Gateway 的核心是「路由 + 断言 + 过滤器」:请求进来先按 order 找第一个命中的路由,然后把全局过滤器和路由过滤器合并成一条链,pre 升序执行,转发到后端后 post 逆序执行。它基于 WebFlux,用少量线程扛大量连接,所以 Filter 里不能有阻塞调用,ThreadLocal 也不可靠。最常见的两个误区:一是以为 GlobalFilter 和 GatewayFilter 分两批跑,实际是一条链;二是以为请求体有 maxRequestSize 上限,实际纯转发不缓冲 body,只有读 body 的过滤器才受 spring.codec.max-in-memory-size(默认 256KB)限制。网关只做入口横切,业务编排不要塞进来。

把一门技术讲透到既懂也会用