
1. 整体架构设计与方案选型1.1 网关在微服务体系中的核心定位先聊一个最基础的问题——为什么我们需要网关。微服务架构拆到最后服务数量可能上百客户端不可能一个个去记每个服务的地址。如果没有网关统一收口每个服务都要处理 CORS、鉴权、限流、日志这些横切关注点代码污染严重管理也很混乱。Spring Cloud Gateway 在几个主流网关方案里是比较受青睐的一个。相比 Zuul 1.x 基于 Servlet 的阻塞模型Gateway 基于 Spring WebFlux 和 Netty 的响应式模型性能上限高线程模型更优。而且它和 Spring Cloud 生态的兼容性天然无缝隙Nacos 注册发现、Sentinel 限流、Sleuth 链路追踪都有直接的整合方案。我这里想说的进阶不是让你去背 API 文档。核心是三个能力的落地自定义过滤器、动态路由、全链路日志监控。这三个东西在真实的业务里几乎必用但网上讲得深的资料不多大多停留在跑个 demo的层面。1.2 为什么需要自定义过滤器和动态路由我们先想一个问题网关上要处理的逻辑除了转发请求之外还有多少从我这边的经验看至少包括这些鉴权验证 token、签名校验权限无效的直接拦截灰度按用户维度分流部分用户走新版本的 provider 服务规范化统一改写请求头、响应头报文处理对请求体做验签或加签在网关层完成 RSA/SM2 这类操作日志记录请求耗时、状态码、目标服务方便问题排查这些逻辑中有一部分是全局的比如日志。有一部分是只针对特定路由生效的比如某个接口的上游服务需要对某个 header 做特殊处理。Spring Cloud Gateway 自带的过滤器工厂只覆盖常规场景比如 StripPrefix、AddRequestHeader 这些真正贴合业务的一定要自己动手写。动态路由解决的又是另一个问题。网关上路由规则如果写死在配置文件里每次变更都要重启服务这在微服务架构里是不可接受的。服务上下线、新接口上线、流量转发策略调整这些都是高频操作。动态路由就是把路由规则从配置文件搬到中间件Nacos、Redis 或数据库运行期可以通过 API 或配置推送更新不用重启网关。1.3 全链路日志监控的实际需求微服务排查问题的痛点在跨服务调用。A 服务调 B 服务B 服务调 C 服务任何一个环节慢了问题出在哪一层再加个 MQ 异步线程一换日志上下文全丢了根本没法串联。全链路日志的核心思路是给一个用户请求生成唯一的 TraceId然后通过 HTTP Header 或者消息队列的附加属性往下传。每个服务的日志里都打上这个 TraceId。排查的时候拿 TraceId 去日志系统里一搜整个调用链的所有日志就都出来了。Spring Cloud Gateway 在链路追踪中的地位很特殊——它是入口。TraceId 在网关生成、路由通过 Header 传给下游服务。如果这一步没做好后面全是白搭。所以网关的全链路日志不光是网关自己日志的问题还决定了整个链路追踪体系能不能打通。2. 自定义过滤器实战2.1 Spring Cloud Gateway 过滤器的运行机制搞清楚 Spring Cloud Gateway 过滤器的运行机制是所有自定义过滤器开发的底层基础。先记住一句话网关而言过滤器就是处理链上的节点。WebFlux 框架把一次完整的请求封装成一个ServerWebExchange对象它持有 request、response、attributes 等所有上下文数据。过滤器链上每个过滤器拿到这个exchange后按顺序处理最终把请求转发到目标服务。Spring Cloud Gateway 的过滤器分为两类GatewayFilter路由级别的过滤器通过spring.cloud.gateway.routes.filters配置生效只作用于该路由GlobalFilter全局过滤器作用于所有请求在源码实现上GlobalFilter会被适配成GatewayFilter并加入每个路由的过滤器链中。如果你看FilteringWebHandler的源码会发现它拿到路由的全部GatewayFilterAdapter然后按Ordered接口的顺序排列成一个有序链表依次执行。执行顺序怎么定的看Ordered接口的getOrder()返回值值越小优先级越高越先执行。一个容易踩的坑是过滤器的 pre 逻辑在chain.filter(exchange)之前的部分按 order 从小到大执行但 post 逻辑在chain.filter(exchange)之后、响应返回给客户端之前的逻辑是逆序的。因为chain.filter(exchange)返回的是一个Mono后面的过滤器嵌套在里面响应回来的时候最先执行的那个过滤器的 post 逻辑最后执行。这类似于 Servlet 里 Filter 的执行模型但很多人第一次写网关过滤器时不注意结果响应日志记录的时间点不对。2.2 自定义全局过滤器签名校验的完整实现下面用签名校验这个实战场景来走一遍完整流程。业务背景外部商户调用网关开放的 API网关需要校验签名。签名规则是md5(appSecret sortedQueryString) salt时间戳超时 5 分钟拒绝。请求头携带x-app-id、x-timestamp、x-sign。第一步创建一个类实现GlobalFilter和Ordered接口Component public class SignVerifyFilter implements GlobalFilter, Ordered { private static final SetString WHITE_LIST Set.of(/health, /internal/); Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path exchange.getRequest().getPath().value(); // 白名单直接放行 if (WHITE_LIST.stream().anyMatch(path::startsWith)) { return chain.filter(exchange); } // 1. 获取请求头信息 String appId exchange.getRequest().getHeaders().getFirst(x-app-id); String timestamp exchange.getRequest().getHeaders().getFirst(x-timestamp); String sign exchange.getRequest().getHeaders().getFirst(x-sign); // 2. 校验参数完整性 if (appId null || timestamp null || sign null) { return reject(exchange, MISSING_SIGN_PARAM); } // 3. 校验时间戳, 拒绝重放攻击 long ts Long.parseLong(timestamp); if (Math.abs(System.currentTimeMillis() / 1000 - ts) 300) { return reject(exchange, SIGN_EXPIRED); } // 4. 根据 appId 从内存/Redis中取该商户的 appSecret String appSecret getSecretByAppId(appId); if (appSecret null) { return reject(exchange, INVALID_APP_ID); } // 5. 服务端重算签名 String sortedQuery sortQueryParams(exchange.getRequest().getURI().getRawQuery()); String serverSign DigestUtils.md5DigestAsHex( (appSecret sortedQuery SALT).getBytes(StandardCharsets.UTF_8)); // 6. 对比签名 if (!serverSign.equalsIgnoreCase(sign)) { return reject(exchange, SIGN_MISMATCH); } // 通过校验, 将 appId 和 appSecret 放入 exchange, 方便下游过滤器使用 exchange.getAttributes().put(appId, appId); return chain.filter(exchange); } private MonoVoid reject(ServerWebExchange exchange, String code) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); DataBuffer buffer exchange.getResponse().bufferFactory() .wrap(({\code\:\ code \}).getBytes(StandardCharsets.UTF_8)); return exchange.getResponse().writeWith(Mono.just(buffer)); } Override public int getOrder() { return -100; } }这个过滤器逻辑上没什么高级的但有几个细节值得注意关于顺序order设为 -100确保它先于大多数过滤器执行。如果网关里还有别的过滤器比如把x-app-id写入ServerWebExchange的 attributes 让下游服务使用就要理解好顺序关系——chain.filter(exchange)之后的代码会在下游过滤器和路由转发完成后执行不要在 post 位置去做需要 pre 阶段数据的操作。关于sortQueryParams的实现记住一个原则去掉sign参数本身其余参数按 key 自然排序然后拼keyvalue用连接空 value 保留空字符串。这个逻辑要和客户端约定好两边算法不一致是最常见的对接失败原因。我的做法是把这部分算法用 Java 和 JS 各写一份单测对接方直接用我们的测试用例验证他们的实现。关于时间戳校验按秒级时间戳做校验容差 5 分钟既防重放又避免比较大的时钟偏差。另外建议把Math.abs(System.currentTimeMillis() / 1000 - ts) 300这个 300 秒的容差放配置中心动态下发线上调整不用重启网关。2.3 自定义 GatewayFilterFactory与配置体系结合GlobalFilter适合全局逻辑但如果某个逻辑只针对部分路由生效更优雅的方式是自定义GatewayFilterFactory。比如有这样的需求内部系统调用对接服务时网关上要把上游返回的某个 header 改写掉只针对/api/private/前缀的路由生效。编写自定义 GatewayFilterFactory 需要继承AbstractGatewayFilterFactoryNameValueConfigComponent public class RewriteHeaderGatewayFilterFactory extends AbstractGatewayFilterFactoryRewriteHeaderGatewayFilterFactory.Config { public RewriteHeaderGatewayFilterFactory() { super(Config.class); } Override public GatewayFilter apply(Config config) { return (exchange, chain) - { ServerHttpResponse response exchange.getResponse(); response.getHeaders().add(config.name, config.value); return chain.filter(exchange); }; } Override public ListString shortcutFieldOrder() { return List.of(name, value); } public static class Config { private String name; private String value; // getter / setter 省略 } }在配置文件里的写法就非常简洁spring: cloud: gateway: routes: - id: private-api uri: lb://private-service predicates: - Path/api/private/** filters: - RewriteHeaderX-Proxy-Env, pro配置里的RewriteHeaderX-Proxy-Env, pro就是基于shortcutFieldOrder的快捷写法。如果不提供shortcutFieldOrder就必须写完整 YAML 结构filters: - name: RewriteHeader args: name: X-Proxy-Env value: pro这个类怎么写是有讲究的。AbstractGatewayFilterFactoryC的泛型 C 就是这个过滤器工厂的配置类框架会在配置绑定阶段自动把配置值绑定到 Config 对象的字段上。注意配置类的字段名必须和配置里 key 的名字保持一致其实是有ConfigurationProperties的绑定规则在里面可以通过Value注解或shortcutFieldOrder控制。我在接手一些老项目时经常看到的做法是不管什么逻辑全塞在GlobalFilter里然后写一堆 if 判断路由路径。最终结果是过滤器类越来越大配置体系完全没用起来。正确的姿势是基于路由级别的过滤器决策树该用GatewayFilterFactory就用让路由规则自解释。这样新同学接手维护也容易理解。2.4 过滤器中的异步操作与线程上下文自定义过滤器里大概率会遇到异步操作比如查 Redis、调远程服务获取密钥。这里必须强调一个 WebFlux 响应式编程的坑不要用Thread.sleep来等待不要用Future.get()阻塞。Spring Cloud Gateway 基于 Netty 的工作线程是 NIO 线程一旦阻塞吞吐量直接崩盘。正确的是用响应式操作Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { return userService.findByToken(token) .flatMap(user - { exchange.getAttributes().put(userId, user.getId()); return chain.filter(exchange); }) .switchIfEmpty(reject(exchange, INVALID_TOKEN)); }配合 Redis 响应式客户端ReactiveRedisTemplate全程无阻塞。另一个大坑是 MDC 上下文。如果你打算在过滤器里打日志用MDC.put(traceId, ...)然后chain.filter(exchange)之后的日志想拿到这个值——拿不到。原因很简单WebFlux 的反应式链路在不同线程间切换时ThreadLocal 不会跟着传递。这就是我们在后面的全链路日志部分要专门讲reactor.context的原因。在这一节先提一嘴后面会展开。3. 动态路由实现3.1 从配置文件路由到运行时路由先明确一下动态路由的目标网关启动时加载路由定义的方式改成从外部数据源读取运行期间可以动态地新增、修改、删除路由所有网关节点同时生效无需重启。目前常见的动态路由方案有几种基于 Nacos 配置中心路由规则用 JSON/YAML 存储在 Nacos 配置中网关监听配置变更事件解析后更新内存中的路由表基于 Redis路由定义存 Hash 结构网关定期拉取或者订阅key的变更消息基于数据库路由表在 MySQL 中管理通过Scheduled或事件推送更新基于 Apollo和 Nacos 类似选择方案要根据团队基础设施情况。已有 Nacos 就用 Nacos已有 Apollo 就用 Apollo不建议在已有配置中心的情况下再引入别的存储。下面以 Nacos 为例完整讲解。Spring Cloud Gateway 原生提供了RouteDefinitionRepository接口Gateway 内部通过这个接口加载路由定义public interface RouteDefinitionRepository { FluxRouteDefinition getRouteDefinitions(); MonoVoid save(MonoRouteDefinition route); MonoVoid delete(MonoString routeId); }内置的InMemoryRouteDefinitionRepository就是把路由存内存PropertiesRouteDefinitionRepository从配置文件加载。我们自己实现一个 Nacos 版本思路是从 Nacos 的某个配置项读取路由定义的 JSON 数组同时启动一个监听器配置变更时刷新本地。3.2 基于 Nacos 的动态路由完整代码实现先写核心类NacosRouteDefinitionRepositoryComponent public class NacosRouteDefinitionRepository implements RouteDefinitionRepository { private static final String DATA_ID gateway-routes.json; private static final String GROUP DEFAULT_GROUP; private final ObjectMapper objectMapper new ObjectMapper(); private final ConfigService configService; private final ListRouteDefinition routeDefinitions new CopyOnWriteArrayList(); public NacosRouteDefinitionRepository(ConfigService configService) { this.configService configService; initAndListen(); } private void initAndListen() { try { // 初始化拉取配置 String config configService.getConfig(DATA_ID, GROUP, 5000); updateRouteDefinitions(config); // 注册监听器 configService.addListener(DATA_ID, GROUP, new Listener() { Override public Executor getExecutor() { return null; // 使用 Nacos 的默认线程池 } Override public void receiveConfigInfo(String configInfo) { updateRouteDefinitions(configInfo); } }); } catch (NacosException e) { throw new RuntimeException(e); } } private void updateRouteDefinitions(String config) { if (config null || config.trim().isEmpty()) { return; } try { ListRouteDefinition list objectMapper.readValue(config, new TypeReferenceListRouteDefinition() {}); routeDefinitions.clear(); routeDefinitions.addAll(list); } catch (JsonProcessingException e) { log.error(解析网关路由配置失败, e); } } Override public FluxRouteDefinition getRouteDefinitions() { return Flux.fromIterable(routeDefinitions); } Override public MonoVoid save(MonoRouteDefinition route) { return route.flatMap(r - { try { String config configService.getConfig(DATA_ID, GROUP, 5000); ListRouteDefinition list new ArrayList(); if (config ! null !config.trim().isEmpty()) { list objectMapper.readValue(config, new TypeReferenceListRouteDefinition() {}); } list.removeIf(d - d.getId().equals(r.getId())); list.add(r); configService.publishConfig(DATA_ID, GROUP, objectMapper.writeValueAsString(list)); return Mono.empty(); } catch (Exception e) { return Mono.error(e); } }); } Override public MonoVoid delete(MonoString routeId) { return routeId.flatMap(id - { try { String config configService.getConfig(DATA_ID, GROUP, 5000); ListRouteDefinition list new ArrayList(); if (config ! null !config.trim().isEmpty()) { list objectMapper.readValue(config, new TypeReferenceListRouteDefinition() {}); } list.removeIf(d - d.getId().equals(id)); configService.publishConfig(DATA_ID, GROUP, objectMapper.writeValueAsString(list)); return Mono.empty(); } catch (Exception e) { return Mono.error(e); } }); } }这个类实现后按理说所有新增、修改、删除路由会被 Nacos 的通知机制触发到receiveConfigInfo方法然后更新routeDefinitions列表。但这里有个关键点单纯更新这个列表还不够RouteDefinitionLocator是一个链式缓存器底层被CachingRouteLocator包了一层它会在第一次加载时缓存结果。怎么解决发布一个RefreshRoutesEvent。Spring Cloud Gateway 在CachingRouteLocator上监听这个事件做缓存刷新。这是我们刷路由的机制。Component public class DynamicRouteService implements ApplicationEventPublisherAware { private ApplicationEventPublisher publisher; Override public void setApplicationEventPublisher(ApplicationEventPublisher publisher) { this.publisher publisher; } public void refresh() { publisher.publishEvent(new RefreshRoutesEvent(this)); } }然后再看一下关联关系。其实不用手动触发也是可以的——RouteDefinitionRouteLocator中getRouteDefinitions()获取到的FluxRouteDefinition来自RouteDefinitionLocator的组合。默认情况下CachingRouteLocator实现了RouteLocator接口并且缓存了路由查询结果监听RefreshRoutesEvent。实际的刷新链路是RouteDefinitionRepository中的数据更新Nacos 监听器触发发布RefreshRoutesEventCachingRouteLocator.onApplicationEvent清除本地缓存下一次请求进来时重新从CompositeRouteDefinitionLocator读取路由所以完整的代码里Nacos 的receiveConfigInfo中拿到新配置后不仅更新列表还要调用publisher.publishEvent(new RefreshRoutesEvent(this))。3.3 动态路由的性能、一致性与灰度发布细节动态路由在上线后有几个藏在细节里的坑必须处理。第一个坑路由更新的事务性。发布到 Nacos 的配置是全量 JSON 字符串如果一个节点解析成功、另一个节点因为网络问题没拿到更新那不同网关节点之间的路由是不一致的。好在 Nacos 的addListener机制会保证推送最终一致性但短暂的窗口期内同一个路由在不同节点上状态可能不同。对于敏感业务建议在刷新逻辑里做一个灰度控制先刷新一台观察 10 分钟确认没有异常流量报错后再放量刷新剩余节点。实现上就是在配置里加一个版本号字段代码里判断版本落差超过阈值就告警。第二个坑路由定义的前缀冲突。动态路由管理界面如果允许不同组的人各自创建路由很容易出现Path/api/**这种「大而全」的断言优先级盖过Path/api/user/**这种精确断言的情况。Spring Cloud Gateway 的断言匹配是按配置顺序从上往下匹配的前面的命中就不会走下去。所以配置管理要约定规则精确路由放在前面通配路由靠后。如果代码层面想兜底可以在保存路由时做一个检查禁止间路由之间断言相互包含。第三个坑动态路由的谓词重算不生效。路由定义里如果带Weight断言这个断言比较特殊它不是路由命中匹配而是分组路由的权重分配。刷新路由时如果不同节点之间Weight配置更新不是同时完成流量分配就乱了严重时会误伤某个集群。建议权重类变更选业务低谷期操作。第四个坑路由定义里的uri是注册中心服务名。如果把lb://user-service直接写成http://127.0.0.1:8080通过 Nacos 动态改路由可以改但服务地址变了又得改一次。生产环境大部分场景用lb://前缀 注册中心的服务发现服务实例上下线网关自动感知路由不用变。最后补充一种更现代化但需要更多依赖的方案如果你想做的不仅仅是网关侧变化还希望服务和 API 维度的元数据管理——那可以上API 网关的管理体系引入 Sentinel 的网关流控规则把动态路由和动态限流放到同一个配置中心管理。这个方案的好处是路由和流控规则在同一个治理平面坏处是复杂度上来了没有一定规模不必强上。我的经验是先做动态路由能覆盖掉的场景比想象中多。4. 全链路日志监控4.1 TraceId 的生成与 MDC 的传递机制全链路日志的第一步是把 TraceId 在网关入口生成然后传给下游所有服务。提到日志就绕不开 MDC。MDC 是日志框架提供的一个 ThreadLocal 类型的存储容器你在代码里MDC.put(traceId, xxx)日志配置里用%X{traceId}引用来输出就能让这条线程上所有日志都带上 traceId。问题前面已经提到WebFlux 是响应式的同一个请求的处理可能发生在多个不同线程上。MDC.put放在一个线程上chain.filter(exchange)的后续日志在另一个线程上打印MDC 里的内容就丢了。社区有常见的两种解法解法一使用 Reactor 的 Context 机制。WebFlux 提供了contextWrite和deferContextual来在整个响应链路中传递上下文。我们可以写一个过滤器把 traceId 放入reactor.core.publisher.ContextComponent public class TraceIdFilter implements GlobalFilter, Ordered { private static final String TRACE_ID_HEADER X-Trace-Id; private static final String TRACE_ID_ATTR traceId; Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String traceId exchange.getRequest().getHeaders().getFirst(TRACE_ID_HEADER); if (StringUtils.hasText(traceId)) { // 如果前端或上游已经传入 traceId, 则复用 exchange.getRequest().mutate().header(TRACE_ID_HEADER, traceId); } else { traceId UUID.randomUUID().toString().replace(-, ); exchange.getRequest().mutate().header(TRACE_ID_HEADER, traceId); } exchange.getAttributes().put(TRACE_ID_ATTR, traceId); MDC.put(TRACE_ID_ATTR, traceId); return chain.filter(exchange) .doFinally(signalType - MDC.remove(TRACE_ID_ATTR)); } Override public int getOrder() { return Integer.MIN_VALUE; } }这种做法在当前线程内打日志没问题。但如果后续的响应式调用发生线程切换日志里就丢了 traceId。Spring Cloud Gateway 内置的响应式链路日志记录也面临同样的问题。这就必须要引入一个结合 Reactor 上下文的工具类在过滤器里contextWrite(ctx - ctx.put(TRACE_ID_ATTR, traceId))然后在日志输出时通过deferContextual从Context取出手动放入 MDC。代码会复杂一些但这是响应式场景最干净的方式。解法二直接用 Micrometer Tracing原 Sleuth集成。spring-cloud-sleuth在 3.x 里被重构成了micrometer-tracing现在是最推荐的方案。它会自动处理TraceId的生成、传播和日志输出把 traceId 注入 Slf4J 的 MDC。网关侧不需要自己写代码去管理 MDC只需要引入依赖和配置dependency groupIdio.micrometer/groupId artifactIdmicrometer-tracing-bridge-brave/artifactId /dependency dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-gateway/artifactId /dependency然后日志配置里直接使用%X{traceId}就能打出来。它对 HTTP 请求的 Header 传播也做了自动处理。这是我认为在普通规模企业里性价比最高的方案。自己维护一套 TraceId 的生成传递和上下文管理成本比大家想象中高。因为不只网关所有下游服务都要配合。4.2 网关日志的标准化输出不管用不用 Micrometer Tracing网关自身的请求日志要标准化。这方面很多项目的做法是拼一个长字符串格式各自为政解析起来痛苦不堪。推荐的做法是用 JSON 格式输出 access log方便 Logstash 这类工具消费。比如在全局过滤器里记录这些字段字段说明timestamp请求处理完成时间毫秒时间戳traceId链路追踪 IDmethodHTTP 方法path请求路径query请求查询参数routeId命中的路由 ID没命中则为空targetUri目标服务实际地址status响应状态码requestTime请求开始时间costMs总耗时毫秒clientIp客户端 IPuserId用户 ID如已鉴权实现里有个重要的点响应状态码和耗时只能在 post 位置拿到但 post 位置拿到的响应对象已经被消费过一部分了。对普通场景exchange.getResponse().getStatusCode()在 post 位置取是没问题的但如果你想记录响应体内容就麻烦了——响应体是异步流只能在DecoratingServerHttpResponse里包装后自己收集才能拿到。这里先说直接可用于生产的 AccessLogFilterComponent public class AccessLogFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { long start System.currentTimeMillis(); String traceId exchange.getRequest().getHeaders().getFirst(X-Trace-Id); return chain.filter(exchange).doFinally(signal - { long cost System.currentTimeMillis() - start; ServerHttpResponse response exchange.getResponse(); Route route exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR); String routeId route null ? : route.getId(); String targetUri route null ? : route.getUri().toString(); JSONObject logObj new JSONObject(); logObj.put(timestamp, System.currentTimeMillis()); logObj.put(traceId, traceId); logObj.put(method, exchange.getRequest().getMethodValue()); logObj.put(path, exchange.getRequest().getPath().value()); logObj.put(routeId, routeId); logObj.put(targetUri, targetUri); logObj.put(status, response.getStatusCode() null ? 0 : response.getStatusCode().value()); logObj.put(costMs, cost); logObj.put(clientIp, getClientIp(exchange)); log.info({}, logObj.toJSONString()); }); } Override public int getOrder() { return Integer.MAX_VALUE; } }注意order设置成Integer.MAX_VALUE也就是最后执行。这样前面所有过滤器包括路由转发都执行完了日志里能拿到最终的 status 和 route 信息。另一个需要注意的地方doFinally里不要做耗时长的操作。有一次我在里面加了一个 Elasticsearch 的同步写入结果响应变慢Netty 线程被占用的时间边长排查了很久才发现是这里的问题。改成异步投递到消息队列就解决了。4.3 网关关键指标与日志分析日志打出来了还要用起来。监控分两个层面基础可观测性网关的 QPS、错误率、P99 耗时。这些指标如果还没有 Prometheus Grafana建议从这一版直接配上。Spring Boot 有现成的 actuator 集成Spring Cloud Gateway 直接暴露/actuator/gateway/metrics类似端点配合 micrometer-registry-prometheus 聚合。业务日志分析通过 ELK 或者 Loki 收集上面的 access log生成仪表盘。我常用的几个视图按 routeId 分组看每个路由的请求量、错误率、耗时趋势按 path 分组找出 TOP N 慢接口按 clientIp 分组识别异常流量来源全局 TraceId 检索链路追踪可视化这几年大家通常会引入分布式链路追踪系统像 SkyWalking、Zipkin 等。它们的优势是自动采集调用链信息可视化展示服务调用拓扑图。网关接入后可以在服务拓扑图里看到网关作为链路的起点。引入时有个建议不要让网关把所有请求细节都上报到追踪系统。每秒几万请求的网关如果每条都上报追踪系统本身会成为瓶颈。采样率配置成 10%慢请求单独全量采样的思路更实用。如果用 SkyWalking 有现成的采样配置用 Zipkin 则是配置spring.sleuth.sampler.rate或者自定义Sampler。4.4 日志丢失、乱序与排查的实战经验用了链路日志后最常见的问题有三个我逐个说。问题一TraceId 在部分服务里是空的。这个十有八九是下游服务没接入 MDC。排查方法进到下游服务容器里直接看它的原始日志有没有 traceId。标准做法是下游服务用同一套spring-cloud-starter-sleuth或micrometer-tracing依赖日志配置统一加%X{traceId}。要确认某一个叫mall-member的服务是否上链路看它 received 的 HTTP 请求头里有没有X-B3-TraceId。这里的背景知识是Sleuth 或 Micrometer Tracing 的 Header 传播默认用的X-B3-TraceId、X-B3-SpanId这类 B3 格式。如果某个服务不识别这个 header链路就断了。问题二日志乱序。日志消费端解析时发现一个 traceId 的日志时间线错乱。原因通常是两个一是多线程输出日志MDC 中同一个 traceId 像异步编程那样切换线程特别容易发生二是日志收集端时间戳解析口径不一致。处理办法日志格式里时间精度到毫秒并设置日志收集端的采集时间与主机进行 NTP 同步。此外在浏览器检索时用 traceId 分组后按时间排序勉强能还原出调用顺序。问题三链路中出现了 MQ 消息TraceId 过不去。同步 HTTP 调用好解决消息队列跨线程跨进程就麻烦。Sleuth 对 Kafka 这种有自动的提取注入支持只要消息头的traceId没有手动覆盖一般能传过去。真正难搞的是自研 RPC 框架或 WebSocket。我的意见是自研框架必须对接后置追踪 SDK官方规范不支持的情况下两端约定好外卖一个 TraceId Header 或者消息属性手动穿透。5. 踩坑记录与性能调优5.1 ID 生成策略对网关性能的影响TraceId 的生成方式对性能影响不小。如果直接用UUID.randomUUID().toString().replace(-, )在高 QPS 下会有比较明显的性能开销。不是不能用但你可以试试用Long.toHexString(System.currentTimeMillis()) Long.toHexString(随机数)来拼一个更短的 TraceId。当前网络上各厂常用的做法还包括 Snowflake 变种。合理性排序是这样的毫秒时间戳 随机填充简单够用适合一般业务Snowflake 算法带机器号分布式下唯一性有保证适合高并发第三方同步发号器保证全局唯一但引入额外依赖个人推荐网关这一层没有复杂的分发要求直接把时间戳和随机因子生成字符长度压缩到 32 位以内。太长的话日志文件体积显著增大ES 索引膨胀。5.2 网关线程模型与性能调优要点Spring Cloud Gateway 的性能调优核心是理解它基于 Netty 的线程模型。总结下来 4 个要点调大 Netty 线程数。默认情况可用 CPU 核数的 2 倍作为 eventLoop 线程数。在高并发场景下这个值不够需要配置spring.webflux.netty.worker-count一般建议按照 CPU 核数的 4-6 倍配置。这个参数不是越大越好——线程膨胀反而带来上下文切换成本。配置连接池的大小。Gateway 转发请求到下游服务时会从 Netty 的 Http 客户端连接池获取连接。默认最大连接数有限QPS 高了容易等待。Spring Boot 2.4 支持用配置调整spring: cloud: gateway: httpclient: connect-timeout: 3000 response-timeout: 10s pool: type: elastic max-connections: 1000 max-idle-time: 5s禁用不必要的组件。Gateway 在实际使用中Redis 限流RequestRateLimiter是基于spring-boot-starter-data-redis-reactive的如果你用了实时限流这个一定保留。但重试机制RetryGatewayFilter建议只在幂等的 GET 请求上启用其他请求慎用——重试会给下游造成成倍压力。注意全局过滤器里的 JSON 序列化要用ObjectMapper复用别每次 new。这个虽然是老生常谈但 Filter 里循环调用高代价方法导致性能下降的情况真的发生过。网关这一层是流量咽喉每一微秒的损耗乘以海量请求结果都会被放大。5.3 内存溢出与 CPU 飙升的实战排查网关线上问题我印象比较深刻的一次是 CPU 飙升到 90%接口大面积超时。排查过程先用top -Hp pid找到高 CPU 线程再用jstack导出线程栈发现大量线程阻塞在 Netty 的连接建立与读取上。进一步看是某个路由配置的lb://服务下线后注册中心上没有及时剔除实例网关一遍又一遍地连接一个已经不可用的服务造成连接建立风暴。处理办法一是动态路由的下线流程要在代码层面确保先从注册中心摘除实例再删路由二是给 HttpClient 配置短超时connect-timeout 和 response-timeout让失败的请求快速失败不占用线程资源。还有一次是内存溢出。原因说出来很蠢——在过滤器里把请求体缓存到内存里// 绕过, 不要这样做!! 大的请求体会把堆撑爆 byte[] body exchange.getRequest().getBody().collectList() .map(list - ... ).block();当有大数据量的上传请求进来时内存灰飞烟灭。如果要读请求体比如验签需要 body用DataBufferUtils.join并限制大小或者改为把 body 缓存到磁盘临时文件。正常场景下网关层不要尝试读取大的请求体除非你的过滤器可以证明它必须这么做。5.4 如何给网关做压测与容量评估网关上线前压测很重要但很多人做得不专业。我给的建议是分三步第一步单机性能基准。用 WRK/Locust 做纯路由转发的压测看单机吞吐量和 P99 延迟。先探底不要参考任何网上别人的数字因为业务复杂度、机器配置、下游服务响应速度都不同。第二步全链路压测。网关 真实下游服务或者足够逼真的 Mock。这一步重点看网关对下游服务慢响应的表现——下游服务如果 P99 是 300ms1000 QPS 请求转发时网关工作线程耗尽的情况是什么样。此时你要确信你的连接池大小、超时配置、限流策略是匹配的。第三步故障演练。模拟下游服务宕机、注册中心抖动、Nacos 配置错误观察网关是快速失败还是本地宕机。这一条中我最有价值的经验是一定要配置本地兜底路由缓存。动态路由依赖中心化配置如果 Nacos 短暂不可用网关重启后路由加载不出来就是整个系统瘫痪。做法是网关启动时如果拉配置失败就加载上一次从 Nacos 拉到的序列化到本地文件的缓存路由。这个兜底救过我的命。6. 动态路由管理 API 与前端对接6.1 提供路由管理的后端 API动态路由还包含一个可管理的后台。我们要提供暴露给管理页面或运维脚本的 HTTP API功能至少包括查询所有路由、新增路由、修改路由、删除路由、刷新路由缓存。路由实体类重写了 Java 端管理 API 使用了RouteDefinitionWriter和RouteDefinitionLocator。这里给出一段简化的后端 ControllerRestController RequestMapping(/route) public class RouteController { private final RouteDefinitionWriter routeDefinitionWriter; private final RouteDefinitionLocator routeDefinitionLocator; private final DynamicRouteService dynamicRouteService; public RouteController(RouteDefinitionWriter routeDefinitionWriter, RouteDefinitionLocator routeDefinitionLocator, DynamicRouteService dynamicRouteService) { this.routeDefinitionWriter routeDefinitionWriter; this.routeDefinitionLocator routeDefinitionLocator; this.dynamicRouteService dynamicRouteService; } GetMapping(/list) public FluxRouteDefinition list() { return routeDefinitionLocator.getRouteDefinitions(); } PostMapping(/save) public MonoResponseEntityObject save(RequestBody RouteDefinition routeDefinition) { return routeDefinitionWriter.save(Mono.just(routeDefinition)) .then(Mono.fromRunnable(dynamicRouteService::refresh)) .thenReturn(ResponseEntity.ok().build()); } DeleteMapping(/delete/{id}) public MonoResponseEntityObject delete(PathVariable String id) { return routeDefinitionWriter.delete(Mono.just(id)) .then(Mono.fromRunnable(dynamicRouteService::refresh)) .thenReturn(ResponseEntity.ok().build()); } }动态路由管理 API 配合细粒度的权限校验很重要。网关是核心组件路由规则一旦被恶意修改整个系统的流量都可能被打到错误的地方。接口必须走内部管理系统鉴权并且建议所有增删改操作记录操作审计日志。6.2 SEO 优化思路从vue动态路由到网关的路由可视化前面提到热搜词里有vue动态路由这个热词。很多人会奇怪为什么它和网关动态路由关联起来。其实道理很简单——后台管理系统中路由权限动态生成本身就是 Spring Cloud Gateway 网关动态路由的商业场景。前端页面根据当前登录用户动态生成菜单菜单背后对应的 API 权限网格又反过来需要网关动态路由去控制访问范围和权限。这块工作做得好呈现出来的就是一个完整的 API 治理后台前端 Vue 菜单一个菜单对应一个路由 ID运维添加一条网关路由生成对应的前端菜单项用户登录时查询自己的权限集合后端把可访问的 API 列表动态下发前端动态渲染菜单这种前后端配合的场景在真实的政企项目中太常见了。网上关于 vue 动态路由的文章多如牛毛但大多只讲了前端怎么挂菜单后端怎么配合做动态讲清楚的不多。网关的动态路由本质上是权限控制的基础设施层谁可以访问哪个 API在网关这里先卡一道前端菜单只是展示层的映射。6.3 路由配置校验与回滚策略最后补一个运维向的经验动态路由变更不能没有回滚机制。在运营后台做变更单的概念任何一次路由变更都生成一个快照存储在数据库或者配置中心的另一个 namespace 里。如果做了一次新增路由的变更导致线上故障可以通过点击回滚按钮一键恢复成上一个版本。路由配置校验分三层格式校验RouteDefinition的 id 不能重复predicates 不能为空uri 必须是合法的 URI 或lb://格式冲突校验新路由的断言与现有路由是否有重叠和冲突尤其是Path/api/**这类白名单兜底路由连通性校验新路由目标lb://service-name在注册中心是否存在如果连服务都没有保存时直接给出警告不允许强制上线这些校验说起来简单但实际开发中经常被忽略。我见过最惨的一次事故是运维在后台把一条Path/的全局兜底删了不到一分钟前端所有页面 404静态资源全部没有转发过去。紧急恢复的代价是十分钟的线上事故——如果当初写校验时拦住删除兜底路由这个操作事故根本不会发生。写在最后网关这个组件平时不起眼一旦出问题就是全局性的事故。我自己做这几次实践下来最大的感受是动态路由、自定义过滤器、全链路日志这三块不是孤立的它们共同构成了网关的可运维性。过滤器负责业务规则动态路由解决变更效率日志链路提供可观测性。三件事都做扎实了网关才真正算得上稳定。最后再分享一个小细节如果你要走向生产环境记得给网关注册一个独立的日志目录不要跟业务服务混在一起日志清理策略提前定好。访问日志保留 30 天、链路追踪日志保留 7 天、错误日志保留 90 天这是我在线上稳定运行下来比较合理的周期配置。希望这篇实战记录能给正在搞网关的人一些参考。