异常分类体系:三种维度看异常

异常这事儿,不同语言、不同框架、不同团队的叫法往往不一样。今天我带你从三个角度把它理清楚。这三个角度并不是互斥的,而是从不同层面去看同一个东西。

核心记住一句话:异常分类不是非此即彼,而是一种多维坐标。我们实际编码时,往往同时用到这三种分类思维。

第一维:检查型异常 vs 非检查型异常

这个分类最早来自 Java,但后来成了很多语言的设计参考。说白了,就是编译器会不会强制你去处理它。

检查型异常(Checked Exception):编译器要求你必须在代码里显式 try-catch 或者 throws 声明。如果你不处理,代码根本编译不过。我刚开始用 Java 时觉得这玩意儿烦得要死,写个文件读取都要 try-catch,满屏的样板代码。

我曾经在做一个金融交易系统时,因为漏掉了 Socket 连接的超时异常处理,导致线上服务在极端负载下直接挂掉。那个异常是检查型的,但我在方法声明里往上一级抛,结果顶层没捕获……嗯,这事之后再也不敢偷懒了。

非检查型异常(Unchecked Exception):编译器不强制你处理。常见的 NullPointerException、ArrayIndexOutOfBoundsException 都属于这一类。你写代码时不一定提前预料得到,运行时才冒出来。

特性检查型异常非检查型异常
编译检查必须处理或声明抛出不要求处理
典型例子IOException, SQLExceptionNullPointerException, IllegalArgumentException
使用场景外部资源、网络、文件系统程序逻辑错误、参数校验
我个人的习惯只对可恢复的、调用方必须知道的情况使用绝大多数场景都用非检查型,省去调用方的负担

第二维:运行时异常 vs 编译时异常

这维度和上面那个方向不同。它看的是异常发生在哪个阶段。你想想看,一个 bug 如果在编译时就能被抓出来,那比运行时再崩掉省钱省时间多了。

编译时异常(Compile-time Exception):语法错误、类型不匹配、未声明的方法调用……这些东西在代码还没跑的时候就暴露了。IDE 里直接划条红线,你立刻知道。我特别喜欢这种“即时反馈”。

运行时异常(Runtime Exception):代码能编译通过,但跑起来某些条件不满足就炸了。比如数组越界、除零、空指针。这类异常最让人头疼——测试覆盖不到就容易遗漏。我曾经在生产环境上遇到过因为浮点数精度问题导致的死循环,编译时完全正常,跑了一周才触发。

小技巧:如果你在用静态语言,尽量把逻辑约束提前到类型系统里表达。比如用枚举代替字符串常量,用 Option 类型代替可能为 null 的引用。这样大量运行时异常可以转成编译时异常。

第三维:系统异常 vs 应用异常

这一维是从异常的责任边界来分的。它更多体现在架构设计和运维监控中。

系统异常(System Exception):底层基础设施出的问题。比如数据库连接池耗尽、磁盘空间不足、内存溢出、线程死锁。这类异常通常不能通过代码逻辑去恢复,需要运维介入或降级策略。我负责过的一个支付网关,因为 Redis 集群脑裂导致大量请求超时,系统异常直接打垮了核心链路。那之后我们才认真搞了熔断和限流。

应用异常(Application Exception):业务逻辑层面的问题。比如用户余额不足、订单状态不允许取消、参数校验失败。这类异常往往可以明确定义错误码和提示信息,并且可以预期会被调用方处理。

维度系统异常应用异常
来源基础设施、运行时环境业务规则、参数校验
恢复方式降级、重试、告警通知语义化错误码、返回友好提示
典型例子OutOfMemoryError, SocketTimeoutExceptionInvalidOrderStateException, InsufficientBalanceException
监控关注点指标、报警、自动恢复业务成功率、错误率分布

我曾经在一个项目中把所有异常都定义为 RuntimeException,结果线上出了系统异常后,错误码全是 -1,业务方根本不知道是数据库挂了还是参数错了。后来我们被迫重构:系统异常打独立日志并用熔断器处理,应用异常才返回具体错误码。

三个维度放在一起看

这三个分类其实是从不同角度切入的。检查型/非检查型是语言设计纬度,运行时/编译时是发生时机纬度,系统/应用是责任边界纬度。它们不是互相替代,而是互为补充。

异常分类体系:三维坐标 异常 检查型异常 非检查型异常 运行时异常 编译时异常 系统异常 应用异常 三个维度可以共存:比如一个IO异常既是“检查型”又是“运行时”(发生时机)还可以是“系统异常”

实际用的时候怎么选?

我自己的经验是:别死磕理论,去理解你团队的语言和框架的习惯。比如在 Java 里,Spring 几乎全用非检查型异常;在 .NET 里,微软建议对预期错误用检查型,对程序 bug 用非检查型;在 Go 里根本没有异常语法,全靠 error 返回值。

你想想看,如果你在一个 kotlin 项目里硬要引入 Java 的 checked exception,就会变得很不顺手。所以更好的做法是:

  • 先搞清楚你所用语言/框架对异常的“默认做法”
  • 再结合系统/应用的边界,定义你自己的异常层次
  • 最后用运行时/编译时这一维去判断,哪些错误应该尽早暴露

关于分类的避坑:很多人把“检查型异常”和“编译时异常”混为一谈,其实不对。检查型异常是语言层面的设计选择,而编译时异常是发生时机。比如 Python 没有检查型异常(它全是运行时),但它的语法错误仍然是编译时异常。不要混淆这两个维度。

一句话总结

检查型/非检查型:语言告诉你“必须处理”还是“可以不处理”。运行时/编译时:代码跑之前发现问题还是跑之后才炸。系统/应用:这个异常是基础设施坏了还是业务逻辑错了。三把尺子各自量不同的东西,综合使用才能写出健壮清晰的异常处理。