时间:2025-10
标签:Spring Cloud Gateway、Netty、TCP连接池、Kubernetes、负载均衡
📌 背景
最近在项目中发现一个非常隐蔽的问题:
使用 Spring Cloud Gateway (SCG) 作为网关时,下游服务部署在 Kubernetes 中,理论上流量应由 Service 的负载均衡机制 均匀分发到各个 Pod。
但实际上,所有请求几乎都被转发到了同一个 Pod ,负载严重不均衡。
最初怀疑是 K8s 的问题,后来深入分析发现------罪魁祸首竟然是 TCP 连接复用。
⚙️ 问题现象
在网关容器中执行命令:
bash
watch -n 0.5 "lsof | grep ESTABLISHED | grep {下游服务k8s负载名称}"
发现所有请求复用相同的 TCP 连接:
gateway -> pod-1:8080 ESTABLISHED
无论发出多少请求,连接始终没有新建,这意味着:
- K8s 的负载均衡机制(基于 TCP 三次握手时的连接调度)被绕过;
- 所有流量都打到了一个后端 Pod 上。
🔍 问题分析
1️⃣ Spring Cloud Gateway 的连接模型
Spring Cloud Gateway 默认使用 Reactor Netty HttpClient 。
其内部维护了一个全局的 ConnectionProvider(连接池),用于:
- 连接复用;
- TCP Keep-Alive;
- 减少握手开销,提高性能。
关键点在于:
Gateway 的所有下游请求默认共享同一个 HttpClient,也就共享同一个连接池。
这意味着:
- 同一目标主机的请求会不断复用同一个 TCP 连接;
- 从 K8s 的视角,这些请求都来自同一个"长连接",所以不会重新做负载调度。
2️⃣ 负载不均衡的根因
Kubernetes 的 Service 负载均衡 是在 TCP 层(L4)做的。
每当客户端(这里是 Gateway)新建一个 TCP 连接时,kube-proxy 才会随机选择一个 Pod。
如果连接被长时间复用,那么 K8s 根本没有机会重新分配流量。
于是出现了:
❌ 请求很多,连接很少 → K8s 只看到一个连接 → 流量集中在一个 Pod 上。
🧩 解决方案尝试
❌ 方案 1:设置 HTTP Header Connection: close
尝试在响应或请求头中加入:
java
exchange.getResponse().getHeaders().add("Connection", "close");
但是无效。
因为 SCG 内部的 RemoveHopByHopHeadersFilter 会在转发前移除 Connection 等 hop-by-hop 头部,根本到不了下游。
❌方案 2:自定义 HttpClient 禁用连接复用
通过自定义 HttpClient,为指定路由禁用连接池和 Keep-Alive:
java
HttpClient client = HttpClient.create(ConnectionProvider.newConnection())
.protocol(HttpClient.Protocol.HTTP11)
.keepAlive(false)
.headers(h -> h.add("Connection", "close"))
.responseTimeout(Duration.ofSeconds(10))
.doOnConnected(conn -> {
log.info("[NoReuseConnectionFilter] 新连接建立:{}", conn.channel().id().asShortText());
conn.addHandlerLast(new ReadTimeoutHandler(5, TimeUnit.SECONDS));
})
.doOnRequest((req, conn) -> {
log.info("[NoReuseConnectionFilter] 发送请求到下游: {} | connectionId={}",
exchange.getRequest().getURI(), conn.channel().id().asShortText());
})
.doOnResponse((res, conn) -> {
log.info("[NoReuseConnectionFilter] 收到响应: {} | 状态码={} | connectionId={}",
exchange.getRequest().getURI(), res.status().code(), conn.channel().id().asShortText());
})
.doOnDisconnected(conn -> {
log.info("[NoReuseConnectionFilter] 连接关闭:{}", conn.channel().id().asShortText());
});
这样,每次请求都会新建 TCP 连接,不再走共享连接池。但是找不到修改转发请求池的地方,NettyRoutingFilter中的client是不能被改变的。
⚙️ 方案 3:继承 NettyRoutingFilter 实现路由级隔离(最终方案 ✅)
在源码分析后发现:
NettyRoutingFilter 的核心方法 getHttpClient() 是决定底层请求池的关键。
默认实现如下:
java
protected HttpClient getHttpClient(ServerWebExchange exchange) {
return this.httpClient;
}
于是我们自定义子类:
java
@Component
@Order(-1)
public class CustomNettyRoutingFilter extends NettyRoutingFilter {
@Override
protected HttpClient getHttpClient(ServerWebExchange exchange) {
// 对指定路由使用独立连接池
if (exchange.getRequest().getURI().getHost().contains("{下游服务k8s负载名称}")) {
return HttpClient.create(ConnectionProvider.newConnection())
.keepAlive(false)
.protocol(HttpClient.Protocol.HTTP11);
}
// 其他路由使用默认client
return super.getHttpClient(exchange);
}
}
✅ 结果:每个请求都会重新建立连接,K8s 的负载均衡恢复正常。
日志输出也能看到每次请求的连接 ID 都不同:
[NoReuseConnectionFilter] 新连接建立:1f2a3b4c
[NoReuseConnectionFilter] 新连接建立:3d4e5f6a
...
💡 最终结论
| 问题 | 原因 | 解决方案 |
|---|---|---|
| K8s 负载均衡失效 | Spring Cloud Gateway 共享 TCP 连接池 | 复写 NettyRoutingFilter#getHttpClient(),为特定路由创建独立 HttpClient |
| Connection: close 无效 | 被内部过滤器移除 | 不推荐 |
| 网关并发极高 | TCP 连接复用 | 可考虑部分路由隔离连接池,避免性能下降 |
🚀 总结与启示
- Spring Cloud Gateway 默认的连接池设计追求性能,但在某些高并发、分布式环境下会导致意料之外的问题;
- K8s 的负载均衡依赖新建连接,如果上游长连接复用,就会破坏其调度逻辑;
- 扩展 NettyRoutingFilter 是一种优雅且非侵入的方案,可以精确控制哪些路由需要隔离。
🧭 延伸阅读
- Spring Cloud Gateway 源码:NettyRoutingFilter.java
- Reactor Netty ConnectionProvider 文档
- Kubernetes Service 负载均衡机制详解