1. 为什么 API 网关要选 Spring Cloud Gateway
1.1 网关在微服务里到底解决什么问题
先说我遇到过的一个真实场景:一个中等规模的项目,服务拆到二十来个,前端有 App、小程序、Web 后台三端。刚开始每个端自己去直连各个服务,配置里写死了七八个域名,服务一换 IP 就要发版,改一次配置三端一起改。后来加了个统一鉴权,结果三个端各自实现一遍,逻辑还不一致,A 端校验了 Token 过期时间,B 端忘了校验签名。再后来做灰度发布、做限流、做跨域,每个服务里都塞一份代码,重复得像复制粘贴。
微服务 API 网关要干的事情很明确:把所有服务对外暴露的入口收拢到一个地方,把鉴权、限流、路由、日志、跨域、协议适配这类横切逻辑统一在这一层做掉,让业务服务只管业务。这带来的直接收益是客户端只认一个域名,服务地址变更对客户端透明,鉴权策略只维护一份,出问题的时候有个统一的观测点。
但我要提醒一句,网关的职责边界一定要划清楚。我见过有团队把订单聚合、商品详情拼装这种业务逻辑往网关里塞,最后网关变成了一个谁都不敢动的"大泥球",改了怕影响所有流量,不改又拖累业务迭代。网关只做横切,不做业务聚合,这条线守不住,后面全是坑。
1.2 三种主流方案的取舍逻辑
选型这件事,很多人上来就问"哪个性能最好",我觉得这个问法本身就偏了。真正的判断维度是:你的技术栈、团队能力、运维成本、以及未来两三年的演进方向。
| 方案 | 线程模型 | 与 Spring Cloud 契合度 | 动态路由 | 主要短板 |
|---|---|---|---|---|
| Zuul 1.x | 同步 Servlet,一请求一线程 | 高(早期主线) | 需二次开发 | 线程阻塞,高并发下线程池打满 |
| Zuul 2.x | Netty 异步 | 低,Spring 官方未集成 | 需二次开发 | 社区生态基本停滞 |
| Spring Cloud Gateway | WebFlux + Reactor Netty 事件循环 | 最高,官方主线 | 原生支持 | 响应式编程门槛,阻塞代码零容忍 |
| Nginx / OpenResty | 事件驱动 | 低,需自己维护配置同步 | 需配套控制面 | 用 Lua 写业务逻辑,团队成本高 |
我当时的判断路径是这样的:项目本身已经在 Spring Cloud 生态里,注册中心、配置中心、熔断组件都是 Spring 系;团队全是 Java 背景,没有专门的网关运维人力;业务侧对动态路由有强需求,运营后台要能随时切流量做灰度。三条一摆,答案就很清楚了——同栈优先,减少跨栈带来的协作成本和故障定位成本。如果是那种已经有一套成熟的 Nginx 运维体系、网关主要做南北向流量转发的团队,硬上 Spring Cloud Gateway 反而是负担。
这里有个容易被忽略的点:Zuul 1.x 到现在还有不少老项目在用,它能跑,但它的问题不是性能不够,而是模型不对。同步阻塞模型下,一个下游服务响应慢,占住的是线程;线程池满了,整个网关就雪崩。Gateway 用的是事件循环,少量线程扛住大量连接,慢服务只会让请求挂起,不会占死线程。这个差异在流量低谷时看不出来,一到大促或者突发流量就是生死线。
1.3 响应式模型带来的收益,以及你要付的代价
我用一句话概括 Spring Cloud Gateway 的运行方式:少量事件循环线程负责接收和分发,真正的等待被抽象成回调,线程不陪着你等。默认 Reactor Netty 的工作线程数等于 CPU 核数,8 核机器就是 8 个线程在跑,理论上能撑住几千个并发连接。这就是它吞吐高的根源。
但收益从来不是白拿的。代价是你在过滤器里写的任何一行阻塞代码,都会把整个事件循环卡死。什么叫阻塞代码?同步的 HTTP 调用、JDBC 查询、Thread.sleep、读写大文件、甚至打一条同步日志到慢磁盘,全都算。我踩过一次:在 GlobalFilter 里加了一段"记录请求日志到数据库"的逻辑,本地压测没感觉,上线后 QPS 一过 800,P99 延迟从 40ms 飙到 3 秒,排查了大半天才发现是那段 JDBC 写入把事件循环堵住了。
正确的做法是全程用响应式 API:调下游用WebClient,缓存用ReactiveRedisTemplate,日志异步落盘或者先丢队列。如果实在有绕不开的阻塞操作,包一层Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic())把它挪到弹性线程池,千万不要图省事直接写在 filter 主链路上。
还有一个隐形成本:调试变难了。响应式链路的异常堆栈又长又碎,一眼看不出根因。我的经验是前期就把日志埋好,在每个关键节点打带 traceId 的标记,别等出问题再现找。
2. 核心概念拆解:路由、断言、过滤器
2.1 三件套的关系,先想清楚再动手
Spring Cloud Gateway 的全部能力,本质上是三个东西的组合:Route(路由)、Predicate(断言)、Filter(过滤器)。一条路由 = 唯一 ID + 目标 URI + 一组断言 + 一组过滤器。请求进来后,网关遍历所有路由,用断言去匹配,第一条匹配成功的路由被选中,然后请求和响应依次穿过这条路由上挂的过滤器链。
理解这个模型有个很实用的类比:断言是门卫,负责判断"这个请求该不该走这条道";过滤器是流水线上的工位,请求过去的时候加工一遍,响应回来的时候再加工一遍;URI 是终点站。听起来简单,但实际配置时九成的问题都出在"门卫判断顺序"和"工位排队顺序"上。
这里划一个重点:路由匹配是按 order 排序后顺序匹配,第一个命中就返回,不会继续往下找。也就是说,如果你配了一条/api/**的宽泛路由和一条/api/order/**的精确路由,而宽泛那条的 order 更小,那么所有/api/order/xxx的请求都会被宽泛路由截胡,精确路由永远不生效。这个坑我见过太多次,症状是"明明配了独立路由,怎么限流策略没生效"。
2.2 断言组合的两个关键规则
断言看着简单,但组合逻辑有两套完全不同的规则,混淆了就会出诡异问题。
第一条规则:同一个断言内,多个参数之间是"或"的关系。比如Path=/order/**,/pay/**,请求路径命中任意一个就通过。
第二条规则:不同断言之间,是"且"的关系。比如同时配了Path=/order/**和Method=POST,必须两个条件同时满足。
常用的断言我列一下,实际项目里高频的就是前五个:
| 断言 | 写法示例 | 典型用途 |
|---|---|---|
| Path | Path=/api/order/** | 按路径前缀路由,最常用 |
| Method | Method=GET,POST | 限制方法 |
| Header | Header=X-Tenant, tenant-a | 多租户按请求头分流 |
| Query | Query=version, v2 | 按查询参数切灰度 |
| Host | Host=**.example.com | 多域名共用一套网关 |
| Weight | Weight=group1, 80 | 按权重灰度,另一条配 20 |
| After/Before/Between | After=2026-01-01T00:00:00+08:00[Asia/Shanghai] | 定时上线、活动开关 |
| RemoteAddr | RemoteAddr=192.168.0.0/16 | 内网接口隔离 |
Weight这个断言值得单独说,因为它是灰度发布里最省事的方案。同一组路由配两条,一条权重 90 指向老版本,一条权重 10 指向新版本,改配置就能调比例,不用动代码。但它有个前提:两条路由的group名必须一致,而且必须成对出现,只配一条会全部打到一个版本上。
注意:
After、Before这类时间断言用的是ZonedDateTime,时区不写清楚会按 UTC 解析,导致活动提前或延后 8 小时生效。这个错误很隐蔽,我建议所有时间断言都显式带上+08:00[Asia/Shanghai]。
2.3 过滤器的执行顺序,是排错的第一把钥匙
过滤器分两类:GatewayFilter(挂在单条路由上)和GlobalFilter(全局生效)。两者最终会被合并成一个链,统一按Ordered接口的 order 值排序。排序之后,请求阶段从小到大依次执行,响应阶段从大到小逆序返回。这个"进去正着走、回来倒着走"的模型,跟 Servlet 的 Filter 链是一致的。
内置过滤器的 order 值有几个关键锚点,记住它们能帮你定位大部分顺序问题:
| 过滤器 | order 值 | 说明 |
|---|---|---|
RemoveCachedBodyFilter | Integer.MIN_VALUE | 最先执行,清理缓存的请求体 |
AdaptCachedBodyGlobalFilter | Integer.MIN_VALUE + 1000 | 缓存请求体,供后续读取 |
NettyWriteResponseFilter | -1 | 响应写回,响应阶段很靠后 |
ForwardPathFilter | 0 | forward 路由处理 |
RouteToRequestUrlFilter | 10000 | 计算最终目标 URL |
ReactiveLoadBalancerClientFilter | 10150 | 负载均衡选实例 |
NettyRoutingFilter | Integer.MAX_VALUE | 真正发起下游请求 |
这张表的价值在于:当你想"在路由确定之后、请求发出去之前"做点什么,order 就得落在 10000 到 Integer.MAX_VALUE 之间。我之前写过一个统计下游耗时的过滤器,order 设成了 0,结果统计出来的时间永远是 0——因为那时候请求还没发出去。改成Ordered.LOWEST_PRECEDENCE - 1之后数据就对了。
3. 从零搭建可落地的工程骨架
3.1 版本对齐,比写代码更重要
Spring 全家桶的版本关系是新手最容易翻车的地方。我见过有人把 Spring Boot 3.x 和 Spring Cloud 2021 混着用,启动直接报NoSuchMethodError,查半天查不出原因。
正确的对应关系是:
| Spring Boot | Spring Cloud | Gateway 大版本 | JDK 要求 |
|---|---|---|---|
| 2.7.x | 2021.0.x | 3.1.x | 8 / 11 / 17 |
| 3.0.x | 2022.0.x | 4.0.x | 17+ |
| 3.2.x / 3.3.x | 2023.0.x | 4.1.x | 17+ |
| 3.4.x | 2024.0.x | 4.2.x | 17+ |
别手写版本号,用 BOM 统一管理:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>2023.0.3</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>然后引依赖:
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency>这里有一个必须记住的红线:千万不要同时引入spring-boot-starter-web。Gateway 基于 WebFlux,Web 基于 Servlet,两个自动配置会打架,启动时抛Spring MVC found on classpath, which is incompatible with Spring Cloud Gateway。如果某个公共依赖间接把 web 带进来了,用exclusion排掉。这个报错信息其实写得很清楚,但很多人不看日志,去网上乱搜,反而越搞越乱。
3.2 配置文件式路由与编码式路由,怎么选
两种写法都支持,我用下来的经验是:固定的、少量的路由用 YAML,需要动态计算的、带复杂条件的用 Java 代码。
YAML 版本长这样:
spring: cloud: gateway: routes: - id: order-service-route uri: lb://order-service predicates: - Path=/api/order/** - Method=GET,POST filters: - StripPrefix=2 - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 100 redis-rate-limiter.burstCapacity: 200 key-resolver: "#{@userKeyResolver}" - id: user-service-route uri: lb://user-service predicates: - Path=/api/user/** filters: - StripPrefix=2StripPrefix=2的意思是剥掉路径前两段,/api/order/create转发到下游就变成/create。这个数字特别容易数错,我的习惯是:路径里有几段是网关前缀,就剥几段,写完拿一个真实请求打一次日志确认,别凭感觉。
编码式路由适合需要条件判断的场景:
@Configuration public class RouteConfig { @Bean public RouteLocator customRoutes(RouteLocatorBuilder builder) { return builder.routes() .route("gray_route", r -> r .path("/api/gray/**") .and().header("X-Gray", "true") .filters(f -> f .stripPrefix(2) .addRequestHeader("X-From", "gateway") .circuitBreaker(c -> c.setName("grayCb") .setFallbackUri("forward:/fallback"))) .uri("lb://gray-service")) .build(); } }编码式的优势是可以用if、可以读配置中心、可以做循环批量注册,缺点是改路由要发版。所以真实项目里我一般是YAML 兜底常态路由 + 编码式处理特殊逻辑,两边并存不冲突。
3.3 接上注册中心,让路由真正"活"起来
uri写lb://服务名的前提是网关能拿到服务实例列表。以常见的注册中心为例,加依赖:
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-loadbalancer</artifactId> </dependency>注意Ribbon 在新版本已经移除,必须用spring-cloud-starter-loadbalancer,只引服务发现不引负载均衡,会报Unable to find instance for xxx。
配置:
spring: application: name: api-gateway cloud: nacos: discovery: server-addr: 127.0.0.1:8848 gateway: discovery: locator: enabled: false lower-case-service-id: truelocator.enabled我建议设成false。开启它会自动为每个注册的服务生成一条/{serviceId}/**的路由,看着方便,实际上等于把内部服务名直接暴露给外部,而且这些自动路由你没法单独配限流和鉴权。除非是内部测试环境图省事,生产环境一律关掉,手工配路由。
还有一点:lb://走的是ReactiveLoadBalancerClientFilter,它依赖ReactorServiceInstanceLoadBalancer。如果你的实例元数据里带了版本号、机房标签,可以自定义负载均衡策略做同机房优先、灰度优先,这块扩展点很干净,实现一个ServiceInstanceListSupplier就行。
4. 生产环境必须落地的四件事
4.1 统一鉴权:一个 GlobalFilter 解决,但有两个细节
鉴权放在网关是最划算的,一次校验,全链路信任。核心就是一个GlobalFilter:
@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { private static final List<String> WHITE_LIST = List.of( "/api/user/login", "/api/user/register", "/actuator/health" ); private final TokenValidator tokenValidator; public AuthGlobalFilter(TokenValidator tokenValidator) { this.tokenValidator = tokenValidator; } @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path = exchange.getRequest().getURI().getPath(); if (WHITE_LIST.stream().anyMatch(path::startsWith)) { return chain.filter(exchange); } String token = exchange.getRequest().getHeaders().getFirst("Authorization"); if (token == null || !token.startsWith("Bearer ")) { return unauthorized(exchange, "缺少凭证"); } return tokenValidator.validate(token.substring(7)) .flatMap(claims -> { // 关键:先清理客户端可能伪造的身份头,再写入可信身份 ServerHttpRequest mutated = exchange.getRequest().mutate() .headers(h -> { h.remove("X-User-Id"); h.remove("X-Tenant-Id"); h.remove("X-User-Roles"); }) .header("X-User-Id", claims.getUserId()) .header("X-Tenant-Id", claims.getTenantId()) .build(); return chain.filter(exchange.mutate().request(mutated).build()); }) .switchIfEmpty(Mono.defer(() -> unauthorized(exchange, "凭证无效"))); } private Mono<Void> unauthorized(ServerWebExchange exchange, String msg) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); byte[] body = ("{\"code\":401,\"msg\":\"" + msg + "\"}").getBytes(StandardCharsets.UTF_8); return exchange.getResponse().writeWith( Mono.just(exchange.getResponse().bufferFactory().wrap(body))); } @Override public int getOrder() { return -100; } }这段代码里有两个细节值得单独讲。
第一个是"先删后加"。如果不删直接header("X-User-Id", ...),add语义是追加,请求里就会出现两个同名的头,下游用getFirst拿到的可能是客户端伪造的那个。正确做法是显式remove再写。这个漏洞一旦被利用,等于把整个权限体系架空了,属于高危问题。
第二个是 order 设成 -100。鉴权必须足够早,早于负载均衡、早于路由转发,否则非法请求也会打到下游服务,白白消耗资源。但也不能设成Integer.MIN_VALUE,那个区间被框架自己的缓存请求体逻辑占了。
注意:不要在
GlobalFilter里用ThreadLocal存用户上下文。WebFlux 的事件循环线程是复用的,ThreadLocal的值会串到别的请求上,出现"A 用户看到 B 用户数据"这种诡异故障。要传递上下文,用exchange.getAttributes()或者 Reactor 的Context。
4.2 限流:参数不是拍脑袋定的,得算
限流用内置的RequestRateLimiter,底层是 Redis + 令牌桶。大部分人卡在参数上,不知道replenishRate和burstCapacity该怎么填。
先讲清楚这两个参数的含义:
replenishRate:每秒往桶里放多少个令牌,决定了长期平均速率,也就是你想限制的 QPS。burstCapacity:桶最多能存多少令牌,决定了允许多大的瞬时突发。requestedTokens:一次请求消耗几个令牌,默认 1。
计算逻辑其实很朴素:长期速率看 replenishRate,瞬时容忍度看 burstCapacity。假设某接口压测出来峰值 QPS 是 300,但瞬时(比如抢购开抢那一秒)会冲到 800,那我希望平时放开 300,允许偶尔冲到 800 但很快回落。这个时候replenishRate = 300,burstCapacity至少要能覆盖 800 的瞬时冲击,同时不能太大否则限流形同虚设,我一般取峰值 QPS 的 1.5 到 2 倍,也就是 500 到 600。
配置:
- name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 300 redis-rate-limiter.burstCapacity: 600 redis-rate-limiter.requestedTokens: 1 key-resolver: "#{@pathKeyResolver}" status-code: 429 deny-empty-key: trueKeyResolver 决定限流维度,这个选择比参数更重要:
@Configuration public class RateLimitConfig { // 按用户维度,登录用户走 userId,未登录退回 IP @Bean public KeyResolver userKeyResolver() { return exchange -> { String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id"); if (StringUtils.hasText(userId)) { return Mono.just("u:" + userId); } String ip = exchange.getRequest().getRemoteAddress() != null ? exchange.getRequest().getRemoteAddress().getAddress().getHostAddress() : "unknown"; return Mono.just("ip:" + ip); }; } // 按接口维度,适合保护单个热点接口 @Bean public KeyResolver pathKeyResolver() { return exchange -> Mono.just(exchange.getRequest().getURI().getPath()); } }几个实战经验:按 IP 限流要小心 NAT,企业内网出口 IP 是同一个,一个人刷爆所有人都被限;按用户限流要处理未登录流量,否则匿名请求全部落到同一个 key 上;X-Forwarded-For如果没做可信校验,客户端可以随便伪造,限流直接被绕过。
还有一个容易忽略的配置项:Redis 挂了怎么办。默认情况下RequestRateLimiter依赖 Redis,连不上会抛异常返回 500。如果你希望"限流组件故障时放行"而不是"全站 500",需要自己包一层降级逻辑,捕获异常后chain.filter(exchange)。这是个取舍:放行有被打垮的风险,拦截会误伤正常用户。我的选择是核心交易链路放行,非核心链路拦截。
4.3 熔断与超时:别让一个慢服务拖垮整个网关
超时是第一道防线,而且必须在网关设,不能指望下游自己控制。配置分全局和单路由两级:
spring: cloud: gateway: httpclient: connect-timeout: 1000 response-timeout: 5sconnect-timeout是建立 TCP 连接的超时,1000ms 足够;response-timeout是从发出请求到收到完整响应的时间,5 秒是个比较稳妥的中位数。但真实项目里不同接口差异很大,查详情页 500ms 就该返回了,导出报表可能要 30 秒。全局一刀切会出问题,所以需要按路由覆盖:
- id: report-route uri: lb://report-service predicates: - Path=/api/report/** metadata: connect-timeout: 1000 response-timeout: 30000 filters: - StripPrefix=2注意metadata里的单位是毫秒,全局配置里的response-timeout用的是Duration写法(比如5s),这两个不一致,我第一次配的时候填了5以为是 5 秒,结果是 5 毫秒,全部请求超时。
熔断用CircuitBreaker过滤器,底层可以接 Resilience4j:
filters: - name: CircuitBreaker args: name: orderCircuitBreaker fallbackUri: forward:/fallback/orderresilience4j: circuitbreaker: configs: default: slidingWindowSize: 100 minimumNumberOfCalls: 20 failureRateThreshold: 50 waitDurationInOpenState: 10s permittedNumberOfCallsInHalfOpenState: 5参数的含义:滑动窗口 100 次调用,至少统计 20 次才开始判断,失败率超过 50% 就打开熔断,10 秒后进入半开状态尝试放 5 个请求探路。这套参数适合大多数场景,但如果你的接口 QPS 很低(比如每分钟几次),slidingWindowSize要调小,否则窗口永远填不满,熔断永不触发。
fallbackUri指向的路径必须在本网关内有对应的 Controller 处理,否则熔断后会 404。这个 fallback 接口要写得非常轻,只返回兜底数据,不能在里边再调下游服务。
4.4 跨域:三处配置打架是最高频的坑
跨域问题在网关层解决最干净,但也是我见过重复配置最多的环节。症状很有辨识度:浏览器控制台报The 'Access-Control-Allow-Origin' header contains multiple values。
原因通常是三处里配了两处以上:网关的globalcors、网关代码里的CorsWebFilter、下游服务自己的跨域配置。响应经过网关时,头被追加了两遍,浏览器就不认了。
标准做法是只在网关配一次,下游服务全部关掉跨域:
spring: cloud: gateway: globalcors: add-to-simple-url-handler-mapping: true cors-configurations: '[/**]': allowedOriginPatterns: - "https://*.example.com" - "http://localhost:*" allowedMethods: - GET - POST - PUT - DELETE - OPTIONS allowedHeaders: "*" allowCredentials: true maxAge: 3600三个关键点:allowedOriginPatterns而不是allowedOrigins,前者支持通配,后者在allowCredentials: true时不允许用*;add-to-simple-url-handler-mapping: true必须加,否则OPTIONS预检请求会被网关当成普通请求去匹配路由,匹配不上直接 404;maxAge设大一点,减少预检请求次数。
5. 常见问题与排查实录
5.1 请求体只能读一次:最常见的"神秘空参数"
现象:鉴权过滤器里读了请求体做签名校验,下游服务收到的 body 是空的。
原因很明确:请求体是流,读一次就没了。WebFlux 里DataBuffer被消费后需要release,重复读会报错或者拿到空数据。
解决方案有三条,按推荐度排序:
- 能不读就不读。签校验放在 Header 上做,别碰 body,这是最省事的。
- 用
CacheRequestBody过滤器缓存(Spring Cloud Gateway 3.1+):
filters: - name: CacheRequestBody args: bodyClass: java.lang.String- 自己写
ServerHttpRequestDecorator重写getBody(),把数据缓存后重新包装:
ServerHttpRequestDecorator decorator = new ServerHttpRequestDecorator(request) { @Override public Flux<DataBuffer> getBody() { return super.getBody() .doOnNext(buf -> { byte[] bytes = new byte[buf.readableByteCount()]; buf.read(bytes); DataBufferUtils.release(buf); cachedBody.set(bytes); }) .map(buf -> exchange.getResponse().bufferFactory() .wrap(cachedBody.get())); } };第 3 种写法有个隐藏陷阱:doOnNext里必须release,否则堆外内存泄漏,跑一天就 OOM。我见过一个项目就是因为漏了这行DataBufferUtils.release,内存缓慢上涨,重启一次撑三天。
5.2 内存溢出与堆外内存
网关的内存问题和普通 Web 应用不太一样,因为它大量使用DataBuffer,而DataBuffer默认分配在堆外。
排查路径是这样的:先看监控里 JVM 的堆内存是否正常,如果堆平稳但进程 RSS 一直涨,八成是堆外的问题。用jcmd <pid> VM.native_memory summary或者 Netty 的ResourceLeakDetector定位。
常见的三个原因:
| 现象 | 根因 | 处理方式 |
|---|---|---|
| 堆外内存持续增长不回落 | DataBuffer未 release | 检查所有手动操作 buffer 的代码,加上release |
| 大文件上传时 OOM | 默认内存缓冲整个请求体 | 调小spring.codec.max-in-memory-size,大文件走流式 |
| 启动就报 Direct buffer memory | 堆外空间不足 | 加-XX:MaxDirectMemorySize=512m |
spring.codec.max-in-memory-size默认只有 256KB,超过直接报DataBufferLimitException。如果是上传图片、报表这类场景,要么调到 10MB 左右,要么就别在网关缓冲,直接透传。
5.3 问题速查表
这张表是我这几年攒下来的,基本都是"报错信息看不懂但原因很固定"的类型:
| 报错 / 现象 | 大概率原因 | 排查动作 |
|---|---|---|
Unable to find instance for xxx | 服务未注册,或没引 loadbalancer | 查注册中心实例列表,检查依赖 |
启动报Spring MVC found on classpath | 引入了spring-boot-starter-web | mvn dependency:tree找出来排除 |
| 504 Gateway Timeout | response-timeout太短或下游慢 | 看网关日志的耗时,按路由调超时 |
| 502 Bad Gateway | 下游连接被拒,端口不通 | telnet下游 IP 端口,查防火墙 |
| 所有请求都命中同一条路由 | 宽泛路由 order 更小 | 给精确路由加order,数字更小 |
429 Too Many Requests但没到阈值 | KeyResolver 的 key 全都一样 | 打印 key 看看是不是都变成了unknown |
| CORS 报多个 Origin 值 | 网关和下游重复配置 | 关掉下游的跨域配置 |
| 上传大文件失败 | max-in-memory-size太小 | 调大或改流式 |
日志里出现大量PrematureCloseException | 客户端提前断开 | 一般是正常现象,关注是否伴随超时 |
WebSocket 我也单独说一句:Gateway 支持 WebSocket 代理,配置很简单:
- id: ws-route uri: lb:ws://message-service predicates: - Path=/ws/**注意 URI 的协议头是ws://不是http://,同时要给 WebSocket 单独配心跳超时,否则空闲连接会被中间设备掐断。
5.4 观测能力,比功能本身更值得投入
最后说一个我个人认为最值得做的事:在网关里把可观测性做扎实。网关是所有流量的必经之地,这里是唯一能看到全貌的位置。
我习惯加一个 order 很高的 GlobalFilter,做三件事:给每个请求生成 traceId 并透传下去、记录"路由 ID + 服务名 + 状态码 + 耗时 + 是否熔断"、慢请求(超过 1 秒)打一条 WARN 日志带上完整路径。这段逻辑不能太重,用doFinally挂在响应结束的时机,别在pre阶段算耗时。
@Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { long start = System.currentTimeMillis(); String traceId = UUID.randomUUID().toString().replace("-", ""); exchange.getAttributes().put("traceId", traceId); return chain.filter(exchange) .doFinally(signal -> { long cost = System.currentTimeMillis() - start; Route route = exchange.getAttribute(GATEWAY_ROUTE_ATTR); String routeId = route != null ? route.getId() : "no-route"; if (cost > 1000) { log.warn("slow request traceId={} route={} path={} cost={}ms status={}", traceId, routeId, exchange.getRequest().getURI().getPath(), cost, exchange.getResponse().getStatusCode()); } }); }这段日志在排查"到底是哪个服务慢、慢在网关还是慢在下游"的时候,价值远超它的代码量。有几次线上告警,就是靠这条 WARN 日志五分钟内定位到具体路由的。
6. 我在实际项目里踩过的几个坑
说几个不值得单独成章、但踩起来很疼的点。
坑一:StripPrefix数错段数。/api/order/v1/create剥两段变成/v1/create,但我想要的是/create,得剥三段。这种错误本地开发很容易发现,但如果路由是复制别人配置改的,测试环境恰好路径能对上,就一路带到生产了。我的做法是每条路由上线前,用curl -v打一次,看网关日志里转发出去的完整 URL。
坑二:过滤器里用exchange.getResponse().setComplete()短路,忘了返回 Mono。有人写限流拦截时这么写,方法返回null,整个链路直接 NPE,但错误日志被淹没了,表现为请求挂住不返回。记住短路要return response.writeWith(...)或者setComplete().then(Mono.empty())。
坑三:lb://后面跟了大写服务名。注册中心里服务名是ORDER-SERVICE,配置里写lb://order-service,注册中心按大小写敏感查不到。要么统一小写并开lower-case-service-id,要么严格对齐实际注册名,别凭记忆写。
坑四:在本地开发时接了生产注册中心。这个出现的后果是本地服务注册上去被真实流量打到。隔离环境这件事,靠人自觉永远不靠谱,配置中心里把注册中心的地址按 profile 分开,本地默认不连,这是唯一的解法。
坑五:改网关配置后只重启了一个实例。网关通常集群部署,滚动发布的时候,新旧配置并存的那几分钟会有一部分流量走旧路由。如果这次改的是鉴权白名单,就等于开了几分钟的口子。稳妥做法是路由配置走配置中心 + 动态刷新,或者至少确认所有实例都刷新了再发布业务变更。
网关这东西,功能本身不难,难的是把它当成整个系统流量的唯一入口来对待——性能、超时、降级、观测,每一项都不能只停留在"能跑"。上面这些配置和参数,抄过去能省掉大部分摸索时间,但每个团队的流量特征不一样,参数一定要根据自己的压测数据重新算一遍,别人云亦云地抄。