网关路由与过滤器:地址映射、执行顺序和代理故障
外部请求 GET /api/orders/42 进入网关,订单服务实际收到的路径可以是 /orders/42。中间至少发生了两件事:网关选择一条路由,再按该路由改写请求。请求如果没匹配到路由,或者在过滤器中被拒绝,订单服务根本不会收到它。
入口出现 404、403 或 504 时,先确定请求走到了哪一步,才能决定该检查路由、策略还是上游。
Route 怎样把外部地址映射到上游
路由的组成
Spring Cloud Gateway 的 Route 包含标识、目标 URI、匹配谓词、过滤器及顺序。下面以一条具体路由拆开各部分:
Route: orders
├── id: orders 日志和管理查询中的路由标识
├── predicates
│ ├── Path: /api/orders/** 请求路径条件
│ └── Method: GET 方法条件,与路径条件共同满足
├── filters
│ └── RewritePath: /api/(.*) → /$1 删除外部 /api 前缀
├── uri: http://upstream:8080 上游通信方式与地址
└── order 与其他候选路由的优先次序Path、Host、Method、Header、Query 等谓词读取请求的不同部分。需要区分“这个请求属于哪条路由”与“这个用户是否有权访问订单”:前者负责匹配,后者还要验证身份和具体资源权限。简单 Header 条件不能代替令牌验证。路由谓词
多条路由都能匹配时,要明确顺序;order 数值越小,优先级越高。同优先级的重叠规则不宜依靠加载次序维持业务。常见配置错误是宽泛的 /** 先匹配,遮住后面的专用路由。
入口网关通常作为反向代理,由服务运营方决定请求去向;正向代理则常由客户端选择,用于访问其他目的站。API 网关可以增加身份校验、限流、协议适配或少量聚合,但聚合越多,它对业务服务的耦合也越强。订单状态机和数据库事务通常仍由业务服务维护。
匹配路径与转发路径分别检查
实验使用 Java DSL 定义真实路由:
.route("orders", route -> route
.path("/api/orders/**").and().method("GET")
.filters(filters -> filters.rewritePath(
"/api/(?<segment>.*)", "/${segment}"))
.uri(upstream))segment 是 Java 正则表达式的命名捕获组,替换后保留 orders/42。写在 YAML 中时,替换串还会遇到 YAML 与 Spring 属性占位符处理,不能把 Java 字符串原样搬过去。RewritePath 配置
常用改写方式有:
| 过滤器 | 常用用途 | 需要核实的输入 |
|---|---|---|
| StripPrefix | 删除固定数量的路径段 | 外部路径是否总有这些前缀段 |
| PrefixPath | 统一补内部前缀 | 上游接口是否已经包含该前缀 |
| SetPath | 按模板生成路径 | 模板变量来自哪条匹配规则 |
| RewritePath | 用正则进行更灵活的替换 | 捕获组、转义和特殊字符 |
目标 URI 主要决定上游协议、主机和端口。不要靠在 http://upstream:8080/internal 的末尾附加路径来代替明确的路径过滤器;Gateway 的 HTTP 转发构造不把这里的路径当作自动前缀。
http://... 直接使用配置地址,lb://catalog 则需要负载均衡组件把服务名解析成某个实例地址。配置一个 lb URI 并不会自动启动注册中心。服务名、客户端实例目录和负载均衡依赖都要可用。Global Filters 中的路由处理
选择具体网关运行栈
实验采用 spring-cloud-starter-gateway-server-webflux,运行在 Spring WebFlux 与 Reactor Netty 上。它与 Server Web MVC 版本、ProxyExchange 用法不同;WebFlux 网关按可执行应用运行,不按传统 Servlet WAR 部署。Gateway WebFlux Starter
把 Web MVC、Servlet 过滤器和 WebFlux GlobalFilter 混到一起,容易出现“代码存在但没有执行”。先确认启动日志中的服务器、引入的 starter,以及请求实际经过的处理栈,再检查过滤器注册。
过滤链在请求和响应两侧怎样执行
一次 exchange 与有序过滤链
ServerWebExchange 保存一次请求处理中的请求、响应和属性。匹配到 Route 后,Gateway 合并全局 GlobalFilter 与该路由的 GatewayFilter,按顺序执行。前置部分先进入的过滤器,其包裹的成功回程通常后退出。
实验有两个真实 GlobalFilter:A 的 order 为 -20,负责记录;B 为 -10,检查实验请求头。它们的成功路径如下:
A.pre
B.pre
路径改写、构造目标 URI
NettyRoutingFilter 发起上游请求
NettyWriteResponseFilter 写回代理响应
A.beforeCommit:响应头提交前回调
B.post
A.post这张图省略了与当前实验无关的内置过滤器。顺序来自本项目两个过滤器与实际写响应过滤器的组合,不能推广成任意 order 下都成立的时序。
A 在进入时注册 beforeCommit 回调:
exchange.getResponse().beforeCommit(() -> {
events.add(id, "A.beforeCommit");
exchange.getResponse().getHeaders().set("X-Lab-Request-Id", id);
exchange.getResponse().getHeaders().set("X-Lab-Route", route.getId());
return Mono.empty();
});
return chain.filter(exchange)
.doOnSuccess(ignored -> events.add(id, "A.post"));本例的 A.post 执行时,代理响应已经写出,适合记录完成情况。需要更改响应头,使用提交前的合适位置;不要把 then 或 doOnSuccess 当作“尚未提交”的保证。响应已经提交后,再把结果改成另一个状态或 JSON 错误体通常已经太晚。
成功、错误、取消和短路
B 拒绝请求时,直接设置 403 并返回响应写入的 Mono,不再调用后续链。于是上游计数保持不变。A 仍能观察自己包裹的处理过程,但 B 的成功转发后置逻辑没有被装配进这个拒绝分支。
响应式回调也有不同语义:
| 位置 | 可观察的事件 | 常见用途 |
|---|---|---|
| 调用 chain 之前 | 当前过滤器已进入 | 建立关联信息、检查请求 |
| doOnSuccess | Mono 正常完成 | 成功完成记录 |
| doOnError | 错误信号 | 记录异常类型 |
| doFinally | 完成、错误或取消的终止信号 | 需要覆盖取消的资源回收 |
| beforeCommit | 即将提交响应 | 修改允许修改的响应头 |
客户端中断连接可能产生取消信号,不能只靠成功回调释放自建资源。过滤器返回的是代表整个操作的 Publisher,通常不应自行 subscribe() 把工作脱离当前链;脱离以后,取消、异常和请求上下文都会更难管理。
请求体与事件循环
网络请求体是一条数据流。过滤器为了审计先把 body 读完,再把原始 exchange 原样交给下游,后者可能已经读不到内容。需要重复使用时,应采用框架支持的缓存或请求装饰机制,同时限制可缓存大小。CacheRequestBody
body 缓存占用真实内存。大文件上传、压缩内容和高并发共同出现时,聚合整个请求会显著增加峰值;日志记录通常只需要安全字段或有限摘要,不必保存完整文件和凭据。操作 pooled DataBuffer 时,还要处理成功、异常和取消下的释放,优先使用框架 codec 与受控工具方法。DataBuffer 与资源释放
WebFlux 网关的事件循环承担多个连接。阻塞数据库调用、同步远程请求或长时间文件读取放进过滤器,会影响同一事件循环上的其他请求。需要阻塞库时,可以把操作调度到受限的工作线程池,但仍要控制队列、并发和超时;把整个入口业务迁进去并不能免费获得扩展性。
SSE、WebSocket 和大文件传输应保留相应的流式语义。等待完整响应再统一包装 JSON 会破坏 SSE 的逐段交付;WebSocket 使用对应的 ws/wss 路由处理,不能用普通 JSON 响应包装器处理升级后的双向数据。
搭建可观察的网关与上游服务
环境、身份和构建
准备 Linux、Bash、Docker Engine、Compose 插件、curl、jq 和 unzip。用具有 Docker 使用权限的普通用户,在隔离实验机运行。下载源码 ZIP,项目入口见解压目录中的 README.md。
test "$(id -u)" -ne 0 || exit 1
set -o pipefail
docker version
docker compose version
curl --version
jq --version
unzip microservice-gateway-routing-lab.zip
cd microservice-gateway-routing-lab
export COMPOSE_PROJECT_NAME=ms-gateway-lab
export LAB_DIR="$PWD"
export LAB_UID="$(id -u)"
export LAB_GID="$(id -g)"
mkdir -p .m2依赖采用 Boot 4.1.1 与 Cloud 版本 2025.1.3,由 BOM 解析 Gateway 5.0.3;Java 编译目标 17,Maven 3.9.12,容器运行 Temurin 25.0.4_7。对应 Cloud 列车支持 Boot 4.1.x。Spring Cloud 版本关系
工程分两部分:gateway 是实际 Spring Cloud Gateway;upstream 是 JDK HttpServer 实现的可观察 HTTP 服务。后者保存进程内请求次数,重启归零,没有数据库业务写入。两个容器均以 10001:10001 运行,根文件系统只读,/tmp 可写;端口仅绑定宿主回环。Compose 服务配置
以宿主 UID/GID 构建,缓存目录使用同一身份:
docker run --rm --user "$LAB_UID:$LAB_GID" \
-e MAVEN_CONFIG=/m2 -e MAVEN_OPTS=-Duser.home=/tmp \
-v "$LAB_DIR:/work" -v "$LAB_DIR/.m2:/m2" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -Dmaven.repo.local=/m2 clean verify
test -s upstream/target/upstream.jar
test -s gateway/target/gateway.jar5 个测试会启动真实网关和 HTTP 上游,检查改写、实际回调顺序、拒绝、两类 404 与慢请求超时。应看到 BUILD SUCCESS。测试内每个 HTTP 请求有 5 秒上限,完成后关闭测试上游及线程池。
启动并完成第一次转发
18151 是网关端口,18152 是上游诊断端口,启动前保证它们空闲。管理与诊断接口没有认证,只能用于本地实验。
docker compose up -d
for endpoint in \
http://127.0.0.1:18152/health \
http://127.0.0.1:18151/actuator/health; do
ready=0
for attempt in $(seq 1 60); do
if curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 1 --max-time 2 "$endpoint" > health.json; then
ready=1
break
fi
sleep 1
done
test "$ready" -eq 1 || { docker compose logs --tail=80; exit 1; }
done
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 5 \
-D response-headers.txt -o response.json \
-H 'X-Lab-Policy: allow' -H 'X-Lab-Client: reader' \
http://127.0.0.1:18151/api/orders/42
jq -e '.path == "/orders/42" and .client == "reader"' response.json关键响应为:
{"path":"/orders/42","client":"reader","calls":1}calls 的具体数值取决于此前已发送多少请求。X-Lab-Policy: allow 只控制实验过滤器是否短路,任何调用者都能填写,不能作为生产身份认证。上游只回显符合限定格式的演示字段,不回显 Authorization、Cookie 等凭据。
curl 的 -q 放在第一个选项位置,并通过 --noproxy '*' 隔离默认配置与代理;成功分支遇到 HTTP 错误应非零退出。curl 参数手册
读取网关真正生成的请求标识:
request_id=$(awk 'tolower($1)=="x-lab-request-id:" {gsub("\r","",$2); print $2}' \
response-headers.txt)
test -n "$request_id" || exit 1
ready=0
for attempt in $(seq 1 20); do
if curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 1 --max-time 2 \
"http://127.0.0.1:18151/lab/events/$request_id" > events.json &&
jq -e '.[-1] == "A.post"' events.json >/dev/null; then
ready=1
break
fi
sleep 0.1
done
test "$ready" -eq 1 || exit 1
jq . events.json输出为:
["A.pre","B.pre","A.beforeCommit","B.post","A.post"]读取事件时允许短暂等待,因为客户端拿到响应与服务器完成最后一个回调存在细微时间差。Events 只保存最近 256 个实验请求,内容来自真实回调,不作为生产日志或追踪存储。
三种失败分别由谁返回
先记录上游次数,再不带 allow 头请求已存在的路由:
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 5 \
http://127.0.0.1:18152/counts > before.json
status=$(curl -q --noproxy '*' --silent --show-error \
--connect-timeout 2 --max-time 5 -o rejected.json -w '%{http_code}' \
http://127.0.0.1:18151/api/orders/42)
transport=$?
test "$transport" -eq 0 || exit 1
test "$status" = 403 || { cat rejected.json; exit 1; }
jq -e '.error == "LAB_POLICY_REJECTED"' rejected.json
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 5 \
http://127.0.0.1:18152/counts > after.json
test "$(jq -r .calls before.json)" = "$(jq -r .calls after.json)" || exit 1错误码与上游次数共同表明 B 在转发前拒绝了这次请求。恢复只需按正例带上实验 allow 头,无需修改网关配置。
下面两条请求都返回 404,发生位置却不同:
for path in /not-a-route /bad/missing; do
status=$(curl -q --noproxy '*' --silent --show-error \
--connect-timeout 2 --max-time 5 -D failure-headers.txt \
-o failure.json -w '%{http_code}' -H 'X-Lab-Policy: allow' \
"http://127.0.0.1:18151$path")
transport=$?
test "$transport" -eq 0 && test "$status" = 404 || exit 1
printf '%s\n' "$path"
jq . failure.json
awk 'tolower($1) ~ /^x-lab-/ {print}' failure-headers.txt
done/not-a-route 不匹配任何 Route,响应没有 X-Lab-Route,上游次数不增加。/bad/missing 匹配 bad-path,StripPrefix 删除 bad 后把 /missing 发给上游;它返回 UPSTREAM_NOT_FOUND,并带 X-Lab-Upstream: orders,上游次数增加。
这里的 marker 是实验服务专门增加的诊断信息。生产中应结合网关访问日志、目标 URI 和服务日志确认,不能假设每个 404 都附带同样的响应头。
慢请求与上游停止
应用设置:
spring.cloud.gateway.server.webflux.httpclient.connect-timeout=1000
spring.cloud.gateway.server.webflux.httpclient.response-timeout=1s连接超时单位为毫秒,全局 response-timeout 使用 Duration。每条路由 metadata 的响应超时采用毫秒,复制参数时注意层级和单位。Gateway 超时配置
上游 /orders/slow 等待 2 秒才返回响应。网关先达到已配置的超时:
status=$(curl -q --noproxy '*' --silent --show-error \
--connect-timeout 2 --max-time 5 -o slow.json -w '%{http_code}' \
-H 'X-Lab-Policy: allow' http://127.0.0.1:18151/api/orders/slow)
transport=$?
test "$transport" -eq 0 && test "$status" = 504 || exit 1
jq '{status,error,path}' slow.json上游已收到请求,计数增加;网关超时并未把这次接收从服务端历史中抹掉。这里没有数据库写入,不能用实验计数推导真实订单已经提交或已经取消。
再停止上游:
docker compose stop upstream
status=$(curl -q --noproxy '*' --silent --show-error \
--connect-timeout 2 --max-time 5 -o stopped.json -w '%{http_code}' \
-H 'X-Lab-Policy: allow' http://127.0.0.1:18151/api/orders/42)
transport=$?
test "$transport" -eq 0 || exit 1
case "$status" in
5??) jq '{status,error,path}' stopped.json ;;
*) cat stopped.json; exit 1 ;;
esac
docker compose logs --tail=80 gateway本次容器网络和 1 秒配置下观察到 504。其他环境可能先得到连接或解析异常,再由异常处理器映射为不同的 5xx;“上游停止”本身没有规定唯一的网关状态码。若 curl 传输退出码非零,则先检查网关连接是否仍可用,不能把 HTTP 000 当作预期代理错误。
Gateway 5.0.3 的 NettyRoutingFilter 对取得上游 HttpClientResponse 的 responseFlux 施加上述响应超时。它会包含该响应出现前的等待,不能理解为仅控制 socket 两次读取之间的间隔;后续完整响应体的流式写回不由这一处 timeout 包住。对应版本源码
恢复并等待上游健康,再重做第一次转发:
docker compose start upstream
ready=0
for attempt in $(seq 1 30); do
if curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 1 --max-time 2 \
http://127.0.0.1:18152/health >/dev/null; then
ready=1
break
fi
sleep 1
done
test "$ready" -eq 1 || exit 1
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 5 -H 'X-Lab-Policy: allow' \
http://127.0.0.1:18151/api/orders/42
docker compose down恢复后应返回 200;上游重启后进程内计数重新开始。down 删除这个实验项目的容器与网络,宿主源码、JAR、缓存与响应文件保留。再次启动后可用 bash verify.sh 复验关键 HTTP 分支。
处理错路由、阻塞、超时与入口变更
从路由标识追到具体连接
| 现象 | 优先读取的信息 | 修复方向 |
|---|---|---|
| 某路径始终 404 | 是否匹配 Route、Host/Method 条件、路由顺序 | 修正谓词或重叠规则 |
| 路由存在,上游仍 404 | 原始路径、改写后路径、上游 context path | 调整路径映射 |
| 403 且上游未收到 | 实际执行的策略与身份验证日志 | 修复调用权限或策略条件 |
| 大量连接错误 | 选中地址、DNS、TLS、端口、实例状态 | 恢复地址或上游连接 |
| 超时伴随网关线程阻塞 | 事件循环栈、阻塞调用、队列与连接池等待 | 移出阻塞操作并限制工作量 |
| 大文件时内存增长 | body 聚合、并发量、缓冲释放与大小限制 | 保留流式传输、限制聚合 |
| 响应头偶尔修改失败 | 提交状态、过滤器 order、回调所在阶段 | 把修改放到提交前 |
访问日志至少应关联路由标识、请求方法、有限形式的路径、响应状态、耗时及关联 ID。订单号等动态路径直接作为指标标签会产生高基数,指标宜使用路由 ID 或模板路径;详细标识留在受保护的日志或追踪里。
入口头、安全与业务授权
反向代理可能终止 TLS,再通过内部 HTTP 访问应用。下游生成绝对 URL 或读取客户端 IP 时,依赖 Forwarded 或 X-Forwarded-* 的处理。必须配置可信代理范围,避免客户端自行提供头部冒充源地址、协议或主机。Gateway 5.0.3 的相关头处理配置包含 trusted-proxies,不宜照搬旧版本默认值。HttpHeadersFilters
经过认证的用户也只能操作自己有权访问的订单。网关可验证令牌与粗粒度接口权限,业务服务仍需执行对象级授权;内部调用、批处理或旁路入口同样要受控制。转发到上游的身份上下文应由可信入口建立,不应把任意客户端灰度头、租户头直接当授权声明。
CORS 解决浏览器跨源访问许可,不能替代上述认证和授权。TLS、HTTP 方法与浏览器行为的基础见从请求到响应。
动态路由发布与保护组合
新增或修改路由会改变外部流量去向。先验证目标 URI、谓词重叠、路径改写和允许的目标范围,再在少量网关实例或受控流量上启用;观察请求分布与错误后逐步扩展。任意用户可写目标 URI 会引入访问内部网络的风险,路由管理属于高权限管理面。
Gateway Actuator 可以查询路由及过滤器,开放写操作还可以影响路由状态。应仅暴露所需端点、选择适当访问级别,并放在认证和网络隔离之后。示例只暴露健康端点,没有启用路由写入 API。Gateway Actuator API
切流后还要处理旧连接和在途请求。WebSocket、SSE 和长上传可能跨越路由发布时刻,旧实例排空要留出对应时间。回退路由也只能影响后续匹配,不能撤销上游已经执行的写操作。
限流、熔断和重试加入过滤链时,应逐项确认调用次数和作用位置。网关重试与客户端重试叠加会放大上游负荷;带请求体的重试还要求可重放的 body,并有明确业务幂等策略。对应的选择过程见负载均衡与重试,保护组件的组合见限流、熔断、隔离与降级。
