主题
服务网关
一句话: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」。实际是:
- 把 GlobalFilter 和当前路由的 GatewayFilter 合并成一个列表。
- 按
Ordered排序(数值小的先执行)。 - 对每个 filter 调
filter(exchange, chain):先执行 pre 逻辑,再chain.filter(),返回后执行 post 逻辑。
所以 pre 是升序,post 是同一批 filter 的逆序;不是「Global 全部 pre → Gateway 全部 pre」。
Spring Cloud Gateway 4.x 常用内置顺序(以官方源码为准):
| Filter | order | 作用 |
|---|---|---|
RemoveCachedBodyFilter | Integer.MIN_VALUE | 清理缓存的请求体 |
AdaptCachedBodyGlobalFilter | Integer.MIN_VALUE + 1000 | 适配已缓存 body |
NettyWriteResponseFilter | -1 | 写响应 |
ForwardPathFilter | 0 | forward 场景改路径 |
RouteToRequestUrlFilter | 10000 | 把路由 URI 转成请求 URL |
ReactiveLoadBalancerClientFilter | 10150 | 选实例并改写 URL |
WebsocketRoutingFilter | Integer.MAX_VALUE - 1 | WebSocket 转发 |
NettyRoutingFilter | Integer.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 + Header | Header 缺失时 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=2java
// 路由装配自检:启动时断言“同一路径前缀下,具体路由的 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: 2MBjava
// 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=2bash
# 启动网关与 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/routeslb:// 表示走 LoadBalancer;目标服务必须在注册中心里,且 order-service 名称一致。StripPrefix=2 会去掉 /api/order 两段,转发到后端的 /1。
生产动作
- 路由变更走发布流程:先发 1 个网关实例验证,再全量;核心路由加「命中数为 0」告警。
- 404 排障:查路由 order、Path 断言、
StripPrefix、lb://服务名、后端是否注册;不要先怀疑后端。 - 超时:
spring.cloud.gateway.httpclient.response-timeout要大于下游 Feign 读超时 ×(重试 + 1);连接超时单独设。 - 重试:
Retryfilter 只给幂等 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)限制。网关只做入口横切,业务编排不要塞进来。