别再满屏 try-catch 了:聊聊异常处理的正确姿势

一个真实场景引发的思考

前几天 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;
}

典型场景:NullPointerExceptionOutOfMemoryError、数据库连接池耗尽等。这类异常通常需要告警,由开发人员介入排查。

第二层:业务异常(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 块之前,先问自己三个问题:这是什么类型的异常?调用方需要知道什么?系统应该怎么恢复? 想清楚这三个问题,你的异常处理就不会差。

相关推荐
葡萄城技术团队43 分钟前
ERP 内置 BI(上):为什么业务部门还在用 Excel 做分析?
前端
用户645965987108843 分钟前
Jenkins CI/CD 实战:Vite 前端发布、Koa + PM2 后端部署、权限隔离与远程发布
前端
deli0070071 小时前
技术案例:使用华为云码道(CodeArts)智能体开发鸿蒙徒步行程记录App
前端
计算机魔术师1 小时前
Anthropic 详解 7·30 安全事件:配置错误致 Claude 访问真实系统,已加强沙箱隔离与实时监控
前端
zlwool1 小时前
图纸管理选型:从变更频率到齐套率
java·前端·python·erp·设备erp·非标机械
阿图灵1 小时前
MakerHub 开发报告:v1.0.0 → v1.1.0(单日 26 提交,图片渲染、目录跟随与数据真实化)
前端·vue·个人网站·deepseek·开发报告
程序员小八7772 小时前
Go Web 工程化:日志、配置与错误处理中间件,让服务「能上线」
前端·中间件·golang
SquabbyZhu2 小时前
从 4 个 URL 到 1 个入口:Peaks-Loop 驱动的微前端聚合实践
前端
Hilaku2 小时前
技术好就能升职是前端圈最大的谎言!
前端·javascript·程序员