Gateway 可靠性:身份、限流、超时与观测
上一页讲清了"请求被哪条路由选中、路径和头怎样改写"。这一页只回答剩下的问题:请求到了边缘之后,身份凭什么可信,配额在哪里扣,超时到底管到哪一步,失败应该显示成哪个状态码,以及出了事从哪几个数看出来。本页用 /secure、/limited、/timeout、/api 四组路由分别观察认证、限流、超时、重试和指标,每一步都有真实 HTTP 观察,不是配置清单。
本页是 U32(#69)的 L02 可靠性权威正文:身份传递、限流、超时、异常与观测。路由谓词、过滤链顺序、路径与头改写见Gateway 路由;MVC 与 Proxy 形态见Gateway 多种形态;服务目录、实例缓存与摘流见Cloud 发现与负载均衡。熔断器与 Feign 客户端归 U33,Sentinel 规则归 U37,配置中心与动态路由归 U34,真实注册中心适配归 U35/U40。
一条请求上的四类失败
GET /limited/echo 带着 Authorization: Bearer <token> 进入网关,可能的结局有四种,处理位置完全不同:
| 结局 | 典型状态 | 判定位置 | 谁负责修 |
|---|---|---|---|
| 没有身份、签名/签发者/受众/过期不合法 | 401 | 资源服务器(ReactiveJwtDecoder 校验链) | 调用方换 token |
| 身份合法但权限不足 | 403 | 授权规则(hasAuthority) | 授权模型或账号 |
| 配额耗尽 | 429 | 限流过滤器(Redis 令牌桶) | 配额策略或调用方退避 |
| 下游慢、连不上、返回 5xx | 504 / 503 / 原样 5xx | 路由过滤器(超时、连接、透传) | 下游容量或网关预算 |
把这四类混成一个"网关 500"是运维事故里最常见的起点:401 被当成下游故障去重启,429 被当成网络问题去扩容,504 被当成业务异常去回滚。状态码本身就是责任分配,前提是网关没有把它改掉。
四类失败共享一条时间线:认证在过滤链前段,限流在路由过滤器里、代理之前,超时与重试包住真正的出站 HTTP,指标在响应提交时落账。任何一步短路,后面的步骤都不会执行;因此"请求没到下游"和"下游拒绝了"必须能从响应头、状态码和指标标签上分开。
版本、工程与观察入口
固定 Spring Cloud Gateway 5.0.4,commit 2780fa2117ce3f8af662aa3d485ed77743aa6fb0;Boot 4.0.9、Cloud 2025.1.4。实际解析:Framework 7.0.10、Security 7.0.8、Reactor Netty 1.3.8、spring-data-redis 4.0.8、Lettuce 6.8.2.RELEASE。运行环境为 Oracle JDK 21.0.5、Maven 3.9.9、macOS 26.7.1 x86_64,限流后端为 Redis 8.2.1 单实例(127.0.0.1:6391,无持久化)。查询与运行日期 2026-10-26。
公开入口是 labs/cloud-gateway 的四个独立工程,本页对应 webflux 模块的 12 个真实 HTTP 用例;完整命令、退出码、观察行与文件 SHA-256 见运行证据,工程边界见实验说明。
python3 labs/cloud-gateway/run.py --redis-port=6391入口先 PING 本机 Redis,连不上直接退出且不写成功证据;它不启动、不重启、不改 Redis 配置。测试进程绑定 127.0.0.1 随机端口,上游是同一进程内的受控 loopback HTTP 服务,JWT 由本机 RSA 密钥签发。
本页引用的源码是 5.0.4 发布源码包中的连续片段,标注整文件 SHA-256 前 16 位;教学代码、配置和运行观察另行标注。源码、配置、编译、HTTP 实跑是四种证据,不能互相替代。
身份:认证在边缘,信任不在头里
网关做认证,不等于下游可以不做。认证解决"这个请求是谁发的",授权解决"这个身份能做什么",两者都在网关执行一次之后,下游仍要面对"这个请求真的经过网关吗"的问题。只要下游端口在内网可达,攻击者就可以绕过网关直接打下游,并自带一个看起来很像网关注入的头。
实验里的信任链是这样搭的:
// webflux/src/test/java/study/gateway/GatewayApplication.java
.route("secure", r -> r.path("/secure/**").filters(f -> f.stripPrefix(1)
.removeRequestHeader("X-User").removeRequestHeader("X-Verified-User")
.filter((exchange,chain) -> exchange.getPrincipal().flatMap(principal ->
chain.filter(exchange.mutate().request(request ->
request.header("X-Verified-User",principal.getName())).build()))))
.uri(peers.uri()))顺序是关键:先删除客户端可能伪造的同名头,再用已认证的 Principal 重建。反过来写(先注入再删除)等于把身份删掉;只删 X-User 而留下 X-Verified-User,则伪造头直接穿透。实验对这两点都做了断言:带 X-User: mallory 和 X-Verified-User: bob 的合法请求,上游看到的是 user=null、verified=alice。
下游的二次校验同样不能省。Upstreams 对 /identity 自己验签、验签发者、验受众、验过期,并检查 scope;即使请求带了 X-Verified-User: alice,只要没有合法 Bearer,仍然返回 401。这条断言的意义是:身份头是网关给下游的便利,不是下游的信任依据。
资源服务器的校验链在 decoder 里显式装配:
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(Duration.ZERO),
new JwtIssuerValidator("https://issuer.example.test"), audience));JwtTimestampValidator(Duration.ZERO) 表示不接受任何时钟偏移;生产上通常给 30–60 秒容忍,代价是过期 token 多活这么久。受众校验是自定义 lambda:aud 不含 u32-api 就失败。实验分别用错签名、错签发者、错受众、已过期四种 token 请求,全部 401,且每次失败后紧跟一次合法请求返回 200——证明失败没有污染后续请求的认证状态。
授权规则只有两行,但边界要说清:
.pathMatchers("/actuator/**").hasAuthority("SCOPE_ops")
.pathMatchers("/secure/**","/limited/**").hasAuthority("SCOPE_read")
.anyExchange().permitAll()- 401 与 403 的分工:没有身份或身份不合法是 401;身份合法但缺权限是 403。实验用
scope=other的合法 token 得到 403,用错签名得到 401。 anyExchange().permitAll()意味着路由本身不做统一认证。这是有意的:不同路由的认证策略可能不同(公开商品页、内部管理页),把permitAll当默认值就必须逐条检查受保护路径,否则新加一条路由就是新开一个口子。SCOPE_前缀来自 Spring Security 对scope/scp声明的转换约定,不是网关自定义。- 实验的安全链显式关闭 CSRF:这是纯 Bearer API,没有 cookie 会话;若网关同时承载浏览器会话,关闭 CSRF 会让跨站写请求失去保护,不能照搬。
反例:把认证放在下游、网关只做转发,看起来"职责清晰",实际把 N 个下游都变成了认证实现方,任何一个漏配就是绕过;把认证只放在网关、下游完全信任头,则内网横向移动即可伪造身份。两种做法都不是纵深防御。
限流:key、令牌桶与每副本配额
限流过滤器做三件事:从请求算出一个 key,用 key 去后端扣令牌,按结果放行或拒绝。
// RequestRateLimiterGatewayFilterFactory(5.0.4,SHA-256 8cacbc8a0bae3b90…)
return (exchange, chain) -> resolver.resolve(exchange).defaultIfEmpty(EMPTY_KEY).flatMap(key -> {
if (EMPTY_KEY.equals(key)) {
if (denyEmpty) {
setResponseStatus(exchange, emptyKeyStatus);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
}
...
return limiter.isAllowed(routeId, key).flatMap(response -> {
for (Map.Entry<String, String> header : response.getHeaders().entrySet()) {
exchange.getResponse().getHeaders().add(header.getKey(), header.getValue());
}
if (response.isAllowed()) return chain.filter(exchange);
...
setResponseStatus(exchange, config.getStatusCode());
return exchange.getResponse().setComplete();
});
});四个默认值决定了行为:denyEmptyKey=true(空 key 直接拒绝)、emptyKeyStatusCode=FORBIDDEN(403,不是 429)、statusCode=TOO_MANY_REQUESTS(429)、响应头先写后判(被拒绝的响应也带 X-RateLimit-Remaining)。空 key 用 403 而不是 429 是有意的:空 key 说明配置或身份缺失,不是"你太快了",把它混成 429 会让调用方一直重试。
RedisRateLimiter 的 key 构造与令牌桶语义:
static List<String> getKeys(String id, String routeId) {
String prefix = "request_rate_limiter.{" + routeId + "." + id + "}.";
String tokenKey = prefix + "tokens";
String timestampKey = prefix + "timestamp";
return Arrays.asList(tokenKey, timestampKey);
}{} 是 Redis Cluster 的 hash tag:两个 key 落在同一个槽,Lua 脚本才能原子地读写它们。id 来自 KeyResolver,默认实现是 PrincipalNameKeyResolver(取认证名),也可以是 API key、租户 ID 或 IP。选 IP 会在 NAT 后把整栋楼算成一个用户;选租户会把同租户的所有用户共享一个桶;选 principal 最贴近"按用户限流",但要求认证先完成。
真正的扣减在 Lua 里(META-INF/scripts/request_rate_limiter.lua):
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3]) or redis.call('TIME')[1]
local requested = tonumber(ARGV[4])
local fill_time = capacity / rate
local ttl = math.floor(fill_time * 2)
local last_tokens = tonumber(redis.call("get", tokens_key)) or capacity
local last_refreshed = tonumber(redis.call("get", timestamp_key)) or 0
local delta = math.max(0, now-last_refreshed)
local filled_tokens = math.min(capacity, last_tokens+(delta*rate))
local allowed = filled_tokens >= requested
local new_tokens = allowed and filled_tokens - requested or filled_tokens
if ttl > 0 then
redis.call("setex", tokens_key, ttl, new_tokens)
redis.call("setex", timestamp_key, ttl, now)
end
return { allowed and 1 or 0, new_tokens }这段脚本有几个容易被忽略的推论:
- 时间粒度是秒。
ARGV[3]传的是空串,tonumber("")为 nil,于是取TIME[1](整数秒)。同一秒内的连续请求不会补充令牌,replenishRate=1意味着"每秒 1 个",不是"每 1000 毫秒均匀 1 个"。 - 首次访问是满桶。
last_tokens缺省为capacity,所以新用户第一次就能突发到burstCapacity。 - TTL = 2 × 充满时间。
capacity/rate是桶从空到满的秒数,乘 2 后过期。空闲超过这个时间的用户,key 被 Redis 删除,下次访问重新满桶。这既控制了内存(只保留活跃用户),也意味着"长期统计"不存在——它是令牌桶,不是滚动窗口。 capacity < requested时永远拒绝,且每次仍写回同样的令牌数;配置错误表现为稳定 429,不会自愈。- 脚本用
setex同时写两个 key,任何一个失败都不会留下半个桶——但 Redis 单实例故障时整个脚本失败,进入下一节的 fail-open 分支。
实验配置是 replenishRate=1、burstCapacity=60、requestedTokens=60,即"一次请求扣 60 个令牌、桶容量 60、每秒补 1"。第一次请求扣光,第二次立刻 429;换 bob 是另一个 key,仍然 200。这就是"按 principal 限流"的最小可观察形态。
每副本配额是这一节最贵的认知:Redis 里的桶是全局的(同一个 Redis),但 replenishRate 是每个网关实例各自读取的配置。如果 6 个副本各自把 replenishRate 理解成"本副本的速率",实际集群总速率就是 6 倍——除非它们共享同一个 Redis 桶(本实验就是共享),此时总速率才是配置值。反过来说,共享 Redis 把限流变成了一个远程依赖:每次请求多一次 RTT,Redis 故障时全站限流同时失效。这两种拓扑的取舍必须在架构层面写明,不能靠"加了 Redis 就安全"。
限流故障:fail-open 的证据与代价
后端不可用时,RedisRateLimiter 的选择是放行:
// RedisRateLimiter.isAllowed(5.0.4,SHA-256 8097defe75203725…)
return flux.onErrorResume(throwable -> {
log.error("Error calling rate limiter lua", throwable);
return Flux.just(Arrays.asList(1L, -1L));
}).reduce(new ArrayList<Long>(), (longs, l) -> { longs.addAll(l); return longs; })
.map(results -> {
boolean allowed = results.get(0) == 1L;
Long tokensLeft = results.get(1);
Response response = new Response(allowed, getHeaders(routeConfig, tokensLeft));
...(1, -1) 的含义是"允许,剩余 -1"。这不是正常配额,而是一个可观测的异常信号:只要看到 X-RateLimit-Remaining: -1,就说明这次请求没有经过真实扣减。实验把 Redis 链路断开后请求 /limited/echo,得到 200 和 -1;一个新用户 carol 在故障期间也只拿到 -1。恢复链路后,carol 的第一次请求 200、第二次 429——证明故障期间的放行没有扣掉她满桶的 60 个令牌;alice 则立刻回到 429,因为她的桶在故障前就已耗尽。故障期间放过的请求没有补扣。
这个设计的代价要说清:
- 限流是保护下游的手段,不是安全边界。Redis 故障时放行,等于在系统最脆弱的时候放弃保护;如果下游承受不住,应该在网关前再加一层本地限流(如 Sentinel 或令牌桶本地缓存)作为兜底。
-1只出现在响应头里,调用方通常不看。要让它有用,必须在网关侧把它变成指标或日志,否则故障期间"看起来一切正常"。- 反过来选 fail-close(拒绝所有请求)会把 Redis 变成单点:Redis 一挂,全站 429。哪种更好取决于"被限流保护的下游"能否承受无保护流量,这是业务判断,不是框架默认值能替代的。
实验还验证了两个不同的恢复时间尺度:alice 的桶在故障前已耗尽,链路恢复后仍要等补令牌才有配额;carol 的桶从未被扣,恢复后立刻可用。若故障期间桶过期(空闲超过 2 × fill_time),用户会重新满桶。这几个时间尺度不同,排障时不能只看"Redis 恢复了没有"。
时间预算:连接、响应头与流式 body
网关的超时不是一个数,而是至少三层:
| 层 | 配置 | 管什么 | 超时后的表现 |
|---|---|---|---|
| 连接 | spring.cloud.gateway.server.webflux.httpclient.connect-timeout | TCP 建连(毫秒) | 连接异常,通常 500/503 |
| 响应 | route metadata response-timeout(毫秒)或全局 ...httpclient.response-timeout | 等响应开始(响应头/首个信号) | 504 |
| 下游自身 | 下游客户端/服务端超时 | 下游内部处理 | 由下游决定 |
响应超时的实现只有几行:
原码连续摘录:NettyRoutingFilter.java,5.0.4,L202–L212;整文件 SHA-256 0d888c5cbcf1223e83fb4496925ca7a27d1311b2221c06f5b61c8b52b7e7e8e7。
Duration responseTimeout = getResponseTimeout(route);
if (responseTimeout != null) {
responseFlux = responseFlux
.timeout(responseTimeout,
Mono.defer(() -> Mono
.error(new TimeoutException("Response took longer than timeout: " + responseTimeout))))
.onErrorMap(TimeoutException.class,
th -> new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, th.getMessage(), th));
}
return responseFlux.then(chain.filter(exchange));timeout 加在 responseFlux 上,作用于"响应到达"这个信号,不是整个响应体的生命周期。实验用两个路由证明差别:
/timeout/head:上游延迟 350ms 才发响应头,路由response-timeout=100,网关返回 504。/timeout/body:上游立刻发响应头与第一块start,阻塞约 350ms 后再发-end。网关返回 200,body 完整读到start-end,实测耗时 368ms(含客户端与调度开销)。
也就是说:响应超时保护的是"迟迟不开始"的下游,不是"开始后很慢"的下游。后者要靠流式读取的每块超时、下游自己的写超时,或者客户端的总 deadline。把 response-timeout 当成"整个请求最多 100ms"是常见的误读,会在流式接口上得出错误结论。
预算要倒着算。设客户端 deadline 为 3s,网关重试 3 次(4 次尝试),每次响应超时 500ms,那么最坏情况是 4×500ms = 2s 加上建连和排队,仍小于 3s;如果把响应超时设成 1s,最坏 4s 就超过了客户端 deadline,客户端会先断开,网关还在重试——用户看到的是"超时",日志里却是"重试中"。每一层都必须比它的调用方短,这是 deadline 传播的基本约束。
connect-timeout 与 response-timeout 也不能混:前者是 TCP 握手,后者是等应用响应。DNS 解析失败、端口拒绝、TLS 握手失败通常表现为连接异常而不是 504;把两者都配成 504 会掩盖真正的网络问题。
重试:次数、条件与放大
重试过滤器的默认值比大多数人以为的保守:
// RetryGatewayFilterFactory.RetryConfig(5.0.4,SHA-256 ce942fa16257cab7…)
private int retries = 3;
private List<Series> series = toList(Series.SERVER_ERROR);
private List<HttpStatus> statuses = new ArrayList<>();
private List<HttpMethod> methods = toList(HttpMethod.GET);
private List<Class<? extends Throwable>> exceptions = toList(IOException.class, TimeoutException.class);默认只重试 GET、只对 5xx 系列、只对 IO/超时异常;POST 默认不重试。这与"重试是幂等性承诺"一致:框架不能替你判断一个 POST 是否可重放。
retries=3 的含义是首次之外再试 3 次:
public boolean exceedsMaxIterations(ServerWebExchange exchange, RetryConfig retryConfig) {
Integer iteration = exchange.getAttribute(RETRY_ITERATION_KEY);
boolean exceeds = iteration != null && iteration >= retryConfig.getRetries();
...实验用 /retry/**(f.retry(3))请求一个总是 503 的上游,GET 上游看到 attempt=4;/no-retry 路由没有重试过滤器,上游看到 attempt=1。同一 /retry/** 路由改用 POST 请求另一路径,上游只看到 attempt=1——默认方法集合不含 POST。这三组数直接证明“3 次重试 = 4 次尝试”与“默认只重试 GET”,而不是只读配置值。
放大成本要按乘法算:
- 网关 3 次重试 × 下游 SDK 2 次重试 = 12 次实际尝试(4×3)。
- 每次尝试都要建连、序列化、占一个连接和一个事件循环任务;下游越慢,占用越久。
- 重试对下游是额外负载,在过载时重试会加速雪崩。因此重试必须配退避(
backoff.firstBackoff/maxBackoff)和总预算(timeout),并且只对"可能瞬时失败"的错误重试。
重试还需要请求体缓存:POST 重试要重放 body,而 Netty 的请求体是一次性流。Gateway 提供 RequestCache 相关过滤器,但缓存 body 意味着内存占用与最大 body 限制;对上传类接口,正确的选择通常是不重试,而不是把 body 全缓进内存。
body 读取重放与 DataBuffer 责任
网关里的请求体是一次性流:Netty 收到的是 DataBuffer 序列,读完即释放。任何需要"再读一次"的功能——ReadBody 谓词、重试、请求日志、签名校验——都必须先把 body 缓存下来,否则第二次读到的是空流。
// 语义示意,非 5.0.4 原码
ServerRequest request = ServerRequest.create(exchange, messageReaders);
byte[] body = request.bodyToMono(byte[].class).block(timeout); // 缓存
exchange.getAttributes().put(CACHED_REQUEST_BODY, body); // 供后续过滤器/重试重放三件事必须一起决定:
- 缓存上限。不设上限就是把请求体当内存用;一个 100MB 上传就能打爆堆。
spring.codec.max-in-memory-size只约束编解码缓冲,不等于业务层缓存上限。 - 引用计数。
DataBuffer是引用计数的,retain()与release()必须成对;提前释放会让下游读到已回收的内存(表现为空 body 或IllegalReferenceCountException),不释放则泄漏直接内存。自定义过滤器改写 body 时,正确做法是DataBufferUtils.join后重新包装,并让框架管理新 buffer 的生命周期。 - 重放与幂等。缓存了 body 才能重试 POST,但"能重放"不等于"该重放":支付、下单这类请求重放会产生重复副作用,是否重试仍取决于业务幂等键,而不是缓存能力。
大 body 的正确选择通常是不缓存、不重试、直接流式转发;需要读取内容的场景(签名、审计)应设明确上限并快速拒绝超限请求,而不是先读进内存再判断。实验没有运行大 body 与 DataBuffer 泄漏分支,这一节是固定源码语义与配置推演;可观察的只有小 body 的路径/查询/头改写(path=/items/7 query=x=1)与流式响应的读取边界。
失败映射:503、504 与下游 5xx 的归属
同一句"网关报错了",在状态码上有五种不同含义:
| 观察 | 含义 | 排查起点 |
|---|---|---|
| 503 + 无实例 | 负载均衡目录为空或全部被摘 | 注册中心、健康检查、实例状态 |
| 503 + 上游返回 | 下游自己返回 503(过载/未就绪) | 下游日志与容量 |
| 504 | 网关等响应超时 | 下游延迟、response-timeout、网络 |
| 500 | 网关内部异常(连接失败、过滤器抛错) | 网关日志、连接配置 |
| 502 | 上游返回非法响应(少见) | 下游协议实现 |
默认情况下,下游的 4xx/5xx 会原样透传,网关不把它们改写成 502/504。这是对的:下游的 429、401、422 有业务语义,网关改写会让调用方失去判断依据。网关自己产生的错误才用网关的状态码:无实例 503、响应超时 504、未匹配路由 404。
实验里这三条都有观察:/not-a-route 404,空目录 /lb/round/echo 503,/timeout/head 504,而上游的 503 在 /retry/always 上以 503 + attempt=4 的形式出现——同样是 503,body 里的 attempt 说明它是下游返回的,不是网关产生的。
异常路径还有两个容易忽略的点:
- 短路过滤器(
RedirectTo、SetStatus)不调用下游,因此不会触发超时和重试;它们的状态码是网关的,不是下游的。 - 错误处理器:Boot 的
ErrorWebExceptionHandler会把未处理异常转成 JSON;ResponseStatusException会保留状态码。自定义错误响应时不要把所有异常都映射成 200 或 500,否则指标和告警都会失真。
观测:指标、管理端点与访问控制
指标过滤器在响应提交时停止计时:
// GatewayMetricsFilter(5.0.4,SHA-256 c8166bbb38611276…)
private void endTimerInner(ServerWebExchange exchange, Sample sample) {
Tags tags = compositeTagsProvider.apply(exchange);
...
sample.stop(meterRegistry.timer(metricsPrefix + ".requests", tags));
}指标名是 <prefix>.requests,默认 spring.cloud.gateway.requests;标签由 GatewayTagsProvider 组合,至少包含 routeId,还可包含 http.method、http.status_code、http.outcome、uri 等(取决于启用的 provider)。实验对 routeId:first 做计数:请求 /api/one-measured-request 前读一次,请求后轮询到计数 +1。
指标能回答和不能回答的问题要分开:
- 能回答:这条路由经过了多少请求、状态分布、耗时分布。
- 不能回答:下游业务是否成功(200 也可能是业务失败)、请求是否被限流(429 会计入,但没有单独的"被限流"标签)、重试了几次(每次尝试是否单独计数取决于过滤器顺序)。
- 未匹配路由的请求没有
routeId标签,但仍会被计入总量(如果启用了 http 标签);按routeId聚合会漏掉这部分。
管理端点默认不暴露,暴露后必须加访问控制。实验的配置是:
management.endpoints.web.exposure.include=gateway,metrics
management.endpoint.gateway.access=read-onlyread-only 的效果是写操作不再映射,而不是保留路径返回 405:/actuator/gateway/refresh 只有写操作,read-only 下整个路径没有映射,所以用 ops 权限 POST 得到 404。这个细节很重要——如果你按 405 去写监控断言,会得到一个永远失败的检查。
访问控制接在同一个 SecurityWebFilterChain 上:匿名 401、只有 read 权限 403、ops 权限 200。三层观察都在实验里。生产上还应叠加:管理端口独立、只监听内网、mTLS 或 IP 白名单。只靠一个 SCOPE_ops 保护管理端点是不够的,因为它和业务流量共享同一个入口。
日志与追踪是指标的补充:网关的全局过滤器可以生成/透传 trace id,但"哪个过滤器改了头""哪次重试失败了"这类信息在日志里,不在指标里。排障顺序通常是:指标定位路由与时间段 → 日志定位过滤器与异常 → 追踪定位下游实例。
容量推演:三个数与恢复顺序
网关可靠性最终要落到三个数上:配额、并发、预算。
配额:集群总限流 = 共享桶的 replenishRate(所有副本共用一个 Redis 时),或 单副本速率 × 副本数(各副本独立计数时)。设 6 副本、每副本 1000 QPS、共享 Redis,则总配额仍是 1000 QPS;若各副本独立本地计数,总配额是 6000 QPS。两种拓扑的容量差 6 倍,配置里却只差一个"是否共享后端"。桶的 key 数量 = 活跃用户数 × 路由数 × 2,按每个 key 约 100 字节估算,10 万活跃用户 × 1 条限流路由 ≈ 20 MB,可接受;但如果按 IP 限流,key 基数会随 NAT 出口数量变化,需要单独估算。
并发:网关是响应式非阻塞的,慢下游不会占线程,但会占连接。设下游 P99 从 50ms 涨到 500ms,同样的 1000 QPS 需要的并发连接从 50 涨到 500;连接池上限、maxConnections、pendingAcquireTimeout 决定了排队还是拒绝。重试会把这个数再乘 4(3 次重试),所以"重试提高成功率"和"重试放大并发"是同一件事的两面。
预算:客户端 deadline ≥ 网关总预算 ≥ 单次尝试超时 × 尝试次数 + 退避总和。举例:客户端 3s、网关响应超时 500ms、重试 3 次、退避 100/200/400ms,最坏 2s + 700ms = 2.7s,留 300ms 给建连和序列化。任何一层超预算,调用方就会在网关还在重试时先断开,用户看到超时、日志看到重试,两边对不上。
恢复顺序不是"服务起来就放量":
- 先确认下游就绪(不是端口通,而是业务健康检查通过)。
- 再逐步放开限流配额(例如从 10% 开始,按错误率和延迟爬坡),而不是瞬间回到 100%。
- 观察
X-RateLimit-Remaining、429 比例、504 比例和重试次数,任何一项异常就停。 - 最后恢复重试与超时到常规值——恢复期间可以把重试关掉,避免放大。
这套顺序的责任人是平台(网关配置)与 SRE(配额与告警)共同承担,不是下游一个团队的事。
实验读法与旧内容唯一归属
本页的实验观察数字来自一次完整运行(15 个测试,12 个在本模块),容量推演的数字按文中假设计算。证据文件记录了 JDK/Maven/OS/Redis 版本、四个模块的 surefire 计数、18 条 U32 观察行和全部源码/配置的 SHA-256。可以按下面的顺序复读:
U32 identity=...:401/403 的分工与伪造头清除。U32 redisQuota=...:200→429、换用户 200、空 key 403、断链-1、新用户恢复后满桶、alice 恢复 429。U32 timeout=...:504 与流式 body 200 的对比,elapsedMs是实测。U32 retry=...:GETattempt=4、无过滤器attempt=1、POSTattempt=1。U32 managementMetrics=...:401/403/200、只读下 POST 404、计数 +1。
旧内容的归属不变:超时重试、熔断限流降级保留 deadline、UNKNOWN、幂等、27 次放大和业务恢复的通用模型;本页只补 Gateway 的装配与真实观察。Security 过滤链保留 Servlet 安全权威;本页只讲网关侧资源服务器与身份头。WebFlux 与取消保留响应式资源释放权威。熔断器(Resilience4j)与 Feign 归 U33,Sentinel 规则归 U37,配置中心与动态路由归 U34,真实注册中心适配归 U35/U40;本页不重复它们的机制,也不把本实验的 loopback 结果推广成集群结论。
未运行且不能由本页证明的:真实注册中心与集群摘流、Gateway 集群与动态路由推送、Redis 集群/哨兵/故障转移、TLS 与 mTLS、WebSocket/gRPC 代理、Sentinel/Resilience4j 与 Gateway 的组合、吞吐与容量压测。这些在上表与实验 README 中单列,不用"配置写好了"冒充"运行通过"。
闭卷验收
本页 21 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。