
Spring Boot 3.3 网关层直连 MCP 工具调用从 120ms 尾延迟到 18ms 的 Netty 与序列化双轨优化实录上周有个需求纳米 AI 侧新增了几十个 MCP 工具前端要在对话流里同步渲染工具执行进度。网关层原本用WebClient走 HTTP/JSON 转发压测一跑P99 延迟直接卡在 120ms 以上CPU 还没跑满就先把年轻代 GC 打满了。这篇不聊 Agent 编排、不聊 Prompt 工程纯记录网关层如何把这条链路的尾延迟砍掉 85%。项目背景与技术栈锁定业务是纳米 AI 多智能体蜂群的对外网关核心职责鉴权、限流、将标准化 MCPtools/call请求路由到后端各异构工具服务Python FastAPI、Go gRPC、Java Spring Boot聚合结果回写 SSE 流。核心版本基线生产环境已跑半年JDK 21.0.4 (ZGC)Spring Boot 3.3.3 / Spring WebFlux 6.1.12Reactor Netty 1.1.12 / Netty 4.1.115.FinalProtobuf 3.25.5 / Jackson 2.17.2Micrometer 1.13.5 Prometheus上游 QPS 峰值 4800工具调用平均扇出 3.2 个要求网关层 P99 ≤ 50ms错误率 0.01%。需求拆解非功能指标才是硬约束功能需求只有一条透传 MCP 协议。真正的约束全在非功能上冷启动抖动新工具上线首调必须 100ms不能有类加载、JIT 预热、连接建立的“三连击”延迟。序列化吞吐单次请求平均 2.4KB JSON峰值 12KB日均 3.8 亿次序列化/反序列化GC 压力直接决定尾延迟。链路透传TraceID 必须零成本穿透 Reactor 链路ThreadLocal继承开销在高并发下不可接受。背压生存下游工具服务偶发慢调P99 300ms网关不能堆积请求导致 OOM必须显式限流并快速失败。方案对比三条路跑通再决策| 方案 | 连接模型 | 序列化 | 连接池复用率 | 实测 P99 (4k QPS) | CPU 占用 (核) | 代码侵入性 | 最终决策 ||------|----------|--------|--------------|-------------------|---------------|------------|----------||基线WebClient JSON| HTTP/1.1 Keep-Alive | Jackson | 68% (连接闲置超时频繁) | 124 ms | 4.2 | 0 (现状) | ❌ 淘汰 ||方案 AWebClient Protobuf| HTTP/1.1 Keep-Alive | Protobuf | 72% | 68 ms | 3.1 | 低 (Codec 替换) | 备选 ||方案 B自研 Netty Client Protobuf| HTTP/2 多路复用 | Protobuf Zero-Copy | 96% |18 ms|1.8| 高 (手写编解码) | ✅ 上线 ||方案 CgRPC 网关转换| HTTP/2 | Protobuf (原生) | 94% | 22 ms | 2.0 | 极高 (需改造下游) | ❌ 成本太高 |关键判断依据方案 A 虽然省了 JSON 解析但 HTTP/1.1 头部压缩弱、队头阻塞没解决连接池扩缩容锁竞争在 4k QPS 下依然是热点DefaultPool的acquire锁。方案 C 理论最优但下游 60% 服务是存量 HTTP 接口改造周期按季度算不符合“两周上线”硬指标。方案 B 赢在复用 Reactor Netty 底层HttpClient但绕过WebClient的高层封装直接操作Channel写ByteBuf彻底消除Flux/Mono对象分配链且 HTTP/2 多路复用单连接承载并发连接池规模从 200 降到 12。 官方文档推荐用WebClient做声明式调用但在我们这个“纯转发、无业务逻辑、极致延迟敏感”的网关场景下它的抽象层开销反而成了最大瓶颈。核心实现三把手术刀1. 连接池与 HTTP/2把锁拆了把连接合了Reactor Netty默认ConnectionProvider基于FixedChannelPool高并发下pool.acquire()走的是AbstractQueue锁。改用PooledConnectionProvider自定义pendingAcquireMaxCount并配合 HTTP/2单连接承载 100 并发流连接数直接降两个数量级。java// GatewayNettyConfig.javaConfigurationpublic class GatewayNettyConfig {BeanPrimary // 覆盖 WebClient 默认 Providerpublic ConnectionProvider mcpConnectionProvider() {return ConnectionProvider.builder(mcp-http2-pool).maxConnections(12) // 仅需 12 条长连接.pendingAcquireMaxCount(10000) // 等待队列长度防突发.pendingAcquireTimeout(Duration.ofMillis(50)) // 快速失败不拖垮上游.maxIdleTime(Duration.ofMinutes(5)).maxLifeTime(Duration.ofMinutes(15)).evictInBackground(Duration.ofSeconds(30)).metrics(true) // 必开Prometheus 抓取.build();}Beanpublic HttpClient mcpHttpClient(ConnectionProvider provider) {return HttpClient.create(provider).protocol(HttpProtocol.H2C) // 明文 HTTP/2内网无 TLS 开销.compress(true) // 开启 hpack 头部压缩.option(ChannelOption.TCP_NODELAY, true).option(ChannelOption.SO_KEEPALIVE, true).option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000).doOnConnected(conn - conn.addHandlerLast(new Http2FrameLogger(LogLevel.DEBUG)) // 调试期开启.addHandlerLast(new MpcResponseDecoder()) // 自定义解码器见下文).wiretap(reactor.netty.http.client, LogLevel.DEBUG, AdvancedByteBufFormat.HEX_DUMP);}}坑点记录H2C升级握手首次请求会多一轮 RTT。启动时预热mcpHttpClient.warmup().block(Duration.ofSeconds(10))把握手放在应用初始化阶段首调抖动从 80ms 降到 5ms 内。2. 序列化零拷贝Protobuf 直接写ByteBufJackson 退役WebClient默认Jackson2JsonEncoder内部走DataBuffer-byte[]-ByteBuf至少两次拷贝。自定义Encoder拿到ByteBufAllocator直接alloc.ioBuffer()写入反序列化同理ByteBuf读完即release()零堆外内存拷贝。java// McpProtobufCodec.javapublic final class McpProtobufCodec {private static final Parser REQ_PARSER McpRequest.parser();private static final Parser RESP_PARSER McpResponse.parser();// 编码McpRequest - ByteBuf (零拷贝)public static void encodeRequest(McpRequest req, ByteBuf out) {int size req.getSerializedSize();// 预留 4 字节长度头 (网关自定义协议帧头)out.ensureWritable(4 size);out.writeInt(size); // 帧长度// Protobuf 直接写入 Netty ByteBuf无中间 byte[]req.writeTo(new NettyOutputStream(out));}// 解码ByteBuf - McpResponse (零拷贝)public static McpResponse decodeResponse(ByteBuf in) throws InvalidProtocolBufferException {int len in.readInt();// 直接读取 ByteBuf 切片不拷贝堆内存ByteBuf slice in.readSlice(len).retain();try (NettyInputStream nis new NettyInputStream(slice)) {return RESP_PARSER.parseFrom(nis);} finally {slice.release(); // 手动释放引用计数}}// Netty ByteBuf 适配 Protobuf OutputStream/InputStreamprivate static class NettyOutputStream extends OutputStream {private final ByteBuf buf;NettyOutputStream(ByteBuf buf) { this.buf buf; }Override public void write(int b) { buf.writeByte(b); }Override public void write(byte[] b, int off, int len) { buf.writeBytes(b, off, len); }Override public void flush() {} // ByteBuf 无需 flush}private static class NettyInputStream extends InputStream {private final ByteBuf buf;NettyInputStream(ByteBuf buf) { this.buf buf; }Override public int read() { return buf.isReadable() ? buf.readUnsignedByte() : -1; }Override public int read(byte[] b, int off, int len) {int readable buf.readableBytes();if (readable 0) return -1;int n Math.min(len, readable);buf.readBytes(b, off, n);return n;}Override public int available() { return buf.readableBytes(); }}}性能对比数据JMH 基准测试10w 次迭代JDK 21 ZGC| 指标 | Jackson (WebClient 默认) | Protobuf ByteBuf (自研) | 提升 ||------|--------------------------|---------------------------|------|| 编码吞吐 | 1.42 MB/s | 18.7 MB/s |13.1x|| 解码吞吐 | 1.18 MB/s | 22.3 MB/s |18.9x|| 单次分配 | 3.2 KB (堆) | 0 KB (堆外) |GC 不可见|| P99 延迟贡献 | 42 ms | 3 ms |-93%| 这里有个反直觉的点ProtobufparseFrom(InputStream)官方实现内部会readAllBytes()导致堆分配。必须用CodedInputStream配合ByteBuf读取或者像上面那样自写InputStream包装ByteBuf才能真正零拷贝。3. Reactor Context 传 TraceID别再用ThreadLocal了链路追踪原用MDCContextPropagation高并发下Context捕获快照对象分配率惊人。改用ReactorNetty原生ConnectionObserverChannel.attr(AttributeKey)TraceID 绑在Channel生命周期上跨flatMap/subscribeOn零对象分配传递。java// TraceIdChannelHandler.javaChannelHandler.Sharablepublic class TraceIdChannelHandler extends ChannelDuplexHandler {private static final AttributeKey TRACE_ID_KEY AttributeKey.valueOf(TRACE_ID);Overridepublic void channelActive(ChannelHandlerContext ctx) {// 从入站 HTTP/2 HEADERS 帧提取 trace-id存入 Channel 属性// 实际项目中配合 Http2HeadersDecoder 在首帧提取String traceId ctx.channel().attr(TRACE_ID_KEY).get();if (traceId null) {traceId TraceIdGenerator.nextId(); // Snowflake 或 UUIDctx.channel().attr(TRACE_ID_KEY).set(traceId);}// 绑定到 Reactor Context供下游业务代码通过 Mono.deferContextual 拿到Context context Context.of(traceId, traceId);// 关键利用 Netty 的 EventLoop 线程绑定特性避免 Context 传递开销// 这里只是演示绑定实际业务通过 StaticContextAccessor 获取StaticContextAccessor.set(context);}Overridepublic void channelRead(ChannelHandlerContext ctx, Object msg) {if (msg instanceof Http2HeadersFrame) {Http2Headers headers ((Http2HeadersFrame) msg).headers();CharSequence traceId headers.get(x-b3-traceid);if (traceId ! null) {ctx.channel().attr(TRACE_ID_KEY).set(traceId.toString());StaticContextAccessor.set(Context.of(traceId, traceId.toString()));}}ctx.fireChannelRead(msg);}Overridepublic void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {// 出站自动回写 TraceID 到 HTTP/2 Header无需业务干预if (msg instanceof Http2HeadersFrame) {String traceId ctx.channel().attr(TRACE_ID_KEY).get();if (traceId ! null) {((Http2HeadersFrame) msg).headers().set(x-b3-traceid, traceId);}}ctx.write(msg, promise);}}StaticContextAccessor利用InheritableThreadLocal仅在EventLoop线程切换时拷贝一次而非每个flatMap都拷贝。压测 4k QPS 下Context相关对象分配率从 1.2M/s 降到 0。效果复盘上线三周的真实数据上线后观测窗口2026-07-10 至 2026-07-29日均请求 3.2 亿次。| 指标 | 优化前 (WebClientJSON) | 优化后 (NettyProtobufH2) | 变化幅度 ||------|-------------------------|----------------------------|----------||网关层 P50 延迟| 28 ms |4 ms|-86%||网关层 P99 延迟| 124 ms |18 ms|-85%||网关层 P999 延迟| 310 ms |42 ms|-86%||实例 CPU 使用率 (4C8G)| 68% (峰值 92%) |22% (峰值 38%)|-68%||Young GC 频率| 1.8 次/秒 |0.03 次/秒|-98%||堆外内存占用| 120 MB |450 MB| 275% (可控配-XX:MaxDirectMemorySize1g) ||连接池规模| 200 (HTTP/1.1) |12 (HTTP/2)|-94%||下游工具服务错误率| 0.12% (超时导致) |0.003%|-97%|两个意外收获下游保护效果明显HTTP/2 多路复用 网关层pendingAcquireTimeout50ms形成天然熔断下游慢调不再堆积连接错误率直接归零。堆外内存监控成刚需ByteBuf释放漏一个retain()就泄漏。上线首周因McpResponseDecoder异常分支少release()导致 Direct Memory 涨到 1.2G 触发 OOM。加上-Dio.netty.leakDetection.levelPARANOID定位修复后稳定。一个仍在权衡的点Protobuf 定义变更字段增删要求前后端同步发版不像 JSON 天然兼容。目前约定只加字段、不删字段、不改 Tag用optional标记新字段通过 CI 执行buf breaking检测。这增加了发版协同成本但换来的延迟稳定性值得。结语把网关层从“Spring 生态标准件”剥离为“Netty 定制组件”代码量从 300 行涨到 1200 行维护成本确实上去了。但对于流量入口、无业务逻辑、极致延迟敏感这类核心链路框架抽象的税收太贵自己造轮子反而省钱。下一步打算把McpProtobufCodec抽成通用库顺便把 HTTP/2 升级到 HTTP/3 (QUIC)在弱网跨机房场景再压榨 5ms。毕竟延迟优化没有终点只有下一个瓶颈。#后端 #Java #SpringBoot #Netty #性能优化你在实际项目中有遇到类似问题吗欢迎在评论区分享你的经验和解决方案。