news 2026/10/15 23:27:53

Spring Cloud Gateway进阶:自定义过滤器、动态路由与全链路日志监控

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Cloud Gateway进阶:自定义过滤器、动态路由与全链路日志监控

做微服务网关这几年,Spring Cloud Gateway 算是我用得最多的组件之一。从最开始只需要配几个路由,到后来要接鉴权、灰度、限流,再到每天盯着网关日志排查线上问题,慢慢把自定义过滤器、动态路由和全链路日志监控这三块啃了下来。这篇不聊官方文档里已经写烂的基础路由配置,直接把我实际项目里的完整做法拆开讲,包括思路、代码和踩过的坑。适合已经会用 Spring Cloud Gateway 做基本转发的同学,想往进阶走、想在生产环境里把网关用扎实的人,这篇应该能帮上不少忙。

1. 项目背景与整体思路拆解

1.1 网关这个组件到底解决了什么问题

微服务拆到一定规模以后,客户端直接去调各个服务会遇到一堆非常麻烦的事:登录鉴权逻辑得在每个服务里重复写一遍,内部接口不小心暴露出去没人管,跨域配置东一块西一块,线上排查问题的时候连个统一入口的日志都没有。网关就是把这些问题集中收口的地方,流量统一从网关口子进出,鉴权、限流、灰度、日志都在这一层做。

Spring Cloud Gateway 和 Zuul 1.x 最大的区别在于底层模型。Gateway 基于 Spring WebFlux 和 Netty,全链路非阻塞,而 Zuul 1.x 是 Servlet 模型,一个请求占一个线程,在高并发下线程池很容易被打满。我早期项目里也试过 Zuul,后来把网关迁到 Gateway 之后,光是线程模型带来的收益就非常明显,同样的机器配置 QPS 翻倍是很正常的事。所以现在新项目只要没有特殊的历史包袱,我基本都推荐直接用 Gateway。

1.2 这次要解决的三个核心需求

需求一,自定义过滤器。 内置过滤器只能解决转发、加头、裁剪路径这些通用场景,但业务上的鉴权、内部接口加签、灰度发布规则,这些逻辑必须自己写过滤器去实现。而且网关这一层写的过滤器,意味着所有服务都能复用,不用每个服务重复造轮子。

需求二,动态路由。 路由配置一直写在 application.yml 里,初期十几个服务还能忍,后来服务一多,每次新增服务或者调整路径都得重新发布网关。有时候为了加一条路由,还得约运维窗口,半夜三更起来改配置,这种日子真的不想再过了。所以路由配置必须从代码里抽出来,放进配置中心,改完以后热生效。

需求三,全链路日志监控。 网关是所有请求的必经通道,排查问题的第一步一定是看网关日志:请求进来没有、转发到哪了、耗时多少、返回状态是什么。但这些信息必须串成一条线,最好有一个统一的 traceId 贯穿从网关到下游服务的整个过程,否则日志只能一段一段零散看,效率很低。

1.3 环境选型

先把我用的技术栈列出来,后面所有代码都是基于这套环境跑的:

  • Spring Boot 2.7.x
  • Spring Cloud 2021.0.x
  • Spring Cloud Alibaba 2021.0.5.0
  • Nacos 2.2.x(配置中心 + 服务注册发现)
  • 链路追踪基于 Sleuth / Micrometer Tracing,版本跟着 Spring Cloud 走
  • 日志框架 logback,输出格式用 JSON

这个组合算是目前生产环境里最稳的一套了,网上踩坑资料也多。如果你用的是 Spring Boot 3.x,那 Gateway 版本要对应 Spring Cloud 2022.0.x 往上,细节有差异,但整体的设计思路是一样的。

2. 自定义过滤器:把网关的业务逻辑真正打开

2.1 先搞清楚两种过滤器

在写代码之前,必须把 Spring Cloud Gateway 的过滤器体系理清楚,很多人就是在这里栽了跟头。

第一种是 GatewayFilter,它只对某一条路由生效。比如配置里经常见的 StripPrefix、AddRequestHeader,都是这种。想给某条路由单独做限流或者单独改请求头的时候用这种。

第二种是 GlobalFilter,它作用于所有路由,适合做横切逻辑。鉴权、日志、灰度这类全局性的需求,基本都是用 GlobalFilter 实现。

不管是哪种过滤器,都有一个共同的 Order 概念。Order 值越小,执行优先级越高。多个过滤器会串成一个过滤链,每个过滤器执行完,必须显式调用chain.filter(exchange)把请求继续往下传,不然链条就断了。这里非常容易出现的问题,就是有人忘了传,请求莫名其妙卡在网关上。

2.2 实战:实现一个鉴权 GlobalFilter

我直接给一段可以拿去改的代码。这个过滤器的业务场景是:公司内部有一套基于 JWT 的单点登录系统,大部分接口进来要求必须带着合法的 token,少数白名单路径(比如登录接口、健康检查接口)直接放行。如果 token 校验通过,就把用户 ID 放到请求头里透传给下游,下游服务就不用再解析一次 JWT 了。

@Component @Slf4j public class AuthGlobalFilter implements GlobalFilter, Ordered { private static final String TRACE_ID_HEADER = "X-Trace-Id"; private static final String USER_ID_HEADER = "X-User-Id"; private static final String AUTHORIZATION_HEADER = "Authorization"; // 实际项目里建议放到Nacos配置里,方便调整 private static final Set<String> WHITE_LIST = new HashSet<>(Arrays.asList( "/api/auth/login", "/api/auth/captcha", "/actuator/health" )); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String path = request.getURI().getPath(); String method = request.getMethod().name(); // 放行白名单 if (WHITE_LIST.contains(path)) { return chain.filter(exchange); } // 网关访问日志放在这里,等响应结束再统一打印 long startTime = System.currentTimeMillis(); // 获取 token,兼容带 Bearer 前缀的情况 String authHeader = request.getHeaders().getFirst(AUTHORIZATION_HEADER); if (StringUtils.isBlank(authHeader)) { return buildErrorResponse(exchange, HttpStatus.UNAUTHORIZED, "missing token"); } String token = authHeader.startsWith("Bearer ") ? authHeader.substring(7) : authHeader; // 解析 JWT,这里只做签名校验和基本字段提取 Claims claims; try { claims = Jwts.parser() .setSigningKey(JWT_SECRET) .parseClaimsJws(token) .getBody(); } catch (Exception e) { log.warn("[gateway] invalid token, path={}", path); return buildErrorResponse(exchange, HttpStatus.UNAUTHORIZED, "invalid token"); } String userId = String.valueOf(claims.get("uid")); // 把用户信息放到header里,传给下游服务 ServerHttpRequest mutatedRequest = request.mutate() .header(USER_ID_HEADER, userId) .build(); ServerWebExchange mutatedExchange = exchange.mutate() .request(mutatedRequest) .build(); return chain.filter(mutatedExchange).doFinally(signal -> { long cost = System.currentTimeMillis() - startTime; log.info("[gateway] accessLog method={} path={} status={} cost={}ms traceId={} userId={}", method, path, mutatedExchange.getResponse().getStatusCode(), cost, mutatedExchange.getRequest().getHeaders().getFirst(TRACE_ID_HEADER), userId); }); } private Mono<Void> buildErrorResponse(ServerWebExchange exchange, HttpStatus status, String msg) { exchange.getResponse().setStatusCode(status); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); byte[] bytes = String.format("{\"code\":%d,\"message\":\"%s\"}", status.value(), msg).getBytes(StandardCharsets.UTF_8); DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes); return exchange.getResponse().writeWith(Flux.just(buffer)); } @Override public int getOrder() { return -100; } }

这段代码看起来不长,但有几个点值得细说。

首先,getOrder()返回-100意味着这个过滤器在大多数过滤器之前执行。因为 Gateway 内置的过滤器在 0 到 100 之间,我们把自定义的鉴权过滤器放在更靠前的位置,可以尽早拦截非法请求。如果顺序放反了,请求都走完一圈过滤器才发现没有凭证,那浪费的资源就大了。

其次,buildErrorResponse里我直接拼了一个 JSON 字符串,而不是引入一个 JSON 库。网关是高频路径,每毫秒都是钱,为了一个错误响应去搞一次 JSON 序列化,完全不划算。自己拼字符串虽然丑,但效率最高。

第三点非常关键:整个过滤器里没有任何阻塞操作。 JWT 解析用的是纯 CPU 计算,不涉及网络 IO。如果你要在过滤器里校验 token 的有效期、判断用户是否被踢下线,那就免不了要查 Redis——这时候必须用响应式的ReactiveStringRedisTemplate,绝不能把StringRedisTemplate的同步调用直接写进来。后面我会单独说这个坑。

2.3 过滤器里的几个大坑

坑一:用同步客户端做远程调用。 这是性能事故的源头。有人习惯了 Spring MVC 的写法,在过滤器里直接注入 RestTemplate 去调用户中心接口,结果网关的吞吐量直接从几千掉到几百。原因很简单:Gateway 底层是 Netty 事件循环,worker 线程就那么几个,一个请求把线程阻塞住,其它所有请求都得排队等。解决方法是改成 WebClient 或者响应式 Redis。如果你的过滤器里实在避免不了同步调用,那就得重新考虑架构了,网关这一层真的不适合做这种操作。

坑二:请求 body 只能读一次。 我见过不止一个团队在过滤器里读了请求参数去做签名校验,校验完了正常往下转发,结果下游服务收到的 body 是空的。原因是 body 是流式的,读完就没了。解决办法是把 body 缓存到 exchange 的 attribute 里,后面要读的人统一从 attribute 拿。我会在第 5 章详细说。

坑三:在过滤器里做重序列化。 有的同学喜欢在过滤器里把请求里的 JSON 反序列化成对象,处理完再序列化回去,这在高并发下就是 CPU 杀手。网关这一层尽量不要动 body,能拿 header 解决的问题就不要去读 body。如果必须读,也尽量用轻量的方式,比如直接拿原始字符串做正则或者前缀匹配,而不是完整反序列化。

2.4 扩展思路:灰度发布过滤器

鉴权过滤器只是开胃菜,自定义过滤器真正的威力在灰度发布上。我的做法是这样的:网关从 header 里读一个X-Version,某些内部用户会带着灰度版本号访问。过滤器拿到这个版本号之后,改写路由的目标地址,把流量转发到灰度服务上去。灰度规则可以放在 Nacos 里,比如按用户 ID hash、按百分比放量,过滤器启动时拉取规则并监听变更。这样做的好处是灰度逻辑完全集中在网关这一层,业务服务不需要关心自己是不是灰度环境,改动量很小。

至于具体的路由改写方式,可以直接替换目标 URI,也可以结合后面的动态路由实现多版本路由共存。过滤器里用 exchange.getAttributes() 传递上下文数据,在 RouteToRequestUrlFilter 执行之前把目标地址换成灰度地址,这套玩法在实践里非常实用。

3. 动态路由:让配置中心成为路由的唯一真相源

3.1 静态路由的痛点

Spring Cloud Gateway 默认的路由来源是配置文件,也就是spring.cloud.gateway.routes。我们用了一段时间之后发现三个问题:

  • 加一个微服务必须要重新发布网关,运维窗口难约,风险还大。
  • 多个环境(测试、预发、生产)的路由配置分散在各处的配置文件里,同步靠人肉,经常漏。
  • 线上出问题想临时切流量,改配置要等一个发版周期,等不及。

后来我们把路由配置挪到了 Nacos。核心原理其实不复杂:Spring Cloud Gateway 提供了一个RouteDefinitionRepository接口,默认实现是从配置文件加载路由定义。只要我们自己实现这个接口,从 Nacos 拉路由配置,再配合RefreshRoutesEvent事件触发框架重新加载路由,就能实现热更新。

3.2 基于 Nacos 的动态路由实现

先看一下动态路由要存的配置格式。我用的是数据文件gateway-routes.json,放在 Nacos 的配置中心里,内容类似下面这样:

[ { "id": "user-service", "uri": "lb://user-service", "predicates": [ { "name": "Path", "args": { "pattern": "/api/user/**" } } ], "filters": [ { "name": "StripPrefix", "args": { "parts": 2 } } ], "metadata": { "description": "用户服务" } }, { "id": "order-service", "uri": "lb://order-service", "predicates": [ { "name": "Path", "args": { "pattern": "/api/order/**" } } ], "filters": [ { "name": "StripPrefix", "args": { "parts": 2 } } ] } ]

这里的lb://user-service是固定写法,意思是走负载均衡去user-service这个服务名对应的一组实例。StripPrefix=2会把/api/user这个前缀剪掉,下游服务接口就不用再带这一层前缀。这种配置格式和application.yml里写的是等价的,区别只是换了一个来源。

接下来是实现RouteDefinitionRepository的核心代码。

@Component public class NacosRouteDefinitionRepository implements RouteDefinitionRepository { private static final String DATA_ID = "gateway-routes.json"; private static final String GROUP = "DEFAULT_GROUP"; private static final Logger log = LoggerFactory.getLogger(NacosRouteDefinitionRepository.class); // volatile很重要:监听线程在更新引用时,读取线程要能立刻看到 private volatile Map<String, RouteDefinition> routeDefinitions = new ConcurrentHashMap<>(); private final NacosConfigManager nacosConfigManager; private final ApplicationEventPublisher publisher; public NacosRouteDefinitionRepository(NacosConfigManager nacosConfigManager, ApplicationEventPublisher publisher) throws NacosException { this.nacosConfigManager = nacosConfigManager; this.publisher = publisher; init(); } private void init() throws NacosException { String config = nacosConfigManager.getConfigService() .getConfigAndSignListener(DATA_ID, GROUP, 3000, new Listener() { @Override public Executor getExecutor() { return null; } @Override public void receiveConfigInfo(String configInfo) { refreshRoutes(configInfo); } }); // 启动时拉到的第一次配置也要主动加载 refreshRoutes(config); } // 这个方法最主要的原则:解析失败也不能让旧路由清空 private void refreshRoutes(String configInfo) { if (StringUtils.isBlank(configInfo)) { log.warn("[gateway] nacos route config is empty, skip refresh"); return; } try { List<RouteDefinition> definitions = JSON.parseArray(configInfo, RouteDefinition.class); // 先构建新map,再整体替换,避免出现中间状态导致路由为空 Map<String, RouteDefinition> newMap = new ConcurrentHashMap<>(); for (RouteDefinition definition : definitions) { newMap.put(definition.getId(), definition); } routeDefinitions = newMap; log.info("[gateway] route config refreshed, total={}", newMap.size()); // 关键一步:通知框架重新加载路由 publisher.publishEvent(new RefreshRoutesEvent(this)); } catch (Exception e) { log.error("[gateway] parse route config error, keep old routes", e); } } @Override public Flux<RouteDefinition> getRouteDefinitions() { return Flux.fromIterable(routeDefinitions.values()); } @Override public Mono<Void> save(Mono<RouteDefinition> route) { return route.doOnNext(r -> { // 实际项目里这里应该把变更写回Nacos,而不是直接改本地map // 否则local map和Nacos配置会出现不一致 log.warn("[gateway] dynamic save route is not supported, routeId={}", r.getId()); }).then(); } @Override public Mono<Void> delete(Mono<String> routeId) { return routeId.doOnNext(id -> { // 同理,建议走Nacos配置下发 log.warn("[gateway] dynamic delete route is not supported, routeId={}", id); }).then(); } }

这段代码里浓缩了几个重要的实现细节。

第一,routeDefinitions必须用 volatile 修饰。 监听回调是在 Nacos 的线程里执行的,而请求转发是在 Netty 的 worker 线程里执行的,两个线程之间靠这个引用变量传递最新路由。如果不用 volatile,或者用了一个 final ConcurrentHashMap 然后 clear 再 put,另一个线程可能看到空的 map,导致线上路由瞬间全丢。我刚开始就踩过这个坑,一个配置变更同时打进来几千个请求,全部 404。

第二,整体替换而不是逐个修改。 我先创建一个新的newMap,把全部解析结果放进去,再整体替换routeDefinitions引用。如果直接 clear 再 put,中间哪怕只有几毫秒的时间窗口,请求线程读取的时候都可能拿到空集合。整体替换加上 volatile,可以把这个时间窗口缩到最小,基本不会出现中间态。

第三,解析失败必须保留旧路由。 这一点很容易忽略。如果 Nacos 上的配置被误改成了一个不合法 JSON,解析抛异常,此时旧路由如果已经被清空了,网关就废了。所以我在 catch 里只是打印错误日志,什么都不动,让旧的路由定义继续生效。

3.3 动态路由的刷新机制

可能有同学会问:RefreshRoutesEvent发布之后,框架那边到底做了什么?简单说一下。Gateway 内部有一个RouteRefreshListener,它监听到这个事件之后,会重新调用所有RouteDefinitionRepository的getRouteDefinitions()方法,拿到最新的路由定义集合,然后通过RouteDefinitionRouteLocator重新构建路由匹配器。整个过程通过事件驱动完成,不需要我们手动管理缓存或者并发锁。

我第一次实现的时候还担心刷新会不会影响正在处理的请求。实际验证下来,已经进入转发阶段的请求走的是各自独立的链路,路由刷新只对后续的请求匹配生效,所以不会中断在线流量。当然,配置频繁变更本身不是好习惯,路由信息属于低频变更的基础设施,建议在 Nacos 上做好版本管理和变更审批。

3.4 动态路由和前端 Vue 动态路由的联动思考

很多前端同事一听到“动态路由”会愣一下,因为他们脑子里想的是 Vue Router 的动态路由:根据用户权限用router.addRoute把菜单路由逐个注册进去,不同角色登录后看到的菜单和页面完全不一样。而网关这边的动态路由指的是转发规则的路由表动态生效,两者看起来风马牛不相及,但底层思想是完全相通的。

都是把“配置”从代码里剥离出来,变成运行期可变更的数据。 前端把菜单权限数据拿到前端工程里,Vue Router 动态生成页面路由;网关从 Nacos 拉取路由 JSON,动态生成转发路由。差异只在于一个控制浏览器里的页面跳转,一个控制服务器之间的流量转发。

实际项目里这两者往往是联动的。我之前搭过一个运维管理后台,前端就是用 Vue 写的,里面有一块“网关路由管理”页面。运维同学登录后,根据角色能看到不同的菜单(这是 Vue 动态路由的部分),进去之后把新增服务的路由表单填一下,后端把数据写入 Nacos,网关这边立刻热生效(这是网关动态路由的部分)。这套流程走顺之后,再也不用半夜发版改路由了,前端管理页面加后端配置中心,一个操作闭环就完成了。

4. 全链路日志监控:每一次请求都要有迹可循

4.1 网关是全链路日志的天然锚点

网关日志的价值远不止“看看请求成没成功”这么简单。它是全链路追踪的第一站,traceId 从网关生成并往下游透传,就能把一次请求经过的所有服务串联起来。出问题的时候,我通常先在网关日志里找到这条请求的 traceId,然后拿着这个 traceId 去下游所有服务日志里 grep,几分钟就能还原出完整的调用路径和耗时分布。

所以网关日志必须回答四个问题:请求是几时几分进来的?转发到了哪个服务的哪个实例?最终状态码是多少?整体耗时是多少?如果还能带上用户 ID、来源 IP 这些业务维度,排查效率会高很多。

4.2 traceId 的生成与透传

最朴素的做法就是在全局过滤器里生成一个 UUID 作为 traceId,塞进请求头,下游服务从请求头里取。代码非常简单:

String traceId = request.getHeaders().getFirst("X-Trace-Id"); if (StringUtils.isBlank(traceId)) { traceId = UUID.randomUUID().toString().replace("-", ""); } ServerHttpRequest mutated = request.mutate() .header("X-Trace-Id", traceId) .build();

这个方案的问题在于:如果下游服务也接入了链路追踪系统,比如 Zipkin、SkyWalking,它们默认用 B3 协议的头(X-B3-TraceId)来传递 traceId,我们自己定义的X-Trace-Id对它们来说只是个普通业务头,两条链路对不上。

所以更专业的做法是直接引入 Spring Cloud Sleuth(新版本用 Micrometer Tracing)。它会自动生成 traceId 和 spanId,并自动放到 MDC 里,同时通过 B3 头往下游透传。我们自己要做的,只是在过滤器里把这个 traceId 读取出来,放到访问日志里打印。这样既兼容了标准链路追踪协议,又有自定义的业务日志关联字段。

如果你用的是 Spring Cloud 2021.0.x,依赖是spring-cloud-starter-sleuth。再往后的版本,官方建议迁移到 Micrometer Tracing,接口会有一点变化,但思路一模一样。

4.3 Reactive 环境里 MDC 的坑

MDC 是 SLF4J 的一个特性,基于 ThreadLocal 实现,logback 在打印日志的时候会从 MDC 里取 traceId 自动填充到日志格式里。在传统的 Spring MVC 里,一个请求从头到尾都在同一个线程里跑,MDC 的值非常稳定。但 WebFlux 是事件循环模型,请求处理过程会切换线程,在过滤器里 put 进 MDC 的值,到了另一个线程的日志里什么都打印不出来。

我第一次遇到这个问题的时候,日志里 traceId 一会儿有一会儿没有,排查了半天才发现是线程切换导致的。网上有不少解决办法,比如用 Reactor 的 Context 功能在操作符之间传递上下文,配合Hooks.onEachOperator或者自定义装饰器把 MDC 恢复回去。这些方案能用,但比较绕,对团队的 Reactor 功底有要求,而且放到生产环境里出了奇怪问题很难排查。

我的建议是务实一点: 网关的访问日志本来就是一条结构化日志,直接在日志消息里打印 traceId,不要指望靠 MDC 自动带。你想记录的 traceId、用户 ID、路径、耗时,全都作为字段拼在日志内容里。这样日志格式是显式的,谁来看都能懂,也不会因为线程切换导致字段丢失。下游服务的日志可以用 Sleuth 自动带 traceId,网关这边自己搞定即可。

4.4 网关访问日志结构化输出

我最终落地的日志过滤器逻辑是这样:在全局过滤器中记录开始时间,响应结束后统一打印一条结构化日志。这里有一个细节,获取响应状态码的时机要放在doFinally里,因为此时响应已经完成,状态码一定是可靠的。

日志本身用 JSON 格式输出到 stdout,采集端可以根据字段去做索引。logback 的 JSON 编码器配置如下:

<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender"> <encoder class="net.logstash.logback.encoder.LogstashEncoder"> <customFields>{"app":"gateway"}</customFields> </encoder> </appender>

然后在 logback 配置里单独给网关访问日志定义一个 logger,把它和业务日志分开,方便日志平台单独采集和告警。这样做的好处是,流量大盘、错误率统计、耗时分位数这些指标,都可以直接基于网关访问日志来做,不需要额外埋点。

4.5 与下游服务的链路打通

网关把带 traceId 的请求转发给下游服务之后,下游服务必须能接住这个 traceId,链路才能通。如果只用了 Sleuth,它会自动处理 B3 头,不用业务代码关心。如果走自定义的X-Trace-Id,下游服务就需要在 filter 或者拦截器里把这个值读出来,放到自己的日志上下文里。

我的做法是让所有微服务公共依赖里带一个过滤器,逻辑很简单:从请求头取 traceId,如果存在就放进 MDC;同时响应头里也回写 traceId,方便客户端排查问题。这个过滤器放在公共 starter 里,所有服务接入成本几乎为零。另外,网关在往上游返回响应的时候,也记得把 traceId 通过响应头带回给调用方,这样即使是最外层的前端同事,也能拿着 traceId 来找后端排查问题。

5. 常见问题与排查技巧实录

下面这些问题都是我在生产环境里实际遇到过的,高频又隐蔽,单独拎出来讲一讲。

5.1 过滤器读了 Body,下游接口收不到参数

典型场景:内部接口做签名校验,过滤器里读了请求 body 算签名,签名通过后往下游转发,下游 controller 发现参数没了。前面说过,body 在 WebFlux 里是流式的,第一个过滤器读完就消费掉了,后续过滤器再想读就是空。

解法是把 body 缓存起来。在之前的过滤器里把 body 读出来保存到 exchange attribute,下游需要的时候再取出来重建 request。

// 保存body exchange.getAttributes().put(CACHED_REQUEST_BODY_ATTR, body); // 重建request DataBuffer bodyBuffer = exchange.getAttribute(CACHED_REQUEST_BODY_ATTR); ServerHttpRequest mutated = exchange.getRequest().mutate() .body(Flux.just(bodyBuffer)) .build();

这里有一个必须注意的问题:缓存 body 等于把内存开销放到了网关路径上,大文件上传这种请求就别在过滤器里缓存 body 了,否则多几个大请求网关内存就报警了。建议设置一个大小上限,超过上限的请求跳过缓存逻辑,或者直接放行不做 body 相关校验。

5.2 动态路由发布后迟迟不生效

按照这个顺序排查,基本都能定位。

  • 第一,Nacos 配置是否真的已经发布。打开 Nacos 控制台,看gateway-routes.json的内容是不是自己期望的那一份。很多人改的是本地文件或者另一个 namespace 的配置,发布之后当然没效果。
  • 第二,Listener 有没有执行。在refreshRoutes方法里加一条打点日志,如果发布之后这条日志没出现,说明 Nacos 的长轮询通知没到,检查一下 Nacos 客户端和服务端的网络、版本兼容性。
  • 第三,事件有没有发出去。RefreshRoutesEvent如果没有 publish,框架不会重新拉取路由。我见过有人写了监听逻辑但忘记 publish 的,改了一天配置都没反应。
  • 第四,路由配置本身有没有解析错误。JSON 格式有一个逗号不对,整个配置就解析失败了,而我的代码里 catch 住了异常,旧路由还在,表面看起来是“没生效”,实际上应该是“拒绝变更”。这种情况日志里会有 error 字样,看一眼就知道了。

5.3 日志里的 traceId 时有时无

最典型的原因就是前面说的 MDC 跨线程失效。另外还有一个可能性:下游服务没有从请求头里拿 traceId,而是自己重新生成了一个,导致链路断裂。排查手段就是拿一条真实请求,在网关日志里记下 traceId,然后去下游日志里搜这个值,搜不到就说明下游没有继承网关的 traceId。

如果你的团队链路追踪体系已经统一接入 Sleuth/Micrometer Tracing,这个问题大概率不会出现。如果还在各写各的,建议尽快把公共过滤器的逻辑统一了,不然每次排查都要人工确认 traceId 的传递是否正确,太浪费时间。

5.4 网关性能压测上不去,问题出在哪

百分之八十的可能是过滤器里有阻塞调用。 压测的时候会发现 Netty worker 线程在等待响应,线程数上不去,CPU 也没用满,但 QPS 就是卡在很低的水平。解决办法只有一个:把所有阻塞调用改成响应式。

另外再检查几个点:连接池配置、内存 buffer 大小、日志打印频率。有个项目压测的时候 QPS 上不去,最后发现是每个请求打了五六条 debug 日志,日志同步写盘把磁盘 IO 打满了。网关日志一定要精简,一条请求一条 JSON 日志就够,不要连环炮一样打一堆。

还有一个小细节:spring.codec.max-in-memory-size这个参数决定了请求 body 缓存的上限,设得太小会报 DataBufferLimitException,设得太大容易吃内存,一般根据实际请求体大小设置,默认 256KB 对大多数接口是够的。

写在最后的一点个人体会

这套自定义过滤器、动态路由和全链路日志监控的组合,基本就是把网关卡进阶的骨干摸了一遍。真要说我最大的体会,其实是三个词:克制、版本、可观测。

克制是指网关上的代码一定要精简。 网关是所有流量的汇聚点,你写的每一行代码都会被放大了无数倍去执行,一个没注意的循环都可能成为瓶颈。过滤器能少做一件事就少做一件事,绝不把业务逻辑往网关里塞。

版本指的是动态路由必须有版本意识。 配置中心和本地内存之间的一致性问题要提前设计好,宁可发布失败也不能让网关在运行过程中出现一段“路由真空期”。

可观测是说链路日志一定要从第一天就规划好。 等到线上出了问题才想加日志,你会发现自己连一次请求经历了什么都说不清楚。有了从网关到下游贯通的 traceId,大部分故障排查都是分钟级的事。

如果你正准备在项目里做这三块,建议按顺序来:先搞定自定义过滤器,把鉴权和日志框架搭起来;再上动态路由,把配置中心接入进去;最后全链路日志监控作为基础设施逐步完善。这套路径我走过一遍,踩过的坑都写在上面了,希望对你有用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 16:11:04

C-MIPS编译器实现:从递归下降到四元式生成汇编的完整指南

简介&#xff1a;面向编译原理课程设计与实验的 C-MIPS 编译器实现资源&#xff0c;完整实现了从 C 语言子集到 MIPS 汇编的编译流程&#xff0c;包括词法/语法分析、语义分析、中间代码生成与优化、目标代码生成&#xff0c;适用于高校编译原理实验、课程设计及相关系统软件研…

作者头像 李华
网站建设 2026/10/15 23:27:08

基于Python的3D-CT肺结节检测:从DICOM到CPM 0.85的实战指南

简介&#xff1a;这是一套面向计算机、人工智能、自动化等专业学生与从业者的3D-CT影像肺结节检测项目源码&#xff0c;源自个人毕业设计&#xff0c;答辩评审分达98分&#xff0c;代码经调试测试可稳定运行&#xff0c;适合作为毕业设计、期末大作业或课程设计参考&#xff0c…

作者头像 李华
网站建设 2026/10/14 15:21:43

机器学习课程设计:Python垃圾分类系统源码解析与实战避坑指南

简介&#xff1a;这份Python垃圾分类系统课程设计源码包&#xff0c;面向机器学习课程设计学生及垃圾分类入门开发者&#xff0c;提供一套从模型训练到界面演示的完整个人大作业方案。项目基于TensorFlow 2.3&#xff0c;核心包括MobileNet模型训练脚本、窗口端垃圾分类测试程序…

作者头像 李华
网站建设 2026/10/15 23:27:15

Python实现配电网经济性与可靠性双目标协同优化规划

搞配电网规划的朋友&#xff0c;应该都体会过经济性和可靠性"打架"的感觉。传统工作流里&#xff0c;这两个维度往往是串行处理&#xff1a;先按年费用最小去定线路和容量&#xff0c;再用N-1准则或可靠性导则去校核&#xff0c;不够就加设备、加大截面&#xff0c;来…

作者头像 李华
网站建设 2026/10/14 21:29:02

Codex与harness详解:模型接入、插件配置与常见报错排查

最近圈子里聊 Codex&#xff0c;聊天记录里十有八九会跟着出现“harness”这个词。很多人第一反应是&#xff1a;这不就是同一个东西吗&#xff1f;其实差别很大。Codex 本身是 AI 编程智能体&#xff0c;负责理解你的需求、写代码、调用工具&#xff1b;而 harness 是套在外面…

作者头像 李华
网站建设 2026/10/13 22:41:52

C++实现四阶龙格库塔RK4飞弹仿真:从物理建模到代码实战

最近我把一块硬骨头啃了下来&#xff1a;用C写一个四阶龙格库塔&#xff08;RK4&#xff09;飞弹仿真。以前在MATLAB里点几下就能出图&#xff0c;换成纯C之后&#xff0c;数值方法、物理建模、编译调试全都要自己过一遍&#xff0c;一个坑接着一个坑。整个工程不算复杂&#x…

作者头像 李华