《Netty 高性能网络编程》6.3 编解码错误与异常传播

解码失败不是一句 catch 就能收尾。本节讲 DecoderException、TooLongFrameException、CorruptedFrameException 在 pipeline 里的传播路径,给出 linkd-v2 的 ERROR 帧与关闭策略、HTTP decoderResult 的 400 处理,并说明 exceptionCaught 吞掉异常后连接为什么会半死。

本节目标:把「解码抛异常之后怎么办」写成确定的策略:哪些错误必须关连接、哪些可以回 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 协议帧设计与魔数 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发