“抛出早”指问题初现即定性,需携带业务id等可追溯信息;“捕获晚”指在service层按异常类型转业务异常,controller层差异化处理;自定义异常须显式声明serialversionuid=1l;异步异常须用handle/exceptionally捕获或配置asyncuncaughtexceptionhandler。

“抛出早”不是越早 throw 越好,而是问题一露头就定性
很多人误以为“尽早抛出”就是在 if (file == null) 后立刻 throw new IllegalArgumentException(),结果导致异常堆栈浅、上下文空、调用方根本看不出是哪次请求、哪个订单出的问题。
真正该做的,是让异常携带可定位的业务事实——比如在读取订单配置时发现 JSON 格式错误,不要在 ObjectMapper.readValue() 外层直接包装成 RuntimeException,而应在 DAO 或 Service 入口处,捕获 JsonProcessingException 后立即封装为 OrderConfigParseException(orderId, rawJson),并确保构造函数里把 orderId 存为 final 字段。
- 错误做法:
catch (Exception e) { throw new RuntimeException("parse failed"); }—— 丢原始异常、丢参数、丢堆栈 - 正确做法:
catch (JsonProcessingException e) { log.error("Failed to parse order config for {}, raw: {}", orderId, rawJson.substring(0, Math.min(100, rawJson.length())), e); throw new OrderConfigParseException(orderId, e); } - 关键点:抛出前必须
log.error(..., e),且日志参数要包含 ID、输入片段等可追溯字段
“捕获晚”不等于全扔给 Controller,而是卡在能决策的位置
捕获太早(比如在工具类里 try-catch 吞掉 IOException 并返回 null),调用方收不到失败信号;捕获太晚(比如整个 Spring Boot 应用只配一个 @ControllerAdvice 统一兜底),又失去了按业务场景差异化处理的能力。
理想位置是:Service 层捕获数据访问异常,转为带语义的业务异常;Controller 层再捕获这些业务异常,决定是返回 400 还是 500,是否重试,要不要告警。
- DAO 层:不捕获,也不声明
throws SQLException,而是让 MyBatis 自动转成DataAccessException向上抛 - Service 层:捕获
DataAccessException,判断是主键冲突(DuplicateKeyException)还是连接超时(CannotGetJdbcConnectionException),分别转成OrderAlreadyExistsException或DatabaseUnavailableException - Controller 层:用
@ExceptionHandler分别处理这两类异常,前者返回 HTTP 409,后者触发熔断并记录告警
自定义异常不写 serialVersionUID,集群环境下可能静默失败
这个坑特别隐蔽:本地单机跑得好好的,一上 K8s 就出现某些异常无法反序列化,日志里连堆栈都不打,只有 InvalidClassException 模糊提示。原因就是没显式声明 serialVersionUID,JVM 在不同节点生成的默认值不一致。
只要你的异常类可能跨 JVM 传递(比如被 Feign 调用、存入 Redis、或经由 RocketMQ 发送),就必须加:
private static final long serialVersionUID = 1L;
而且不能用 IDE 自动生成的随机数——那每次改类都会变。固定写 1L 或带业务版本号的值(如 102601L 表示 2026.01 版本),才是生产可用的做法。
异步场景下 CompletableFuture 的异常会丢失
supplyAsync 抛出的异常不会打断主线程,也不会进全局异常处理器,更不会触发 log.error——除非你显式处理。
- 错误写法:
CompletableFuture.supplyAsync(() -> riskyParse(json)).thenAccept(this::save)—— 异常被吞,save根本不执行,也没日志 - 正确写法:
CompletableFuture.supplyAsync(() -> riskyParse(json)).handle((result, ex) -> { if (ex != null) { log.error("Async parse failed for json: {}", json.substring(0, 50), ex); throw new CompletionException(ex); } else { save(result); return null; } }) - 更稳妥方案:统一用
Thread.setDefaultUncaughtExceptionHandler做兜底,但仅限于未被 handle/exceptionally 捕获的线程内异常
最常被忽略的一点:Spring 的 @Async 方法如果抛出异常,且没有配置 AsyncUncaughtExceptionHandler,异常就真的消失了——连线程池的 reject 日志都不会有。










