异常处理最佳实践
本教程共 100 篇 · 第 71 篇 · 更新于 2026-08-05 · 约 17 分钟阅读
71. 异常处理最佳实践
本节目标:把前面六章的知识串成一套可执行的准则,学完知道在什么位置抛、在什么位置捕、怎么记日志才不添乱。
早抛:错误发现得越早越好
「早抛」(Fail Fast)的意思是:一旦发现输入或状态不对,立刻抛异常,别往下走。
对比两种写法。
// 不好:错误潜伏,到很深的地方才爆
static void register(String name, int age) {
User user = new User();
user.setName(name);
user.setAge(age);
saveToDatabase(user); // 到这一层才发现 age 是 -5
}
```bash
```java
// 好:方法入口就拦住
static void register(String name, int age) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("用户名不能为空");
}
if (age < 0 || age > 150) {
throw new IllegalArgumentException("年龄超出范围:" + age);
}
User user = new User();
user.setName(name);
user.setAge(age);
saveToDatabase(user);
}
第二种写法的好处:异常栈直接指向调用方那一行,一眼看出谁传了脏数据。第一种得从数据库层往回追好几层。
参数校验写在方法最开头,这是一条几乎无脑正确的规则。
JDK 提供了现成的工具方法,能少写几行:
import java.util.Objects;
static void process(String data) {
// null 就抛 NPE,消息是第二个参数
Objects.requireNonNull(data, "data 不能为 null");
System.out.println(data.trim());
}
```bash
## 晚捕:在能真正处理的地方才捕获
「晚捕」的意思是:**别在半路上乱捕,让异常传到那个真正知道该怎么办的层**。
什么叫「知道该怎么办」?举例说明。
数据访问层查数据库失败,它能做什么?重试?决定不了。返回默认值?不知道业务需要什么默认值。**它什么都做不了,就该让异常往上传**。
到了 Web 控制器层,就能做事了:把异常转成 HTTP 500,返回一个友好提示给前端。这里才是捕获的地方。
```java
// 数据层:不捕获,让异常传上去
class UserRepository {
User findById(String id) throws SQLException {
// 查库,出错直接抛
return doQuery(id);
}
}
// 服务层:包装成业务异常,加上业务上下文
class UserService {
private final UserRepository repo = new UserRepository();
User getUser(String id) {
try {
return repo.findById(id);
} catch (SQLException e) {
throw new UserQueryException("查询用户失败,id=" + id, e);
}
}
}
// 控制器层:真正处理,转成给用户看的响应
class UserController {
private final UserService service = new UserService();
String handle(String id) {
try {
User u = service.getUser(id);
return "成功:" + u;
} catch (UserQueryException e) {
logger.error("用户查询失败", e); // 记完整日志
return "系统繁忙,请稍后重试"; // 给用户看的话
}
}
}
三层各司其职:底层抛,中层包装加上下文,顶层处理并记录。
Tip判断该不该在这里捕获,问自己一句:捕获之后我能做什么有意义的事吗? 答不上来就别捕,让它继续往上走。
记日志:只记一次,记全一点
最常见的日志反模式是同一个异常记了好几遍。
// 反模式:一路记,日志爆炸
class Layer1 {
void doWork() {
try {
layer2.doWork();
} catch (Exception e) {
logger.error("layer1 出错", e); // 第一遍
throw e;
}
}
}
class Layer2 {
void doWork() {
try {
layer3.doWork();
} catch (Exception e) {
logger.error("layer2 出错", e); // 第二遍
throw e;
}
}
}
```bash
一个错误在日志里出现三次,每次都带着几十行栈。日志文件迅速膨胀,排查时还得费劲判断这三条到底是一个错还是三个错。
**规则:谁最终处理,谁记录日志。中间层只包装、只往上抛,不记日志。**
```java
// 中间层:只包装,不记日志
try {
repo.findById(id);
} catch (SQLException e) {
throw new UserQueryException("查询用户失败,id=" + id, e);
}
// 最上层:记一次完整的
try {
service.getUser(id);
} catch (UserQueryException e) {
logger.error("处理用户请求失败, id={}", id, e); // 就这一次
return errorResponse();
}
别用 printStackTrace
练习代码里用 e.printStackTrace() 没问题,但正式项目里必须换成日志框架。
三个理由。
printStackTrace() 输出到标准错误流,不进日志文件。线上服务器上这些输出很可能直接丢失。
它没有时间戳、没有日志级别、没有线程名,跟其他日志混在一起完全对不上号。
它不受日志级别控制,想在生产环境关掉都关不掉。
// 练习可以
catch (IOException e) {
e.printStackTrace();
}
// 正式代码用这个(以 SLF4J 为例)
catch (IOException e) {
logger.error("读取配置文件失败: {}", path, e);
}
```bash
注意 `logger.error` 的写法:**异常对象放最后一个参数,不要用字符串拼接**。
```java
// 错:只有消息,栈信息丢了
logger.error("出错了:" + e.getMessage());
// 对:完整栈都进日志
logger.error("出错了", e);
异常消息要能定位问题
异常消息的读者是几个月后半夜被叫起来排查故障的人,可能就是你自己。
// 无效消息
throw new IllegalArgumentException("参数错误");
throw new RuntimeException("失败");
throw new IllegalStateException("状态不对");
// 有效消息:说清哪个东西、什么值、期望什么
throw new IllegalArgumentException("折扣率必须在 0.0-1.0 之间,实际收到:" + rate);
throw new IllegalStateException("连接已关闭,无法发送消息,connectionId=" + id);
throw new IllegalArgumentException("orderId 不能为空");
```bash
标准是:**看到这条消息,不翻代码也能猜到问题在哪**。
> [!WARNING]
> 消息里不要放密码、令牌、身份证号这类敏感信息。异常日志经常被广泛采集和传阅,泄露风险很高。
## 抛具体的,别抛笼统的
抛异常时优先选最贴切的类型,不要图省事抛父类。
```java
// 不好:调用方分不清是什么问题
throw new RuntimeException("转换失败");
throw new Exception("出错");
// 好:类型本身就是信息
throw new NumberFormatException("无法解析为数字:" + s);
throw new IllegalStateException("订单已支付,不能重复支付");
捕获时反过来,也要捕具体的:
// 不好:把 NPE 这种 bug 也一起吞了
try {
parseConfig();
} catch (Exception e) {
logger.error("配置解析失败", e);
}
// 好:只接自己预期的
try {
parseConfig();
} catch (IOException | NumberFormatException e) {
logger.error("配置解析失败", e);
}
```bash
**只有在系统最外层做兜底时,才适合 `catch (Exception e)`**,比如 Web 框架的全局异常处理器。
## 不用异常做流程控制
异常是给「意外情况」准备的,不是给正常分支用的。
```java
// 反模式:拿异常当 if 用
static boolean isNumber(String s) {
try {
Integer.parseInt(s);
return true;
} catch (NumberFormatException e) {
return false;
}
}
功能上没错,但如果这个方法在循环里被调用几万次,而且大部分输入都不是数字,性能会很难看。
// 改进:先用低成本判断挡掉大部分情况
static boolean isNumber(String s) {
if (s == null || s.isEmpty()) {
return false;
}
for (int i = 0; i < s.length(); i++) {
if (!Character.isDigit(s.charAt(i))) {
return false;
}
}
return true;
}
```bash
判断标准:**这件事在正常业务流程里会频繁发生吗?** 会,就用条件判断;只在极少数意外时发生,才用异常。
## 异常到底有多慢
坊间常说「异常性能差」,但要说清慢在哪。
慢的是**填充调用栈**(`fillInStackTrace`),这一步要遍历整个方法调用链。异常对象越深、栈越长,越慢。
不慢的是 `try...catch` 结构本身。**没有异常抛出时,`try` 块的开销接近于零**。JVM 用的是异常表机制,正常路径上不做任何额外检查。
结论有两条。
第一,**放心用 `try...catch` 包代码,不抛异常就不花钱**。
第二,**别在高频循环里疯狂抛异常**。如果确实需要,可以重写 `fillInStackTrace()` 返回 `this` 来跳过栈填充,不过这属于极端优化,一般项目用不上。
## 不要把异常存成静态变量
有人为了省对象创建开销,把异常做成常量复用:
```java
// 反模式
private static final RuntimeException TIMEOUT =
new RuntimeException("超时");
void doWork() {
if (isTimeout()) {
throw TIMEOUT; // 栈信息永远是类初始化时的那一份
}
}
问题在于异常对象的调用栈是创建时记录的,不是抛出时。这个常量的栈永远指向类加载那一刻,跟实际出错位置完全无关,比没有栈还误导人。
每次都 new 一个新的异常对象,这点开销完全值得。
try 块要小
try 包住的代码越少越好。
// 不好:try 包了一大坨,出了异常不知道是哪一句
try {
Config c = loadConfig();
Connection conn = connect(c);
List<Data> data = query(conn);
process(data);
save(data);
} catch (Exception e) {
logger.error("处理失败", e);
}
```bash
包这么大,`catch` 里根本没法针对性处理,只能笼统地记一句「处理失败」。
```java
// 好:只包真正需要处理的那一段
Config c;
try {
c = loadConfig();
} catch (IOException e) {
logger.warn("配置读取失败,使用默认配置", e);
c = Config.defaults(); // 有针对性的补救
}
// 其他部分让异常自然往上传
Connection conn = connect(c);
List<Data> data = query(conn);
process(data);
save(data);
清单速查
把上面的准则压缩成一份检查表:
| 场景 | 做法 |
|---|---|
| 方法入口 | 校验参数,不合法立刻抛 |
| 底层技术异常 | 不捕获,让它往上传 |
| 中间业务层 | 包装成业务异常,带上 cause 和上下文,不记日志 |
| 最外层 | 捕获、记一次完整日志、返回友好提示 |
| 日志写法 | logger.error("消息", e),异常放最后一个参数 |
| 异常消息 | 带上出错的实际值,不含敏感信息 |
| catch 类型 | 捕具体的;只有全局兜底才用 Exception |
| finally | 只做清理,不写 return、不 throw |
| 资源关闭 | 一律用 try-with-resources |
| 高频判断 | 用 if,不用异常 |
小结
早抛:参数一进方法就校验,错了立刻抛,让栈信息直指调用方。
晚捕:捕获点放在真正能处理的那一层,中间层只包装不吞。
日志只记一次,记在最终处理的地方,用日志框架而不是 printStackTrace()。
try 块尽量小,catch 类型尽量具体,异常消息尽量具体。异常留给意外情况,正常分支用 if。