线上偶发 502 排查:用 Netty 成功复现 KeepAlive 时间窗口案例实战(附完整源码)
起因
前段时间生产环境偶发 502 错误,不是那种大面积的,就是偶尔冒出来几个。客户端重试一下基本都能成功,所以一开始没太当回事。
我们的架构是这样的:
scss
Java 应用 → Ingress (Nginx) → Higress Gateway → 外部服务
问题就出在 Ingress 到 Higress 这一段。
线上排查过程
当时排查了挺久的,日志里也没啥明显的异常。应用层的监控看着都正常,CPU、内存、连接数都没问题。Higress 的监控也没异常。
后来有同事建议抓包看看,这一抓还真抓到了东西。
发现了这样的情况:
- Ingress 向 Higress 发送请求
- Higress 返回了 RST 包(连接重置)
- Ingress 收到 RST 后返回 502 给客户端
当时就奇怪了,为啥 Higress 要发 RST?是不是哪里配置有问题?
翻了半天配置,发现了一个可疑的点:
- Higress 的
keepalive_timeout配置是 120 秒 - Ingress 的
upstream-keepalive-timeout配置是 180 秒
问题就出在这!
问题的本质
这个问题的核心是时间窗口差异导致的连接复用问题。
时间线:
- Ingress 和 Higress 建立连接,发送第一次请求,成功
- 连接进入 Keep-Alive 状态,双方都保持连接
- 120 秒后,Higress 认为连接空闲,主动关闭
- 但 Ingress 不知道!它的 keepalive 是 180 秒,还认为连接活着
- 130 秒时(120 < 130 < 180),Ingress 收到新请求
- Ingress 从连接池拿出这条"已关闭"的连接,尝试复用
- 在已关闭的连接上写数据 → 收到 RST
- Ingress 返回 502 给客户端
这就是为什么 502 是偶发的:
- 只有在 120-180 秒这个时间窗口内的请求才会触发
- 如果请求很频繁,连接一直在用,不会空闲 120 秒
- 如果请求很稀疏,可能早就超过 180 秒,Ingress 也关了连接
本地复现
光看抓包和日志还是不够直观,我决定在本地搭个环境把这个问题复现出来。
架构设计
用 Netty 搭了个简化版:
scss
Client (测试程序) → Proxy A (模拟 Ingress) → Proxy B (模拟 Higress)
Proxy B (Higress 模拟):
- 监听 8081
IdleStateHandler(120, 0, 0)- 120 秒空闲后关闭连接- 收到请求返回固定的 200 OK
Proxy A (Ingress 模拟):
- 监听 8080
- 维护到 8081 的连接池,复用连接
IdleStateHandler(180, 0, 0)- 180 秒空闲后才关闭- 转发请求到 Proxy B,如果失败返回 502
Client (测试程序):
- 发送第一次请求(建立连接)
- 等待 130 秒(进入时间窗口)
- 并发发送多个请求(触发复用)
Proxy B 的实现
这个比较简单,就是个 HTTP Server,加上空闲检测:
java
// Pipeline 配置
ch.pipeline()
.addLast(new HttpServerCodec())
.addLast(new HttpObjectAggregator(10 * 1024 * 1024))
.addLast(new IdleStateHandler(120, 0, 0, TimeUnit.SECONDS)) // 120秒
.addLast(new ProxyBHandler());
// Handler 实现
public class ProxyBHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) {
System.out.println("[ProxyB] 接收请求: " + req.method() + " " + req.uri());
// 返回 200 OK
FullHttpResponse response = new DefaultFullHttpResponse(
HTTP_1_1, OK,
Unpooled.copiedBuffer("OK", CharsetUtil.UTF_8)
);
response.headers().set(CONNECTION, HttpHeaderValues.KEEP_ALIVE);
response.headers().set(CONTENT_TYPE, "text/plain; charset=utf-8");
response.headers().setInt(CONTENT_LENGTH, response.content().readableBytes());
ctx.writeAndFlush(response);
}
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
if (event.state() == IdleState.READER_IDLE) {
// 120秒空闲,关闭连接
System.out.println("[ProxyB] 连接空闲 120s,关闭连接");
ctx.close();
}
}
}
}
Proxy A 的实现
这个稍微复杂一点,要维护连接池:
java
// 连接池(刻意不检查 isActive,增加复用概率)
class ConnectionPool {
private Map<String, Connection> connections = new ConcurrentHashMap<>();
public Connection get(String upstream) {
Connection conn = connections.get(upstream);
if (conn != null) {
System.out.println("[ProxyA] 复用已有连接 ✓ " + upstream);
return conn;
}
System.out.println("[ProxyA] 创建新连接到 " + upstream);
conn = createConnection(upstream);
connections.put(upstream, conn);
return conn;
}
}
// 转发逻辑
public class ProxyAHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) {
System.out.println("[ProxyA] 接收请求: " + req.method() + " " + req.uri());
try {
// 从连接池获取连接
Connection conn = connectionPool.get("localhost:8081");
// 发送请求到 Proxy B
CompletableFuture<FullHttpResponse> future = new CompletableFuture<>();
conn.handler.setPending(future);
FullHttpRequest upstreamReq = new DefaultFullHttpRequest(
HTTP_1_1, GET, "/"
);
upstreamReq.headers().set(HOST, "localhost");
upstreamReq.headers().set(CONNECTION, HttpHeaderValues.KEEP_ALIVE);
conn.channel.writeAndFlush(upstreamReq).addListener(f -> {
if (!f.isSuccess()) {
future.completeExceptionally(f.cause());
}
});
// 等待响应
FullHttpResponse upstreamResp = future.join();
ctx.writeAndFlush(upstreamResp);
} catch (Exception e) {
// 连接复用失败,返回 502
System.out.println("[ProxyA] 转发失败: " + e.getMessage());
FullHttpResponse response = new DefaultFullHttpResponse(
HTTP_1_1,
HttpResponseStatus.BAD_GATEWAY,
Unpooled.copiedBuffer("502 Bad Gateway", CharsetUtil.UTF_8)
);
response.headers().set(CONNECTION, HttpHeaderValues.KEEP_ALIVE);
response.headers().set(CONTENT_TYPE, "text/plain; charset=utf-8");
response.headers().setInt(CONTENT_LENGTH, response.content().readableBytes());
ctx.writeAndFlush(response);
}
}
}
这里有个关键点:
我故意没有在连接池里检查 channel.isActive()。为什么?因为要模拟 Ingress 的"天真"行为 - 它不知道连接已经被 Higress 关了,还傻傻地去复用。
这样能更容易触发问题。
测试客户端
java
public class TestClient {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
String url = "http://localhost:8080/";
// 第一次请求(建立连接)
System.out.println("[Client] 发送第一次请求");
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
HttpResponse<String> resp1 = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("[Client] 收到响应: " + resp1.statusCode());
// 等待 130 秒(进入时间窗口)
System.out.println("[Client] 等待 130 秒...");
Thread.sleep(130 * 1000);
// 并发发送请求
System.out.println("[Client] 开始并发请求,线程数: 10");
ExecutorService executor = Executors.newFixedThreadPool(10);
AtomicBoolean found502 = new AtomicBoolean(false);
for (int i = 0; i < 10; i++) {
executor.submit(() -> {
while (!found502.get()) {
try {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 502) {
System.out.println("[Client] 收到响应: 502 Bad Gateway ✓");
found502.set(true);
}
Thread.sleep(100);
} catch (Exception e) {
// ignore
}
}
});
}
Thread.sleep(5000);
executor.shutdownNow();
if (found502.get()) {
System.out.println("[Client] ✓ 成功复现 502 错误!");
}
}
}
为了加快测试,我把 Proxy B 的空闲时间改成了 5 秒(通过 -Dproxyb.idle=5 参数),Client 等待 6 秒。这样十几秒就能看到效果,不用真等 130 秒。
复现结果
Proxy A 日志
csharp
[ProxyA] 启动,监听端口 8080,keepalive: 180s
[ProxyA] 接收请求: GET /
[ProxyA] 创建新连接到 localhost:8081
[ProxyA] 接收请求: GET /
[ProxyA] 复用已有连接 ✓ localhost:8081
[ProxyA] 转发失败: io.netty.channel.StacklessClosedChannelException
[ProxyA] 接收请求: GET /
[ProxyA] 复用已有连接 ✓ localhost:8081
[ProxyA] 转发失败: io.netty.channel.StacklessClosedChannelException
[ProxyA] 接收请求: GET /
[ProxyA] 复用已有连接 ✓ localhost:8081
[ProxyA] 转发失败: io.netty.channel.StacklessClosedChannelException
...
关键信息:
- "复用已有连接 ✓" - 说明从连接池拿了旧连接
- "StacklessClosedChannelException" - 连接已经关了,写入失败
- 然后 Proxy A 会返回 502
Proxy B 日志
csharp
[ProxyB] 启动,监听端口 8081,keepalive: 5s
[ProxyB] 接收请求: GET /
[ProxyB] 连接空闲 120s,关闭连接
[ProxyB] 接收请求: GET /
...
可以看到 Proxy B 在空闲后主动关闭了连接。
Client 日志
csharp
[Client] 发送第一次请求
[Client] 收到响应: 200
[Client] 等待 6 秒...
[Client] 开始并发请求,线程数: 10
[Client] 收到响应: 502 Bad Gateway ✓
[Client] 收到响应: 502 Bad Gateway ✓
[Client] 收到响应: 502 Bad Gateway ✓
[Client] 收到响应: 502 Bad Gateway ✓
[Client] 收到响应: 502 Bad Gateway ✓
[Client] ✓ 成功复现 502 错误!
完美复现!
抓包验证
用 tcpdump 抓了包:
bash
sudo tcpdump -i lo0 -s 0 port 8081 -w keepalive-502.pcap
用 Wireshark 打开,过滤 RST 包:
ini
tcp.flags.reset == 1

可以清楚地看到:
- 8081 → 8080: [RST, ACK]
- 这就是 Proxy B 关闭连接时发的 RST 包
证据确凿!
为什么是 Proxy A 生成 502?
有人可能会问:为什么不是 Proxy B 返回 502,而是 Proxy A 自己生成的?
这里要区分两种情况:
情况1:上游返回 502(应用层)
这种情况下,Higress 主动返回 HTTP/1.1 502 Bad Gateway 响应,Ingress 只是转发。
情况2:网关生成 502(连接层)
这种情况下,Higress 没有返回任何 HTTP 响应,只是在 TCP 层关闭了连接(FIN 或 RST)。
Ingress 在尝试写数据时发现连接已关,只能自己生成 502 返回给客户端。
我们复现的就是情况2!
真实网关的行为
Nginx(Ingress 常用):
当上游连接失败时,Nginx 会在错误日志里记录:
csharp
[error] upstream prematurely closed connection while reading response header from upstream
然后返回 502 Bad Gateway 给客户端。
Envoy(Higress 基于 Envoy):
Envoy 的错误码映射:
- Connection reset by peer → 502 Bad Gateway
- Connection timeout → 504 Gateway Timeout
- Connection refused → 503 Service Unavailable
所以网关生成 502 是标准行为,不是我瞎写的!
解决方案
既然问题找到了,解决起来就简单了。
核心原则:上游的 keepalive 要小于下游的 keepalive
在我们的场景里:
- Ingress 是上游
- Higress 是下游
- Ingress 的 keepalive 要小于 Higress 的 keepalive
方案1:调小 Ingress 的 keepalive
yaml
# Ingress ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-configuration
namespace: ingress-nginx
data:
upstream-keepalive-timeout: "90" # 改成 90 秒,小于 Higress 的 120 秒
keepalive-timeout: "90"
这样 Ingress 会在 90 秒后主动关闭连接,不会出现"Higress 关了但 Ingress 不知道"的情况。
方案2:调大 Higress 的 keepalive
yaml
# Higress 配置
keepalive_timeout: 200s # 改成 200 秒,大于 Ingress 的 180 秒
这样 Higress 会等更久才关闭连接。
我们选了方案1,因为:
- Ingress 是我们自己管的,改起来方便
- Higress 可能有全局配置,不想动
- 90 秒够用了,没必要保持太长
验证修复
改完配置后,观察了几天,502 完全消失了。
我也在本地验证了一下,把 Proxy A 的 keepalive 改成 90 秒(小于 Proxy B 的 120 秒),再跑测试,确实不会出现 502 了。
几个要注意的地方
1. KeepAlive 的层次
KeepAlive 有好几个层次,容易搞混:
TCP 层(操作系统):
bash
# Linux
net.ipv4.tcp_keepalive_time = 7200
这是 TCP 协议的 keep-alive,和 HTTP 的 Keep-Alive 不是一回事。
HTTP 层(应用):
nginx
# Nginx
keepalive_timeout 60s;
这是 HTTP/1.1 的 Keep-Alive,用于复用 HTTP 连接。
Upstream 层(反向代理):
nginx
# Nginx upstream
upstream backend {
server 127.0.0.1:8080;
keepalive 32;
keepalive_timeout 90s;
}
这是反向代理到上游的连接保持。
我们遇到的问题是 HTTP 层的 Keep-Alive。
2. 为什么是偶发的?
这个问题只有在特定时间窗口才会触发:
请求间隔 < 120s → 连接一直在用,不会触发
120s < 请求间隔 < 180s → 触发 502
请求间隔 > 180s → Ingress 也关了连接,会重新建立,不会触发
所以如果你的流量很大,可能永远遇不到这个问题。如果流量很小,也可能遇不到。
只有不大不小的流量,才容易踩坑。
3. 如何抓包
在 K8s 环境里抓包有点麻烦,这里记录一下:
方法1:在 Pod 所在节点抓包
bash
# 找到 Pod 所在节点
kubectl get pod -n ingress-nginx -o wide
# SSH 到节点
ssh node-xxx
# 找到容器的网络命名空间
docker inspect <container-id> | grep Pid
# 进入命名空间抓包
nsenter -t <pid> -n tcpdump -i any -w /tmp/capture.pcap
方法2:用 ksniff(推荐)
bash
# 安装 krew 插件管理器
kubectl krew install sniff
# 直接抓包
kubectl sniff <pod-name> -n <namespace>
过滤 RST 包:
bash
# Wireshark 过滤器
tcp.flags.reset == 1
# tcpdump 过滤器
tcpdump -r capture.pcap 'tcp[tcpflags] & tcp-rst != 0'
4. 连接池的复用策略
不同的 HTTP 客户端对连接池的处理不一样:
OkHttp:
java
// 默认会检查连接是否可用
if (!connection.isHealthy(false)) {
// 丢弃,重新建立
}
Apache HttpClient:
java
// 有专门的连接验证器
connectionManager.setValidateAfterInactivity(2000); // 2秒
Nginx:
nginx
# Nginx 比较"天真",不会主动检查
# 直接复用,如果失败了再重试
我们的 Netty 实现故意不检查 isActive(),就是为了模拟这种"天真"的行为。
5. 生产环境的监控
如果要在生产环境发现这类问题,可以监控:
Ingress 侧:
- 502 错误数量和比例
- upstream 连接失败次数
connection reset by peer错误
Higress 侧:
- 连接关闭原因(idle timeout)
- 新建连接数
如果发现:
- 502 偶发
- upstream 连接失败和连接关闭时间相关
- 抓包看到 RST
那多半就是 keepalive 配置的问题了。
写在最后
这个问题排查了挺久的,一开始完全没往 keepalive 配置上想。毕竟这种基础配置,一般都是默认值,谁会想到会有问题呢?
但网络问题就是这样,往往是一些很基础的配置导致的。抓包真的很重要,很多时候日志看不出来的问题,抓包一看就明白了。
本地复现也很有价值。虽然搭环境花了点时间,但能稳定复现问题,对理解原理帮助很大。而且后面验证修复方案也方便。
如果你也遇到过类似的 502 问题,或者有更好的排查方法,欢迎在评论区交流。网络问题千奇百怪,多交流总能学到新东西。
项目代码已开源: gateway502-demo
包含完整的 Proxy A、Proxy B、Client 实现,可以直接运行复现问题。
如果觉得这篇文章有帮助,欢迎点赞、收藏、转发。你的支持是我继续写下去的动力。
下一篇打算写什么?有几个想法:
- SimpleDynamicTp:手写一个动态线程池
- 继续手写SimpleRaft
你们想看哪个?评论区告诉我。
周末愉快!