HTTP 长连接和心跳机制,是很多 Java 服务稳定运行的基础能力。它们看起来像网络细节,但在真实项目里会直接影响:
理解这一篇的重点,不是记几个 Keep-Alive 参数,而是搞清楚"连接为什么要复用""连接为什么会失效""应用层为什么还要心跳"。
HTTP 长连接(Persistent Connection),指的是客户端和服务端在一次 TCP 建连后,复用同一条连接完成多次请求和响应,而不是每次请求都重新建立 TCP 连接。
在 HTTP/1.0 中,默认使用短连接,每次请求都会新建连接。HTTP/1.1 开始,默认开启长连接。
它的核心价值包括:
在现代 HTTP 客户端和服务端里,连接复用通常是默认能力。但"默认支持"不代表"可以不理解"。
短连接缺点:
长连接优点:
如果每次请求都重新建连,代价通常包括:
短连接场景下,主动关闭连接的一方会进入 TIME_WAIT 状态(默认 60 秒)。如果客户端高频使用短连接,大量端口被 TIME_WAIT 占用,最终导致端口耗尽,新连接无法建立。在高 QPS 微服务调用中,这是最常见的连接抖动根因之一。
可通过以下方式缓解:
tcp_tw_reuse(允许复用 TIME_WAIT 端口)ip_local_port_range对于高频调用场景,例如:
连接复用几乎是默认前提,而不是可选优化。
这是最常见的误解之一。HTTP 长连接只是表示:
它并不表示:
Keep-Alive 只表示"请求完成后不立即关闭连接,保留给后续请求复用",并不保证连接永远存活。以下因素都可能导致连接失效:
因此,客户端必须同时具备连接复用、空闲清理、有效性检测和失败重试能力。
连接仍然可能因为以下原因失效:
Connection: close:明确告知客户端关闭连接所以真实项目中必须同时考虑:
HTTP/1.1 中,Connection: keep-alive 表示希望保持长连接。服务端也可以在响应中返回 Keep-Alive 相关参数:
Connection: keep-alive
Keep-Alive: timeout=5, max=100参数含义:
timeout:连接空闲超时时间(秒),超过此时间服务端可能关闭连接max:连接上最多处理的请求数,超过后服务端可能关闭连接timeout 建议设置为 30-120 秒,过短会导致连接频繁重建,过长会占用服务端资源max 建议设置 100-1000,避免单个连接长期占用导致内存泄漏(如 ThreadLocal 未清理)keepalive_timeout 应略小于下游服务的 Keep-Alive 超时不同 HTTP 客户端的处理方式:
浏览器:
Apache HttpClient:
OkHttp:
Java 11 HttpClient:
Java 11 HttpClient 默认优先协商 HTTP/2。HTTP/2 的多路复用机制在单条 TCP 连接上支持并发请求,从根本上解决了 HTTP/1.1 连接池需要维护多条连接的问题。在微服务内部调用中,如果双方都支持 HTTP/2,可以显著减少连接数和连接管理复杂度。但需注意:HTTP/2 多路复用在单连接上的队头阻塞(TCP 层)仍然是潜在风险,QUIC/HTTP/3 才是最终解决方案。
Tomcat 配置:
<!-- server.xml -->
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
keepAliveTimeout="60000"
maxKeepAliveRequests="100" />参数说明:
connectionTimeout:连接建立超时(毫秒)keepAliveTimeout:长连接空闲超时(毫秒)maxKeepAliveRequests:单个连接最大请求数,-1 表示无限制Nginx 配置:
http {
keepalive_timeout 65; # 空闲超时(秒)
keepalive_requests 100; # 最大请求数
}Spring Boot 配置:
server:
tomcat:
connection-timeout: 20000 # 连接超时(毫秒)
keep-alive-timeout: 60000 # Keep-Alive 超时(毫秒)
max-keep-alive-requests: 100 # 最大请求数Keep-Alive 的核心作用,是让客户端可以在请求完成后不立即关闭连接,而是把连接保留下来供后续请求继续使用。
它主要解决的是:
从本质上说,Keep-Alive 解决的是:
也就是说,它关注的是:
它不直接解决:
这些问题往往需要空闲探测、健康检查或应用层心跳一起配合。
连接复用带来性能收益的同时,也引入了一个新的现实问题:
典型场景包括:
这时,如果客户端直接复用旧连接,常见现象就是:
心跳(Heartbeat)是应用层主动发送的一种轻量消息,目的通常不是传业务数据,而是:
对 HTTP 短连接调用来说,更多依赖连接池和空闲清理;但对 WebSocket、IM、网关长连接和自定义协议连接来说,心跳几乎是必备能力。
这几个概念经常被混在一起,但关注点并不相同。
| 机制 | 所在层次 | 主要作用 | 典型特点 | 配置方式 |
|---|---|---|---|---|
| HTTP Keep-Alive | 应用协议层 | 连接复用 | 关注请求之间是否复用已有连接 | HTTP 头部 |
| TCP Keepalive | 传输层 | 系统级保活 | 粒度较粗,间隔通常较长 | 操作系统参数 |
| 应用层心跳 | 应用协议层 | 失效探测、保活、断连感知 | 灵活可控,可按业务定制 | 应用代码 |
| 空闲探测 | 客户端/服务端连接管理层 | 清理长期未活跃连接 | 常结合连接池和超时策略使用 | 连接池配置 |
TCP Keepalive 是操作系统提供的保活机制:
Linux 配置:
# 查看 TCP Keepalive 参数
cat /proc/sys/net/ipv4/tcp_keepalive_time # 空闲多久开始发送探测(秒,默认7200)
cat /proc/sys/net/ipv4/tcp_keepalive_intvl # 探测间隔(秒,默认75)
cat /proc/sys/net/ipv4/tcp_keepalive_probes # 探测次数(默认9)
# 修改配置(临时)
echo 600 > /proc/sys/net/ipv4/tcp_keepalive_time
echo 30 > /proc/sys/net/ipv4/tcp_keepalive_intvl
echo 3 > /proc/sys/net/ipv4/tcp_keepalive_probesJava 中开启 TCP Keepalive:
Socket socket = new Socket();
socket.setKeepAlive(true); // 开启 TCP KeepaliveTCP Keepalive 的局限性:
为什么需要应用层心跳?
实践中可以这样理解:
TCP Keepalive 工作原理:
tcp_keepalive_time(默认 7200 秒)tcp_keepalive_intvl(默认 75 秒)重发探测tcp_keepalive_probes(默认 9 次)未响应,则判定连接断开,通知应用层应用层心跳工作原理:
关键差异对比:
| 维度 | TCP Keepalive | 应用层心跳 |
|---|---|---|
| 检测粒度 | TCP 连接是否存活 | 应用是否正常(连接+服务) |
| 默认间隔 | 7200 秒(2 小时) | 自定义(通常 30-60 秒) |
| 探测内容 | 空 ACK 报文 | 可携带业务状态信息 |
| 失败判定 | 9 次探测失败(约 11 分钟) | 自定义(通常 3 次未响应) |
| 穿透性 | 可能被中间设备过滤 | 应用层数据,不会被过滤 |
| 可编程性 | 仅操作系统参数 | 完全可编程 |
| 代理感知 | 代理可能不会转发 | 端到端感知 |
| 半开连接检测 | 能检测 | 能检测 |
生产环境建议:
大多数 Java 客户端不会自己手工管理 socket,而是使用连接池统一管理连接,例如:
HttpClient连接池通常需要管理这些维度:
如果这些参数配置不合理,常见问题包括:
客户端连接池里的连接,并不一定一直有效。因为在连接池空闲期间,服务端或中间网络设备可能已经把它关掉了。
如果连接池下一次直接复用这条旧连接,就可能出现:
复用已失效的连接是生产环境中"第一次请求失败,重试成功"现象的最常见根因。以下场景尤为突出:
解决方案:
validateAfterInactivity(使用前验证)evictIdleConnections)NoHttpResponseException 重试)因此客户端通常要具备这些能力:
这也是线上排障里非常常见的一个链式问题。
如果连接池拿不到连接,请求线程通常会:
然后就会继续引发:
这是一个典型的级联故障链路:
连接池耗尽
→ 请求线程阻塞等待连接
→ 线程池堆积
→ 新请求被拒绝或排队
→ 上游调用超时
→ 上游线程池也开始堆积
→ 服务整体雪崩关键预防措施:
connectionRequestTimeout(1-2 秒),快速失败而非无限等待pending 指标,设置告警阈值所以排查 HTTP 问题时,不能只看"请求超时"这一个现象,还要看:
客户端通常需要负责:
正确配置连接池:
区分不同类型的超时:
定期清理空闲连接:
失败时快速重试或重建连接:
避免无限重试导致雪崩放大:
不合理的重试策略可以在几秒内让一个微小抖动演变为服务雪崩:
下游 1% 请求超时(正常波动)
→ 客户端对超时请求重试 3 次
→ 实际请求量变为原来的 3 倍
→ 下游负载激增
→ 更多请求超时
→ 更多重试...
→ 雪崩正确做法:
服务端通常需要负责:
合理设置连接空闲超时:
控制最大并发连接数:
正确处理 Keep-Alive 和 Connection: close:
Connection: close在长连接场景下设计心跳与超时剔除策略:
保证连接关闭行为和实例下线行为一致:
如果客户端和服务端只做好其中一边,系统仍然会出现连接抖动和偶发失败。
下面是一个基于 Apache HttpClient 5 的完整示例,用来说明长连接调用时常见的配置点:
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.core5.http.HttpHost;
import org.apache.hc.core5.util.TimeValue;
import org.apache.hc.core5.util.Timeout;
import java.util.concurrent.TimeUnit;
public class HttpClientFactory {
public static CloseableHttpClient createClient() {
// 请求配置
RequestConfig requestConfig = RequestConfig.custom()
.setConnectionRequestTimeout(Timeout.ofSeconds(2)) // 获取连接超时
.setConnectTimeout(Timeout.ofSeconds(3)) // 建立连接超时
.setResponseTimeout(Timeout.ofSeconds(5)) // 响应超时
.build();
// 连接池配置
PoolingHttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
.setMaxConnTotal(200) // 最大总连接数
.setMaxConnPerRoute(50) // 单路由最大连接数
.setValidateAfterInactivity(TimeValue.ofSeconds(10)) // 空闲多久后验证连接
.build();
return HttpClients.custom()
.setConnectionManager(connectionManager)
.setDefaultRequestConfig(requestConfig)
// 定期清理空闲连接(后台线程)
.evictIdleConnections(TimeValue.ofSeconds(30))
// 开启后台清理线程
.evictExpiredConnections()
.build();
}
// 为特定服务配置独立路由
public static CloseableHttpClient createClientWithCustomRoute() {
PoolingHttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
.setMaxConnTotal(200)
.setMaxConnPerRoute(20)
.build();
// 为特定服务设置更大的连接数
HttpHost customHost = new HttpHost("http", "api.example.com", 8080);
connectionManager.setMaxPerRoute(customHost, 100);
return HttpClients.custom()
.setConnectionManager(connectionManager)
.build();
}
}关键配置说明:
// 三个超时的含义不同,线上排障时经常被混淆
// 1. connectionRequestTimeout:等待连接池中连接的超时
// - 连接池满了,等待其他请求释放连接
// - 超时后抛出 ConnectionPoolTimeoutException
// 2. connectTimeout:建立 TCP 连接的超时
// - 三次握手的时间
// - 网络不通、服务端未启动时会超时
// - 超时后抛出 ConnectTimeoutException
// 3. responseTimeout:连接建立后等待响应的超时
// - 等待服务端返回数据的时间
// - 包括服务端处理时间
// - 超时后抛出 SocketTimeoutException连接池大小不是拍脑袋定的,有一个经典的计算公式:
连接数 = QPS × 平均响应时间(秒) × 安全系数示例:
单路由连接数也按同样公式计算,只是把 QPS 替换为单服务的 QPS。
注意:连接池大小还需考虑服务端的承载能力,不是越大越好。过大的连接池会导致:
使用示例:
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.http.io.entity.EntityUtils;
public class HttpClientExample {
public static void main(String[] args) throws Exception {
try (CloseableHttpClient httpClient = HttpClientFactory.createClient()) {
HttpGet request = new HttpGet("http://localhost:8080/api/users");
httpClient.execute(request, response -> {
System.out.println("响应状态:" + response.getCode());
String body = EntityUtils.toString(response.getEntity());
System.out.println("响应内容:" + body);
return null;
});
}
}
}import okhttp3.ConnectionPool;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import java.util.concurrent.TimeUnit;
public class OkHttpExample {
public static void main(String[] args) {
OkHttpClient client = new OkHttpClient.Builder()
.connectionPool(new ConnectionPool(50, 5, TimeUnit.MINUTES))
.connectTimeout(3, TimeUnit.SECONDS) // 连接超时
.readTimeout(5, TimeUnit.SECONDS) // 读取超时
.writeTimeout(5, TimeUnit.SECONDS) // 写入超时
.retryOnConnectionFailure(true) // 连接失败自动重试
.build();
Request request = new Request.Builder()
.url("http://localhost:8080/api/users")
.build();
try (Response response = client.newCall(request).execute()) {
System.out.println("响应状态:" + response.code());
System.out.println("响应内容:" + response.body().string());
} catch (Exception e) {
e.printStackTrace();
}
}
}OkHttp 连接池参数说明:
// ConnectionPool(50, 5, TimeUnit.MINUTES)
// - 50: 最大空闲连接数
// - 5: 空闲连接存活时间
// - TimeUnit.MINUTES: 时间单位
// OkHttp 自动清理空闲连接,无需手动管理import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class Java11HttpClientExample {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3)) // 连接超时
.version(HttpClient.Version.HTTP_2) // 优先使用 HTTP/2
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8080/api/users"))
.timeout(Duration.ofSeconds(5)) // 请求超时
.GET()
.build();
HttpResponse<String> response = client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
System.out.println("响应状态:" + response.statusCode());
System.out.println("响应内容:" + response.body());
}
}Java 11 HttpClient 特点:
Apache HttpClient 的连接池核心是 PoolingHttpClientConnectionManager,它内部维护了 CPool(全局连接池) → RouteSpecificPool(路由连接池) 的层级结构:
validateAfterInactivity 是连接池中一个非常关键的参数,它决定了连接在使用前是否需要验证有效性:
// PoolingHttpClientConnectionManager 内部逻辑(简化版)
public void connect(CPoolEntry entry) {
// 获取连接的空闲时间
long idleTime = System.currentTimeMillis() - entry.getLastUsedTime();
// 如果空闲时间超过 validateAfterInactivity,则验证连接
if (validateAfterInactivity > 0 && idleTime > validateAfterInactivity) {
// isStale() 返回 true 表示连接已经失效(对端已关闭)
boolean stale = entry.getConnection().isStale();
if (stale) {
// 连接已失效,关闭并重建
entry.closeConnection();
// 重新建立连接
establishConnection(entry);
}
// 未失效则直接使用
}
// 如果空闲时间未超过阈值,直接使用(不验证,性能更优)
}工作机制详解:
validateAfterInactivity,直接使用(不验证,零开销)validateAfterInactivity,先验证连接是否仍然有效参数配置建议:
// 推荐配置:10-30 秒
// 过短:每次获取连接都验证,增加延迟
// 过长:可能使用已失效的连接
PoolingHttpClientConnectionManagerBuilder.create()
.setValidateAfterInactivity(TimeValue.ofSeconds(10)) // 空闲 10 秒后验证
.build();这两个机制经常被混淆,但作用不同:
两者配合使用效果最佳:
evictIdleConnections(30s) 保证池中不会有空闲超过 30 秒的连接validateAfterInactivity(10s) 保证即使池中连接在 10 秒内失效,使用前也能检测到对 WebSocket、IM、Netty 长连接服务而言,通常需要在服务端记录连接活跃状态,并在连接长时间无读写时主动剔除。
服务端心跳检测(Netty):
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.handler.timeout.IdleStateEvent;
import io.netty.handler.timeout.IdleStateHandler;
import java.util.concurrent.TimeUnit;
public class WebSocketHeartbeatServer {
public static void main(String[] args) throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
// 空闲检测:60秒未读取数据触发空闲事件
pipeline.addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS));
// 编解码器
pipeline.addLast(new StringDecoder());
pipeline.addLast(new StringEncoder());
// 业务处理器
pipeline.addLast(new HeartbeatHandler());
}
});
ChannelFuture future = bootstrap.bind(8080).sync();
System.out.println("WebSocket 服务端已启动,端口:8080");
future.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
// 心跳处理器
class HeartbeatHandler extends ChannelInboundHandlerAdapter {
private static final String PING = "PING";
private static final String PONG = "PONG";
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
if (event.state() == IdleState.READER_IDLE) {
System.out.println("连接读空闲超时,关闭连接:" + ctx.channel().remoteAddress());
ctx.close();
return;
}
}
super.userEventTriggered(ctx, evt);
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
String message = (String) msg;
// 处理心跳消息
if (PING.equals(message)) {
System.out.println("收到心跳:" + message);
ctx.writeAndFlush(PONG);
return;
}
// 处理业务消息
System.out.println("收到业务消息:" + message);
super.channelRead(ctx, msg);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
}客户端心跳发送:
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import java.util.concurrent.TimeUnit;
public class WebSocketHeartbeatClient {
private static final String PING = "PING";
public static void main(String[] args) throws Exception {
EventLoopGroup group = new NioEventLoopGroup();
try {
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline()
.addLast(new StringDecoder())
.addLast(new StringEncoder())
.addLast(new ClientHandler());
}
});
Channel channel = bootstrap.connect("localhost", 8080).sync().channel();
System.out.println("已连接到服务端");
// 定时发送心跳
group.scheduleAtFixedRate(() -> {
if (channel.isActive()) {
channel.writeAndFlush(PING);
System.out.println("发送心跳:" + PING);
}
}, 0, 30, TimeUnit.SECONDS);
channel.closeFuture().sync();
} finally {
group.shutdownGracefully();
}
}
}
class ClientHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
System.out.println("收到消息:" + msg);
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
System.out.println("连接断开,尝试重连...");
// 实现重连逻辑
}
}自动重连实现:
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import java.util.concurrent.TimeUnit;
public class ReconnectingClient {
private final String host;
private final int port;
private Bootstrap bootstrap;
private Channel channel;
private EventLoopGroup group;
// 重连参数
private int retryInterval = 5; // 重连间隔(秒)
private int maxRetries = 10; // 最大重连次数
private int retryCount = 0;
public ReconnectingClient(String host, int port) {
this.host = host;
this.port = port;
}
public void start() throws Exception {
group = new NioEventLoopGroup();
bootstrap = new Bootstrap();
bootstrap.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline()
.addLast(new StringDecoder())
.addLast(new StringEncoder())
.addLast(new ReconnectHandler(ReconnectingClient.this));
}
});
connect();
}
public void connect() {
if (retryCount >= maxRetries) {
System.out.println("达到最大重连次数,停止重连");
return;
}
System.out.println("尝试连接 " + host + ":" + port + " (第" + (retryCount + 1) + "次)");
bootstrap.connect(host, port).addListener((ChannelFuture future) -> {
if (future.isSuccess()) {
System.out.println("连接成功");
channel = future.channel();
retryCount = 0; // 重置重连计数
} else {
System.out.println("连接失败:" + future.cause().getMessage());
retryCount++;
// 延迟重连(指数退避)
long delay = Math.min(retryInterval * (1L << Math.min(retryCount, 5)), 60);
future.channel().eventLoop().schedule(() -> connect(), delay, TimeUnit.SECONDS);
}
});
}
public static void main(String[] args) throws Exception {
ReconnectingClient client = new ReconnectingClient("localhost", 8080);
client.start();
}
}
class ReconnectHandler extends ChannelInboundHandlerAdapter {
private final ReconnectingClient client;
public ReconnectHandler(ReconnectingClient client) {
this.client = client;
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
System.out.println("连接断开,尝试重连...");
client.connect();
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
}背景:某微服务线上偶现 HTTP 调用第一次失败、重试成功的现象,错误日志为 NoHttpResponseException,每天出现数十次。
排查过程:
实际排查步骤:
// 1. 确认错误类型
// NoHttpResponseException 说明客户端尝试向已关闭的连接写数据
// 这意味着服务端已经关闭了连接,但客户端不知道
// 2. 检查服务端配置
// Tomcat keepAliveTimeout = 60s
// Nginx keepalive_timeout = 65s
// 客户端 evictIdleConnections 未配置!
// 3. 分析时间线
// 客户端获取连接 -> 空闲 70 秒 -> 再次使用
// 服务端 60 秒超时已关闭连接
// Nginx 65 秒超时已关闭连接
// 客户端复用旧连接 -> NoHttpResponseException
// 4. 解决方案
PoolingHttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
.setMaxConnTotal(200)
.setMaxConnPerRoute(50)
.setValidateAfterInactivity(TimeValue.ofSeconds(10)) // 使用前验证
.build();
CloseableHttpClient httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.evictIdleConnections(TimeValue.ofSeconds(30)) // 30 秒清理空闲连接
.build();
// 空闲清理时间(30s) < 服务端超时(60s),确保连接在服务端关闭前被客户端主动回收修复效果:
NoHttpResponseException背景:某 IM 系统使用 WebSocket 长连接,客户端连接数约 10 万,需要设计可靠的心跳和断线重连机制。
架构设计:
心跳参数设计:
// 心跳参数配置
public class HeartbeatConfig {
// 客户端心跳间隔
// 必须小于中间设备(NAT/负载均衡器)的空闲超时
// 负载均衡器空闲超时通常 90 秒,所以心跳设为 30 秒
private static final int HEARTBEAT_INTERVAL = 30; // 秒
// 客户端断连判定
// 连续 3 次心跳未响应则判定连接断开
private static final int MAX_MISSED_HEARTBEATS = 3;
// 服务端空闲超时
// 应为心跳间隔的 2-3 倍,容忍 1-2 次心跳丢失
private static final int SERVER_IDLE_TIMEOUT = 90; // 秒
// 重连参数
// 指数退避:5, 10, 20, 40, 60, 60, 60...
private static final int BASE_RETRY_INTERVAL = 5; // 秒
private static final int MAX_RETRY_INTERVAL = 60; // 秒
private static final int MAX_RETRY_COUNT = 100; // 无限重连(但限流)
}服务端空闲检测实现:
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.timeout.IdleStateEvent;
import io.netty.handler.timeout.IdleState;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
public class IMHeartbeatHandler extends ChannelInboundHandlerAdapter {
// 连接活跃时间记录
private static final ConcurrentHashMap<String, AtomicLong> lastActiveTime =
new ConcurrentHashMap<>();
@Override
public void channelActive(ChannelHandlerContext ctx) {
// 连接建立时记录活跃时间
String channelId = ctx.channel().id().asLongText();
lastActiveTime.put(channelId, new AtomicLong(System.currentTimeMillis()));
System.out.println("连接建立:" + ctx.channel().remoteAddress());
}
@Override
public void channelInactive(ChannelHandlerContext ctx) {
// 连接断开时清理记录
String channelId = ctx.channel().id().asLongText();
lastActiveTime.remove(channelId);
System.out.println("连接断开:" + ctx.channel().remoteAddress());
}
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
if (event.state() == IdleState.READER_IDLE) {
// 读空闲超时,说明客户端可能已断开
System.out.println("读空闲超时,关闭僵尸连接:" + ctx.channel().remoteAddress());
// 通知客户端连接即将关闭
ctx.writeAndFlush("{\"type\":\"disconnect\",\"reason\":\"idle_timeout\"}");
// 延迟 1 秒后关闭,确保通知消息能发出
ctx.channel().eventLoop().schedule(() -> ctx.close(), 1, java.util.concurrent.TimeUnit.SECONDS);
return;
}
}
super.userEventTriggered(ctx, evt);
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
// 更新活跃时间
String channelId = ctx.channel().id().asLongText();
AtomicLong time = lastActiveTime.get(channelId);
if (time != null) {
time.set(System.currentTimeMillis());
}
// 处理心跳
if ("PING".equals(msg)) {
ctx.writeAndFlush("PONG");
return;
}
// 处理业务消息
super.channelRead(ctx, msg);
}
}生产环境关键指标:
| 指标 | 告警阈值 | 说明 |
|---|---|---|
| 心跳成功率 | < 95% | 客户端 PING 收到 PONG 的比例 |
| 僵尸连接数 | > 100 | 服务端检测到空闲超时关闭的连接数 |
| 重连成功率 | < 90% | 客户端断线后重连成功的比例 |
| 平均重连时间 | > 10 秒 | 客户端从断连到恢复的平均时间 |
| 消息丢失率 | > 0.1% | 因断连导致的业务消息丢失比例 |
| 特性 | Apache HttpClient 5 | OkHttp | Java 11 HttpClient | Spring WebClient |
|---|---|---|---|---|
| 连接池实现 | PoolingHttpClientConnectionManager | ConnectionPool | 内置连接池 | 基于 Reactor Netty |
| 最大空闲连接数 | setMaxConnTotal / setMaxConnPerRoute | ConnectionPool(maxIdle, keepAliveDuration) | 不暴露配置 | 默认 500,可配置 |
| 空闲连接清理 | evictIdleConnections | 自动清理(Daemon 线程) | 自动管理 | 自动管理 |
| 连接验证 | validateAfterInactivity | 无(使用时检测) | 无 | responseTimeout |
| HTTP/2 支持 | 需额外配置 | 支持(需 ALPN) | 默认支持 | 支持 |
| 异步支持 | Future / Callback | Call.enqueue | CompletableFuture | 响应式(Mono/Flux) |
| 重试策略 | DefaultHttpRequestRetryStrategy | retryOnConnectionFailure | 内置重试 | retry / retryWhen |
| 超时粒度 | 3 种(连接请求/连接/响应) | 3 种(连接/读取/写入) | 2 种(连接/请求) | 3 种(连接/读取/写入) |
| 适用场景 | 企业级复杂配置 | Android / 轻量服务 | JDK 原生 / HTTP/2 | 响应式 / WebFlux |
| 维护状态 | 活跃 | 活跃 | JDK 内置 | 活跃 |
| 维度 | HTTP/1.1 Keep-Alive | HTTP/2 多路复用 | WebSocket 持久连接 |
|---|---|---|---|
| 连接复用 | 同一连接串行复用 | 同一连接并行多路复用 | 全双工持久连接 |
| 并发请求 | 需要多条连接 | 单连接支持并发(Stream) | 天然支持 |
| 队头阻塞 | 应用层队头阻塞(串行) | TCP 层队头阻塞 | 无(全双工) |
| 心跳机制 | 依赖连接池空闲检测 | 内置 PING/PONG 帧 | 应用层心跳或 WebSocket Ping |
| 服务器推送 | 不支持 | Server Push | 天然支持 |
| 协议开销 | 文本协议,头部较大 | 二进制帧,头部压缩(HPACK) | 二进制帧,开销小 |
| 典型场景 | 微服务 HTTP 调用 | 浏览器 / gRPC | IM / 实时推送 / 游戏服 |
| 连接管理 | 连接池 + Keep-Alive | 单连接 + Stream 管理 | 心跳 + 断线重连 |
| 保活方式 | 连接池空闲清理 | HTTP/2 PING 帧 | 应用层心跳 / WebSocket Ping |
在 Nginx 反向代理场景中,Keep-Alive 涉及两段连接,需要分别配置:
客户端 ↔ Nginx 和 Nginx ↔ 上游服务
客户端 <──Keep-Alive──> Nginx <──Keep-Alive──> 上游服务
(第一段连接) (第二段连接)两段连接的 Keep-Alive 配置需要协调,否则容易出现"中间断裂"问题。
Nginx 完整配置示例:
http {
# === 第一段:客户端 ↔ Nginx ===
keepalive_timeout 65; # 客户端连接空闲超时(秒)
keepalive_requests 100; # 客户端单连接最大请求数
# === 第二段:Nginx ↔ 上游服务 ===
upstream backend {
server 127.0.0.1:8080;
keepalive 50; # 保留 50 个到上游的空闲连接
keepalive_timeout 60; # 上游连接空闲超时(秒)
keepalive_requests 200; # 上游单连接最大请求数
}
server {
listen 80;
location /api/ {
proxy_pass http://backend;
# 必须设置:让 Nginx 与上游使用 HTTP/1.1
proxy_http_version 1.1;
# 必须设置:清除 Connection 头,避免透传客户端的 close
proxy_set_header Connection "";
# 上游连接超时
proxy_connect_timeout 5s; # 连接上游超时
proxy_read_timeout 30s; # 读取上游响应超时
proxy_send_timeout 30s; # 发送请求到上游超时
}
}
}常见陷阱:
未设置 proxy_http_version 1.1:Nginx 默认使用 HTTP/1.0 代理上游,HTTP/1.0 不支持 Keep-Alive,每次请求都会新建连接。这是最常见的"明明配了 Keep-Alive 但不生效"的原因。
未清除 Connection 头:如果客户端发送了 Connection: close,Nginx 会透传给上游,导致上游连接被关闭。必须显式设置 proxy_set_header Connection ""。
两段超时不匹配:
keepalive_timeout(65s) > 上游 Tomcat 的 keepAliveTimeout(60s),上游会先关闭连接,但 Nginx 可能还会把已关闭的连接分配给新请求keepalive 参数含义:upstream 中的 keepalive 50 表示 Nginx 为每个 worker 进程保留 50 个到上游的空闲连接,不是最大连接数。活跃连接 + 空闲连接可以超过这个值,但空闲连接最多保留 50 个。
长连接与负载均衡:当 upstream 有多个 server 时,keepalive 连接是按 server 独立计算的。如果某个 server 下线,其连接会被关闭,但不影响其他 server 的连接。
如果一个 Java 服务高频调用下游 HTTP 接口,但没有正确配置连接池和超时,常见问题是:
排查步骤:
// 1. 检查连接池配置
PoolingHttpClientConnectionManager connectionManager =
(PoolingHttpClientConnectionManager) httpClient.getConnectionManager();
System.out.println("总连接数:" + connectionManager.getTotalStats().getLeased());
System.out.println("最大连接数:" + connectionManager.getMaxTotal());
System.out.println("可用连接数:" + connectionManager.getTotalStats().getAvailable());
// 2. 检查超时配置
// 注: HttpClient 5 未提供公开的 RequestConfig getter,建议在创建客户端时自行保存该引用
RequestConfig config = this.requestConfig; // 构建客户端时保存的引用
System.out.println("获取连接超时:" + config.getConnectionRequestTimeout());
System.out.println("连接超时:" + config.getConnectTimeout());
System.out.println("响应超时:" + config.getResponseTimeout());优化方案:
// 增加连接池大小
connectionManager.setMaxTotal(300);
connectionManager.setDefaultMaxPerRoute(100);
// 缩短超时时间,快速失败
RequestConfig requestConfig = RequestConfig.custom()
.setConnectionRequestTimeout(Timeout.ofSeconds(1))
.setConnectTimeout(Timeout.ofSeconds(2))
.setResponseTimeout(Timeout.ofSeconds(3))
.build();
// 定期清理空闲连接
httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.setDefaultRequestConfig(requestConfig)
.evictIdleConnections(TimeValue.ofSeconds(30))
.build();这类场景下,连接不是一次请求结束就关闭,而是长期存在。因此通常要具备:
完整架构设计:
客户端心跳机制:
- 连接建立后启动心跳定时器
- 每 30 秒发送一次心跳
- 3 次心跳未响应则认为连接断开,触发重连
服务端空闲检测:
- 记录每个连接的最后活跃时间
- 定时扫描所有连接
- 超过 60 秒未收到心跳则主动断开
- 断开前通知客户端
断线重连:
- 检测到连接断开后延迟 5 秒重连
- 重连失败则延迟加倍(5, 10, 20, 40...)
- 最大延迟 60 秒
- 重连成功后恢复心跳网关场景中最容易被忽略的一点是,中间设备本身也可能回收空闲连接。因此排查时不能只盯着应用代码,还要看:
Nginx 配置排查:
http {
# 连接超时配置
keepalive_timeout 65; # 保持连接超时(秒)
keepalive_requests 100; # 最大请求数
send_timeout 60; # 响应超时
# 代理超时配置
proxy_connect_timeout 30; # 代理连接超时
proxy_read_timeout 60; # 代理读取超时
proxy_send_timeout 60; # 代理发送超时
# 代理 Keep-Alive
proxy_http_version 1.1;
proxy_set_header Connection "";
upstream backend {
server 127.0.0.1:8080;
keepalive 50; # 保留 50 个空闲连接
}
}常见问题:
问题:偶发"第一次请求失败,重试成功"
原因:
1. 客户端连接池中的连接已失效(服务端或中间设备关闭)
2. Keep-Alive 超时设置不合理
3. 连接池未清理空闲连接
解决方案:
1. 客户端定期清理空闲连接
2. 使用前验证连接有效性
3. 失败后自动重试
4. 调整 Keep-Alive 超时时间// 推荐的客户端配置
public class HttpClientBestPractice {
public static CloseableHttpClient createHttpClient() {
// 1. 连接池配置
PoolingHttpClientConnectionManager connectionManager =
PoolingHttpClientConnectionManagerBuilder.create()
.setMaxConnTotal(200) // 最大总连接数
.setMaxConnPerRoute(50) // 单路由最大连接数
.setValidateAfterInactivity(TimeValue.ofSeconds(10)) // 空闲验证
.build();
// 2. 超时配置
RequestConfig requestConfig = RequestConfig.custom()
.setConnectionRequestTimeout(Timeout.ofSeconds(1)) // 获取连接超时
.setConnectTimeout(Timeout.ofSeconds(3)) // 连接超时
.setResponseTimeout(Timeout.ofSeconds(5)) // 响应超时
.build();
// 3. 重试配置
HttpRequestRetryHandler retryHandler = (exception, executionCount, context) -> {
if (executionCount > 3) {
return false; // 最多重试 3 次
}
if (exception instanceof org.apache.hc.core5.http.NoHttpResponseException) {
return true; // 连接已关闭,可以重试
}
if (exception instanceof java.net.SocketTimeoutException) {
return false; // 超时不重试
}
return false;
};
// 4. 构建 HttpClient
return HttpClients.custom()
.setConnectionManager(connectionManager)
.setDefaultRequestConfig(requestConfig)
.setRetryStrategy(new DefaultHttpRequestRetryStrategy(3, TimeValue.ofSeconds(1)))
.evictIdleConnections(TimeValue.ofSeconds(30)) // 清理空闲连接
.build();
}
}Tomcat 配置(server.xml):
<!-- connectionTimeout: 连接超时 20 秒; keepAliveTimeout: Keep-Alive 超时 60 秒;
maxKeepAliveRequests: 最大请求数 100; maxThreads: 最大线程数 200;
acceptCount: 等待队列长度 100 -->
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
keepAliveTimeout="60000"
maxKeepAliveRequests="100"
maxThreads="200"
acceptCount="100"
enableLookups="false"
compression="on"
compressionMinSize="2048" />Spring Boot 配置(application.yml):
server:
port: 8080
tomcat:
connection-timeout: 20000 # 连接超时(毫秒)
keep-alive-timeout: 60000 # Keep-Alive 超时(毫秒)
max-keep-alive-requests: 100 # 最大请求数
threads:
max: 200 # 最大线程数
min-spare: 10 # 最小空闲线程数
accept-count: 100 # 等待队列长度客户端监控:
// 监控连接池状态
PoolingHttpClientConnectionManager connectionManager = ...;
Set<HttpRoute> routes = connectionManager.getRoutes();
for (HttpRoute route : routes) {
PoolStats stats = connectionManager.getStats(route);
System.out.println("路由:" + route);
System.out.println(" 租用连接数:" + stats.getLeased());
System.out.println(" 可用连接数:" + stats.getAvailable());
System.out.println(" 等待连接数:" + stats.getPending());
}
PoolStats totalStats = connectionManager.getTotalStats();
System.out.println("总租用连接数:" + totalStats.getLeased());
System.out.println("总可用连接数:" + totalStats.getAvailable());
System.out.println("总等待连接数:" + totalStats.getPending());服务端监控:
// Spring Boot Actuator 监控
@RestController
public class MetricsController {
@Autowired
private MeterRegistry meterRegistry;
@GetMapping("/metrics/http")
public Map<String, Object> httpMetrics() {
Map<String, Object> metrics = new HashMap<>();
// 连接数指标
metrics.put("http.server.requests.active",
meterRegistry.get("http.server.requests.active").gauge().value());
// 请求延迟
metrics.put("http.server.requests",
meterRegistry.get("http.server.requests").timer().mean());
return metrics;
}
}错误。Keep-Alive 只解决连接复用问题,不代表连接永远有效,也不代表客户端就不需要管理连接池。
实际情况:
正确做法:
错误。Keep-Alive 关注的是连接复用,心跳关注的是保活和失效探测。两者有关,但并不等价。
对比:
| 特性 | Keep-Alive | 心跳 |
|---|---|---|
| 目的 | 连接复用 | 保活、失效探测 |
| 触发时机 | 请求完成后 | 定时触发 |
| 携带数据 | 无 | 可携带业务数据 |
| 适用场景 | HTTP 短连接调用 | WebSocket、IM、长连接 |
错误。很多所谓"下游慢",其实是线程卡在等待连接、连接池耗尽,或者复用了失效连接。
排查思路:
// 检查是否是连接池问题
if (connectionManager.getTotalStats().getPending() > 0) {
System.out.println("有线程在等待连接,可能是连接池耗尽");
}
// 检查是否是超时配置问题
RequestConfig config = ...;
if (config.getConnectionRequestTimeout().toSeconds() < 1) {
System.out.println("获取连接超时时间太短");
}
// 检查是否是连接失效问题
if (exception instanceof NoHttpResponseException) {
System.out.println("连接已失效,可能是服务端关闭了连接");
}错误。心跳过于频繁会带来额外网络流量、CPU 开销和服务端处理压力。心跳间隔应该结合:
推荐配置:
心跳间隔:30-60 秒
空闲超时:心跳间隔的 2-3 倍
最大重连次数:根据业务重要性设置
重连间隔:指数退避(5, 10, 20, 40...)错误。连接池过大也会带来问题:
推荐配置:
// 根据实际并发量设置
int maxTotal = 并发请求数 * 2; // 总连接数
int maxPerRoute = 单服务并发请求数 * 2; // 单路由连接数
// 示例:并发 100,QPS 1000,平均响应 100ms
// 并发连接数 ≈ QPS * 平均响应时间 = 1000 * 0.1 = 100
// 连接池大小 ≈ 并发连接数 * 2 = 200如果线上出现:
可以优先排查这些方向:
// 检查连接池状态
PoolingHttpClientConnectionManager connectionManager = ...;
PoolStats stats = connectionManager.getTotalStats();
System.out.println("租用连接数:" + stats.getLeased());
System.out.println("可用连接数:" + stats.getAvailable());
System.out.println("等待连接数:" + stats.getPending());
System.out.println("最大连接数:" + connectionManager.getMaxTotal());
if (stats.getPending() > 0) {
System.out.println("警告:有线程在等待连接!");
}// 检查各路由的连接状态
for (HttpRoute route : connectionManager.getRoutes()) {
PoolStats routeStats = connectionManager.getStats(route);
System.out.println("路由:" + route);
System.out.println(" 可用连接:" + routeStats.getAvailable());
System.out.println(" 租用连接:" + routeStats.getLeased());
}// 检查超时配置(建议在创建客户端时自行保存 RequestConfig 引用)
RequestConfig config = this.requestConfig;
System.out.println("获取连接超时:" + config.getConnectionRequestTimeout());
System.out.println("连接超时:" + config.getConnectTimeout());
System.out.println("响应超时:" + config.getResponseTimeout());
// 建议:
// 获取连接超时:1-2 秒
// 连接超时:2-3 秒
// 响应超时:根据业务设置,建议 3-10 秒# 查看服务端 Keep-Alive 配置
# Tomcat:查看 server.xml 中的 keepAliveTimeout
# Nginx:查看 11-Nginx基础概述.conf 中的 keepalive_timeout
# 抓包分析
tcpdump -i eth0 port 8080 -w http.pcap
# 使用 Wireshark 分析是否有大量 TCP Keep-Alive 或 FIN 包// 检查心跳配置
// 1. 心跳间隔是否合理(30-60 秒)
// 2. 空闲超时是否是心跳间隔的 2-3 倍
// 3. 是否有断线重连机制答案:
答案: 因为服务端、代理、负载均衡器、NAT 或网络波动,都可能导致连接在空闲期间被关闭。Keep-Alive 只解决连接复用问题,不保证连接一直有效。
答案:
Connection: keep-alive 头部Connection: close答案:
timeout:连接空闲超时时间(秒),超过此时间服务端可能关闭连接max:连接上最多处理的请求数,超过后服务端可能关闭连接答案:
答案:
答案:
答案: 因为线程可能阻塞在等待连接,进一步导致线程池堆积、超时扩散和整体 RT 上升。连接池耗尽会导致所有请求线程阻塞,系统进入"假死"状态。
答案:
答案:
答案:
答案:
答案:
答案:
答案:
这一篇至少要掌握下面几点:
| 特性 | 旧版(本文编写时) | 当前 |
|---|---|---|
| Java NIO | JDK 8 | JDK 21 NIO 不变;虚拟线程可简化 IO 密集编程 |
| Netty | 4.1.x | 4.1.x 持续维护(最主流高性能网络框架) |
| HTTP | HTTP/1.1 | HTTP/2、HTTP/3(QUIC)逐步普及 |
| 心跳机制 | 自定义 | 不变;可结合 HTTP/2 PING 帧 |
网络编程基础(TCP/UDP、NIO、事件驱动)原理不变;Java 21 虚拟线程为 IO 密集场景提供新选择,Netty 4.1 仍是最主流的高性能网络框架选择。