時間:2025-10
標籤:Spring Cloud Gateway、Netty、TCP連接池、Kubernetes、負載均衡


📌 背景

最近在項目中發現一個非常隱蔽的問題:
使用 Spring Cloud Gateway (SCG) 作為網關時,下游服務部署在 Kubernetes 中,理論上流量應由 Service 的負載均衡機制 均勻分發到各個 Pod。

但實際上,所有請求幾乎都被轉發到了同一個 Pod,負載嚴重不均衡。
最初懷疑是 K8s 的問題,後來深入分析發現——罪魁禍首竟然是 TCP 連接複用


⚙️ 問題現象

在網關容器中執行命令:

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

嘗試在響應或請求頭中加入:

exchange.getResponse().getHeaders().add("Connection", "close");

但是無效。
因為 SCG 內部的 RemoveHopByHopHeadersFilter 會在轉發前移除 Connection 等 hop-by-hop 頭部,根本到不了下游。


❌方案 2:自定義 HttpClient 禁用連接複用

通過自定義 HttpClient,為指定路由禁用連接池和 Keep-Alive:

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() 是決定底層請求池的關鍵。

默認實現如下:

protected HttpClient getHttpClient(ServerWebExchange exchange) {
    return this.httpClient;
}

於是我們自定義子類:

@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 是一種優雅且非侵入的方案,可以精確控制哪些路由需要隔離。