一个真实场景引发的思考
前几天 Review 同事的代码,看到一个典型的写法:
kotlin
public User getUser(Long id) {
try {
return userRepository.findById(id).orElse(null);
} catch (Exception e) {
log.error("查询用户失败", e);
return null;
}
}
调用方拿到 null 之后:
sql
User user = userService.getUser(id);
if (user != null) {
// 正常逻辑
} else {
// 用户不存在?数据库异常?超时?不知道,反正就是 null
}
这段代码看起来"很安全"------有 try-catch,有日志,有兜底。但仔细想想,它把三种完全不同的情况 (用户不存在、数据库连接失败、查询超时)压成了一个 null,调用方根本无法区分,也无法做出正确的处理。
这不是异常处理,这是异常吞没。
类似的问题在项目中非常普遍。今天从工程实践的角度,聊聊异常处理中常见的误区和正确的设计思路。
误区一:把异常当"流程控制"用
最常见的反模式之一:用异常来控制业务逻辑的走向。
java
try {
User user = userRepository.findById(id);
// 用户存在,走正常逻辑
} catch (UserNotFoundException e) {
// 用户不存在,走注册逻辑
}
异常的设计初衷是处理非预期的、异常的情况,而不是作为常规的分支判断。用异常做流程控制有两个问题:
- 性能开销:JVM 在抛出异常时需要生成完整的调用栈快照,这个操作的成本远高于普通的条件判断;
- 语义混乱:阅读代码的人无法快速分辨哪些异常是"预期内的业务分支",哪些是"真正的错误"。
正确的做法是用返回值或 Optional 表达"可能不存在"这种预期内的情况:
scss
Optional<User> user = userRepository.findById(id);
if (user.isPresent()) {
// 正常逻辑
} else {
// 用户不存在,走注册逻辑
}
原则:异常处理"异常",返回值处理"预期"。
误区二:catch 了 Exception,却不区分类型
kotlin
try {
// 业务逻辑
} catch (Exception e) {
log.error("操作失败", e);
return Result.fail("系统异常");
}
这个 catch (Exception e) 像一个黑洞,把所有异常------无论是业务异常、数据库异常、网络超时、空指针------全部吞掉,统一返回一个"系统异常"。
问题在于:
- 调用方无法区分:是参数校验失败?是数据库宕机?是第三方接口超时?不同的异常需要不同的处理策略,但调用方拿到的永远是同一个模糊的错误码;
- 排查困难:日志里虽然有堆栈,但返回给前端或上游服务的只有一个笼统的"系统异常",排查链路断裂;
- 掩盖真实问题 :一个
NullPointerException和一个ConnectionTimeoutException被同等对待,真正的 Bug 可能在这个catch块里被悄悄隐藏。
正确的做法是按异常类型分层处理:
kotlin
try {
// 业务逻辑
} catch (BusinessException e) {
// 业务异常:参数校验失败、业务规则不满足等
return Result.fail(e.getCode(), e.getMessage());
} catch (RepositoryException e) {
// 数据层异常:数据库连接失败、查询超时等
log.error("数据层异常: {}", e.getMessage(), e);
return Result.fail("SYSTEM_ERROR", "数据服务暂时不可用");
} catch (Exception e) {
// 兜底:未预期的异常
log.error("未预期异常", e);
return Result.fail("SYSTEM_ERROR", "系统内部错误");
}
误区三:只记日志,不做任何处理
php
try {
orderService.createOrder(request);
} catch (Exception e) {
log.error("创建订单失败", e);
// 然后呢?没了。调用方不知道失败了。
}
日志记了,但调用方完全不知道操作失败了。如果是创建订单这种关键操作,用户以为下单成功了,实际上订单根本没有创建,这就是数据不一致的根源。
异常处理不是"记个日志就完事了",你需要明确回答一个问题:异常发生后,调用方应该怎么知道?系统应该怎么恢复?
误区四:在底层框架层抛出业务异常
sql
// 数据库访问层
public User findById(Long id) {
User user = jdbcTemplate.queryForObject(sql, ...);
if (user == null) {
throw new UserNotFoundException("用户不存在"); // 问题:DAO 层不应该知道"用户"这个业务概念
}
return user;
}
DAO 层的职责是数据访问,它不应该感知"用户不存在"这种业务语义。如果 DAO 层抛出了 UserNotFoundException,就意味着 DAO 层和业务层产生了反向依赖,违反了分层架构的基本原则。
正确的做法是:
- DAO 层 抛出通用的数据访问异常(如
DataAccessException); - Service 层 捕获数据访问异常,根据业务语义转换为业务异常(如
UserNotFoundException); - Controller 层捕获业务异常,转换为统一的 HTTP 响应。
kotlin
// DAO 层:只关心数据访问
public User findById(Long id) {
return jdbcTemplate.queryForObject(sql, ...); // 查不到就抛 DataAccessException
}
// Service 层:负责业务语义转换
public User getUser(Long id) {
try {
return userRepository.findById(id);
} catch (DataAccessException e) {
throw new UserNotFoundException("用户不存在: " + id);
}
}
// Controller 层:负责 HTTP 响应映射
@GetMapping("/users/{id}")
public ResponseEntity<UserVO> getUser(@PathVariable Long id) {
try {
User user = userService.getUser(id);
return ResponseEntity.ok(UserVO.from(user));
} catch (UserNotFoundException e) {
return ResponseEntity.status(404).body(null);
}
}
每一层只处理自己职责范围内的异常,层层转换,职责清晰。
正确姿势:构建结构化的异常体系
一个成熟的工程项目,应该有一套清晰的异常分类体系。以下是推荐的三层结构:
第一层:系统异常(SystemException)
代表系统级的、非预期的错误,通常意味着程序 Bug 或基础设施故障。
scala
public class SystemException extends RuntimeException {
private final String errorCode;
private final String errorMessage;
}
典型场景:NullPointerException、OutOfMemoryError、数据库连接池耗尽等。这类异常通常需要告警,由开发人员介入排查。
第二层:业务异常(BusinessException)
代表业务规则校验不通过或业务状态不允许操作,是预期内的、可恢复的错误。
scala
public class BusinessException extends RuntimeException {
private final String errorCode; // 如 "USER_NOT_FOUND", "ORDER_ALREADY_PAID"
private final String errorMessage; // 如 "用户不存在", "订单已支付"
}
典型场景:参数校验失败、库存不足、订单状态不允许取消等。这类异常应该返回明确的错误码和错误信息给调用方,由调用方决定如何处理。
第三层:第三方异常(IntegrationException)
代表调用外部依赖(第三方 API、消息队列、缓存等)时发生的错误。
scala
public class IntegrationException extends RuntimeException {
private final String targetService; // 如 "payment-service", "sms-gateway"
private final String errorCode;
}
典型场景:调用支付接口超时、短信网关返回错误等。这类异常通常需要配合重试、降级、熔断策略来处理。
全局异常处理器
在 Controller 层统一捕获并转换为标准的 HTTP 响应:
java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(BusinessException e) {
return ResponseEntity.status(400)
.body(new ErrorResponse(e.getErrorCode(), e.getErrorMessage()));
}
@ExceptionHandler(IntegrationException.class)
public ResponseEntity<ErrorResponse> handleIntegration(IntegrationException e) {
log.error("第三方服务调用失败: {}", e.getTargetService(), e);
return ResponseEntity.status(503)
.body(new ErrorResponse("SERVICE_UNAVAILABLE", "服务暂时不可用"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleSystem(Exception e) {
log.error("系统异常", e);
return ResponseEntity.status(500)
.body(new ErrorResponse("INTERNAL_ERROR", "系统内部错误"));
}
}
几个实操建议
1. 优先使用 Checked Exception 还是 Unchecked Exception?
Java 社区对此争论多年。务实的建议是:
- 业务异常用 Unchecked(RuntimeException 子类) :因为业务异常是调用方"应该预料到并处理"的,强制 catch 反而导致大量无意义的 try-catch 嵌套;
- 可恢复的基础设施异常用 Checked :如
IOException,强制调用方显式处理。
2. 异常信息要包含上下文
arduino
// 不好:不知道是哪个用户、什么操作
throw new BusinessException("操作失败");
// 好:包含关键上下文,排查时一目了然
throw new BusinessException(String.format(
"订单支付失败: orderId=%s, userId=%s, amount=%s, reason=余额不足",
orderId, userId, amount
));
3. 不要丢失原始异常链
csharp
// 错误:原始异常信息丢失
catch (SQLException e) {
throw new BusinessException("数据库操作失败");
}
// 正确:保留异常链,排查时能看到完整的调用栈
catch (SQLException e) {
throw new BusinessException("数据库操作失败", e); // 传入 cause
}
4. 区分"可重试"和"不可重试"
不是所有异常都值得重试。网络超时、临时限流可以重试;参数校验失败、权限不足重试一万次也没用。在异常体系中应该明确标识:
scala
public class IntegrationException extends RuntimeException {
private final boolean retryable; // 是否可重试
}
配合重试框架(如 Spring Retry、Resilience4j),根据 retryable 标识自动决定是否重试。
小结
异常处理的本质不是"让程序不报错",而是让系统在出错时能够优雅地降级、准确地反馈、快速地恢复。
满屏的 try-catch (Exception e) 不是健壮,是懒惰。好的异常设计应该做到三点:
- 分类清晰:不同类型的异常有不同的处理策略;
- 信息完整:异常消息包含足够的上下文,调用方能据此做出正确决策;
- 职责分明:每一层只处理自己该处理的异常,不越界、不吞没。
下次写 catch 块之前,先问自己三个问题:这是什么类型的异常?调用方需要知道什么?系统应该怎么恢复? 想清楚这三个问题,你的异常处理就不会差。