ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Spring Cloud Gateway微服务网关路由、限流与线程模型实践

Spring Cloud Gateway微服务网关路由、限流与线程模型实践

1. 从一次凌晨的接口雪崩说起:网关在架构里到底站什么位置

微服务网关这个话题,很多人是在项目拆到七八个服务、前端开始抱怨“接口地址一天变三次”的时候才被迫认真研究的。我印象最深的一次,是某项目的订单服务做了一次扩容,实例从 3 个变成 8 个,结果客户端那边硬编码的 IP 列表没同步,半夜一半请求打到了已经下线的旧节点上,监控面板上错误率曲线直接拉成一条竖线。第二天复盘,问题根本不在业务代码,而在于没有任何一层东西替客户端做统一的入口收敛和实例寻址。Gateway 服务网关要解决的,恰恰是这类“架构层面的搬运工作”。

先把话说清楚:Gateway 在这里指的是微服务体系里的 API 网关层,典型实现是基于响应式编程模型的 Spring Cloud Gateway,也可以是其他同类框架。它的位置很明确——所有外部流量(浏览器、App、第三方回调)先进网关,再由网关按规则转发到后端的各个微服务。它解决的是三件事:入口统一、横切逻辑收敛、后端拓扑对客户端透明。适合正在做服务拆分、或者已经被“前端要维护十几个域名”折磨过的团队参考;如果你只有一个单体应用、日请求量几千,那老老实实把 Nginx 配好就行,上网关属于给自己加作业。

我写这篇的出发点是把网关这个东西从“配置几行 yml 就能跑”的表象里拽出来。真正让网关在生产上出问题的,从来不是路由写不出来,而是路由写得出来但顺序不对、过滤器写得出来但把线程堵死了、限流配得出来但阈值是拍脑袋定的。下面按我实际踩过的顺序,一层一层往下拆。

1.1 客户端直连服务暴露出的三个具体麻烦

在没有网关的阶段,客户端直连微服务通常靠注册中心 + 客户端负载均衡,或者更粗暴一点,直接配一堆域名。这两种做法在服务数量少于五个、团队只有一个前端组的时候勉强能忍,一旦规模上去,麻烦会以三种形态集中爆发。

第一种是地址治理失控。每个微服务都有自己的域名或 IP 端口,客户端需要维护一张越来越长的映射表。服务改个端口、加个灰度环境、迁一次机房,客户端就得跟着发版。移动端更惨,用户不升级就永远拿着旧地址。第二种是横切逻辑重复实现。鉴权、限流、日志埋点、跨域处理、请求 ID 生成,这些东西每个服务都要写一遍,写十遍就有十种实现,出问题的时候排查口径都不一样。第三种是后端拓扑直接暴露。客户端能感知到你有哪些服务、每个服务几个实例,这在安全和演进灵活性上都是负担——你想把订单服务拆成两个,客户端那边就炸了。

网关的价值就是把这三种麻烦一次性收口。客户端只认一个域名,后面的服务怎么拆、怎么扩、怎么灰度,全在网关内部完成。这个收益是架构级的,不是省几行代码的问题。

1.2 网关的职责边界:该收的收上来,不该管的一律别碰

这一点我必须重点讲,因为见过太多项目把网关做成了一个“什么都往里塞”的怪物。有个团队的网关里塞了业务校验、订单状态机判断、甚至还有一段查询用户等级的数据库逻辑,最后网关的接口响应时间比业务服务还长,一次数据库慢查询直接把整个站点的入口堵死。

判断一个逻辑该不该放网关,我的标准很简单:它是否对所有或大部分路由都成立,且不依赖具体业务语义。按这个标准划一下:

该放在网关不该放在网关
统一鉴权与令牌校验具体业务参数校验
限流、熔断、黑白名单订单状态流转判断
跨域头处理查询数据库拼装业务数据
请求 ID 注入与日志透传复杂的响应体业务字段改写
路径改写与服务寻址业务级的聚合编排(那是 BFF 的活)
协议转换、灰度分流缓存业务数据

网关是基础设施层,不是业务层。它的健康标准是“稳定、低延迟、无状态”,任何会让它变慢或变复杂的东西都应该往下沉。我后来给团队定了一条硬规矩:网关代码库里不允许出现任何@Service命名的业务类,只允许 Filter 和配置。这条规矩救过我们不止一次。

1.3 选型对比:现成网关框架、Nginx 层、还是自研

选型这块不用纠结太久,但得知道各自的边界。Nginx(或 OpenResty)做接入层转发性能极好,配置成熟,适合做最外层的 TLS 卸载和静态资源;但它做动态路由、服务发现集成、按 Header 做灰度这类事情,配置维护成本会陡增,而且团队里能写 Lua 的人往往就一两个。基于响应式模型的网关框架优势在于和后端服务共用一套注册中心与配置中心,路由能动态刷新,Filter 用主语言写,团队上手快。

自研网关这件事,我的态度是:除非你的流量规模或者特殊协议需求已经到了现成方案扛不住的量级,否则不要碰。网关看着简单,实际上是连接管理、背压、超时、重试、内存这几件事的高难度组合,自己写一遍大概率会在某个深夜被 direct buffer 泄漏教做人。

提示:如果你的团队里没人能说清楚“响应式线程模型下为什么不能做阻塞调用”,那就先别急着把网关推到核心链路,先在非核心业务上跑一两个月。


2. 路由配置的坑几乎全在匹配顺序和路径改写上

路由配置是所有人接触网关的第一课,也是最容易被低估的一课。表面上看就是id + uri + predicates + filters四个字段,实际上线后遇到最多的问题都集中在这里:明明配了路由却报 404、请求转发了但下游收到的路径少了一段、两个路由互相抢流量。这些问题有一个共同点——配置本身语法没错,错在对匹配机制的理解。

我一般要求团队在写路由前先明确两件事:这条路由的唯一标识语义是什么(按业务域还是按服务名),以及它的路径改写规则由谁负责(网关剥前缀还是下游自己带前缀)。这两件事定不下来,后面一定会返工。

2.1 断言工厂的常用组合与真实写法

断言(Predicate)决定一条路由在什么条件下生效。常用的大致分四类:路径类(Path)、方法类(Method)、请求头/参数类(Header、Query、Cookie)、时间与权重类(After、Before、Between、Weight)。实际项目里 90% 的路由是 Path + Method 的组合,剩下 10% 用在灰度和特殊场景。

一个能直接抄的配置骨架长这样:

spring: cloud: gateway: routes: - id: order-service uri: lb://order-service predicates: - Path=/api/order/** - Method=GET,POST filters: - StripPrefix=2 - AddRequestHeader=X-Gateway-Source, edge - id: user-service uri: lb://user-service predicates: - Path=/api/user/** filters: - StripPrefix=2

逐行解释几个容易出错的点。uri: lb://order-service里的lb是负载均衡前缀,它要求类路径上存在负载均衡组件,否则启动时会提示找不到对应实例;如果写成http://127.0.0.1:8081就是写死地址,失去动态扩缩容能力,只建议在本地调试时用。StripPrefix=2表示剥掉路径的前两段,/api/order/123转发后变成/123,这里的数字必须和实际路径段数严格对应,多剥一段少剥一段都会让下游 404。

Weight断言是做灰度最省事的方式,同一个路径配两条路由,分别给group和weight,权重加起来是 100:

- id: order-service-v1 uri: lb://order-service predicates: - Path=/api/order/** - Weight=order-group, 90 - id: order-service-v2 uri: lb://order-service-v2 predicates: - Path=/api/order/** - Weight=order-group, 10

注意group名字必须一致才会被算进同一组权重,名字写错就变成两条独立路由,各走各的,表现出来就是“灰度没生效”。

2.2 路由优先级:为什么你的规则总是被另一条抢先命中

这是踩过最疼的一个坑。网关的路由匹配是按顺序遍历、命中第一条即返回,不是“最精确匹配优先”。也就是说,如果你先声明了Path=/api/**,后面再声明Path=/api/order/**,那么订单请求永远走第一条,第二条形同虚设。

我们当时的现场是这样的:为了兼容老客户端,加了一条/api/**的兜底路由转发到旧服务;后来新增订单服务,路由加在配置文件末尾。测试环境订单接口一切正常,上线后订单请求全部打到了旧服务上,返回的都是过期的数据结构,前端解析直接报错。排查了快两个小时,最后把路由列表打印出来一看,顺序问题一目了然。

解决办法有两个。一是用order字段显式排序,数字越小优先级越高:

- id: order-service uri: lb://order-service order: -1 predicates: - Path=/api/order/**

二是从设计上避免通配兜底,路径规划时就用不同前缀区分,比如内部接口统一/internal/**、开放接口统一/open/**,从源头上消灭“谁先谁后”的歧义。我更推荐第二种,因为它不依赖任何人记得加order。

注意:路由既可能来自配置文件,也可能来自注册中心或数据库动态推送,两批路由合并后的顺序往往不由你控制。生产环境务必通过网关自带的路由查询端点把最终生效列表拉出来核对一遍,别只看自己写的那份。

2.3 StripPrefix 与 RewritePath 的取舍

路径改写有两个常用手段:StripPrefix按段数剥,RewritePath按正则替换。前者简单但僵硬,后者灵活但正则容易写错。

StripPrefix适合“网关前缀固定、下游路径干净”的场景,比如外部统一/api/{service}/**,内部服务不感知api和{service}两段。RewritePath适合路径结构不一致的迁移期,比如老接口/v1/getUserInfo要映射到新服务/user/info:

filters: - RewritePath=/v1/getUserInfo, /user/info

正则替换的坑在于分组引用写错不会报错,只会静默转发到错误路径。比如RewritePath=/api/(?<segment>.*), /$\{segment}里的大括号在某些 YAML 解析器下需要转义,写漏了就会把字面量传给下游。我的习惯是改完路径后先用一条测试请求打到回显接口上,把下游收到的requestURI打印出来确认,再往下推进。

还有一个隐蔽问题:下游服务的 context-path 和网关改写叠加。如果下游自己配了/order前缀,网关又剥了段,最终路径可能变成/order/order/123。这类问题排查成本极高,所以在项目初期就要约定清楚“前缀归谁管”,并且写进接口规范里。


3. 全局过滤器:鉴权、上下文透传和请求体只能读一次

过滤器是网关真正干活的地方。断言负责“选路”,过滤器负责“加工”。框架自带的过滤器工厂能覆盖大部分需求,但统一鉴权、用户上下文透传、统一响应包装这三件事,基本每个项目都得自己写全局过滤器。

写全局过滤器有个前提认知:它运行在响应式线程上,任何一个阻塞操作都会拖垮整个事件循环。这一点我在第 6 节会展开,这里先记住结论——过滤器里不要出现同步的数据库查询、同步的远程调用、Thread.sleep、以及任何返回非响应式类型的客户端。

3.1 统一鉴权放在网关做的理由与实现骨架

鉴权放网关,最直接的理由是避免每个服务重复实现,更重要的是能形成一道统一防线:新服务上线时只要在网关注册路由,就自动获得了鉴权保护,不会因为某个开发者忘了加注解而裸奔。

实现上,一个鉴权过滤器需要做四步:取令牌、验令牌、解析出用户身份、把身份塞进请求头传给下游。骨架大致是:

@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { private final TokenVerifier tokenVerifier; public AuthGlobalFilter(TokenVerifier tokenVerifier) { this.tokenVerifier = tokenVerifier; } @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path = exchange.getRequest().getURI().getPath(); if (path.startsWith("/open/") || path.startsWith("/actuator/")) { return chain.filter(exchange); } String token = exchange.getRequest().getHeaders().getFirst("Authorization"); Claims claims; try { claims = tokenVerifier.verify(token); } catch (Exception e) { return reject(exchange, 401, "invalid token"); } ServerHttpRequest mutated = exchange.getRequest().mutate() .header("X-User-Id", claims.getUserId()) .header("X-Tenant-Id", claims.getTenantId()) .build(); return chain.filter(exchange.mutate().request(mutated).build()); } @Override public int getOrder() { return -100; } }

几个细节值得说。白名单判断必须放在验令牌之前,否则健康检查接口也会被拦。getOrder()返回负数保证它在业务过滤器之前执行。reject方法要自己写完整响应,包括状态码、Content-Type和响应体,否则客户端可能收到一个空响应,前端无法定位问题。

这里有个安全上的坑:下游服务不能无条件信任X-User-Id这类请求头。如果攻击者绕过网关直连服务端口,伪造这个头就能冒充任意用户。所以内网服务要么只监听内网地址,要么在网关侧对这类头做覆盖而非追加——用header()而不是headers(h -> h.add()),确保客户端传来的同名头被冲掉。

3.2 用户上下文怎么传给下游服务

传递上下文有三条路:请求头、URL 参数、请求体。请求头是唯一合理的选择,参数会污染日志和缓存键,改请求体会引入一串麻烦。

传递时要注意字符编码和头长度。用户身份信息如果塞了完整权限列表,头可能超长,某些中间件对单个头有长度上限,超了直接 400。我的做法是只传三个必要字段:用户 ID、租户 ID、令牌过期时间戳,权限校验交给下游缓存或者再查一次。

还有一点是上下文在异步链路里的丢失。响应式编程里没有 ThreadLocal 的天然支持,如果你在下游服务里用ThreadLocal存用户上下文,跨线程池切换时会拿到空值。要么用响应式上下文传递,要么在入口处把上下文显式作为方法参数往下传。这个问题在引入异步之后才会暴露,属于典型的“上线才发现”系列。

3.3 请求体重复读取的三种处理方式

请求体在响应式模型里是一个Flux<DataBuffer>,只能被消费一次。如果你的过滤器读了一遍做签名校验,转发时下游收到的就是空 body,表现为下游报“参数缺失”,而你的过滤器日志一切正常。

处理方式有三种,按复杂度递增:

第一种是能不读就不读,把需要 body 的校验下沉到下游,网关只做基于头的判断。这是最省事的,很多时候签名校验其实可以用头里的时间戳 + 令牌完成,未必需要 body。

第二种是缓存后重写,把 body 读出来存成字节数组,然后重建请求:

@Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { return DataBufferUtils.join(exchange.getRequest().getBody()) .flatMap(dataBuffer -> { byte[] bytes = new byte[dataBuffer.readableByteCount()]; dataBuffer.read(bytes); DataBufferUtils.release(dataBuffer); String body = new String(bytes, StandardCharsets.UTF_8); ServerHttpRequest request = exchange.getRequest().mutate() .header("X-Body-Length", String.valueOf(bytes.length)) .build(); ServerHttpRequestDecorator decorator = new ServerHttpRequestDecorator(request) { @Override public Flux<DataBuffer> getBody() { return Flux.defer(() -> Mono.just( exchange.getResponse().bufferFactory().wrap(bytes))); } }; return chain.filter(exchange.mutate().request(decorator).build()); }); }

第三种是用框架提供的请求体改写过滤器工厂,配置化完成,代价是灵活性差一些。

第二种方案的风险在于内存:大文件上传时把整个 body 读进内存,几个并发就能把堆打满。所以必须配合大小限制,超过阈值直接拒绝,并且把这条路由明确排除在 body 缓存之外。


4. 限流参数的推导过程:不要凭感觉填 replenishRate

限流是最容易“配了个数字但不知道为什么”的地方。我看过太多配置里replenishRate: 100、burstCapacity: 100,问配置的人依据是什么,回答是“抄的”。这种配置在流量平稳时看不出问题,一旦遇到突发就会要么误杀正常用户,要么完全不起作用。

4.1 令牌桶两个参数的业务含义

基于 Redis 的令牌桶限流有两个核心参数:

  • replenishRate:令牌生成速率,单位是个/秒,等价于你允许的长期平均 QPS。
  • burstCapacity:桶容量,等价于允许的瞬时突发量。

还有一个requestedTokens,表示一次请求消耗几个令牌,默认 1。对于普通接口填 1 就行,但如果是批量接口(一次请求处理 100 条数据),把它设成和批量规模成正比的数值更合理,否则限流就形同虚设。

关键关系是:一个请求需要requestedTokens个令牌,桶里不够就会等待或拒绝,而桶每 1/replenishRate秒补充一个令牌。这里有个非常容易踩的坑——当burstCapacity小于replenishRate时,补充速率会被桶容量卡住,实际表现是“每隔若干秒才放行一个请求”。举个具体例子:replenishRate=100、burstCapacity=10,那么桶里最多存 10 个令牌,而补充速度是每秒 100 个——看起来补充很快,但因为桶太小,一瞬间就被打空,随后的请求只能等下一个令牌,实际通过的瞬时并发只有 10。这个配置在压测时特别具有欺骗性:低并发下一切正常,一上量就大量 429。

4.2 从容量规划倒推参数的完整算法

我的推法是三步。

第一步,确定单实例的目标 QPS。从压测数据来,不要从理论来。假设压测显示网关单实例在 P99 小于 100ms 的前提下能稳定扛住 800 QPS,留 30% 余量,目标定为 550 QPS。

第二步,把总目标按路由拆分。假设订单接口占整体流量的 40%,那订单路由的目标就是 220 QPS。replenishRate取 220。

第三步,按业务容忍的突发窗口定桶容量。如果业务允许 3 秒的突发缓冲,burstCapacity = replenishRate × 3 = 660。这样设计的效果是:正常情况下按 220 QPS 匀速放行,遇到秒杀这种瞬时尖峰时,桶里最多有 660 个令牌可以一次性消耗,撑过约 3 秒,之后回落到匀速。

简化成一张对照表:

业务场景replenishRateburstCapacity说明
查询类接口单实例目标 QPSreplenishRate × 2容忍短时突发,用户体验优先
写入类接口单实例目标 QPS × 0.6replenishRate × 1.5保护下游数据库
第三方回调按对方约定速率略大于速率,不小于速率桶容量绝不能小于速率
登录/短信类严格按业务规则等于速率防刷优先,不留突发空间

还有一条硬约束必须记住:burstCapacity必须大于等于replenishRate,否则限流器的工作方式会和你预期完全相反。这条我在代码评审里见过至少三次被违反。

限流后的行为也要定义清楚:是直接返回 429 让客户端重试,还是排队等待。默认是拒绝,这通常是对的——排队会占住连接,把网关变成瓶颈。响应体里最好带上Retry-After头,告诉客户端多久后再来。

4.3 限流之外的熔断与降级边界

限流保护的是网关自己,熔断保护的是下游服务。两者经常被混为一谈。

限流解决的是“进来的请求太多”,熔断解决的是“下游已经不行了,别再打了”。当一个下游服务的错误率超过阈值,熔断器打开,后续请求直接走降级逻辑,不再发往下游,给下游留出恢复时间。典型配置是错误率超过 50%、且在 10 秒统计窗口内请求数超过 20 才触发,避免低流量时误判。

熔断的坑在于降级返回值的设计。很多项目降级时返回一个空对象或空列表,前端拿到后渲染出“暂无数据”,用户以为是真没数据,实际上服务已经挂了。降级响应应该带上明确的状态标识,让前端能区分“真的没有”和“暂时不可用”。另外熔断是有状态的,要确认所用的实现在多实例部署下是每实例独立统计还是全局统计,这直接影响到阈值该设多少。


5. 上线后才暴露的故障:502、504、404 的排查链路

网关的故障排查有个特点:现象在网关,根因大多不在网关。所以排查的第一步不是看网关日志,而是先把状态码归类,快速定位问题在哪一层。

5.1 从状态码反推故障层的判断表

状态码常见根因排查入口
404路由未命中,或路径被剥错段数查最终生效路由列表,打印下游收到的 URI
502下游实例不可达,或下游返回了非法响应查注册中心实例健康状态、下游端口是否监听
503无可用实例,负载均衡选不出节点查服务发现是否正常、实例是否全部被摘除
504下游响应超时查网关超时配置、下游耗时分布
429触发限流查限流参数与实际 QPS 是否匹配
431请求头过大查是否透传了过大的 Cookie 或鉴权信息

这张表贴在我们值班文档的第一页,能挡掉七成的无效沟通。举两个真实案例。

一个是 404 的。某次发布后部分客户端报 404,但用 Postman 请求同样的路径却正常。差异在于客户端带了一个额外的路径段,比如/api/order/v2/list,而路由写的是/api/order/**配StripPrefix=2,剥完变成/v2/list,下游只认/list,于是 404。解决方式是把改写规则改成RewritePath精确处理版本段。

一个是 504 的。下游某个接口偶发超时,网关默认没有配置响应超时,导致连接一直挂着,连接池耗尽后整个网关开始拒绝新请求,表现是“所有接口都慢”,看起来像网关崩了,实际上是单个慢接口拖垮了连接池。这件事之后我们把响应超时设成了明确值,并且给每个路由单独配。

5.2 日志与链路追踪怎么开

网关日志默认级别信息量不够,排查时临时开 debug:

logging: level: org.springframework.cloud.gateway: DEBUG reactor.netty.http.client: DEBUG

reactor.netty.http.client这个包名打出来能看到实际的连接建立、请求发送、响应接收过程,定位 502/504 特别有效。但它日志量很大,只能临时开,别留在生产默认配置里。

链路追踪这块,网关是整条链路的起点,必须在这里生成或透传请求 ID。做法是在全局过滤器里检查请求头里有没有X-Request-Id,没有就生成一个 UUID 塞进去,并在响应头里回写一份。这样客户端报障时能直接给出 ID,后端所有服务的日志都能按这个 ID 串起来。这个动作成本极低,收益极高,我建议所有项目无脑加上。

还有一点:网关自己的日志要能区分“转发前”和“转发后”。至少记录三行——收到请求(路径、方法、来源 IP)、转发目标(实例地址、耗时)、响应结果(状态码、耗时)。有了这三行,绝大多数问题不需要复现就能定位。

5.3 一份踩过的坑清单

整理几条不那么常见但很折磨人的:

跨域头重复。网关配了跨域处理,下游服务也配了一份,浏览器收到两个Access-Control-Allow-Origin,直接判定跨域失败。规则是:跨域要么全在网关做,要么全在服务做,绝不能两边都做。推荐在网关统一做,并且用DedupeResponseHeader过滤器去重。

响应体压缩与内容改写冲突。如果开了响应压缩,又用过滤器改写响应体,可能拿到的是压缩后的字节流,改写直接失败。要么关压缩,要么在改写前解压。

重试放大了写操作。Retry过滤器配置在写接口上,下游超时但实际上已经处理成功,重试导致重复下单。重试只能用于幂等的 GET,或者下游接口本身有幂等键保护。

上传接口的 body 缓存。前面提到的 body 缓存方案如果没有排除文件上传路由,几十兆的文件会直接把内存打爆。

健康检查路径被鉴权拦截。注册中心探活请求没有令牌,被鉴权过滤器拦成 401,实例被判定不健康反复摘除,表现是“实例明明活着但流量进不来”。


6. 线程模型与部署参数:为什么阻塞代码会把网关拖死

前面反复提到阻塞调用的问题,这里系统说一下,因为这是网关性能问题里最有代表性的一类。

6.1 Reactor Netty 的线程模型到底长什么样

网关底层的运行模型是少量事件循环线程处理大量连接。默认情况下,事件循环线程数等于 CPU 核心数(可以调整),每个线程负责一批连接的读写事件。一个请求进来,从解析、匹配路由、执行过滤器链到转发,整个过程都在同一个事件循环线程上以非阻塞方式推进,只有在真正等待网络 IO 时才让出线程。

这个模型的好处是线程数少、内存占用低、上下文切换开销小,能扛住很高的并发连接数。代价是任何一个线程被阻塞,它负责的所有连接都会卡住。假设 8 个事件循环线程,其中一个因为同步调用数据库卡了 3 秒,那么这段时间里分配到它上面的所有请求全部排队,表现为“部分请求莫名很慢”,而且随机的、不可复现的,因为它取决于请求被分配到哪个线程。

这种故障最难排查的地方在于:CPU 不高、内存不高、下游也不慢,就是有零星请求超时。很多人会怀疑网络抖动,其实是自己代码里埋了一个阻塞点。

6.2 常见的伪异步写法

以下几种写法看着像异步,实际上都是阻塞:

在过滤器里调用同步 HTTP 客户端。用同步的 HTTP 工具去调鉴权中心,即使加了超时,调用期间线程也是被占住的。正确做法是用响应式 HTTP 客户端返回Mono,让线程在等待期间去处理别的连接。

在过滤器里操作关系型数据库。JDBC 是阻塞的,没有例外。网关里如果需要查配置数据,要么启动时一次性加载到内存并监听变更,要么用响应式的缓存客户端。

用同步的 Redis 客户端。这是最常见的,因为很多人对 Redis 的印象是“很快”,觉得阻塞一下没关系。但 Redis 抖动的时候,一次操作可能卡几百毫秒,足够把事件循环拖垮。用响应式客户端,或者干脆把限流逻辑交给框架内置的实现。

在过滤器里写日志文件。同步的文件 IO 也是阻塞的,高并发下日志写入会成为瓶颈。用异步日志框架,并且注意队列满时的丢弃策略。

一个自检方法:在代码里搜Thread.sleep、同步的RestTemplate、JdbcTemplate、同步 Redis 客户端,在网关模块里出现任何一个都要在评审时打回去。

6.3 上线前的压测与关键配置

压测这块,我的建议是不要只压网关本身,要用真实的链路压——网关 + 至少一个真实下游服务,因为网关的很多性能问题来自于和下游的交互方式,比如连接复用没开、超时配置不合理。

压测时重点看三组指标:网关的 P99 响应时间、事件循环线程的忙碌程度、以及连接池的活跃连接数。如果 P99 随着并发上升出现台阶式跳变,通常是某个资源池被打满了。

几个值得显式配置的参数:

spring: cloud: gateway: httpclient: connect-timeout: 1000 response-timeout: 5s pool: max-connections: 500 max-idle-time: 30s acquire-timeout: 2000

connect-timeout不建议设太大,连接建立都超时的下游,基本可以判定不可用了,快速失败比慢慢等更有价值。response-timeout必须设,不设就是无限等待,这是连接池耗尽的头号原因。pool.max-connections要根据下游实例数和单实例承受能力算,配太大反而会把下游打挂。acquire-timeout是从连接池取连接的等待时间,设短一点能快速暴露连接池不够用的问题,而不是让它变成一个隐藏的慢查询。

另外还有 JVM 层面的:网关处理大量连接会用到堆外内存,-XX:MaxDirectMemorySize要显式设置,不能依赖默认值。如果看到内存缓慢增长且不回落、Full GC 后也不释放,优先怀疑堆外内存泄漏,排查方向通常是响应体没有正确释放。

提示:网关是无状态组件,实例数至少给两个,并且要保证它们在不同的物理故障域上。网关是单点入口,它挂了就是整站不可用,这一点比任何参数调优都重要。

最后分享一个我在实际项目里坚持的小习惯:给网关单独做一套接口,只返回当前生效的路由列表、限流配置和过滤器顺序,不做任何业务逻辑,仅供值班时人工核对。这套东西在三次线上故障里帮我省掉了大量猜测时间——很多时候问题不在代码,而在于线上跑的那份配置和你以为的那份不是同一份。

返回列表