Spring Cloud Gateway内部链路与生产治理
本章建立在 Gateway 总览之上,重点解释一次请求在 WebFlux、Route、Filter、LoadBalancer、Reactor Netty 和连接池之间怎样流动,以及请求体、流式响应、可信 Header、取消、重试和优雅停机的失败边界。
一、版本边界
Spring Cloud Gateway 要与 Spring Boot、Spring Framework 和 Spring Cloud Release Train 精确匹配。Boot 2/JDK 8 存量体系和 Boot 3/Java 17+ 体系使用不同依赖基线,但 WebFlux 的非阻塞原则、Exchange、Route、Filter 和 Reactor Netty 主链一致。
配置键、默认 Filter 顺序、Reactor Netty 版本和观测集成会随版本演进。生产排查应先保存 /actuator/gateway/routes、依赖树和实际配置,不能把某个版本的内部 Order 数字背成永久 API。
二、启动与路由快照
flowchart TD
A["配置文件、代码DSL或动态路由源"] --> B["RouteDefinitionLocator"]
B --> C["校验Predicate和Filter定义"]
C --> D["RouteDefinition转换为Route"]
D --> E["RouteLocator形成运行期路由流/快照"]
E --> F["刷新事件触发重新加载"]
F --> G["新请求使用新路由视图"]动态刷新不能把正在匹配的集合逐条原地修改,否则请求可能看到半更新。平台应以版本化完整 Route 集合构建候选快照,校验成功后再发布;旧请求继续按已选 Route 完成,新请求进入新快照。
路由删除不等于已有 TCP/WebSocket 连接立即断开。是否强制终止要按业务决定,通常先停止新建连接,再排空存量连接。
三、一次请求的内部对象链
flowchart TD
A["Reactor Netty接收连接和HTTP报文"] --> B["WebFlux创建ServerWebExchange"]
B --> C["RoutePredicateHandlerMapping匹配Route"]
C --> D["Exchange记录Route和原始URL"]
D --> E["FilteringWebHandler合并Global/Gateway Filter"]
E --> F["前置Filter:Trace、鉴权、限流、改写"]
F --> G["lb方案解析ServiceInstance"]
G --> H["RouteToRequestUrl构造目标URI"]
H --> I["NettyRoutingFilter借连接并转发"]
I --> J["下游响应写入Exchange"]
J --> K["后置Filter和NettyWriteResponse写回"]ServerWebExchange 是一次请求的可变上下文壳,Request/Response 通过 Decorator 生成新视图,Attributes 保存 Route、目标 URL、原始 URL、状态等中间结果。过滤器不要使用实例字段保存单请求数据,否则并发请求会互相污染。
四、Filter顺序与洋葱模型
前置逻辑按 Order 从高优先级进入,后置逻辑按调用栈逆序返回。一个 Filter 不调用 chain.filter(exchange) 就会短路链路。
建议按职责组织:
规范化真实客户端信息
-> 清除外部伪造内部Header
-> 生成Trace与请求ID
-> CORS/白名单
-> 身份认证
-> 租户与资源上下文
-> 限流/配额
-> 灰度和路由
-> 路径/Header改写
-> 转发
-> 状态码、字节数、耗时和审计内部 Filter 的精确 Order 属于版本实现细节。自定义 Filter 应使用相对职责和集成测试验证,不要依赖一个未经约束的魔法数字恰好排在 Netty Filter 前后。
五、EventLoop为什么不能阻塞
Reactor Netty 用少量 EventLoop 线程处理大量连接。Filter 中执行 JDBC、阻塞 HTTP、Thread.sleep、大文件同步读写,会占住 EventLoop,使其他无关连接也无法读写。
flowchart TD
A["少量EventLoop承载大量连接"] --> B["某Filter执行阻塞调用"]
B --> C["EventLoop无法处理其他Socket事件"]
C --> D["连接排队、P99升高"]
D --> E["超时和重试增加"]
E --> F["网关形成正反馈雪崩"]能异步就使用真正非阻塞客户端;遗留阻塞调用必须隔离到有界线程池,并设置队列、超时和拒绝策略。把阻塞任务随意 publishOn(boundedElastic()) 只能移动阻塞位置,不能消除下游容量限制。
六、请求体为什么不能随便读取两次
WebFlux Body 是异步 DataBuffer 流,通常消费一次。Filter 为验签/日志读取后若不重新包装 Request,下游可能拿到空 Body。缓存整个 Body 又会把大上传聚合进内存。
治理原则:
- 在读取前限制 Content-Length,并处理 Chunked 无长度场景。
- 设置网关全局和路由级最大请求体。
- 只对需要验签的小 JSON 路由缓存。
- 文件上传尽量流式转发,不记录完整 Body。
- DataBuffer 使用和释放遵循框架 API,避免引用计数泄漏。
- 日志只记录脱敏摘要、长度和 Hash,不打印密码、Token、病历原文。
请求体修改应使用官方 ModifyRequestBody 等机制或正确 Decorator;具体 API 受 Spring Cloud 版本影响。
七、响应体、SSE和流式下载
普通 JSON 可以有限聚合后修改;SSE、Flux 流、文件下载不能等完整响应结束再缓存,否则失去流式语义并可能 OOM。
响应日志 Filter 若包装 Body,要处理:背压、客户端取消、分块编码、压缩、Content-Length 失效和 DataBuffer 释放。对流式路由通常只记录状态、首字节时间、总字节和结束信号,不记录完整响应。
Gateway Timeout 不应套在无限 SSE 流的整个生命周期上;应区分连接建立、首字节、心跳空闲和业务会话超时。
八、WebSocket转发
WebSocket 通过 HTTP Upgrade 建立长期双向连接。握手阶段可以鉴权和路由,升级后不再按普通 HTTP 请求逐条执行 Gateway Filter。
生产要关注:连接数、连接寿命、Ping/Pong、空闲超时、单连接消息速率、最大帧、反压、实例下线和会话恢复。扩容只影响新连接,旧连接仍粘在原实例;缩容需先从路由摘除,再等待连接排空或发送业务重连通知。
九、可信Header与身份边界
客户端可以伪造 X-User-Id、X-Tenant-Id、X-Forwarded-For。网关在信任边界上应:
- 清除来自公网的内部身份 Header。
- 验证 Token/mTLS 后重新写入签名或受信上下文。
- 只信任明确代理链产生的 Forwarded Header。
- 下游仍执行资源级授权,不能只信一个可伪造 Header。
- 防止 Hop-by-hop Header 被错误转发。
鉴权缓存必须以 Token/主体和权限版本为 Key,并处理撤销窗口;不能只按用户 ID 永久缓存“已授权”。
十、Reactor Netty连接池与超时阶段
要区分:DNS、Connect、TLS Handshake、Pool Acquire、Response/Read、Write、Idle 和总 Deadline。Pool Acquire 超时表示请求可能尚未发到下游。
关键指标:Active/Idle/Pending Connection、Pending Acquire Time、建连/TLS时间、连接复用率、最大连接寿命、下游实例维度连接分布。
连接池按目标地址管理。服务扩容后旧连接仍在旧实例,需最大连接年龄、空闲淘汰和连接排空逐步再平衡;配置过短会制造建连与 TLS 握手风暴。
十一、Timeout和取消不代表下游事务回滚
Gateway 超时后会停止等待并尝试取消上游链,但请求可能已经到达下游并提交数据库。客户端收到 504 不能推断业务失败;写接口需要幂等键和结果查询。
flowchart TD
A["Gateway转发写请求"] --> B["下游提交本地事务"]
B --> C["响应返回前Gateway超时"]
C --> D["客户端收到504"]
D --> E["业务事实可能已经成功"]
E --> F["按幂等键查询,不盲目创建新请求"]超时预算应从入口向下传递。网关总预算大于内部单次尝试预算,并预留响应时间;剩余预算不足时不再发起重试。
十二、Gateway重试边界
GET 等幂等读取可对连接失败、特定 5xx 做有限重试;POST 即使 Body 可缓存,也不代表业务可重复。必须同时满足稳定幂等键、下游去重、请求体可重放、剩余 Deadline 和 Retry Budget。
Gateway、Feign、Mesh 只能指定一个主要重试层。每次 Attempt 要记录目标实例、失败阶段、状态码和剩余预算,防止一次用户请求乘成大量下游请求。
十三、鉴权、限流与配额顺序
公网基础防护可先按 IP 做粗限流,防止鉴权本身被打满;通过认证后再按租户、用户、API 和套餐做精确配额。只按 IP 会误伤 NAT 后大量用户,只按用户会让匿名攻击进入昂贵鉴权。
限流失败返回 429 和合理 Retry-After,不能让客户端立即无抖动重试。Redis 限流故障时按接口风险决定 Fail-Open/Fail-Closed,资金写接口和公开查询不能使用同一策略。
十四、Gateway优雅停机
flowchart TD
A["实例进入终止流程"] --> B["Readiness失败并从入口摘流"]
B --> C["等待路由和LB传播"]
C --> D["停止接受新连接/请求"]
D --> E["排空HTTP请求和可控长连接"]
E --> F["到达上限后关闭连接池和EventLoop"]终止宽限期必须覆盖摘流传播与请求排空。WebSocket/SSE 可能持续数小时,应设计客户端重连和服务端通知,不能无限阻止发布,也不能无通知强杀所有会话。
十五、动态路由和灰度
动态路由发布需要版本、校验和原子快照。灰度按用户/租户 Hash 比随机权重更稳定,避免同一用户请求在 v1/v2 来回跳;但热租户会造成不均,应监控实际版本流量和业务指标。
旧版本无实例时,不应无条件回退到不兼容版本。路由策略要明确:Fail Closed、回主版本、降级或返回维护状态,并记录决定。
15.1 动态路由不是每次请求查数据库
动态路由属于控制面能力。控制面负责保存、审批、发布、通知和回滚路由规则;数据面负责在每次请求到来时用本地已加载的路由快照做快速匹配和转发。高质量网关不会让每个请求都同步查数据库或配置中心,否则配置中心和数据库会被业务QPS拖进数据面。
flowchart TD
A["管理员发布路由版本"] --> B["控制面校验RouteDefinition"]
B --> C["保存版本和审计记录"]
C --> D["通知Gateway实例刷新"]
D --> E["实例拉取完整路由快照"]
E --> F["构建Route对象"]
F --> G["原子替换本地RouteLocator视图"]
G --> H["新请求使用新路由"]动态路由要关注两条链:
| 链路 | 发生频率 | 关键点 |
|---|---|---|
| 发布刷新链 | 路由变更时发生 | 校验、版本、原子替换、实例收敛、回滚 |
| 请求匹配链 | 每次请求发生 | 读取本地快照、Predicate匹配、Filter执行、转发 |
如果把两条链混在一起,常见事故是:路由中心抖动导致所有请求卡住,或者数据库慢查询直接拖垮网关入口。
15.2 动态路由发布必须校验什么
动态路由不能让用户在控制台随便写一段配置就全量推生产。发布前至少校验:
| 校验项 | 为什么重要 |
|---|---|
| routeId唯一 | 避免覆盖已有路由或回滚找不到目标 |
| Predicate合法 | Path、Method、Header、Host 条件错误会导致404或串路由 |
| Filter合法 | StripPrefix、RewritePath、RequestRateLimiter 参数错误会导致路径错乱或全量限流 |
| uri合法 | lb://service 服务名、固定URL、协议是否允许 |
| 权限边界 | 禁止把公网路由到内网敏感服务 |
| 顺序Order | 宽泛路由不能排在精确路由前面 |
| 版本兼容 | 新旧服务、API、Header、Body、响应结构是否兼容 |
| 回滚版本 | 新路由失败时能否回到上一个可用快照 |
灰度发布时还要校验目标版本实例是否存在、metadata是否完整、下游服务是否Ready、监控维度是否带版本标签。
15.3 路由匹配顺序为什么会导致串路由
多个 Route 都能匹配同一个请求时,顺序会决定最终命中谁。比如 /api/admin/** 和 /api/** 同时存在,宽泛路由如果排在前面,管理接口可能被转到普通服务。
flowchart TD
A["请求 /api/admin/users"] --> B["检查 /api/**"]
B --> C{"是否匹配"}
C -- "是" --> D["命中普通API路由"]
D --> E["后续 /api/admin/** 不再生效"]正确做法通常是精确路由优先,宽泛路由靠后,并用自动化测试覆盖核心路径:
| 请求 | 预期routeId |
|---|---|
/api/admin/users | admin-service |
/api/assets/100 | asset-service |
/openapi/v1/orders | openapi-service |
/actuator/health | 不暴露或只内网暴露 |
这类测试比肉眼看YAML可靠得多。路由系统上线前,应该把重要路径、方法、Header、Host和灰度标签都做成回归用例。
15.4 灰度路由为什么推荐稳定Hash
随机权重会让同一个用户多次请求可能一会儿到 v1,一会儿到 v2。如果 v1/v2 使用不同缓存、Session、Feature Flag 或响应字段,用户体验和排查都会变差。稳定Hash会基于用户、租户、设备或医院编码计算版本,使同一主体在灰度期间尽量固定到同一版本。
flowchart TD
A["请求带tenantId或userId"] --> B["Gateway计算Hash"]
B --> C{"是否落入灰度区间"}
C -- "是" --> D["写入灰度标签 version=v2"]
C -- "否" --> E["写入稳定标签 version=v1"]
D --> F["LoadBalancer按metadata选择实例"]
E --> F灰度标签必须来自可信边界。客户端传来的 X-Version、X-Gray、X-User-Id 不能直接信任,网关要先清洗外部Header,再根据已认证身份、租户、配置规则重新生成受信标签。
| 灰度维度 | 优点 | 风险 |
|---|---|---|
| 用户ID | 体验稳定,便于回访 | 未登录请求不适用 |
| 租户ID | B端灰度清晰 | 大租户可能流量过重 |
| 医院编码 | 医疗采集场景可控 | 单医院接口异常会影响灰度判断 |
| 百分比 | 简单推进比例 | 随机抖动,不利于状态相关场景 |
| Header | 调试方便 | 必须由网关生成或白名单信任 |
15.5 动态路由和灰度Runbook
出现“刚发路由就大量404/503/串版本”时,不要先重启网关,先取证:
- 查本次变更的 routeId、路由版本、发布人、发布时间和上一版本。
- 查每个 Gateway 实例当前生效的路由版本和更新时间。
- 用请求样本复现 Predicate:Path、Method、Host、Header、Query 是否命中。
- 查 Filter 后的真实路径,特别是 StripPrefix、RewritePath、PrefixPath。
- 查
lb://服务名、Nacos namespace/group、目标版本 metadata 和候选实例数。 - 查灰度标签是否由网关生成,是否被下游 Feign/RPC 继续透传。
- 查 routeId、targetUri、targetInstance、version、traceId 是否进入日志和Trace。
- 如果是规则错误,原子回滚到上一兼容快照;如果是目标实例缺失,先切回稳定版本再修复注册和metadata。
动态路由事故的重点是“快速回到已知正确版本”,不是现场手工修配置。手工热改如果没有版本和审计,下一次发布可能把现场修复覆盖掉。
十六、JDK 8 Demo:版本化路由快照
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
public final class RouteSnapshotStore {
static final class Snapshot {
final long version;
final Map<String, String> routes;
Snapshot(long version, Map<String, String> routes) {
this.version = version;
this.routes = Collections.unmodifiableMap(
new HashMap<String, String>(routes));
}
}
private final AtomicReference<Snapshot> current;
RouteSnapshotStore(Snapshot initial) {
this.current = new AtomicReference<Snapshot>(initial);
}
boolean publish(Snapshot candidate) {
for (;;) {
Snapshot old = current.get();
if (candidate.version <= old.version) return false;
if (current.compareAndSet(old, candidate)) return true;
}
}
String route(String path) {
return current.get().routes.get(path);
}
public static void main(String[] args) {
Map<String, String> v1 = new HashMap<String, String>();
v1.put("/assets", "lb://asset-v1");
RouteSnapshotStore store = new RouteSnapshotStore(new Snapshot(1L, v1));
Map<String, String> v2 = new HashMap<String, String>();
v2.put("/assets", "lb://asset-v2");
System.out.println("v2Applied=" + store.publish(new Snapshot(2L, v2)));
System.out.println("lateV1Applied=" + store.publish(new Snapshot(1L, v1)));
System.out.println("target=" + store.route("/assets"));
}
}Demo 表达“完整候选快照构建成功后原子替换,迟到版本不能回退”。真实 Gateway 的路由匹配和刷新由框架完成,业务平台不要绕过官方扩展直接修改内部集合。
十七、网关失败窗口
| 窗口 | 风险 | 治理 |
|---|---|---|
| 路由发布中 | 实例看到不同版本 | 版本快照、实例ACK、灰度 |
| Body已转发后超时 | 下游可能已提交 | 幂等键和结果查询 |
| 连接池Pending | 请求尚未到下游 | 分阶段指标和Acquire超时 |
| 实例摘除传播中 | 旧连接继续命中 | 排空、被动失败、连接年龄 |
| 响应包装聚合大流 | OOM或失去流式 | 路由白名单、字节上限、流式指标 |
| EventLoop阻塞 | 全站P99上升 | 非阻塞客户端、有界隔离池 |
| 全局限流依赖故障 | 全开或全拒绝 | 按接口风险分级降级 |
| WebSocket缩容 | 长连接被强断 | 摘流、通知、客户端重连 |
十八、Gateway生产Runbook
- 按状态码分层:无 Route 的404、无实例503、下游502/504、鉴权401/403、限流429。
- 保存 routeId、路由版本、原始/目标URI、实例、TraceId、Attempt和耗时阶段。
- 检查 EventLoop CPU/阻塞栈、BlockHound/线程转储和自定义 Filter。
- 检查连接池 Active/Idle/Pending、Acquire、Connect、TLS和Response时间。
- 检查请求/响应大小、Body缓存、DataBuffer泄漏和流式路由。
- 检查注册发现快照、Readiness、灰度标签和长连接分布。
- 检查多层重试、幂等键和下游业务事实,不能把504直接记失败。
- 检查动态路由版本和实例应用状态,必要时原子回滚兼容快照。
- 修复后压测普通JSON、上传、下载、SSE、WebSocket和下游慢/断场景。
十九、常见错误
| 错误 | 正确理解 |
|---|---|
| Gateway非阻塞就不会阻塞 | 自定义Filter仍可阻塞EventLoop |
| Body可以随便读两遍 | DataBuffer流通常一次消费,需正确缓存和包装 |
| 504等于业务失败 | 下游事务可能已经提交 |
| 路由删除后连接立即消失 | 存量HTTP2/WebSocket仍需排空 |
| 网关鉴权后下游不用鉴权 | 下游仍要资源级授权和可信边界 |
| 响应日志可记录完整Body | 大流、敏感数据和内存风险 |
| POST配置Retry就安全 | 还需幂等、Body重放和Deadline |
| 精确Order值永久不变 | 内部Filter顺序受版本影响,应集成验证 |
