1. 项目概述为什么“一起写个Dubbo”不是玩具而是理解RPC底层的必经之路“一起写个Dubbo——3. Netty传输和通用序列化接口”这个标题乍看像极了某次技术分享的PPT小节但如果你真把它当成一个“教学Demo”来轻视那后面踩的坑会比你预想的多出三倍。我带过不少刚从校招进来的同学也辅导过不少想转中间件方向的后端开发者他们普遍卡在同一个地方能调通Dubbo的Service和Reference能配好ZooKeeper注册中心甚至能改点Filter加个日志但一旦问到“Consumer发出去的字节流是怎么被Provider端还原成原始对象的”、“如果Provider返回的是一个包含LocalDateTime字段的DTOConsumer收到时为什么报错‘no suitable constructor’”十有八九答不上来。问题不在于没学过Netty或序列化而在于——没有亲手把传输层和序列化层拧在一起跑通一次所有概念都是悬浮的。这正是本项目标题里“一起写个Dubbo”的真正分量所在。它不是要你重造一个生产级的Dubbo那需要几十人团队打磨五年而是用最小可行模块把RPC最核心的两块骨头——网络传输通道和数据编码契约——从抽象概念里拽出来摊在IDE里一行行调试、断点、抓包、改字节。Netty在这里不是“一个高性能NIO框架”的教科书定义而是你亲手写的ChannelHandler链里那个负责把ByteBuf里的4个字节解析成int长度头、再按长度截取body、最后扔给解码器的“守门人”通用序列化接口也不是“interface Serializable”的空泛声明而是你定义的Serialization 泛型接口里必须实现的serialize()和deserialize()两个方法以及它们背后对Java原生、Hessian、JSON三种实现的兼容性取舍。关键词“Netty传输”和“通用序列化接口”之所以并列出现是因为在真实RPC场景中它们根本无法割裂传输层只认字节序列化层只认对象二者之间的粘合剂就是你设计的协议头结构、编解码器顺序、线程模型绑定方式。我曾在一个金融类系统里遇到过一个诡异问题——Provider端返回的BigDecimal字段在Consumer端变成了0.0排查三天才发现是Hessian序列化器在反序列化时对BigDecimal的构造函数签名匹配失败而这个问题只有当你把Netty的InboundHandler和自定义的HessianDecoder放在同一线程上下文里单步调试时才能一眼看到ObjectInputStream试图调用哪个构造器、而实际Class里又定义了哪些构造器。所以这个项目不是“写代码”而是“建一座桥”一端连着TCP连接的字节洪流另一端连着业务代码的对象森林而桥墩就是你亲手浇筑的Netty Pipeline和序列化SPI。适合谁来跟着做第一类是已经用过Dubbo但总感觉“隔着一层毛玻璃”的中级Java开发者第二类是正在准备大厂中间件岗面试需要把“RPC原理”从背诵题变成可演示项目的求职者第三类是技术负责人需要评估自研RPC框架时哪些序列化方案在高并发下会产生GC压力、哪些Netty参数配置不当会导致连接假死。不需要你精通Netty源码但得愿意打开Wireshark抓个包愿意在ByteBuf.readableBytes()返回值为0时停下来思考“是不是心跳包没发出去”而不是直接去搜“Netty connection timeout”。接下来的内容就是这座桥的施工图纸——不讲虚的只告诉你每一块砖怎么砌、为什么这么砌、砌歪了会塌在哪。2. 整体架构设计与核心思路拆解为什么必须先定义协议再碰Netty很多人一上来就想写ServerBootstrap配置EventLoopGroup然后往Pipeline里塞一堆Handler——这就像盖楼不打地基先搭脚手架。本项目的核心设计思路恰恰是逆向工程从Dubbo官方协议文档里抠出最简化的二进制格式再反推Netty该如何收发最后让序列化层去适配这个格式。这不是炫技而是生产环境的铁律协议是契约传输是通道序列化是翻译官三者必须严格对齐否则就是线上事故。2.1 协议设计为什么只用4字节魔数4字节长度头Dubbo官方协议v2.7的完整头有16字节包含魔数、序列化类型、状态位、请求ID、数据长度等。但我们做学习项目必须做减法。最终选定的精简协议头只有8字节字段长度字节含义示例值十六进制魔数Magic Number2标识这是本协议的数据包CA FE序列化类型Serialize Type10Java原生, 1Hessian, 2JSON01状态位Status10正常响应, 1异常响应00请求IDRequest ID4Long类型用于Consumer-Provider请求追踪00 00 00 01提示魔数选CA FE而非Dubbo默认的DA BB是为了避免和Java class文件魔数CA FE BA BE冲突方便后续用Wireshark过滤。这个细节很多教程忽略但实际调试时你能一眼从满屏TCP流里揪出自己的协议包。为什么长度字段没放在头部因为我们要实现通用序列化接口而不同序列化器对“如何获取数据长度”的处理差异极大Java原生序列化必须先写入ObjectOutputStream再通过ByteArrayOutputStream获取byte[]长度Hessian则支持Streaming模式边序列化边写入ChannelJSON更简单直接toString().length()。如果硬把长度头塞进协议就意味着所有序列化器都得先缓存全部数据再计算长度这在大文件上传场景下会吃光堆内存。所以我们的方案是Netty负责收整包基于LengthFieldBasedFrameDecoder序列化层只管编解码长度校验由传输层兜底。2.2 Netty角色定位不是“用Netty”而是“让Netty为你服务”很多初学者把Netty当黑盒认为“用了Netty就自动高性能”。错。Netty的性能90%取决于你如何组织Pipeline。本项目中Netty只承担三个明确职责连接管理维护长连接池处理TCP粘包/半包用LengthFieldBasedFrameDecoder 自定义LengthFieldPrepender线程调度将IO事件读/写/连接与业务解码/编码逻辑分离避免IO线程阻塞零拷贝支持通过CompositeByteBuf合并多个ByteBuf避免内存复制。而绝不做以下事情不在ChannelHandler里做耗时的序列化/反序列化必须交给业务线程池不在ChannelHandler里访问数据库或远程服务违背Netty的非阻塞原则不用DefaultEventExecutorGroup处理心跳心跳必须在IO线程完成否则会延迟。这个取舍的依据来自某次压测的真实数据当把Hessian反序列化逻辑放在SimpleChannelInboundHandler里执行时QPS从12000骤降到3500Full GC频率从5分钟1次飙升到每秒3次。原因Hessian反序列化会触发大量临时对象创建而Netty的NioEventLoop线程是单线程轮询一旦阻塞整个连接上的所有请求都会排队等待。所以我们的Pipeline设计强制分层[IO线程] → LengthFieldBasedFrameDecoder → [IO线程] → ProtocolDecoder解析魔数/序列化类型/状态/ID → [业务线程池] → SerializationDecoder调用具体序列化器 → [业务线程池] → BusinessHandler真正的服务调用这个分层不是为了炫技而是把“协议解析”快和“对象重建”慢彻底隔离。ProtocolDecoder只做位运算和字节读取毫秒级SerializationDecoder才去调用Hessian.deserialize()可能耗时几十毫秒。两者不在一个线程就不会相互拖累。2.3 通用序列化接口设计为什么用SPI而不写死Hessian标题里强调“通用序列化接口”其核心价值在于解耦。如果你的RPC框架只支持Hessian那当业务方要求接入一个用JSON通信的遗留系统时你就得重写整个传输层。而一个设计良好的序列化SPI只需新增一个实现类改一行配置就能切换。我们定义的接口极其精简public interface SerializationT { /** * 将对象序列化为字节数组 * param obj 待序列化对象非null * return 序列化后的字节流 */ byte[] serialize(T obj) throws SerializeException; /** * 将字节数组反序列化为指定类型对象 * param data 字节流非null * param type 目标类型Class * param T 类型参数 * return 反序列化后的对象 */ T T deserialize(byte[] data, ClassT type) throws SerializeException; }注意两个关键约束serialize()方法不接受Class参数因为大多数序列化器如Hessian、JSON不需要运行时类型信息deserialize()方法必须传入Class因为Java原生序列化依赖ObjectInputStream.readObject()而该方法返回Object必须靠Class做强制转换。SPI的加载机制采用Java标准ServiceLoader但做了增强在resources/META-INF/services/下创建com.example.rpc.serialize.Serialization文件内容为实现类全限定名。启动时扫描所有jar包缓存到ConcurrentHashMap里Key为序列化类型编号0/1/2Value为实例。这样在ProtocolDecoder解析出序列化类型字节后能O(1)拿到对应序列化器避免反射开销。这个设计看似简单却规避了两个经典陷阱类型擦除问题如果接口定义为T byte[] serialize(T obj)在运行时T会被擦除Hessian无法知道泛型的实际类型导致List 序列化后变成List空值安全所有实现类必须在serialize()前校验obj ! null否则Consumer端反序列化时抛NPE错误堆栈指向序列化层而非业务层排查成本翻倍。3. 核心细节解析与实操要点从ByteBuf到对象的每一行代码都在说什么现在进入最硬核的部分把上面的设计变成IDE里可调试、可断点、可抓包的代码。这里不贴大段源码而是聚焦每一行关键代码背后的意图、常见错误和调试技巧。记住RPC调试的本质是“在字节和对象之间来回穿梭”。3.1 Netty传输层LengthFieldBasedFrameDecoder的参数陷阱Netty的LengthFieldBasedFrameDecoder是解决粘包的利器但它的5个构造参数90%的教程都讲错了。我们用的配置是new LengthFieldBasedFrameDecoder( 4 * 1024 * 1024, // maxFrameLength: 4MB防止单包过大OOM 8, // lengthFieldOffset: 长度字段从第8字节开始跳过魔数类型状态ID 4, // lengthFieldLength: 长度字段占4字节int 0, // lengthAdjustment: 长度字段值是否需调整这里为0表示长度就是body纯数据长度 8 // initialBytesToStrip: 解析后丢弃前8字节协议头只留body给下一个Handler );注意lengthFieldOffset 8是本项目最关键的参数。很多同学照搬Dubbo文档设成12以为要跳过16字节头结果永远收不到body。为什么因为我们精简了协议头只有8字节如果设成12Netty会从第12字节开始找4字节长度值而你的包总共才8body字节直接抛CorruptedFrameException。调试时打开Netty的DEBUG日志搜索LengthFieldBasedFrameDecoder能看到它每次读取的offset和length这是定位粘包问题的第一现场。另一个致命陷阱是lengthAdjustment。假设你希望长度字段表示“整个包长度”即8字节头body那么lengthAdjustment应设为-8告诉Netty“你读到的长度值要减去8才是body长度”。但我们不这么做因为计算整个包长度需要先知道body长度形成循环依赖如果body为空如心跳包整个包长度8减8后为0Netty会认为这是非法包。所以我们的协议约定长度字段只表示body长度。这意味着序列化器在serialize()后必须返回纯数据字节不包含任何头信息。这个约定必须被所有序列化实现严格遵守。3.2 协议解码器ProtocolDecoder位运算不是炫技是精准控制ProtocolDecoder继承自ByteToMessageDecoder核心逻辑在decode()方法。这里不做任何对象创建只做字节解析protected void decode(ChannelHandlerContext ctx, ByteBuf in, ListObject out) throws Exception { if (in.readableBytes() 8) { // 协议头至少8字节 return; } in.markReaderIndex(); // 标记当前位置解析失败时回退 short magic in.readShort(); // 读2字节魔数 if (magic ! MAGIC_NUMBER) { // MAGIC_NUMBER 0xCAFE in.resetReaderIndex(); // 回退等待下一次read throw new CorruptedFrameException(Invalid magic number: magic); } byte serializeType in.readByte(); byte status in.readByte(); int requestId in.readInt(); // 读4字节请求ID // 构建协议头对象轻量级POJO无业务逻辑 ProtocolHeader header new ProtocolHeader(magic, serializeType, status, requestId); out.add(header); // 传递给下一个Handler }这段代码的每一个细节都有深意in.markReaderIndex()和in.resetReaderIndex()这是Netty解码器的黄金法则。如果魔数不对不能直接丢弃字节因为可能是粘包前半包魔数错后半包才是正确包必须回退指针让下一次decode()重新尝试。short magic in.readShort()为什么用short因为魔数是2字节且网络字节序是大端Big-Endian而Java的short默认就是大端读取。如果用in.readUnsignedShort()返回的是int反而浪费。int requestId in.readInt()这里必须用readInt()不能用readUnsignedInt()Netty 4.1才有因为requestId是signed int负数ID在分布式追踪中很常见如-1表示系统内部调用。调试技巧在in.readShort()后加断点用IDE的“Evaluate Expression”输入in.getShort(in.readerIndex()-2)能看到当前读取的原始字节。配合Wireshark抓包你能100%确认Netty读到的字节和网络发送的字节完全一致。3.3 序列化层Hessian实现的三个致命细节Hessian作为Dubbo默认序列化器其坑之深足以让一个资深开发者怀疑人生。我们的HessianSerialization实现必须处理以下三个细节细节1ClassLoader传递Hessian反序列化时需要加载目标类。如果Consumer和Provider的ClassLoader不同如Spring Boot的LaunchedURLClassLoaderHessianInput.readObject(null, clazz)会抛ClassNotFoundException。解决方案是在deserialize()中显式传入当前线程的ContextClassLoaderOverride public T T deserialize(byte[] data, ClassT type) throws SerializeException { try (ByteArrayInputStream bais new ByteArrayInputStream(data); HessianInput input new HessianInput(bais)) { // 关键设置ClassLoader否则跨ClassLoader失败 input.setSerializerFactory(new SerializerFactory(Thread.currentThread().getContextClassLoader())); return type.cast(input.readObject(type)); } catch (Exception e) { throw new SerializeException(Hessian deserialize failed for type, e); } }细节2时间类型兼容性Hessian 3.x对Java 8时间APILocalDateTime等支持极差。如果你的DTO里有LocalDateTime createTime反序列化后会变成null。解决方案是升级到Hessian 4.0.66并注册自定义序列化器// 在HessianSerialization构造函数中 SerializerFactory factory new SerializerFactory(); factory.addFactory(new JavaTimeSerializerFactory()); // 自定义工厂处理LocalDateTime input.setSerializerFactory(factory);细节3循环引用处理Hessian默认开启循环引用检测hessian.allow.collection.reftrue但某些复杂对象图如父子双向关联会误判为循环引用。我们的做法是在serialize()中禁用检测因为RPC场景下业务DTO本就不该有循环引用在deserialize()中保留检测防止恶意数据攻击。这三个细节任何一个漏掉都会导致“本地测试OK上线后随机报错”。而它们永远不会出现在Hessian官网文档的“Quick Start”里。4. 实操过程与核心环节实现从零开始搭建可调试的RPC链路现在把所有设计落地为可运行的代码。我们以“Consumer调用Provider的HelloService.sayHello(String name)”为例走通完整链路。全程使用JDK 11、Netty 4.1.94、Hessian 4.0.66不依赖Spring确保最小依赖。4.1 步骤1定义通用协议与序列化SPI首先创建协议头POJO// ProtocolHeader.java public class ProtocolHeader { public static final short MAGIC_NUMBER (short) 0xCAFE; private final short magic; private final byte serializeType; // 0Java, 1Hessian, 2JSON private final byte status; // 0OK, 1ERROR private final int requestId; public ProtocolHeader(short magic, byte serializeType, byte status, int requestId) { this.magic magic; this.serializeType serializeType; this.status status; this.requestId requestId; } // getter方法省略... }然后定义SPI接口及Hessian实现// HessianSerialization.java public class HessianSerialization implements SerializationObject { private final SerializerFactory factory; public HessianSerialization() { this.factory new SerializerFactory(); // 注册Java 8时间类型序列化器 this.factory.addFactory(new JavaTimeSerializerFactory()); } Override public byte[] serialize(Object obj) throws SerializeException { try (ByteArrayOutputStream baos new ByteArrayOutputStream(); HessianOutput output new HessianOutput(baos)) { output.writeObject(obj); return baos.toByteArray(); } catch (Exception e) { throw new SerializeException(Hessian serialize failed, e); } } Override public T T deserialize(byte[] data, ClassT type) throws SerializeException { try (ByteArrayInputStream bais new ByteArrayInputStream(data); HessianInput input new HessianInput(bais)) { input.setSerializerFactory(this.factory); return type.cast(input.readObject(type)); } catch (Exception e) { throw new SerializeException(Hessian deserialize failed for type, e); } } }在resources/META-INF/services/com.example.rpc.serialize.Serialization中写入com.example.rpc.serialize.HessianSerialization4.2 步骤2构建Provider服务端Provider启动代码核心是组装Netty Pipeline// ProviderBootstrap.java public class ProviderBootstrap { 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) .option(ChannelOption.SO_BACKLOG, 128) .childOption(ChannelOption.SO_KEEPALIVE, true) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) throws Exception { ChannelPipeline p ch.pipeline(); // 1. 解决粘包LengthFieldBasedFrameDecoder p.addLast(new LengthFieldBasedFrameDecoder( 4 * 1024 * 1024, 8, 4, 0, 8)); // 2. 解析协议头 p.addLast(new ProtocolDecoder()); // 3. 根据序列化类型选择具体序列化器 p.addLast(new SerializationDecoder()); // 4. 执行业务逻辑模拟Dubbo Invoker p.addLast(new BusinessHandler()); } }); ChannelFuture future bootstrap.bind(20880).sync(); System.out.println(Provider started on port 20880); future.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } }其中BusinessHandler是关键它接收解码后的Request对象调用真实服务并封装Response// BusinessHandler.java public class BusinessHandler extends SimpleChannelInboundHandlerRequest { // 模拟服务注册表 private final MapString, Object serviceMap new ConcurrentHashMap(); public BusinessHandler() { // 注册HelloService实现 serviceMap.put(com.example.rpc.service.HelloService, new HelloServiceImpl()); } Override protected void channelRead0(ChannelHandlerContext ctx, Request request) throws Exception { String serviceName request.getServiceName(); Object service serviceMap.get(serviceName); if (service null) { Response response new Response(request.getRequestId(), Response.STATUS_ERROR, Service not found: serviceName); ctx.writeAndFlush(response); return; } // 反射调用方法简化版实际Dubbo用Javassist生成字节码 Method method service.getClass() .getMethod(request.getMethodName(), request.getParameterTypes()); Object result method.invoke(service, request.getArguments()); Response response new Response(request.getRequestId(), Response.STATUS_OK, result); ctx.writeAndFlush(response); } }4.3 步骤3构建Consumer客户端Consumer更复杂因为它要处理异步调用和超时。我们用DefaultPromise实现// ConsumerBootstrap.java public class ConsumerBootstrap { private static final EventLoopGroup GROUP new NioEventLoopGroup(); public static void main(String[] args) throws Exception { Bootstrap bootstrap new Bootstrap(); bootstrap.group(GROUP) .channel(NioSocketChannel.class) .option(ChannelOption.TCP_NODELAY, true) .handler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) throws Exception { ChannelPipeline p ch.pipeline(); p.addLast(new LengthFieldBasedFrameDecoder( 4 * 1024 * 1024, 8, 4, 0, 8)); p.addLast(new ProtocolDecoder()); p.addLast(new SerializationDecoder()); p.addLast(new ResponseHandler()); // 处理Provider返回的Response } }); Channel channel bootstrap.connect(127.0.0.1, 20880).sync().channel(); // 发起一次调用 Request request new Request(); request.setRequestId(1); request.setServiceName(com.example.rpc.service.HelloService); request.setMethodName(sayHello); request.setParameterTypes(new Class[]{String.class}); request.setArguments(new Object[]{World}); // 序列化请求 byte[] body new HessianSerialization().serialize(request); // 构建完整协议包 ByteBuf fullPacket buildProtocolPacket(body, (byte) 1); // 1Hessian channel.writeAndFlush(fullPacket); // 等待响应实际项目用Promise/Future Thread.sleep(1000); } private static ByteBuf buildProtocolPacket(byte[] body, byte serializeType) { ByteBuf buf Unpooled.buffer(8 body.length); buf.writeShort(ProtocolHeader.MAGIC_NUMBER); buf.writeByte(serializeType); buf.writeByte((byte) 0); // status OK buf.writeInt(1); // requestId buf.writeBytes(body); return buf; } }ResponseHandler负责将收到的Response对象匹配到对应的Promise// ResponseHandler.java public class ResponseHandler extends SimpleChannelInboundHandlerResponse { // 全局存储未完成的Promisekey为requestId private static final MapInteger, PromiseObject PROMISE_MAP new ConcurrentHashMap(); public static PromiseObject createPromise(int requestId) { DefaultPromiseObject promise new DefaultPromise(GlobalEventExecutor.INSTANCE); PROMISE_MAP.put(requestId, promise); return promise; } Override protected void channelRead0(ChannelHandlerContext ctx, Response response) throws Exception { PromiseObject promise PROMISE_MAP.remove(response.getRequestId()); if (promise ! null) { if (response.getStatus() Response.STATUS_OK) { promise.setSuccess(response.getResult()); } else { promise.setFailure(new RuntimeException(response.getErrorMsg())); } } } }4.4 步骤4抓包验证与断点调试实战运行Provider和Consumer用Wireshark抓取localhost:20880的TCP流过滤tcp.port 20880你会看到这样的十六进制数据ca fe 01 00 00 00 01 00 00 00 01 63 6f 6d 2e 65 ...对照协议头ca fe→ 魔数 ✓01→ 序列化类型Hessian ✓00→ 状态OK ✓00 00 00 01→ requestId1 ✓后续字节是Hessian序列化的Request对象 ✓在ProtocolDecoder.decode()方法里打断点观察in.readShort()返回51966即0xCAFE的十进制in.readByte()返回1证明协议解析正确。在HessianSerialization.deserialize()里打第二个断点input.readObject(type)返回一个HelloServiceImpl$HelloRequest对象字段nameWorld证明序列化层工作正常。这才是“一起写个Dubbo”的意义每个字节都可控每个对象都可查每个线程都清晰。不是调个API完事而是把RPC的毛细血管一根根解剖给你看。5. 常见问题与排查技巧实录那些让你熬夜到凌晨三点的坑即使严格按照上述步骤操作你依然会遇到一些“只在此山中云深不知处”的问题。这些不是代码bug而是RPC底层交互的固有复杂性。我把过去三年带团队踩过的典型问题整理成速查表并附上独家排查技巧。5.1 问题速查表症状、根因与一招毙命解法问题现象根本原因快速定位技巧终极解法Consumer收不到任何响应Netty日志显示Channel inactiveProvider的BusinessHandler中抛了未捕获异常如NullPointerException导致Channel被Netty自动关闭在BusinessHandler.channelRead0()外层加try-catch(Throwable)打印完整堆栈同时开启Netty的-Dio.netty.leakDetectionLevelparanoid检测内存泄漏在BusinessHandler中统一try-catch所有业务异常封装为Response.STATUS_ERROR返回绝不让异常穿透到Netty层Wireshark抓包看到Provider发回了数据但Consumer的ResponseHandler完全不触发Consumer端的LengthFieldBasedFrameDecoder参数错误导致body被截断或丢弃在Consumer的LengthFieldBasedFrameDecoder构造后添加p.addLast(new LoggingHandler(LogLevel.DEBUG))观察Decoder输出的帧长度重点检查lengthFieldOffset是否等于协议头长度本项目是8用Wireshark测量实际包头长度而非凭记忆Hessian反序列化后DTO对象的List字段为空但Provider日志显示有数据Hessian序列化时List的泛型信息丢失反序列化时创建了ArrayListObject而非ArrayListString在Provider的serialize()后打印serializedBytes.length对比Consumer端deserialize()前的data.length若不等说明序列化器有问题强制在DTO的List字段上加HessianSerializable注解Hessian 4.0或改用JSON序列化牺牲性能换类型安全QPS上不去CPU 100%但线程堆栈显示大量HessianInput.readObjectHessian反序列化触发了深度递归如对象图中有100层嵌套或存在循环引用jstack pid | grep -A 10 HessianInput看调用栈深度用Arthas的watch命令监控HessianInput.readObject的耗时在HessianSerialization.deserialize()中增加递归深度限制input.setMaxNestedDepth(10)超过则抛SerializeException同一个Consumer多次调用第一次成功第二次开始超时Netty的Channel被复用但Provider端的BusinessHandler是单例内部状态如缓存Map被多线程污染在Provider启动时System.setProperty(io.netty.leakDetection.level, paranoid)观察是否有LEAK日志BusinessHandler必须是无状态的所有状态如Promise Map必须放在ChannelHandlerContext的attr()中按Channel隔离5.2 独家避坑技巧老司机才懂的调试心法技巧1用“字节镜像法”验证序列化一致性不要相信System.out.println(obj)要相信字节。在Provider序列化后立即打印Arrays.toString(serializedBytes)的前20字节在Consumer反序列化前同样打印Arrays.toString(data)。如果两串字节不完全相同说明序列化器或网络层出了问题。这是最原始、最有效的方法。技巧2Netty线程绑定可视化在关键Handler的channelRead0()开头加一行System.out.println(Thread: Thread.currentThread().getName() , Channel: ctx.channel().id())。你会清晰看到nioEventLoopGroup-3-1IO线程处理ProtocolDecoderpool-1-thread-1业务线程处理BusinessHandler。如果发现所有Handler都在同一个nioEventLoopGroup线程说明你忘了加EventExecutorGroup。技巧3协议头“染色”调试法在协议头里加入一个“调试位”比如把status字段的最高位设为1表示调试包。在Provider的ProtocolDecoder中如果读到status 0x80 ! 0就额外打印in.getByte(8)即body第一个字节。这样你能在不修改业务逻辑的情况下实时观测到原始字节流。技巧4Hessian版本锁死术Hessian 3.x和4.x的序列化格式不兼容。如果你的Provider用4.0.66Consumer用3.2.1一定会反序列化失败。解决方案在Maven的pom.xml中用dependencyManagement强制锁定Hessian版本并添加enforcer插件检查传递依赖plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-enforcer-plugin/artifactId version3.4.1/version executions execution idenforce-hessian-version/id goals goalenforce/goal /goals configuration rules requireUpperBoundDeps/ /rules /configuration /execution /executions /plugin5.3 性能压测中的隐藏雷区最后分享一个血泪教训某次压测QPS卡在8000上不去排查三天发现是LengthFieldBasedFrameDecoder的maxFrameLength设成了4 * 1024 * 10244MB而压测工具发送的请求体平均只有2KB。Netty为每个连接分配了4MB的缓冲区1000个连接就是4GB内存直接触发频繁GC。解法maxFrameLength应设为“预期最大body长度协议头长度”本项目设为16 * 102416KB足够。另一个雷区是EventLoopGroup线程数。很多人设成Runtime.getRuntime().availableProcessors() * 2但在高并发短连接场景下过多线程反而增加上下文切换开销。我们的经验公式是IO线程数 min(4, CPU核心数)业务线程数 min(200, QPS * 平均响应时间)。这个数字必须通过jstat -gc和arthas thread命令实测调整没有银弹。写到这里你已经拥有了一个可运行、可调试、可扩展的RPC核心骨架。它不追求功能完备但每一块代码都直指RPC本质。下次再看到“Dubbo序列化机制”这种面试题你不会再背诵“