throws 还是 try-catch?Code Review 里被我打回最多的异常处理

0 阅读9分钟

#Java #代码规范 #架构设计 #避坑指南

上周 Code Review,一个实习生把整个 Service 层的异常全 catch 了,catch 块里只有一行 e.printStackTrace()。我问他为什么,他说:"老师说异常要处理,我处理了啊。" 那一刻我理解了为什么线上出了 bug 永远查不到根因。


先说结论

异常处理就两个选择,选错了代价完全不同:

异常发生了
  │
  ├─ try-catch  → 自己消化,调用者感知不到
  │                选错了 → 静默吞异常,线上埋雷
  │
  └─ throws     → 抛给调用者,让上级决定
                   选错了 → 框架层崩溃,用户看到500

大部分人的纠结本质上是一个问题:这个异常,到底谁来处理更合适?


一、先搞清楚 Java 异常的分类

很多人写了三年 Java,异常体系都没理清:

                    Throwable
                   /         \
              Error         Exception
           (不用管)        /          \
                    受检异常          RuntimeException
                 (编译器逼你处理)      (非受检异常)
                 IOException          NullPointerException
                 SQLException         IndexOutOfBoundsException
                 FileNotFoundException

关键区分:

类型编译器态度你必须做的事
受检异常编译不通过catch 或 throws,二选一
非受检异常编译不管想处理就处理,不想处理就不管
Error编译不管不要处理,处理了也没用

受检异常是 Java 设计上最有争议的一个决策。有观点认为,如果重来,设计者可能不会引入受检异常。


二、什么时候用 throws

原则:调用者比你更清楚该怎么处理

场景 1:异常是业务流程的一部分

//  错误写法:自己 catch 了,调用者完全不知道连接失败
public static Connection getConnection() {
    try {
        return ds.getConnection();
    } catch (SQLException e) {
        e.printStackTrace();
        return null;  //  调用者拿到 null,后面全是 NPE
    }
}

//  正确写法:抛出去,让调用者决定是重试还是终止
public static Connection getConnection() throws SQLException {
    return ds.getConnection();
}

为什么 getConnection() 该 throws?因为调用者必须知道"连接是否成功",才能决定下一步是重试、降级还是直接报错。你把异常吞了,调用者拿到一个 null,后面每一步都是 NPE,真正的异常源头被你埋掉了

场景 2:你是工具方法,不该替别人做决定

//  工具类方法:不知道调用者想怎么处理,所以抛出去
public static User findById(int id) throws SQLException {
    String sql = "select * from user where id = ?";
    // ...
}

// 调用者 A:找不到直接抛异常 → 合理
// 调用者 B:找不到返回默认值 → 也合理
// 你作为工具方法,不应该替调用者做选择

场景 3:异常需要逐层上报,最顶层统一处理

// Dao 层 → throws
public User queryById(int id) throws SQLException {
    // 只管查,出了问题往上抛
}

// Service 层 → throws
public User getUser(int id) throws SQLException {
    return userDao.queryById(id);  // 继续往上抛
}

// Controller 层 → 统一兜底
public Response handleRequest(int id) {
    try {
        User user = userService.getUser(id);
        return Response.success(user);
    } catch (SQLException e) {
        log.error("查询用户失败, id={}", id, e);  //  这里统一记录日志
        return Response.error("系统繁忙");
    }
}

每一层都不处理,一直抛到最顶层统一 catch —— 这是 Spring、MyBatis 等框架的标准做法。Service 层一旦自己 catch 了,上层就永远感知不到问题。


三、什么时候用 try-catch

原则:你比调用者更清楚该怎么处理,或者调用者根本处理不了

场景 1:你有明确的 fallback 策略

//  数据库读不了?从缓存读,调用者不需要知道底层出了什么问题
public String getConfig(String key) {
    try {
        return readFromDatabase(key);
    } catch (SQLException e) {
        log.warn("数据库读取失败,降级到缓存, key={}", key);
        return readFromCache(key);  //  有备用方案
    }
}

这里的 try-catch 是有价值的:你 catch 了,但你不是吞掉,而是走了备用方案。调用者只关心"能不能拿到配置",不关心底层走了数据库还是缓存。

场景 2:异常发生在这里,也只有这里能处理

//  关闭资源的操作
public static void close(ResultSet rs, Statement stmt, Connection conn) {
    try {
        if (rs != null) rs.close();
        if (stmt != null) stmt.close();
        if (conn != null) conn.close();
    } catch (SQLException e) {
        throw new RuntimeException("关闭资源失败", e);
        //  关闭失败是严重问题,但调用者也处理不了
        //    所以转成 RuntimeException 强制终止
    }
}

关不掉连接你让调用者怎么办?调用者能做的也就是重新关一次。这种情况,catch 之后转 RuntimeException 是合理选择。

场景 3:语法上必须处理

//  FileInputStream 构造器声明了 throws FileNotFoundException
//    不 catch 就编译不过,没有选择余地
public static Properties loadConfig() {
    Properties props = new Properties();
    try {
        props.load(new FileInputStream("config.properties"));
    } catch (FileNotFoundException e) {
        // 文件不存在,用默认配置(返回空 Properties,业务层按默认值处理)
        log.warn("配置文件不存在,使用默认配置");
    } catch (IOException e) {
        // 读取失败,这个必须终止
        throw new RuntimeException("读取配置文件失败", e);
    }
    return props;
}

注意这里有个细节:同一个方法里,不同的异常可以有不同的处理策略FileNotFoundException 有备用方案所以 catch 吞掉,IOException 是意外所以转 RuntimeException。

场景 4:静态代码块(语法限制)

//  静态代码块不能 throws,语法不允许
static {
    try {
        ds = DruidDataSourceFactory.createDataSource(properties);
    } catch (Exception e) {
        throw new ExceptionInInitializerError(e);
        //  用 ExceptionInInitializerError 包装,比吞掉强
    }
}

静态代码块里 throw 只能抛 RuntimeExceptionError,不能抛受检异常。ExceptionInInitializerErrorError 的子类,语法上合法。


四、throws 和 try-catch 能交叉使用吗?

能,但要分情况。四种组合只有一种没意义:

组合意义说明
Utils throws → 调用者 try-catch✅ 最常见,异常传递畅通工具方法抛出,调用者按需处理
Utils throws → 调用者 throws✅ 异常链,继续往上抛层层上抛,最顶层统一兜底
Utils try-catch → 调用者 try-catch✅ 各管各的异常Utils 处理自己的异常,调用者处理可能出现的 null 等后续问题
Utils try-catch → 调用者 throws⚠️ 能编译,但没意义Utils 已吞掉异常,调用者的 throws 等不到任何异常,形同虚设

核心规律:异常只能从 throws 端传递到 try-catch 端,不能反向传递。Utils 吞了异常,调用者就接不到了。因此工具方法通常用 throws,让调用者决定怎么处理。


五、决策流程图

下次写代码之前,对着这张图走一遍:

异常发生了
  │
  ├─ 语法上必须处理吗?(受检异常,编译器报红)
  │   ├─ 是 ↓
  │   │   ├─ 你有备用方案? → try-catch + fallback
  │   │   ├─ 调用者能处理? → throws
  │   │   └─ 都不能?      → catch + RuntimeException
  │   └─ 否(非受检异常)↓
  │       ├─ 是编程错误(NPE/越界)? → 不要 catch,修复代码
  │       └─ 是业务异常?            → 抛出自定义 RuntimeException(如 BusinessException)

六、Code Review 高频踩坑点

踩坑 1:catch 吞异常

//  这是 Code Review 里我打回最多的写法
try {
    doSomething();
} catch (Exception e) {
    // 什么都不做
}

// 后果:线上出了问题,日志里干干净净,排查无从下手
//  至少要打日志
try {
    doSomething();
} catch (Exception e) {
    log.error("操作失败", e);  //  打日志,保留现场
    throw new RuntimeException(e);
}

踩坑 2:catch 了又原样抛出

//  没有任何意义
try {
    doSomething();
} catch (SQLException e) {
    throw e;  //  catch 了又原样抛出,不如直接 throws
}

//  要么处理,要么转换
try {
    doSomething();
} catch (SQLException e) {
    throw new BusinessException("操作失败", e);  //  包装成业务异常
}

要么处理,要么转换。转换的意义不仅在于包装错误信息,更在于防止抽象泄露。不要让底层的 SQLException 污染到全都是业务逻辑的 Service 层,用自定义的 RuntimeException 斩断这种技术栈耦合。在 Spring 项目中,转换后的自定义异常最终由 @ControllerAdvice 统一处理,形成完整的异常处理闭环。

踩坑 3:catch Exception 吃掉所有异常

//  NPE、越界、业务异常全部被吞
try {
    doSomething();
} catch (Exception e) {
    log.error("出错了", e);
    // 然后继续往下执行,仿佛什么都没发生
}

//  只 catch 你预期的异常
try {
    doSomething();
} catch (SQLException e) {
    // 只处理数据库异常
} catch (FileNotFoundException e) {
    // 只处理文件不存在
}
// 其他异常正常往上抛,不要 catch Exception

踩坑 4:finally 里抛异常

//  finally 里的异常会覆盖 try 块里的异常
Connection conn = null;
try {
    conn = dataSource.getConnection();
    // 业务逻辑...
} finally {
    if (conn != null) {
        conn.close();  //  如果这里抛异常,try 块里的异常就丢了
    }
}

//  用 try-with-resources(JDK 7+)
try (Connection conn = dataSource.getConnection();
     PreparedStatement ps = conn.prepareStatement(sql)) {
    // 业务逻辑...
    // 自动关闭,不会吞异常
}

既然提到了 try-with-resources 和底层资源的 close() 释放,这里面其实藏着极其容易翻车的致命陷阱如果你对 Java 基础的底层资源边界还不够清晰,强烈建议顺手补课本系列的高赞排雷文: 👉 《以为用了 try-with-resources 就稳了?这三个底层漏洞让TCP双向通讯直接卡死》 👉 《线上日志被清空?这段仅10行的 IO 代码里竟然藏着3个毒瘤》

踩坑 5:Utils 吞异常,调用者 throws 空等

//  Utils 吞了异常
public static Connection getConnection() {
    try {
        return ds.getConnection();
    } catch (SQLException e) {
        e.printStackTrace();
        return null;
    }
}

//  调用者 throws,但等不到异常
public void doSomething() throws SQLException {
    Connection conn = JDBCUtilsByDruid.getConnection();
    // throws 声明形同虚设,异常到不了这里
}

//  Utils throws,调用者 try-catch
public static Connection getConnection() throws SQLException {
    return ds.getConnection();
}

public void doSomething() {
    try {
        Connection conn = JDBCUtilsByDruid.getConnection();
    } catch (SQLException e) {
        // 异常正常传递到这里
    }
}

七、生产项目中的分层异常策略

┌─────────────────────────────────────────────────┐
│  Controller 层                                   │
│  try-catch → 统一兜底,返回友好响应              │
│  "系统繁忙,请稍后再试" + 记录完整日志            │
├─────────────────────────────────────────────────┤
│  Service 层                                      │
│  throws → 业务异常往上抛                         │
│  try-catch → 只处理有 fallback 的情况             │
├─────────────────────────────────────────────────┤
│  Dao 层                                          │
│  throws → 数据库异常往上抛                       │
│  这一层不该处理异常,只负责操作数据库              │
├─────────────────────────────────────────────────┤
│  Utils 工具层                                    │
│  throws → 工具方法不替调用者做决定               │
└─────────────────────────────────────────────────┘

Spring Boot 项目里通常有一个全局异常处理器 @ControllerAdvice,所有未被 catch 的异常都会汇聚到这里,统一处理、统一格式化响应。这就是 throws 策略的终极形态。


八、面试防御

面试官问:"try-catch 和 throws 怎么选?"

高分回答:

核心判断标准是谁更有能力处理这个异常

如果当前方法有明确的 fallback 策略(比如降级到缓存),就 try-catch。如果当前方法只是工具方法,不知道调用者想怎么处理,就 throws,把决定权交给调用者。

另外要注意异常传递的方向:异常只能从 throws 端传递到 try-catch 端。如果 Utils 层已经 catch 吞掉了异常,调用者写的 throws 声明就形同虚设。所以工具方法通常用 throws,让异常传递链保持畅通。

特别要注意的是,catch 之后不能吞异常,至少要打日志。我见过太多线上问题因为异常被吞掉,导致排查时完全没有线索。生产项目通常会在最顶层做统一异常处理,比如 Spring 的 @ControllerAdvice,这样 Service 和 Dao 层就可以放心 throws,保持代码干净。


你在 Code Review 中见过哪些“诡异”的异常处理?欢迎在评论区分享,一起避坑。