本节目标:把「解码抛异常之后怎么办」写成确定的策略:哪些错误必须关连接、哪些可以回 ERROR 帧后继续、异常应该由 pipeline 的哪个 handler 接住。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-codec-base、netty-codec-http模块)。
承接的问题
6.2 的 LengthFieldBasedFrameDecoder 只负责「切出来的字节数对」,不负责「切出来的内容是合法帧」。对端可以发错误的 magic、不支持的 version、声明 4 GB 的长度,或者在帧中间断开。这些情况会让解码器抛异常,而异常一旦离开 decode,就不再是「解码器内部的事」:它沿 pipeline 往尾部走,最后由某个 exceptionCaught 决定连接是关还是留。
linkd-v2 在 9093 上见过三种典型现场。第一种,扫描器连上来发了一段 HTTP 请求,magic 不匹配,解码器抛 CorruptedFrameException;如果没有人关连接,这条连接会一直挂着。第二种,客户端把长度字段写成 0x7fffffff,解码器在等一个 2 GB 的负载;如果没有上限检查,内存先被吃光。第三种,业务 handler 收到合法帧但负载为空,抛了 IllegalArgumentException;这种错误帧边界是好的,应该回一个 ERROR 帧而不是直接断开。
这三种现场对应三个不同决策:关连接、回错误帧、还是只记录。本节先把异常类型和传播路径讲清楚,再给 linkd-v2 的 ERROR 帧格式与关闭策略。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。下面标注「本机输出」的内容都来自这台机器,单次运行,未做统计。
核心机制一:三类解码异常
Netty 的编解码异常都挂在 DecoderException 下。DecoderException 继承 CodecException,CodecException 继承 RuntimeException;TooLongFrameException 和 CorruptedFrameException 都是 DecoderException 的子类:
RuntimeException
└── CodecException
└── DecoderException
├── TooLongFrameException
└── CorruptedFrameException
CorruptedFrameException 表示「字节流里有一个帧,但帧头或长度不合法」,比如 magic 不对、version 不支持、调整后的长度小于头长度。TooLongFrameException 表示「帧长度超过 maxFrameLength」,LengthFieldBasedFrameDecoder、DelimiterBasedFrameDecoder 会直接抛它;HttpObjectAggregator 处理超限响应时抛的 TooLongHttpContentException 也是它的子类。两者的区别不是严重程度,而是流同步是否还可信:两者通常都不可信,但 TooLong 至少知道「当前帧声明了多大」。
ByteToMessageDecoder 还会做一层包装:decode 里抛出的非 DecoderException 会被包成 DecoderException,原始异常放在 cause 里。本机让解码器抛 IllegalStateException("bad magic"),exceptionCaught 收到的是:
io.netty.handler.codec.DecoderException: java.lang.IllegalStateException: bad magic: 0x00000000
如果 decode 直接抛 CorruptedFrameException,它本身就是 DecoderException,不会再包一层。本机用 LengthFieldBasedFrameDecoder 触发两种内置错误,消息分别是:
io.netty.handler.codec.TooLongFrameException: Adjusted frame length exceeds 20: 104 - discarded
io.netty.handler.codec.CorruptedFrameException: Adjusted frame length (-79) is less than lengthFieldEndOffset: 16
第一条来自 maxFrameLength=20、长度字段声明 100 的配置:调整后 104 超过 20,解码器在读取负载前就拒绝。第二条来自 lengthAdjustment=-100:16 减 100 加 5 得到 -79,小于 lengthFieldEndOffset=16,说明长度字段和 adjustment 的组合不合法。
lengthFieldLength=4 时,长度值按无符号读取。对端写 ff ff ff ff,解码器看到的是 4,294,967,295,通常先触发 TooLongFrameException,而不是「负数长度」。要区分「字段是负数」还是「字段太大」,得在子类里用有符号 getInt 自己判断,不能指望父类。
核心机制二:异常在 pipeline 里往哪走
入站事件从 head 往 tail 走,异常也一样。某个 handler 的 channelRead 抛出异常时,异常先在这个 handler 自己的 exceptionCaught 上被调用;如果它不处理、调用 ctx.fireExceptionCaught(cause),才继续传给下一个 inbound handler。因此:
exceptionCaught要放在解码器之后。放在解码器之前的 handler 看不到解码器抛出的异常,因为异常往 tail 方向走,不会回头。- 最后一个 handler 必须显式处理异常。
ChannelInboundHandlerAdapter的默认实现只是继续往下传;传到 tail 的TailContext时,Netty 只打印一条 warning,不会自动关闭连接。
本机用一个「只记录、不关闭」的 handler 模拟吞异常,再用一个「记录并关闭」的 handler 对比:
SwallowHandler saw DecoderException and returns without closing
channel active after swallow = true
exception in channel = none
CloseHandler saw DecoderException, closing embedded
channel active after close handler = false
吞掉异常后,checkException() 返回空,嵌入式 channel 仍然是 active。换成真实 TCP,这条连接保持 ESTABLISHED,客户端在等响应,服务端没有任何进展;解码器的 cumulation 里还留着那段坏字节,下一次 read 会再次进入 decode、再次抛同一个异常。每来一次数据就报一次错,连接却一直不关,这就是「半死」。它最终可能被 7.2 的空闲检测收掉,但在那之前,文件描述符、连接表项和错误日志都在持续消耗。
关闭动作要用 addListener 挂在写操作上,而不是 writeAndFlush 之后立刻 ctx.close():
ctx.writeAndFlush(errorFrame).addListener(ChannelFutureListener.CLOSE);
writeAndFlush 是异步的,ctx.close() 如果先执行,写队列里的 ERROR 帧可能来不及刷出去,对端只看到 TCP 断开,不知道原因。ChannelFutureListener.CLOSE 在写完成(成功或失败)后关闭,至少给了 ERROR 帧一次发送机会。连接已经关闭时不要再写,先判断 ctx.channel().isActive()。
核心机制三:HTTP 的 decoderResult
HTTP 编解码器对「畸形请求」的处理和二进制解码器不同:它尽量不抛异常,而是把失败记录在 DecoderResult 里,仍然把 FullHttpRequest 交给下一个 handler。HttpObjectAggregator 聚合之后,request.decoderResult().isSuccess() 可能是 false,cause() 里是具体原因。
本机喂入一个 Content-Length: abc 的请求,HttpServerCodec 加 HttpObjectAggregator 之后的输出是:
decoderResult success = false
http cause -> java.lang.IllegalArgumentException: Content-Length value is not a number: abc
exception in http channel = none
注意最后一行:没有异常抛到 exceptionCaught。如果 handler 不检查 decoderResult(),它会拿着一个「头解析失败、内容可能为空」的请求继续路由,可能返回 200,也可能在读取 content() 时得到意料之外的结果。正确做法是在 channelRead0 的第一行检查,失败时回 400 并关闭:
package com.plume.linkd.v2.server;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.DefaultFullHttpResponse;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpUtil;
import io.netty.handler.codec.http.HttpVersion;
import io.netty.util.CharsetUtil;
public final class LinkdHttpHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) {
if (!request.decoderResult().isSuccess()) {
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
HttpResponseStatus.BAD_REQUEST,
Unpooled.copiedBuffer("bad request", CharsetUtil.UTF_8));
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8");
HttpUtil.setContentLength(response, response.content().readableBytes());
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
return;
}
// 正常路由由 15.1 展开;这里只处理协议层错误。
}
}
SimpleChannelInboundHandler<FullHttpRequest> 会在 channelRead0 返回后自动释放请求,所以失败分支直接 return,不要手动 release。HttpUtil.setContentLength 写 Content-Length,ChannelFutureListener.CLOSE 让 400 写完后断开。HTTP/1.1 名义上可以 keep-alive,但请求的帧边界已经不可信,继续复用这条连接会把后续字节解释错;400 加关闭是更稳的选择。
HttpObjectAggregator 自己的 maxContentLength 超限时走的是另一条路:对请求,它直接写 413(HttpResponseStatus.REQUEST_ENTITY_TOO_LARGE)并可能关闭连接,不会给你一个 decoderResult 失败的请求;对响应,它抛 TooLongHttpContentException,这是 TooLongFrameException 的子类。400 分支只处理 decoderResult,不要和聚合器的 413 重复写两个响应。
核心机制四:linkd-v2 的 ERROR 帧与关闭策略
linkd-v2 的 ERROR 帧就是 type=FrameType.ERROR(值为 7)的普通 LinkdFrame。负载约定为「4 字节错误码 + UTF-8 文本」,flags 为 0,requestId 能确定就填,不能确定就填 0。错误码是协议内部约定:
| 错误码 | 含义 | 触发点 | 是否关闭 |
|---|---|---|---|
| 1 | 帧损坏 | bad magic、bad version、长度非法 | 是 |
| 2 | 帧超长 | 超过 MAX_PAYLOAD | 是 |
| 3 | 负载语义错误 | 合法帧但内容不合法 | 否 |
| 4 | 不支持的帧类型 | type 不在 1..7 | 是 |
关闭策略的判断标准只有一个:帧边界是否还可信。bad magic、bad version、长度非法、超长,都会让解码器无法确定下一帧从哪里开始,必须关闭;负载语义错误发生在帧边界已经确认之后,可以回 ERROR 帧并保持连接,让客户端修正请求。type 不支持也是帧边界可信、但协议无法继续的例子,保守起见关闭。
关闭不是唯一选择。若帧边界仍然可信——magic 和长度字段都合法,只是 type 不支持或负载语义错误——可以只丢弃当前帧:按长度字段算出 frameLength,skipBytes(frameLength) 后继续读下一帧。LengthFieldBasedFrameDecoder 的 failFast=false 对超长帧做的就是这件事:先按长度字段丢弃整帧的字节,读完后抛 TooLongFrameException;如果调用方吞掉异常,流位置已经对准下一帧,理论上可以继续。linkd-v2 不在 9093 上采用这条路径:超长声明本身可能说明对端状态已经错乱,继续复用连接会把错误放大;关闭更可控。丢弃帧的前提是「知道这一帧有多长」,bad magic 时连长度字段的位置都不可信,没有丢弃的锚点。
LinkdFrameHandler 放在解码器和编码器之后,统一处理入站帧和异常:
package com.plume.linkd.v2.server;
import com.plume.linkd.v2.protocol.FrameType;
import com.plume.linkd.v2.protocol.LinkdFrame;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.CorruptedFrameException;
import io.netty.handler.codec.DecoderException;
import io.netty.handler.codec.TooLongFrameException;
import java.nio.charset.StandardCharsets;
public final class LinkdFrameHandler extends SimpleChannelInboundHandler<LinkdFrame> {
private static final System.Logger LOG =
System.getLogger(LinkdFrameHandler.class.getName());
private static final int ERR_BAD_FRAME = 1;
private static final int ERR_TOO_LONG = 2;
private static final int ERR_BAD_PAYLOAD = 3;
private static final int ERR_BAD_TYPE = 4;
@Override
protected void channelRead0(ChannelHandlerContext ctx, LinkdFrame frame) {
if (frame.type() < FrameType.HELLO || frame.type() > FrameType.ERROR) {
sendErrorAndClose(ctx, frame.requestId(), ERR_BAD_TYPE, "unsupported type");
return;
}
if (frame.type() == FrameType.REQUEST && frame.payload().length == 0) {
sendError(ctx, frame.requestId(), ERR_BAD_PAYLOAD, "empty request");
return;
}
LOG.log(System.Logger.Level.DEBUG, "frame type=" + frame.type()
+ " requestId=" + frame.requestId() + " bytes=" + frame.payload().length);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
if (cause instanceof TooLongFrameException) {
sendErrorAndClose(ctx, 0, ERR_TOO_LONG, "frame too long");
} else if (cause instanceof CorruptedFrameException) {
sendErrorAndClose(ctx, 0, ERR_BAD_FRAME, "corrupted frame");
} else if (cause instanceof DecoderException) {
sendErrorAndClose(ctx, 0, ERR_BAD_FRAME, "decode failed");
} else {
LOG.log(System.Logger.Level.WARNING,
"unexpected error from " + ctx.channel().remoteAddress(), cause);
ctx.close();
}
}
private static void sendError(ChannelHandlerContext ctx, int requestId,
int code, String message) {
ctx.writeAndFlush(errorFrame(ctx, requestId, code, message));
}
private static void sendErrorAndClose(ChannelHandlerContext ctx, int requestId,
int code, String message) {
if (!ctx.channel().isActive()) {
return;
}
ctx.writeAndFlush(errorFrame(ctx, requestId, code, message))
.addListener(ChannelFutureListener.CLOSE);
}
private static LinkdFrame errorFrame(ChannelHandlerContext ctx, int requestId,
int code, String message) {
byte[] text = message.getBytes(StandardCharsets.UTF_8);
ByteBuf buf = ctx.alloc().buffer(4 + text.length);
try {
buf.writeInt(code);
buf.writeBytes(text);
byte[] payload = new byte[buf.readableBytes()];
buf.readBytes(payload);
return new LinkdFrame(FrameType.ERROR, 0, requestId, payload);
} finally {
buf.release();
}
}
}
这段代码有三个顺序上的约束。第一,instanceof 必须从具体到一般:TooLongFrameException 和 CorruptedFrameException 都是 DecoderException,如果先判断 DecoderException,前两个分支永远不会执行,所有错误都被当成「decode failed」。第二,帧边界错误用 requestId=0:此时头字段还没通过校验,哪怕 offset 8 上有 4 个字节,也不能确认它真的是 requestId。第三,sendErrorAndClose 先检查 isActive(),连接已经进入关闭流程时不再写,避免 ClosedChannelException 盖住原始错误。
channelRead0 里的语义错误用 sendError,不关闭。客户端收到 requestId 匹配的 ERROR 帧后可以修正请求重发;帧边界没有坏,连接可以继续用。这也意味着 ERROR 帧必须经过 LinkdFrameEncoder:pipeline 顺序要是 LinkdFrameDecoder、LinkdFrameEncoder、LinkdFrameHandler,编码器在业务 handler 之前,ctx.writeAndFlush 往 head 走时才能遇到它。如果编码器放在 handler 之后,ctx.writeAndFlush 不会回头找它,LinkdFrame 对象会直接到达 channel,因为不是 ByteBuf 而写出失败。
对端在帧中间断开时,走的是 channelInactive 和 decodeLast,不是先走 exceptionCaught。6.2 的 LinkdFrameDecoder 在 decodeLast 里检查残余,还有字节就抛 CorruptedFrameException;此时连接已经在关闭,isActive() 为 false,sendErrorAndClose 直接返回,正确动作是记录对端地址和残余字节数,让关闭继续。给一个已经半关的连接写 ERROR,只会多一条 ClosedChannelException 日志。
常见错误与边界
错误一:exceptionCaught 里只记日志,不关闭也不继续传播。
// 反例:异常被吞掉,连接保持 active
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
LOG.log(System.Logger.Level.WARNING, "decode error", cause);
}
本机输出里 channel active after swallow = true,checkException() 为空。真实连接不会自动关闭,cumulation 里的坏字节留在原地,下一次 read 继续抛同一个异常;客户端等不到响应,服务端连接数只增不减。至少要做到「记录 + 关闭」,或者在确认帧边界可信时回 ERROR 后继续。
错误二:把 exceptionCaught 写在解码器之前。 入站异常从出错 handler 往 tail 走,放在 head 方向的 handler 收不到后面的解码器异常。exceptionCaught 应该放在 pipeline 尾部,或者放在业务 handler 里并显式 ctx.fireExceptionCaught 给更后面的 handler。
错误三:writeAndFlush 之后立刻 ctx.close()。
// 反例:ERROR 帧可能还没刷出去就断了
ctx.writeAndFlush(errorFrame);
ctx.close();
writeAndFlush 返回时数据可能还在 EventLoop 的写队列里。直接 close() 会让对端只看到 FIN,不知道错误码。用 addListener(ChannelFutureListener.CLOSE) 把关闭挂到写完成之后。
错误四:不检查 decoderResult() 就处理 HTTP 请求。 HttpServerCodec 对畸形请求不抛异常,而是把失败放进 DecoderResult。不检查就会拿 Content-Length: abc 的请求继续路由,可能返回 200,也可能在 content() 上得到空数据。第一行检查,失败回 400 并关闭。
错误五:所有异常都回 ERROR 帧并保持连接。 帧边界错误回 ERROR 之后,后续字节仍然按错误的偏移解析,客户端会收到一串看似合法、实际错位的帧。只有「帧边界已确认、错误在负载语义层」才适合保持连接;其余情况关闭。
边界:TooLongFrameException 可能来自多个地方。 LengthFieldBasedFrameDecoder 的超长声明、DelimiterBasedFrameDecoder 的找不到分隔符、HttpObjectAggregator 处理超限响应时的 TooLongHttpContentException。前两者要关闭自定义协议连接;HTTP 的超限请求由聚合器自己写 413,超限响应则以异常形式到达 exceptionCaught。如果只按异常类型判断、不区分协议,会把 HTTP 的错误写成自定义协议的 ERROR 帧。
边界:DecoderException 的 cause 可能为 null。 内置解码器直接抛 CorruptedFrameException 时,它自己就是根因;包装型 DecoderException 才有 cause。日志里同时打印异常类型和 cause,不要只打印 cause.getMessage(),否则根因型异常会打出 null。
边界:关闭前后仍可能收到异常。 channelInactive 里 decodeLast 抛出的截断错误、以及 handler 内部的运行时异常,都可能让 exceptionCaught 在连接已经进入关闭流程后再被调用。处理函数要幂等:先看 isActive(),再看是否已经关闭,避免重复写 ERROR 或重复关闭。
小结
DecoderException是编解码异常的根,TooLongFrameException和CorruptedFrameException都是它的子类;ByteToMessageDecoder会把非DecoderException包一层。- 入站异常从出错 handler 往 tail 走,
exceptionCaught要放在解码器之后;默认的 tail 只记 warning,不会自动关闭连接。 - 关连接还是留连接,看帧边界是否还可信:bad magic、bad version、长度非法、超长都关闭;负载语义错误回 ERROR 帧后可继续。
linkd-v2的 ERROR 帧是type=FrameType.ERROR,负载为 4 字节错误码加 UTF-8 文本;帧边界错误用requestId=0并关闭,语义错误用真实requestId且不关闭。- HTTP 的畸形请求不抛异常,而是
decoderResult().isSuccess()==false;检查后回 400 并关闭,超限请求由HttpObjectAggregator自己回 413。 - 吞掉异常会让连接半死:TCP 还开着,解码器每次 read 都抛同一个错,客户端一直等;至少要记录并关闭。
帧的边界和错误处理都定下来了,下一节回到协议设计本身:magic 为什么选 LKDF、16 字节头各字段怎么排、version 和 flags 怎么扩展,以及 linkd-v2 的握手帧如何在不破坏兼容性的前提下加字段。
阅读导航:上一节:6.2 长度域、分隔符与定长解码 · 下一节:7.1 协议帧设计与魔数 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。