做微服务网关这几年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.xSpring Cloud 2021.0.xSpring Cloud Alibaba 2021.0.5.0Nacos 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 SetString WHITE_LIST new HashSet(Arrays.asList( /api/auth/login, /api/auth/captcha, /actuator/health )); Override public MonoVoid 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 MonoVoid 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这个服务名对应的一组实例。StripPrefix2会把/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 MapString, 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 { ListRouteDefinition definitions JSON.parseArray(configInfo, RouteDefinition.class); // 先构建新map再整体替换避免出现中间状态导致路由为空 MapString, 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 FluxRouteDefinition getRouteDefinitions() { return Flux.fromIterable(routeDefinitions.values()); } Override public MonoVoid save(MonoRouteDefinition 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 MonoVoid delete(MonoString 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 nameJSON classch.qos.logback.core.ConsoleAppender encoder classnet.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大部分故障排查都是分钟级的事。如果你正准备在项目里做这三块建议按顺序来先搞定自定义过滤器把鉴权和日志框架搭起来再上动态路由把配置中心接入进去最后全链路日志监控作为基础设施逐步完善。这套路径我走过一遍踩过的坑都写在上面了希望对你有用。