一次深入排查:Spring Cloud Gateway TCP 连接复用导致 K8s 负载均衡失效

时间: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 是一种优雅且非侵入的方案,可以精确控制哪些路由需要隔离。

🧭 延伸阅读


相关推荐
せいしゅん青春之我2 小时前
【JavaEE初阶】网络原理——TCP处理先发后至问题
java·网络·笔记·网络协议·tcp/ip·java-ee
能不能别报错4 小时前
K8s学习笔记(二十四) ingress
笔记·学习·kubernetes
能不能别报错5 小时前
K8s学习笔记(二十三) 网络策略 NetworkPolicy
笔记·学习·kubernetes
suknna5 小时前
记一次 Kubebuilder Operator 开发中的 CRD 注解超限问题
kubernetes
victory04318 小时前
K8S 安装 部署 文档
算法·贪心算法·kubernetes
z10_148 小时前
什么是住宅IP,住宅IP应用场景有哪些
linux·服务器·tcp/ip
后端小张8 小时前
【JAVA 进阶】Mybatis-Plus 实战使用与最佳实践
java·spring boot·spring·spring cloud·tomcat·mybatis·mybatis plus
Wang's Blog12 小时前
Linux小课堂: 网络配置详解之DHCP动态分配与静态IP地址设置
linux·网络·tcp/ip
fat house cat_16 小时前
【netty】基于主从Reactor多线程模型|如何解决粘包拆包问题|零拷贝
java·服务器·网络·netty