手写 RPC 框架零拷贝实战:把 Codec 从 byte[] 搬到 ByteBuf,一次干掉全链路内存拷贝

手写 RPC 框架零拷贝实战:把 Codec 从 byte[] 搬到 ByteBuf,一次干掉全链路内存拷贝 本文以开源 RPC 框架 jawsJava 17 Netty目标是用 1.7 万行代码吸纳 Dubbo 的核心能力最近一轮编解码层重构为背景自底向上拆解一次 RPC 请求的编码、传输、解码全过程中每一次内存分配和拷贝发生在哪里、为什么发生、怎么消除以及零拷贝改造引入的新问题——引用计数泄漏的排查与修复。〇、背景一次对比分析暴露的效率短板jaws 的定位是保持轻量的同时吸纳 Dubbo 的核心能力。在完成传输层瘦身、Directory/Filter 对齐 Dubbo 等三轮重构后我写了一份 codec 层的 Dubbo 对比分析文档把 jaws 和 Dubbo 的编解码路径逐次分配/拷贝数了一遍结论不太好看Jaws 旧编码路径 JawsCodec.encodeRequest() ① ByteArrayOutputStream → body byte[] [分配 拷贝] ② encode() → new byte[16] header arraycopy 拼接 [分配 拷贝] ③ encode() → new byte[16 bodyLen] 两次 arraycopy [分配 2 拷贝] FrameEncoder.encodeFrame() ④ new byte[16 total] → arraycopy 嵌入传输帧 [分配 拷贝] NettyEncoder.encode() ⑤ ByteBuf.writeBytes(finalFrame) [拷贝到 ByteBuf]不算序列化器内部的缓冲一次请求编码至少 5 次分配、5 次拷贝。而 Dubbo 的ExchangeCodec是在 ChannelBuffer 上预留 header 空间、body 直写、最后回填 header中间 byte[] 分配接近于零。差距的根源不在写法而在接口模型jaws 的 Codec SPI 签名是byte[] encode(Channel, Object)——接口把byte[]定为契约那么每个跨层边界都必然产生一次数组拷贝。所以这轮重构的思路很明确改契约而不是改实现。顺带把对比文档里列的另一个改进项——serializationId 嵌入协议头——也一起落地了。一、先搞清楚旧架构的拷贝为什么会发生自底向上看旧编解码的分层NettyEncoder/Decoder (传输层 - 传输帧拆包/组帧, magic0xF1F1) ↕ FrameEncoder (帧封装层 - 把协议帧嵌入传输帧) ↕ JawsCodec (协议层 - 业务编解码, magic0xF0F0)这是一个双 magic 双层帧结构JawsCodec 产出 16B 协议头 body 的 byte[]FrameEncoder 在外面再包一层 16B 传输头0xF1F1最后 NettyEncoder 把整个 byte[] 写进 Netty 的 ByteBuf。每一层拿到的都是上一个层的byte[]而每层的产出又必须是新的byte[]因为要加自己的头于是 arraycopy 不可避免。更隐蔽的浪费有两个一是ByteArrayOutputStream.toByteArray()本身就是一次内部缓冲数组的防御性拷贝二是序列化每个参数时serialize()返回独立的 byte[] 再被 ObjectOutputStream 包装一层多参数请求的分配次数随参数个数线性增长。解码侧同理NettyDecoderin.readBytes(data)把完整协议帧拷进 byte[]JawsCodec 再System.arraycopy提取 body然后包 ByteArrayInputStream 反序列化——又是两次分配两次拷贝。结论只要 Codec 的契约是 byte[]拷贝次数就是分层层数 × 2 起步。要根治就得让 Codec 直接操作 Netty 的 ByteBuf让最终目的缓冲区贯穿所有层。二、改造一编码路径——预留 header、直写 body、回填新接口Spi(scopeScope.PROTOTYPE)publicinterfaceCodec{voidencode(Channelchannel,Objectmessage,ByteBufout)throwsIOException;Objectdecode(Channelchannel,ByteBufin)throwsIOException;}调用方NettyChannel直接把 Netty 分配的目标缓冲区传进来ByteBufbufchannel.alloc().buffer();try{codec.encode(this,request,buf);}catch(IOExceptione){buf.release();thrownewJawsServiceException(encode request error: urlgetUrl().getUri(),e);}ChannelFuturewriteFuturechannel.writeAndFlush(buf);注意两个细节channel.alloc().buffer()用的是该 channel 绑定的分配器走 Netty 的池化 PooledByteBuffer本身就是省 GC 的关键编码失败时主动release()因为writeAndFlush不会发生Netty 不会替你释放。encodeRequest的核心写法——这正是 DubboExchangeCodec的经典手法privatevoidencodeRequest(Channelchannel,Requestrequest,ByteBufout)throwsIOException{SerializationserializationExtensionLoader.getExtensionLoader(Serialization.class).getExtension(channel.getUrl().getParameter(URLParamType.serialization));// Reserve header spaceintheaderStartout.writerIndex();out.writerIndex(headerStartHEADER_LENGTH);// Write body directly to ByteBufByteBufOutputStreambodyOutnewByteBufOutputStream(out);ObjectOutputoutputcreateOutput(bodyOut);output.writeUTF(request.getInterfaceName());output.writeUTF(request.getMethodName());output.writeUTF(request.getParamDesc());if(request.getArguments()!null){for(Objectobj:request.getArguments()){serialize(output,obj,serialization);}}// ... attachments ...output.flush();output.close();intbodyLengthout.writerIndex()-headerStart-HEADER_LENGTH;// Backfill header with serializationId embedded in flagbyteflag(byte)(FLAG_REQUEST|((serialization.getSerializationNumber()3)SERIALIZATION_MASK));writeHeader(out,headerStart,flag,request.getRequestId(),bodyLength);}三步走先把 writerIndex 前推 16 字节留出 header 空间body 通过ByteBufOutputStream直写进最终缓冲区ObjectOutputStream 直接建立在它之上中间零中转写完后用out.writerIndex() - headerStart - HEADER_LENGTH算出 body 长度回填 header。body 从序列化器到网络发送只经过一个池化 ByteBuf中间 byte[] 分配清零。body 长度只有写完才知道所以预留 回填是唯一顺序这也是为什么协议头里必须有 bodyLen 字段——协议设计决定了编码实现能不能做零拷贝。服务端的sendResponse同构同样直写 channel 的 alloc 分配的缓冲区。三、改造二解码路径——readRetainedSlice 切片零拷贝传输层的NettyDecoder继承ByteToMessageDecoder完成拆包后不再把帧拷进 byte[]而是直接把 ByteBuf 切片传下去// Pass ByteBuf directly to JawsCodec.decode (zero-copy, no frame byte[] allocation)in.resetReaderIndex();// Retain the buffer since the caller (ByteToMessageDecoder pipeline) may release it;// NettyChannelHandler is responsible for releasing after processing.ByteBufframein.readRetainedSlice(JawsCodec.HEADER_LENGTHbodyLength);NettyMessagemessagenewNettyMessage(isRequest,requestId,frame);out.add(message);readRetainedSlice与readSlice的区别是前者会把引用计数 1——这个细节是后文泄漏问题的伏笔。NettyMessage也随之改成了携带 ByteBuf 的 recordpublicrecordNettyMessage(booleanisRequest,longrequestId,ByteBufdata){}JawsCodec 解码时同样切片body 部分用ByteBufInputStream直接包给反序列化器// Slice body region from ByteBuf (zero-copy, no body byte[] allocation)ByteBufbodyBufin.retainedSlice(in.readerIndex(),bodyLength);in.skipBytes(bodyLength);try(ByteBufInputStreambodyInnewByteBufInputStream(bodyBuf)){ObjectInputinputcreateInput(bodyIn);if(isResponse){returndecodeResponse(input,dataType,requestId,serializationId,serialization);}else{returndecodeRequest(input,requestId,serializationId,serialization);}}finally{bodyBuf.release();}解码路径上 body 也没有任何中间数组——反序列化器直接从网络缓冲区的切片视图里读。旧路径的readBytes 拷一次 arraycopy 拷一次归零。四、改造三顺手砍掉双 magic 传输帧接口 ByteBuf 化之后回头看旧分层FrameEncoder 那层的存在理由已经不成立了。它当年干的活是校验 JawsCodec 输出的 magic、套 16B 传输头0xF1F1、做编码降级。而现在帧检测由 NettyDecoder 直接校验协议 magic0xF0F0完成编码降级挪进了 sendResponse 的异常处理。于是这轮把FrameEncoder80 行和 NettyEncoder32 行整个删掉协议变成单层 16B headerBytes 0-1 : magic 0xF0F0 Byte 2 : version (当前 1) Byte 3 : flag (低 3 位 消息类型, 高 5 位 serializationId) Bytes 4-11 : requestId Bytes 12-15 : body length收益是三重的每个请求省 16 字节传输帧头、省一次帧封装的分配拷贝、Netty pipeline 少一跳 handlerMessageToByteEncoder内部还有一次请求跨线程的 Promise 语义开销。有人会问双层帧不是为多协议复用传输层预留的吗实践中的判断是YAGNI。传输帧的唯一消费者就是 jaws 协议自己这层抽象没有第二个使用者时它就是纯税。Dubbo 的单层 0xdabb 帧跑了十年也证明 16B header 够用。五、改造四serializationId 嵌入 flag 高 5 位——每消息独立序列化这是 Dubbo 协议早就有、jaws 一直缺的能力序列化方式跟着消息走而不是跟着连接配置走。Dubbo 把 serializationId 放在 flag 的低 5 位。jaws 的 flag 低 3 位已被消息类型占用0x00request / 0x01response / 0x03void / 0x05exception高 5 位正好空闲语义完全同构实现上分三步。第一步给 SPI 注解加数字标识publicinterfaceSpiMeta{Stringname();/** Optional numeric identifier for the SPI extension... Value must be in range 0-31. Default -1 means not assigned. */intnumber()default-1;}SpiMeta(namehessian2,number0)publicclassHessian2SerializationextendsAbstractSerialization{...}SpiMeta(namefastjson2,number1)publicclassFastJson2SerializationextendsAbstractSerialization{...}第二步 ExtensionLoader 建一张 number → name 的映射O(1) 反查publicTgetExtensionByNumber(intnumber){checkInit();StringnamenumberToName.get(number);returnname!null?getExtension(name):null;}第三步是全链路闭环这是最容易漏的部分。编码端在第二节已经看到serialization.getSerializationNumber() 3写进 flag。解码端提取byteflagin.readByte();bytedataType(byte)(flagMASK);byteserializationId(byte)((flagSERIALIZATION_MASK)3);然后request 上带着它进入业务处理handler 把它拷到 responseencodeResponse 据此选序列化器// NormalRequestHandlerresponse.setSerializationNumber(request.getSerializationNumber());privatevoidencodeResponse(Responseresponse,ByteBufout)throwsIOException{// Use the serialization carried on the response (copied from request by handler),// so the response is encoded with the same serialization the client used.SerializationserializationExtensionLoader.getExtensionLoader(Serialization.class).getExtensionByNumber(response.getSerializationNumber());...}为什么 response 必须跟随 request因为消费端解码用的是自己配置的序列化方式如果服务端用配置里的另一种方式编码响应消费端就解不出来了。这个请求-响应序列化一致性约束就是 Dubbo 在协议头里放 serializationId 的根本原因。落地之后同一服务端可以同时接收 hessian2 客户端和 fastjson2 客户端的调用互不干扰——多客户端异构场景打通。六、零拷贝的代价两处引用计数泄漏Byte[] 是 JVM 管理的扔了就有 GC 兜底ByteBuf 是池化 引用计数的谁 retain 谁 release少一次 release 就泄漏一块池化内存而且 GC 救不了。这正是零拷贝改造最需要警惕的地方——我这轮 review 就抓出了两处。Netty 的引用计数规则先说清楚readRetainedSlice/retainedSlice会 1ByteToMessageDecoder在一次channelRead结束后释放自己持有的引用所以只要切片对象要在 channelRead 返回后继续存活比如提交给业务线程池就必须再 retain并在用完后 release。泄漏点一线程池拒绝路径。NettyChannelHandler.channelRead里异步路径先 retain 再提交线程池if(threadPoolExecutor!null){try{// Retain the ByteBuf for async processing (pipeline may release after this method returns)nettyMsg.data().retain();threadPoolExecutor.execute(()-{try{processMessage(ctx,nettyMsg);}finally{nettyMsg.data().release();}});}catch(RejectedExecutionExceptionrejectException){// Only server-side requests go through the thread pool;// reject and return error response to client when pool is full.rejectMessage(ctx,nettyMsg);// ← 旧代码这里没 release}}任务正常执行时异步任务的 finally release 一次 外层 finally release 一次 两次抵消 retain 和 readRetainedSlice 各一次平衡。但execute()抛RejectedExecutionException时任务根本没提交成功异步 release 不会发生——外层 finally 只 release 掉 readRetainedSlice 那次retain 的那次就漏了。最坑的是触发条件是线程池打满恰好是流量高峰泄漏速度和事故规模正相关。修复就是 catch 里补一次}catch(RejectedExecutionExceptionrejectException){// Release the ByteBuf retained above; the finally block below only releases// the reference acquired by the decoder (readRetainedSlice).nettyMsg.data().release();rejectMessage(ctx,nettyMsg);}泄漏点二异常抛出早于 try-with-resources。JawsCodec.decode 里旧顺序是先切片、后校验 serializationId// 旧顺序有泄漏风险ByteBufbodyBufin.retainedSlice(in.readerIndex(),bodyLength);// 1in.skipBytes(bodyLength);Serializationserialization...getExtensionByNumber(serializationId);if(serializationnull){thrownewJawsFrameworkException(decode error: unknown serializationId serializationId,...);// ← bodyBuf 已经 1但 try-with-resources 还没进来永远没人 release}try(ByteBufInputStreambodyInnewByteBufInputStream(bodyBuf)){...}retainedSlice和 try 块之间任何一行抛异常这次引用计数就丢了。恶意客户端持续发 unknown serializationId 的请求可以稳定放大泄漏。修复方式是把纯校验逻辑挪到任何切片之前——“先做所有不持有资源的校验再获取资源”这是引用计数环境下的通用防御性顺序// Resolve serialization from the id embedded in the protocol header// (must happen before the retainedSlice below, otherwise an unknown id// would throw and leak the sliced body buffer)SerializationserializationExtensionLoader.getExtensionLoader(Serialization.class).getExtensionByNumber(serializationId);if(serializationnull){thrownewJawsFrameworkException(...);}ByteBufbodyBufin.retainedSlice(in.readerIndex(),bodyLength);in.skipBytes(bodyLength);try(ByteBufInputStreambodyInnewByteBufInputStream(bodyBuf)){...}finally{bodyBuf.release();}顺带一提ByteBufInputStream.close()不会释放包装的 ByteBuf所以 finally 里的显式 release 不能省——这是 Netty 的一个经典反直觉点很多人以为 try-with-resources 能兜底其实不能。排查这类问题的标准姿势是开 Netty 的泄漏检测压测# SIMPLE 是采样检测PARANOID 是全量检测性能损失大仅排查用java-Dio.netty.leakDetection.levelPARANOID-jarprovider.jar泄漏发生时会打出LEAK: ByteBuf.release() was not called before its garbage-collected的堆栈指明 retain 的来源。七、收益结算与 Dubbo 对照这轮重构整体 56 文件 1003/-498同期还有 jaws-transport-netty 模块并入 jaws-core 等结构调整编解码路径的前后对照维度改造前改造后编码中间分配≥5 次body/header/拼接/传输帧/writeBytes0 次池化 ByteBuf 直写编码拷贝≥5 次0 次解码中间分配2 次帧 byte[] body byte[]0 次retainedSlice 视图每请求协议头开销32B传输帧 16B 协议帧 16B16B序列化方式选择按 URL 配置连接级flag 高 5 位消息级响应自动跟随请求内存管理责任GC 兜底引用计数需显式 release新增两类泄漏风险点对照 Dubbo协议层的序列化透传、零拷贝编码、预留回填手法已经完全同构jaws 仍缺的是 FLAG_EVENT 心跳位、twoway/oneway 标记flag 还有 bit7/bit6 两个空位实现方式和 serializationId 完全同构属于低成本跟进项和 status 状态码粒度。最后说点务虚的。这轮改造给我的最大触动是性能问题的根因经常在接口契约层而不是实现层。当 Codec 的签名是byte[]时写得多聪明都躲不开边界拷贝把签名改成ByteBuf正确实现自然就是零拷贝。但换契约是有代价的——byte[] 世界里 GC 兜底的一切到引用计数世界里都要人工对账retain/release 的配对正确性成了新的正确性维度。零拷贝从来不是免费的它只是把成本从运行时延迟转移到了工程纪律上。jaws 项目地址github.com/javahongxi/jaws一个用 1.7 万行代码吸纳 Dubbo 核心能力的轻量 RPC 框架支持 ZK/Nacos 注册中心、五种负载均衡、三种容错策略、泛化调用、动态配置、MCP 桥接。编解码设计详见 doc/codec-comparison.md。