時間: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 連接池
|
複寫 |
|
Connection: close 無效
|
被內部過濾器移除
|
不推薦
|
|
網關併發極高
|
TCP 連接複用
|
可考慮部分路由隔離連接池,避免性能下降
|
🚀 總結與啓示
- Spring Cloud Gateway 默認的連接池設計追求性能,但在某些高併發、分佈式環境下會導致意料之外的問題;
- K8s 的負載均衡依賴新建連接,如果上游長連接複用,就會破壞其調度邏輯;
- 擴展 NettyRoutingFilter 是一種優雅且非侵入的方案,可以精確控制哪些路由需要隔離。