1. 项目概述:为什么“一起写个Dubbo”不是玩具,而是理解RPC底层的必经之路
“一起写个Dubbo——3. Netty传输和通用序列化接口”,这个标题乍看像极了某次技术分享的PPT小节,但如果你真把它当成一个“教学Demo”来轻视,那后面踩的坑会比你预想的多出三倍。我带过不少刚从校招进来的同学,也辅导过不少想转中间件方向的后端开发者,他们普遍卡在同一个地方:能调通Dubbo的@Service和@Reference,能配好ZooKeeper注册中心,甚至能改点Filter加个日志,但一旦问到“Consumer发出去的字节流,是怎么被Provider端还原成原始对象的?”、“如果Provider返回的是一个包含LocalDateTime字段的DTO,Consumer收到时为什么报错‘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 Number) | 2 | 标识这是本协议的数据包 | CA FE |
| 序列化类型(Serialize Type) | 1 | 0=Java原生, 1=Hessian, 2=JSON | 01 |
| 状态位(Status) | 1 | 0=正常响应, 1=异常响应 | 00 |
| 请求ID(Request ID) | 4 | Long类型,用于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模式,边序列化边写入Channel;JSON更简单,直接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骤降到3500,Full 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 Serialization<T> { /** * 将对象序列化为字节数组 * @param obj 待序列化对象(非null) * @return 序列化后的字节流 */ byte[] serialize(T obj) throws SerializeException; /** * 将字节数组反序列化为指定类型对象 * @param data 字节流(非null) * @param type 目标类型Class * @param <T> 类型参数 * @return 反序列化后的对象 */ <T> T deserialize(byte[] data, Class<T> 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/2),Value为实例。这样在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字节!如果设成12,Netty会从第12字节开始找4字节长度值,而你的包总共才8+body字节,直接抛CorruptedFrameException。调试时,打开Netty的DEBUG日志,搜索LengthFieldBasedFrameDecoder,能看到它每次读取的offset和length,这是定位粘包问题的第一现场。
另一个致命陷阱是lengthAdjustment。假设你希望长度字段表示“整个包长度”(即8字节头+body),那么lengthAdjustment应设为-8,告诉Netty:“你读到的长度值,要减去8才是body长度”。但我们不这么做,因为:
- 计算整个包长度需要先知道body长度,形成循环依赖;
- 如果body为空(如心跳包),整个包长度=8,减8后为0,Netty会认为这是非法包。
所以我们的协议约定:长度字段只表示body长度。这意味着序列化器在serialize()后,必须返回纯数据字节,不包含任何头信息。这个约定,必须被所有序列化实现严格遵守。
3.2 协议解码器(ProtocolDecoder):位运算不是炫技,是精准控制
ProtocolDecoder继承自ByteToMessageDecoder,核心逻辑在decode()方法。这里不做任何对象创建,只做字节解析:
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> 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实现,必须处理以下三个细节:
细节1:ClassLoader传递Hessian反序列化时,需要加载目标类。如果Consumer和Provider的ClassLoader不同(如Spring Boot的LaunchedURLClassLoader),HessianInput.readObject(null, clazz)会抛ClassNotFoundException。解决方案是在deserialize()中显式传入当前线程的ContextClassLoader:
@Override public <T> T deserialize(byte[] data, Class<T> 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时间API(LocalDateTime等)支持极差。如果你的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.ref=true),但某些复杂对象图(如父子双向关联)会误判为循环引用。我们的做法是:在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; // 0=Java, 1=Hessian, 2=JSON private final byte status; // 0=OK, 1=ERROR 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 Serialization<Object> { 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, Class<T> 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 ChannelInitializer<SocketChannel>() { @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 SimpleChannelInboundHandler<Request> { // 模拟服务注册表 private final Map<String, 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 ChannelInitializer<SocketChannel>() { @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); // 1=Hessian 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 SimpleChannelInboundHandler<Response> { // 全局存储未完成的Promise,key为requestId private static final Map<Integer, Promise<Object>> PROMISE_MAP = new ConcurrentHashMap<>(); public static Promise<Object> createPromise(int requestId) { DefaultPromise<Object> promise = new DefaultPromise<>(GlobalEventExecutor.INSTANCE); PROMISE_MAP.put(requestId, promise); return promise; } @Override protected void channelRead0(ChannelHandlerContext ctx, Response response) throws Exception { Promise<Object> 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→ requestId=1 ✓- 后续字节是Hessian序列化的Request对象 ✓
在ProtocolDecoder.decode()方法里打断点,观察in.readShort()返回51966(即0xCAFE的十进制),in.readByte()返回1,证明协议解析正确。
在HessianSerialization.deserialize()里打第二个断点,input.readObject(type)返回一个HelloServiceImpl$HelloRequest对象,字段name="World",证明序列化层工作正常。
这才是“一起写个Dubbo”的意义:每个字节都可控,每个对象都可查,每个线程都清晰。不是调个API完事,而是把RPC的毛细血管,一根根解剖给你看。
5. 常见问题与排查技巧实录:那些让你熬夜到凌晨三点的坑
即使严格按照上述步骤操作,你依然会遇到一些“只在此山中,云深不知处”的问题。这些不是代码bug,而是RPC底层交互的固有复杂性。我把过去三年带团队踩过的典型问题,整理成速查表,并附上独家排查技巧。
5.1 问题速查表:症状、根因与一招毙命解法
| 问题现象 | 根本原因 | 快速定位技巧 | 终极解法 |
|---|---|---|---|
Consumer收不到任何响应,Netty日志显示Channel inactive | Provider的BusinessHandler中抛了未捕获异常(如NullPointerException),导致Channel被Netty自动关闭 | 在BusinessHandler.channelRead0()外层加try-catch(Throwable),打印完整堆栈;同时开启Netty的-Dio.netty.leakDetectionLevel=paranoid检测内存泄漏 | 在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的泛型信息丢失,反序列化时创建了ArrayList<Object>而非ArrayList<String> | 在Provider的serialize()后,打印serializedBytes.length,对比Consumer端deserialize()前的data.length,若不等,说明序列化器有问题 | 强制在DTO的List字段上加@HessianSerializable注解(Hessian 4.0+),或改用JSON序列化(牺牲性能换类型安全) |
QPS上不去,CPU 100%,但线程堆栈显示大量HessianInput.readObject | Hessian反序列化触发了深度递归,如对象图中有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)。如果两串字节不完全相同,说明序列化器或网络层出了问题。这是最原始、最有效的方法。
技巧2:Netty线程绑定可视化
在关键Handler的channelRead0()开头,加一行:System.out.println("Thread: " + Thread.currentThread().getName() + ", Channel: " + ctx.channel().id())。你会清晰看到:nioEventLoopGroup-3-1(IO线程)处理ProtocolDecoder,pool-1-thread-1(业务线程)处理BusinessHandler。如果发现所有Handler都在同一个nioEventLoopGroup线程,说明你忘了加EventExecutorGroup。
技巧3:协议头“染色”调试法
在协议头里加入一个“调试位”,比如把status字段的最高位设为1表示调试包。在Provider的ProtocolDecoder中,如果读到status & 0x80 != 0,就额外打印in.getByte(8)(即body第一个字节)。这样你能在不修改业务逻辑的情况下,实时观测到原始字节流。
技巧4:Hessian版本锁死术
Hessian 3.x和4.x的序列化格式不兼容。如果你的Provider用4.0.66,Consumer用3.2.1,一定会反序列化失败。解决方案:在Maven的pom.xml中,用<dependencyManagement>强制锁定Hessian版本,并添加enforcer插件检查传递依赖:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-enforcer-plugin</artifactId> <version>3.4.1</version> <executions> <execution> <id>enforce-hessian-version</id> <goals> <goal>enforce</goal> </goals> <configuration> <rules> <requireUpperBoundDeps/> </rules> </configuration> </execution> </executions> </plugin>5.3 性能压测中的隐藏雷区
最后分享一个血泪教训:某次压测,QPS卡在8000上不去,排查三天,发现是LengthFieldBasedFrameDecoder的maxFrameLength设成了4 * 1024 * 1024(4MB),而压测工具发送的请求体平均只有2KB。Netty为每个连接分配了4MB的缓冲区,1000个连接就是4GB内存,直接触发频繁GC。解法:maxFrameLength应设为“预期最大body长度+协议头长度”,本项目设为16 * 1024(16KB)足够。
另一个雷区是EventLoopGroup线程数。很多人设成Runtime.getRuntime().availableProcessors() * 2,但在高并发短连接场景下,过多线程反而增加上下文切换开销。我们的经验公式是:IO线程数 = min(4, CPU核心数),业务线程数 = min(200, QPS * 平均响应时间)。这个数字,必须通过jstat -gc和arthas thread命令实测调整,没有银弹。
写到这里,你已经拥有了一个可运行、可调试、可扩展的RPC核心骨架。它不追求功能完备,但每一块代码都直指RPC本质。下次再看到“Dubbo序列化机制”这种面试题,你不会再背诵“