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

相关推荐
子兮曰16 小时前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
子兮曰16 小时前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
前端小万16 小时前
写公众号赚了 3000 块后,我做了一款叫 "一键成稿" 的软件
前端·微信小程序
爱勇宝16 小时前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
三十而立洋17 小时前
Cookie 详解:从产生到安全,一次讲透
前端·javascript
卡布鲁17 小时前
把一个 Vite + Vue3 应用塞进 qiankun (React + Umi3) 主站:十个坑的复盘
前端·javascript·react.js
汉堡大王952719 小时前
Jev:不是聊天机器人, 而是一个智能 if 语句
前端·人工智能·后端
梦想很大很大19 小时前
从运行事实到回归证据:Workrun 的 Telemetry 与 Evaluation 实践
前端·人工智能·后端
计算机魔术师19 小时前
Meta Muse agent 接入 Shopify 的 Shop Pay 实现代理式购物
前端
沙蒿同学20 小时前
我用 Go 搭了一条 AI Agent 流水线:从 1 张商品图到一整套淘宝详情页
前端·javascript·后端