一条连接跑满并发请求,RPC多路复用全链路

RPC 多路复用全链路架构图
RPC 多路复用全链路:客户端连接池与 Pending Map、一条 TCP 连接上的交错帧、服务端 EventLoop 与业务线程池,requestId 贯穿两端

长连接省掉的只是反复握手的成本;真正让 RPC 性能发生质变的,是多路复用——多个逻辑请求共享一条物理连接,服务端按请求标识异步处理,响应按完成顺序回写,客户端用同一个标识唤醒对应的等待方。一条连接,就能撑起成百上千的并发请求。

但「同一连接如何并发请求」在面试和实战里都是高频困惑点:服务端要不要为每条连接建请求池?ConcurrentHashMap 是不是标配?业务线程池和 EventLoop 怎么分工?本文沿着一条请求的完整生命周期,把客户端连接池、Pending Map、协议帧设计、Netty 服务端处理全链路一次性拆开,并给出每一处状态管理的选型依据。

一句话主线:多路复用 = requestId 解耦连接与并发 + EventLoop 与业务线程池分工 + 生命周期治理兜底。前两者决定性能上限,最后一项决定系统会不会在半夜挂掉。

多路复用的本质:连接数与并发数解耦

多路复用解决的是「连接数量」与「并发请求数量」的绑定问题。同步短连接里两者 1:1 绑死,连接数随并发线性增长;长连接若每次仍只处理一个请求,只是把连接复用成了串行通道;多路复用引入请求标识后,连接上的字节流被切分为多个逻辑帧,每帧携带 requestId,声明自己属于哪一次调用。

连接模型连接与并发请求标识等待方式
同步短连接1 请求 : 1 连接,握手与 TIME_WAIT 成本高不需要同步阻塞,连接数随并发线性增长
串行长连接复用连接,但请求必须排队不需要前一个响应回来才能发下一个,吞吐被 RTT 锁死
多路复用长连接N 请求 : 1 连接,帧交错收发requestIdFuture 异步等待,谁先回来先唤醒谁

可以把一条连接理解成一条铁路:连接是铁轨,requestId 是车厢编号。火车可以同时装载多节车厢,不同车厢的卸货顺序也可以不同——只要编号不丢,货物最终都能准确交还。HTTP/2 的 streamId、gRPC 的 stream、自定义 RPC 的 requestId,本质都是这节车厢的编号。

服务端全链路:六层接力

一条请求从进入 Netty 到客户端 Future 被唤醒,要经过六层接力:

  1. 接入层:TCP 三次握手建立连接,Netty 为每条物理连接分配一个 Channel。
  2. 解码层:ByteToMessageDecoder 从字节流切帧,还原 requestId、method、body 等协议字段。
  3. 分发层:按 method 定位服务处理器,把请求提交给业务线程池——EventLoop 的工作到此为止。
  4. 业务处理层:执行真正的方法,可能访问数据库、缓存或下游服务,耗时不可控。
  5. 响应编排层:拿到结果后构造响应帧,携带原 requestId 写回同一个 Channel。
  6. 客户端匹配层:客户端收到响应,用 requestId 查 Pending Map,complete 对应的 Future。

下面是一个保留核心结构的简化实现。关键动作只有一个:解码出 RpcRequest 后,不在 EventLoop 中执行耗时业务,而是交给业务线程池,完成后按原 requestId 回写。

public class RpcServerHandler extends SimpleChannelInboundHandler<RpcRequest> { private static final ExecutorService BUSINESS_POOL = Executors.newFixedThreadPool(64); @Override protected void channelRead0(ChannelHandlerContext ctx, RpcRequest request) { long requestId = request.requestId(); // 关键:慢业务绝不放在 EventLoop 线程里执行 BUSINESS_POOL.execute(() -> { RpcResponse response; try { Object result = invoke(request.method(), request.body()); response = RpcResponse.ok(requestId, result); } catch (Throwable error) { response = RpcResponse.fail(requestId, error.getMessage()); } // 回写必须回到同一个 Channel,并携带原 requestId ctx.writeAndFlush(response).addListener(future -> { if (!future.isSuccess()) { future.cause().printStackTrace(); } }); }); } }
为什么同一连接能并发回写:Netty 的 Channel 是线程安全的——多个业务线程可以并发调用同一个 Channel 的 writeAndFlush,写请求进入该 Channel 绑定 EventLoop 的任务队列,由同一个 I/O 线程串行编码后写出。并发发生在业务线程,串行收敛在 I/O 线程,这正是「一条连接跑满并发」的物理基础。
反模式警告:把查库、远程调用等慢操作直接写在 channelRead 里,等于把这条连接上的所有后续请求冻结在同一个 EventLoop 上。更糟的是,EventLoop 往往管理着多条连接,其他连接的读写也会被一起拖慢。多路复用的性能红利,前提是 I/O 线程永不做慢事

协议设计:requestId 是唯一的钥匙

一个最小可用的 RPC 帧可以这样设计:

magic(2B) | version(1B) | type(1B) | requestId(8B) | methodLen(2B) | method | bodyLen(4B) | body // requestId 是服务端「回写给谁」、客户端「唤醒哪个 Future」的唯一线索

requestId 必须遵守四条铁律,每一条背后都是一类真实事故:

01同一连接内唯一
不要求全局唯一。连接内自增计数器或窗口分配即可;跨连接重复无害,因为响应永远从原连接回来。
02未完成请求不可复用
一个 requestId 还挂着 pending Future 时就再次发出,响应到来必然误匹配——轻则调用串结果,重则数据错配。
03响应必须原样带回
服务端不能重新生成 requestId,解码出什么就回写什么。它是两端之间唯一的关联凭证。
04超时后谨慎复用
请求超时后立刻重用该 ID,迟到的旧响应可能匹配到新请求。用长周期计数器(如 64 位递增)或等待窗口滑动后再回收。

客户端:连接池与 Pending Map

下面把两个并发请求的完整时序摊开:调用线程 A、B 共享一条连接,req#2 因为不查库先完成,于是先于 req#1 返回——客户端靠 requestId 而不是到达顺序完成匹配。

同一连接上并发请求的多路复用时序图
同一连接上两个并发请求的完整时序:req#2 先于 req#1 返回,客户端按 requestId 唤醒对应 Future

连接池的价值不在正确性,而在资源治理:

  • 控制连接数:防止连接随实例数与并发无限膨胀
  • 隔离故障:某条连接断开,只影响其上的 pending 请求
  • 降低建连成本:连接复用减少握手与 TLS 开销
  • 提供背压:无可用连接时排队等待或快速失败

客户端调用侧的完整闭环只需三步:借出连接、挂上 pending、写帧并兜底清理。

public CompletableFuture<Object> call(String method, Object body, Duration timeout) { Channel channel = connectionPool.acquire(); // 1. 借出连接 long requestId = channel.attr(REQUEST_ID_GEN).get().nextId(); CompletableFuture<Object> future = new CompletableFuture<>(); PendingRequest pending = new PendingRequest(requestId, future, timeout); channel.attr(PENDING_MAP).get().put(requestId, pending); // 2. 挂到 Channel 上 channel.writeAndFlush(RpcRequest.of(requestId, method, body)) .addListener(writeFuture -> { if (!writeFuture.isSuccess()) { // 3. 写失败立即清理 channel.attr(PENDING_MAP).get().remove(requestId); future.completeExceptionally(writeFuture.cause()); } }); return future.whenComplete((r, err) -> connectionPool.release(channel)); }

Pending Map 的推荐落点不是全局单例,而是挂在 Channel 上:

private static final AttributeKey<ConcurrentHashMap<Long, PendingRequest>> PENDING_MAP = AttributeKey.valueOf("pendingRequests"); public void initChannel(Channel channel) { channel.attr(PENDING_MAP).set(new ConcurrentHashMap<>()); }
挂 Channel 的好处:pending map 的作用域天然等于连接的生命周期——连接关闭即整表作废,不同连接互不干扰,也不参与全局锁竞争。

容器选型到这里可以顺带回答一个高频疑问:ConcurrentHashMap 是不是标配?判断依据是状态的作用域和访问线程,而不是流行做法:

状态类型推荐容器理由
单连接 pending 请求Channel attr map作用域在连接内,生命周期随连接关闭,天然隔离,没有全局竞争
全局连接注册表ConcurrentHashMap多个 I/O 线程、管理线程、监控线程会并发读写连接状态
服务端请求追踪按需服务端透传 requestId 即可;只有链路追踪、限流、超时治理才需要保存
客户端请求池优先挂 Channel响应一定从原连接回来,挂 Channel 减少误匹配与全局锁竞争

即使选对了容器,它也只保证并发安全。生产系统必须同时处理:连接关闭时取消所有 pending Future、响应超时移除记录并失败回调、write 失败立即清理、requestId 回收策略、pending 容量上限与背压。这些没有一件是容器能替你做的。

连接复用之外:与 HTTP/1.1、HTTP/2、gRPC 的差异

HTTP/1.1 keep-alive 是连接复用,不是多路复用——一个连接上的多个请求必须按序等响应,否则无法区分响应属于谁,所以浏览器要对同一域名开 6 条连接。HTTP/2 才把多路复用做成协议能力:stream 帧交错传输,streamId 关联请求与响应,思路与自定义 RPC 的 requestId 完全同源。

协议多路复用能力请求标识适用场景
HTTP/1.1 keep-alive连接复用,但响应必须按序返回浏览器、通用网关;客户端靠连接池撑并发
HTTP/2完整多路复用,stream 帧交错streamId网关、API、跨系统调用,生态最成熟
gRPCHTTP/2 之上叠加 RPC 语义streamId跨语言 RPC;流控、超时、错误码标准化
Netty 自定义 RPC自定义帧 + 乱序响应requestId内部服务间高性能调用,协议与链路完全可控

生产注意事项

多路复用不是把并发全塞进一条连接就万事大吉,它引入了新的复杂度。上线前对照这份清单:

生产 checklist:多路复用的六条底线
  • 队头阻塞:大响应占住写队列会延迟同连接的其他响应,必要时分片或限制大响应
  • 背压:pending 请求要有容量上限,超限排队或快速失败,否则内存被放大到 OOM
  • 超时清理:定时器扫描过期 pending,completeExceptionally 并移除,防止 map 慢性泄漏
  • 乱序协议:多线程完成后回写顺序天然乱序,协议与客户端必须支持乱序响应
  • 连接关闭:主动关闭、对端关闭、I/O 异常统一触发「取消全部 pending」流程
  • 分层限流:连接数与单连接并发请求数分开限流,二者挡住的是不同故障

总结

把全链路压缩成三个可迁移的洞见:

  • 多路复用的本质是解耦:requestId 把「并发请求数」从「连接数」里解放出来,连接回归纯传输语义,并发回归线程模型。
  • 异步分发是正确性的前提:EventLoop 只做编解码与分发,业务进线程池——否则一条慢请求会冻结整条连接甚至整个 EventLoop 上的所有请求。
  • ConcurrentHashMap 是工具不是答案:选型依据是状态的作用域与访问线程;真正决定系统稳定性的,是生命周期治理——超时、失败、关闭、背压与清理。

回到最初的问题:服务端要不要建请求池?多数情况下不需要——透传 requestId 即可。真正要建的是客户端的 Pending Map,而它最好挂在 Channel 上。多路复用不是某个 API,而是一条完整的协议与生命周期闭环:标识逻辑请求、承载物理传输、控制资源边界、异步分发、按标识回写、按标识唤醒。把这个闭环走通,一条连接撑起整个服务的并发,是水到渠成的事。

参考资料

  1. Netty · Channel Javadoc(线程安全声明:一个 Channel 可被多线程安全调用) netty.io/4.1/api
  2. Netty · User Guide for 4.x(EventLoop 与线程模型) netty.io/wiki
  3. IETF · RFC 9113 HTTP/2(stream 多路复用与帧交错) rfc-editor.org/rfc/rfc9113
  4. gRPC · What is gRPC?(HTTP/2 之上的 RPC 语义) grpc.io
  5. Apache Dubbo(长连接多路复用的工业级实践) github.com/apache/dubbo
  6. Apache brpc · IO 设计文档(连接复用与单连接并发请求的权衡讨论) github.com/apache/brpc