本节目标:知道什么时候该池化、池的每个回调该做什么、池多大合适,以及为什么
linkd-v2的两种后端需要两套并发模型。
适用版本:JDK 25 + Netty 4.2.19.Final。
承接的问题
8.1 的 ReconnectHandler 维护一条到 9093 的长连接,断线重连、请求排队都在这一条连接上完成。这套模型放到网关就不成立了:GatewayHandler 收到 GET /books/{id} 后要转发到 libraryd 的 8080 端口,HTTP/1.1 一条连接同一时刻只能处理一个请求,如果所有请求共用一条 channel,响应会互相覆盖;如果每个请求新建一次 Bootstrap.connect(),每次都要付 TCP 握手,QPS 一高就把 fd 和端口耗光。
连接池解决的是「物理连接的复用」,但它不解决所有并发问题。linkd-v2 同时存在两种后端:
- libraryd(HTTP/1.1):一个请求占用一条连接直到响应回来,适合
FixedChannelPool的借还模型。 - linkd 9093(自定义协议):7.1 的帧头带
requestId,一条连接上可以同时有多个在途请求,池只需要维持固定几条长连接,不需要按请求借还。
这一节先把 FixedChannelPool 的机制拆开,再说明两种模型的分界,最后给出网关侧按 host:port 建池的代码。HTTP/2 的流级多路复用只在取舍表里对比,具体实现留给第 11 章。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。池大小相关的数字都是算式示例,不是压测结论。
核心机制一:FixedChannelPool 的构造与三个回调
FixedChannelPool 在 netty-transport 模块,构造参数决定了池的上限、排队上限和获取超时:
FixedChannelPool pool = new FixedChannelPool(
bootstrap, // 已配置 remoteAddress 的 Bootstrap
new BackendPoolHandler(), // 连接生命周期回调
ChannelHealthChecker.ACTIVE, // 获取时健康检查
FixedChannelPool.AcquireTimeoutAction.FAIL,
500, // acquireTimeoutMillis
32, // maxConnections
512); // maxPendingAcquires
| 参数 | 作用 | 调错的后果 |
|---|---|---|
maxConnections | 物理连接上限 | 太小排队,太大压垮后端 |
maxPendingAcquires | 等待获取的请求上限 | 无界会让内存随积压上涨 |
acquireTimeoutMillis | 等待可用连接的最长时间 | 太长会把超时压力推给上游 |
AcquireTimeoutAction | 超时后的动作 | NEW 会突破 maxConnections |
healthChecker | 借出前判断连接是否可用 | 只查 isActive 会借出半死连接 |
AcquireTimeoutAction.FAIL 让获取超时的请求立刻失败;NEW 会新建一条连接交给调用方,即使已经达到 maxConnections。NEW 适合突发流量,但它把「池有上限」这个前提打破了,后端看到的连接数可能远超配置,使用前要确认后端能承受。
ChannelPoolHandler 的三个回调分工不同:
| 回调 | 调用时机 | 该做什么 |
|---|---|---|
channelCreated | 物理连接建好、第一次交给池时,一次 | 装每连接 handler、打日志 |
channelAcquired | 每次 acquire() 成功 | 重置请求级属性、检查可写 |
channelReleased | 每次 release() | 清理属性、关闭不该回池的连接 |
package com.plume.linkd.v2.client;
import io.netty.channel.Channel;
import io.netty.channel.pool.ChannelPoolHandler;
public final class BackendPoolHandler implements ChannelPoolHandler {
@Override
public void channelCreated(Channel ch) {
ch.pipeline().addLast(new BackendResponseHandler());
}
@Override
public void channelAcquired(Channel ch) {
ch.attr(BackendResponseHandler.RESPONSE).set(null);
}
@Override
public void channelReleased(Channel ch) {
if (!ch.isActive()) {
ch.close();
}
}
}
channelCreated 在 Bootstrap.handler 的 ChannelInitializer 执行之后调用,编解码器已经在 pipeline 里;在这里再加一个 HttpClientCodec 会重复解码,是常见错误。channelAcquired 和 channelReleased 成对出现,请求级状态必须在这两个回调里清掉,否则下一个借用者会读到上一个请求的残留。
核心机制二:ChannelHealthChecker 与获取超时
ChannelHealthChecker.ACTIVE 只判断 channel.isActive()。TCP 连接还在、但后端应用已经不处理请求时,它仍然返回健康,调用方借到一条「能写但不会有响应」的连接。要更严格,就把握手结果或最近一次心跳写进 channel 属性:
package com.plume.linkd.v2.client;
import io.netty.channel.Channel;
import io.netty.channel.pool.ChannelHealthChecker;
import io.netty.util.concurrent.Future;
public final class HandshakeHealthChecker implements ChannelHealthChecker {
@Override
public Future<Boolean> isHealthy(Channel channel) {
boolean healthy = channel.isActive()
&& Boolean.TRUE.equals(channel.attr(ClientHandshakeHandler.READY).get());
return channel.eventLoop().newSucceededFuture(healthy);
}
}
isHealthy 返回 Netty 的 Future<Boolean>,允许异步判定;不要在这里发一个 PING 然后 sync() 等 PONG,那会阻塞 EventLoop。READY 是客户端 ClientHandshakeHandler 暴露的 AttributeKey<Boolean>,握手成功时置 true;需要网络往返的健康检查由 7.2 的 IdleStateHandler 加心跳维护属性,检查器只读属性。
获取超时有两层,别混在一起:
Future<Channel> acquired = pool.acquire();
Channel channel;
try {
channel = acquired.get(1, TimeUnit.SECONDS);
} catch (ExecutionException e) {
throw new IllegalStateException("acquire failed", e.getCause());
} catch (TimeoutException e) {
acquired.cancel(false);
throw new IllegalStateException("caller wait timeout", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted", e);
}
池的 acquireTimeoutMillis 是 500 ms,调用方等 1 秒。这样超时先由池触发,get() 抛出的 ExecutionException 里能拿到池给的原因;如果两层都设 500 ms,调用方自己的 TimeoutException 会盖掉池的失败信息。池的等待队列满了(maxPendingAcquires)时,acquire() 的 future 会立即失败,不会等到 500 ms。
核心机制三:AbstractChannelPoolMap 按 host:port 建池
网关可能转发到多个 libraryd 实例,池必须按目标地址分开:不同 host 的连接不能混用,池上限也要各自计算。AbstractChannelPoolMap 把「key → 池」的懒创建和关闭收在一起:
package com.plume.linkd.v2.client;
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.pool.AbstractChannelPoolMap;
import io.netty.channel.pool.ChannelHealthChecker;
import io.netty.channel.pool.FixedChannelPool;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.http.HttpClientCodec;
import io.netty.handler.codec.http.HttpObjectAggregator;
import java.net.InetSocketAddress;
public final class HttpBackendPools
extends AbstractChannelPoolMap<InetSocketAddress, FixedChannelPool> {
private final EventLoopGroup group;
private final int maxConnections;
private final int maxPendingAcquires;
public HttpBackendPools(EventLoopGroup group, int maxConnections, int maxPendingAcquires) {
this.group = group;
this.maxConnections = maxConnections;
this.maxPendingAcquires = maxPendingAcquires;
}
@Override
protected FixedChannelPool newPool(InetSocketAddress key) {
Bootstrap bootstrap = new Bootstrap()
.group(group)
.channel(NioSocketChannel.class)
.remoteAddress(key)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000)
.option(ChannelOption.TCP_NODELAY, true)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new HttpClientCodec());
ch.pipeline().addLast(new HttpObjectAggregator(1 << 20));
}
});
return new FixedChannelPool(bootstrap, new BackendPoolHandler(),
ChannelHealthChecker.ACTIVE, FixedChannelPool.AcquireTimeoutAction.FAIL,
500, maxConnections, maxPendingAcquires);
}
}
三个要点:
- 每个池一个
Bootstrap。AbstractBootstrap不是线程安全的,池的建连又可能发生在不同 EventLoop 上;共用同一个Bootstrap实例会让配置和remoteAddress互相覆盖。在newPool里新建,成本可以忽略。 remoteAddress(key)必须设置。FixedChannelPool内部调用无参bootstrap.connect(),地址来自Bootstrap的remoteAddress;不设置会在第一次建连时报「远程地址未指定」。- key 用
InetSocketAddress.createUnresolved(host, port)。new InetSocketAddress(host, port)会在调用线程做 DNS 解析,网关的 EventLoop 线程不应该被 DNS 阻塞;未解析地址的equals只比较 host 和 port,适合做 map key。
HttpBackendPools 继承的 close() 会关闭所有池和池里的连接,必须在 8.3 的优雅关闭里调用;只关 EventLoopGroup 不会逐个释放池的引用计数和属性。
核心机制四:池大小与多路复用的取舍
池大小不是「越大越好」,也不是「等于 QPS」。用 Little 定律估下界:
L = λ × W
λ 是到达率,W 是请求在后端的平均驻留时间,L 是平均在途请求数。池至少要覆盖 L,否则请求会在 acquire() 上排队,客户端看到的延迟里混进了池等待时间。
| 场景 | λ | W | L = λW | 建议 maxConnections |
|---|---|---|---|---|
| 网关转发图书详情 | 500 req/s | 40 ms | 20 | 32 |
| 批量导入代理 | 100 req/s | 300 ms | 30 | 48 |
W 要用后端处理时间,不是客户端观测到的 p99;把排队时间算进 W 会高估所需连接。32 是在 20 之上留出的余量,用于吸收抖动,不是算出来的精确值。
三种并发模型要分开看:
| 维度 | HTTP/1.1 连接池 | HTTP/2 单连接多流 | linkd 9093 requestId |
|---|---|---|---|
| 并发单位 | 物理连接 | stream | 帧里的 requestId |
| 每请求借还 | 是 | 否 | 否 |
| 典型连接数 | 多个 | 每目标 1 条 | 每目标固定几条 |
| 队头阻塞 | 连接级 | TCP 层仍有 | 应用层自行排序 |
| 连接故障影响 | 该连接上的一个请求 | 该连接上的所有流 | 该连接上的所有在途请求 |
HTTP/2 的多路复用把并发单位从连接降到了流,但它没有消除 TCP 层的队头阻塞:一个丢包仍会拖住这条连接上的所有流。所以「用了 HTTP/2 就不需要池」只对「每个目标一条连接」成立,不对「一条连接扛无限并发」成立。
linkd-v2 的 9093 协议属于第三种:requestId 已经在应用层多路复用,FixedChannelPool 的按请求借还反而会增加开销。正确做法是每个后端维持 2–4 条长连接,RpcClient 按 requestId 轮转发帧;池只负责建连和健康检查,不参与请求调度。把 FixedChannelPool 硬套到这条链路上,会把「一条连接多请求」退化成「一个请求一条连接」。
在 linkd-v2 里落地:网关的借还与响应关联
GatewayHandler 不直接持有 channel,它通过 HttpBackendPools 借一条、写请求、等响应、还回去。响应通过 channel 属性关联到调用方的 future:
package com.plume.linkd.v2.client;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.util.AttributeKey;
import java.util.concurrent.CompletableFuture;
public final class BackendResponseHandler extends SimpleChannelInboundHandler<FullHttpResponse> {
static final AttributeKey<CompletableFuture<FullHttpResponse>> RESPONSE =
AttributeKey.valueOf("linkd.pool.response");
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpResponse response) {
CompletableFuture<FullHttpResponse> future =
ctx.channel().attr(RESPONSE).getAndSet(null);
if (future != null) {
future.complete(response.retain());
}
}
}
SimpleChannelInboundHandler 会在 channelRead0 返回后释放 response,所以交给 future 的必须是 retain() 之后的引用;消费方读完要自己 release()。如果这里直接 future.complete(response),调用方拿到的对象已经失效,表现为 IllegalReferenceCountException 或读到脏数据。
借还的完整流程:
public CompletableFuture<FullHttpResponse> get(String host, int port, String path, Duration timeout) {
FixedChannelPool pool = pools.get(InetSocketAddress.createUnresolved(host, port));
CompletableFuture<FullHttpResponse> result = new CompletableFuture<>();
pool.acquire().addListener((Future<Channel> f) -> {
if (!f.isSuccess()) {
result.completeExceptionally(f.cause());
return;
}
Channel channel = f.getNow();
channel.attr(BackendResponseHandler.RESPONSE).set(result);
result.whenComplete((response, error) -> pool.release(channel));
FullHttpRequest request = new DefaultFullHttpRequest(
HttpVersion.HTTP_1_1, HttpMethod.GET, path);
HttpUtil.setKeepAlive(request, true);
channel.writeAndFlush(request).addListener((ChannelFutureListener) w -> {
if (!w.isSuccess()) {
result.completeExceptionally(w.cause());
}
});
});
return result.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS);
}
流程里的所有权和时序:
whenComplete负责归还。 正常响应、写失败、超时三条路径都会让result完成,release只执行一次;不要同时在写监听器和读处理器里 release。- 超时由
orTimeout触发。 超时后 future 异常完成,whenComplete立即归还连接;此时响应可能还在路上,回池前channelReleased的健康检查只检查isActive,无法判断响应是否已经被丢弃。对时序敏感的后端,超时后应该关闭连接而不是回池。 HttpUtil.setKeepAlive(request, true)与后端能力匹配。 如果libraryd返回Connection: close,这条连接不该再借出;可以在响应处理器里记一个属性,channelReleased看到后close()。result只完成一次。 写失败后再收到响应、或超时后响应到达,complete都是空操作,不会重复 release。
常见错误与边界
错误一:在 channelCreated 里重复添加编解码器。
// 反例:Bootstrap.handler 已经加过 HttpClientCodec
@Override
public void channelCreated(Channel ch) {
ch.pipeline().addLast(new HttpClientCodec());
}
channelCreated 在 ChannelInitializer 之后执行,再加一次会让同一条连接上出现两个 HttpClientCodec,响应被解码两次,第二个解码器读到的是空缓冲,日志里会出现莫名的 DecoderException。
错误二:把请求级 handler 加到池化连接上。
// 反例:每次 acquire 都加一个 handler
pool.acquire().addListener((Future<Channel> f) -> {
f.getNow().pipeline().addLast(new ResponseCollector(result));
});
handler 不会随 release 移除,同一条连接被借 N 次就累积 N 个 ResponseCollector,响应会被第一个实例消费,后面的实例永远等不到,pipeline 也越来越长。请求级状态放 AttributeKey,handler 在 channelCreated 里只加一次。
错误三:把 maxConnections 设成 QPS。
1000 QPS 不等于 1000 条连接。连接数由「在途请求数」决定,在途请求数由到达率和后端处理时间共同决定。按 QPS 配置会让后端瞬间收到上千条连接,每条都要握手、分配缓冲区、占用一个 worker;实际并发可能只有几十。
边界:连接池不修复慢后端。 池只限制并发上限,后端变慢时,请求会在 acquire() 或响应等待上排队,池满后新请求快速失败。这是保护后端的手段,不是提升吞吐的手段;吞吐问题要看后端处理时间和连接上的并发模型。
小结
FixedChannelPool用maxConnections限制物理连接,用maxPendingAcquires限制等待队列,用acquireTimeoutMillis限制等待时间。channelCreated每连接一次,channelAcquired和channelReleased每次借还各一次;请求级状态必须在后两者里清理。ChannelHealthChecker.ACTIVE只查isActive(),要识别半死连接就把握手或心跳结果放进属性,检查器只读属性,不做阻塞 IO。AbstractChannelPoolMap按host:port懒建池,每个池要有独立的Bootstrap和remoteAddress,key 用未解析地址避免调用线程做 DNS。- 池大小按 Little 定律
L = λW估下界再加余量,不按 QPS 配置,也不是越大越好。 - HTTP/1.1 按请求借还,HTTP/2 按流复用,linkd 9093 按
requestId复用;三种模型不能混用同一套池语义。
连接借出、用完、归还之后,还剩一个问题:这条连接到底该回池还是该关闭,半关闭的连接怎么算,进程退出时怎么在期限内把连接收干净。8.3 处理关闭语义与优雅回收。
阅读导航:上一节:8.1 客户端连接与重连 · 下一节:8.3 关闭、半关闭与连接回收 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。