本节摘要:Netty 的异常沿入站方向逐工位传播,无人接手则到 tail 由 Netty 默认处理器打日志。本节讲清传播路径、默认行为的问题、分类收口方案,以及"关连接还是回错误包"的决策规则。
任何工位抛出异常(包括解码失败、业务空指针、写失败),Netty 会捕获并沿流水线向下游工位调用 exceptionCaught——方向与 channelRead 一致(head 到 tail),出站工位不参与。若一路无人处理,走到 tail 时由 Netty 内置兜底:
// Netty 内置 TailContext 的兜底行为(等价逻辑) protected void onUnhandledInboundException(Throwable cause) { logger.warn("An exceptionCaught() event was fired, and it reached " + "at the tail of the pipeline. It usually means the last handler " + "in the pipeline did not handle the exception.", cause); }
只打一行 WARN,连接继续活着。这有两个隐患:其一,异常量一大日志被刷爆,真正的线索被淹没;其二,出过错的连接可能已处于半解析状态(解码器内部缓冲错乱),继续用它等于带病生产。
不是所有异常都该一关了之。按来源与含义分四类,处置各不相同:
| 异常类型 | 典型代表 | 处置建议 |
|---|---|---|
| 协议错误 | 解码器抛出的 CorruptedFrame、非法长度 | 回协议错误包后关闭,客户端需重连重试 |
| 对端正常断开 | IOException 中的 Connection reset / Broken pipe | 静默关闭,不留 ERROR 日志 |
| 业务可恢复错误 | 参数校验失败、余额不足 | 回业务错误码,连接保留 |
| 系统级异常 | 空指针、数据库超时 | 记 ERROR 带堆栈,关闭连接防带病运行 |
分类的价值在于降噪:线上最怕的就是把前两类也打成 ERROR,值班同学对告警脱敏,真事故反被忽略。
把分类逻辑放进流水线最后一个工位,全线异常在此收口:
@ChannelHandler.Sharable public class ExceptionGuard extends ChannelDuplexHandler { private static final Logger log = LoggerFactory.getLogger(ExceptionGuard.class); @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { if (cause instanceof DecoderException de && de.getCause() instanceof CorruptedFrameException) { replyAndClose(ctx, ProtoErr.BAD_FRAME); // 协议错:回错误包再关 return; } if (cause instanceof IOException) { ctx.close(); // 对端断开:静默关 return; } if (cause instanceof BizException biz) { ctx.writeAndFlush(biz.toErrorPacket()); // 业务错:回包保连接 return; } log.error("未预期异常 关闭连接 remote={}", ctx.channel().remoteAddress(), cause); // 系统错:带堆栈记录 ctx.close(); } private void replyAndClose(ChannelHandlerContext ctx, ProtoErr err) { ctx.writeAndFlush(err.toPacket()) .addListener(ChannelFutureListener.CLOSE); // 写完再关,给对端收到错误包的机会 } } // 装配:放在 addLast 的最后一位,兜住全线 ch.pipeline().addLast(decoder, encoder, bizHandler, new ExceptionGuard());
三个细节值得咀嚼:ChannelFutureListener.CLOSE 保证错误包先写出去再关(直接 close 对端可能收不到);@Sharable 让一个实例服务全部连接(收口器无状态);IOException 在 Windows 与 Linux 上消息文本不同,用类型判断而不是字符串匹配。
exceptionCaught 只收入站方向的异常。出站写失败(连接已断还 write)走的是另一条路:写操作返回的 ChannelFuture 会标记失败。所以两处都要设防:
ctx.writeAndFlush(resp).addListener(future -> { if (!future.isSuccess()) { future.cause().printStackTrace(); // 出站失败在这里暴露,exceptionCaught 看不见 } });
漏掉出站监听是"日志干净但消息没到"的典型成因——流水线看着毫无异常,实际上写全失败了。
收口 Handler 自己抛异常会怎样?Netty 会再次捕获继续传播,极端情况下形成"异常处理递归"。守规矩的写法:收口工位内部全 try-catch,日志调用也包住(appender 故障时不能反噬业务线程):
try { log.error(...); } catch (Throwable ignore) { } // 日志系统自身故障不扩散
⚠️ 常见坑三连:收口 Handler 没放最后一位,前面的工位异常直接漏到 tail 兜底;把对端断开打成 ERROR,日志噪音淹没真事故;catch 里只
e.printStackTrace(),生产日志框架根本收不到。
💡 关键直觉:异常处理的目标不是"消灭异常",而是三件事:给每类异常一个明确出路(回包或关连接)、把线索留在正确的日志级别、保证出错后的连接不带病运行。质检工位收的不是异常,是秩序。