如何优雅处理Java异常-这几种实战方案让代码更健壮

25 阅读11分钟

一、 先说痛点:这些异常处理方式,你是不是很熟悉?

先约定一个简单的电商下单链路:

Controller 收下单请求 → Service 做各种校验(库存、余额、风控…) → 远程调用支付/库存等系统 → 结果返回给前端

1–3 年经验的同学,在这条链路里常见的写法大概是:

  • try-catch 到处都是:一层套一层,catch (Exception e) {} 一把梭,逻辑全被包在大括号里,主流程根本看不清。
  • 异常信息模糊:远程调用失败、余额不足、库存不足,全都变成“下单失败”或 e.printStackTrace()
  • 只抛不管:Controller 直接 throws Exception,上层只看到 500,没人知道怎么兜底。
  • Checked / Unchecked 随缘用:要么全 RuntimeException,要么方法签名写满 throws Exception

下面我们就围绕“电商下单”这一条主线,用 4 个实战方案,把异常从底层到前端串起来: 技术异常 → 业务异常 → 全局异常处理器 → 前端响应


二、方案一:在下单链路中划清 Checked / Unchecked 边界

1. 电商场景里的 Checked / Unchecked

  • Checked 异常:继承 Exception,编译器强制处理,适合“外部系统调用失败”,如支付、库存接口超时。
  • Unchecked 异常:继承 RuntimeException,多用于“编程错误/不该发生的状态”,如数量为负、订单状态非法。

2. 坏写法 vs 好写法(调用支付示例)

对比项坏写法好写法
异常类型统一 throws Exception技术异常在边界层转成语义清晰的 Checked 业务异常
调用方感受不知道要不要处理一眼能看出“这是支付失败,需要显式处理”
可维护性日志乱、栈乱按“外部依赖失败”归类,便于监控和重试

坏写法:所有异常混成一锅粥

public class PaymentService {

    public void pay(Long orderId) throws Exception {
        // 调远程支付
        String result = paymentClient.request(orderId);
        if (!"SUCCESS".equals(result)) {
            throw new Exception("支付失败");
        }
    }
}

调用方完全不知道是网络问题还是业务失败,自然也不知道要不要重试、要不要提示用户余额不足。

好写法:边界层转换 + 编程错误用 RuntimeException

// 业务可理解的 Checked 异常:支付失败
public class PaymentFailedException extends Exception {
    public PaymentFailedException(String message) {
        super(message);
    }
}

// 支付客户端:技术异常 → 业务异常(Checked)
public class PaymentClient {

    public String request(Long orderId) throws PaymentFailedException {
        try {
            String result = httpClient.post("/pay", orderId);
            if (!"SUCCESS".equals(result)) {
                throw new PaymentFailedException("支付结果失败");
            }
            return result;
        } catch (IOException e) {
            throw new PaymentFailedException("支付服务不可用,请稍后重试");
        }
    }
}

// 订单服务:显式处理支付失败
public class OrderService {

    public void payOrder(Long orderId) {
        try {
            paymentClient.request(orderId);
        } catch (PaymentFailedException e) {
            // 记录日志,转换为统一的业务异常给上层
            throw new BusinessException("PAYMENT_FAILED", e.getMessage());
        }
    }

    // 编程错误用 RuntimeException 表达
    public void validateQuantity(Integer quantity) {
        if (quantity == null || quantity <= 0) {
            throw new IllegalArgumentException("购买数量必须为正数");
        }
    }
}

小结
外部依赖失败(IO/HTTP)→ 边界层转成 Checked 异常;
业务层再转成 BusinessException
编程错误(参数非法)→ 直接 RuntimeException,这类问题通常靠测试/监控尽早暴露。


三、方案二:用自定义业务异常,把“下单失败”说清楚

在下单场景里,常见业务失败有:余额不足、库存不足、风控拦截
如果写成 return nullthrow new RuntimeException("下单失败"),调用方根本没法区分。

1. 坏写法 vs 好写法(余额不足示例)

对比项坏写法好写法
表达方式return null / 错误码明确的业务异常类,如 InsufficientBalanceException
调用方体验容易忘记判空 / 判码用异常强提醒,必须处理或交给全局异常
日志/排查只能看到 NPE 或统一报错从异常名+消息就能看出“余额不足”

2. 自定义业务异常类(保留原有 BusinessException 思路)

// 通用业务异常
public class BusinessException extends RuntimeException {

    private final String code;

    public BusinessException(String code, String message) {
        super(message);
        this.code = code;
    }

    public String getCode() {
        return code;
    }
}

// 更具体的业务异常:余额不足
public class InsufficientBalanceException extends BusinessException {

    public InsufficientBalanceException(Long userId, BigDecimal currentBalance, BigDecimal needAmount) {
        super("INSUFFICIENT_BALANCE",
                String.format("用户[%d]余额不足,当前:%s,需要:%s",
                        userId, currentBalance, needAmount));
    }
}

3. 服务层在下单时使用

public class OrderService {

    public void placeOrder(Long userId, Long productId, BigDecimal amount) {
        BigDecimal balance = accountService.getBalance(userId);
        if (balance.compareTo(amount) < 0) {
            // 直接抛出业务语义清晰的异常
            throw new InsufficientBalanceException(userId, balance, amount);
        }

        // 其他下单逻辑:扣库存、锁单、生成订单等
    }
}

相比“返回 null / 错误码”:

  • 不会悄悄失败:异常没被捕获就会往上冒,必然有地方兜底;而 null 很容易被忽略,最后变成莫名其妙的 NPE。
  • 业务语义直接暴露:从异常类就能看出“余额不足”“库存不足”,日志和监控可以按异常类型统计。
  • 职责更清晰:服务层只管“发现规则不满足就抛异常”,怎么展示给前端交给全局异常处理器。

四、方案三:全局异常处理器,让前端拿到稳定的错误响应

1. 坏写法 vs 好写法(Controller 层)

对比项坏写法好写法
控制器结构每个方法写 try-catch控制器只写业务流程,异常交给全局处理器
重复度大量复制粘贴捕获日志代码异常处理集中在一个类
前端体验有的返回 JSON,有的 500,有的 HTML响应结构统一:code + message + data

2. 统一返回对象(保留原有 ApiResponse 思路)

public class ApiResponse<T> {

    private String code;
    private String message;
    private T data;

    public static <T> ApiResponse<T> success(T data) {
        ApiResponse<T> resp = new ApiResponse<>();
        resp.code = "SUCCESS";
        resp.message = "OK";
        resp.data = data;
        return resp;
    }

    public static <T> ApiResponse<T> failure(String code, String message) {
        ApiResponse<T> resp = new ApiResponse<>();
        resp.code = code;
        resp.message = message;
        return resp;
    }

    // getter / setter 省略
}

3. 全局异常处理器(补充字段错误提取)

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

import java.util.stream.Collectors;

@Slf4j
@RestControllerAdvice   // 等价于 @ControllerAdvice + @ResponseBody
public class GlobalExceptionHandler {

    // 处理业务异常(下单失败、余额不足等)
    @ExceptionHandler(BusinessException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiResponse<Void> handleBusinessException(BusinessException ex) {
        log.warn("业务异常:code={}, message={}", ex.getCode(), ex.getMessage());
        return ApiResponse.failure(ex.getCode(), ex.getMessage());
    }

    // 处理参数校验异常(数量、收货地址等字段校验失败)
    @ExceptionHandler({MethodArgumentNotValidException.class, BindException.class})
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiResponse<Void> handleValidationException(Exception ex) {
        String message = "参数校验失败";

        if (ex instanceof MethodArgumentNotValidException) {
            message = ((MethodArgumentNotValidException) ex).getBindingResult()
                    .getFieldErrors()
                    .stream()
                    .map(this::formatFieldError)
                    .collect(Collectors.joining("; "));
        } else if (ex instanceof BindException) {
            message = ((BindException) ex).getBindingResult()
                    .getFieldErrors()
                    .stream()
                    .map(this::formatFieldError)
                    .collect(Collectors.joining("; "));
        }

        log.warn("参数校验异常:{}", message, ex);
        return ApiResponse.failure("VALIDATION_FAILED", message);
    }

    private String formatFieldError(FieldError error) {
        return String.format("%s %s (实际值:%s)",
                error.getField(),
                error.getDefaultMessage(),
                error.getRejectedValue());
    }

    // 兜底的系统异常
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ApiResponse<Void> handleException(Exception ex) {
        log.error("系统异常", ex);
        // 对外只暴露通用提示,避免泄露内部信息
        return ApiResponse.failure("INTERNAL_ERROR", "系统开小差了,请稍后再试");
    }
}

4. 一句话“文字流程图”

支付 HTTP 超时(IOException 等技术异常)
→ 在 PaymentClient 转成 PaymentFailedException(Checked)
→ 在 OrderService 转成 BusinessException("PAYMENT_FAILED", …)
→ 被 GlobalExceptionHandler.handleBusinessException 捕获
→ 返回 {"code":"PAYMENT_FAILED","message":"支付服务不可用,请稍后重试"} 给前端

前端只需要根据 code 做不同提示/跳转,不关心后端内部细节


五、方案四:日志要“能查问题”,而不是“陪跑”

1. 坏写法 vs 好写法(处理订单示例)

对比项坏写法好写法
日志内容e.printStackTrace() 或压根不打明确业务场景 + 关键标识(userId、orderId)
查问题体验线上看不到 / 看不懂从一条日志就能大致知道发生了什么
对外信息可能把堆栈原样返回前端对内详细,对外简洁安全

坏写法:吞掉异常或只打印堆栈

try {
    doSomething();
} catch (Exception e) {
    e.printStackTrace(); // 打印到控制台,线上日志系统未必采集到
    // 或者更糟糕:什么都不做
}

这样的结果是:问题发生了你也不知道,用户只是反馈“偶尔有错误”,日志里却什么线索都没有

好写法:有上下文的日志 + 配合 BusinessException

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class OrderService {

    public void processOrder(Long userId, Long orderId) {
        try {
            doProcess(orderId);
        } catch (Exception e) {
            // 日志中记录关键上下文,方便排查
            log.error("处理订单失败,userId={}, orderId={}", userId, orderId, e);
            // 向上抛给全局异常处理器,由它决定返回什么给前端
            throw new BusinessException("ORDER_PROCESS_FAILED", "订单处理失败,请稍后重试");
        }
    }
}

原则小结

  • 不吞异常:记录日志 or 抛给上层,别默默吃掉。
  • 日志写给“人”看:谁在干什么、对哪个对象操作、结果如何。
  • 对外响应只给必要信息,不直接把堆栈抛给用户。

六、异常处理避坑清单(现象 + 危害 + 修复)

坑点典型现象危害修复方案
到处 catch (Exception)任何问题都变成“下单失败”无法快速定位问题业务异常/参数异常/系统异常分别捕获
null 表示失败调用方忘记判空,NPE调试成本高失败用业务异常表示,不再用 null
业务异常返回 500前端误以为服务器挂了用户体验差,告警污染业务异常统一用 4xx,如 400
不打日志或乱打日志没有 userId/orderId排查困难在关键链路统一格式日志
暴露完整堆栈给前端前端直接看到 SQL/路径安全风险堆栈只打日志,对外给通用提示
所有异常都 RuntimeException方法签名看不出会失败可读性差外部依赖失败用 Checked,内部再包成业务异常
每个 Controller 自己处理异常复制粘贴大量 try-catch难以统一改动@RestControllerAdvice 统一收口

七、不同规模项目怎么选

  • 小型单体应用(人少、接口少)

    • 至少有:BusinessException + ApiResponse + GlobalExceptionHandler 三件套。
    • 不用一上来就定义很多子类异常,先保证“都能返回统一 JSON”。
  • 中型电商/业务系统

    • 为核心域(订单、支付、库存)各定义少量关键业务异常,如 InsufficientBalanceExceptionInventoryNotEnoughException
    • 严格使用统一的 ApiResponseGlobalExceptionHandler,便于前端对接和统一监控。
  • 大型分布式系统

    • 异常处理要和错误码规范、链路追踪(TraceId)、重试/补偿机制一起设计。
    • 业务异常需要能直接喂给监控/告警系统,按异常类型出报表。

八、新手落地“三步法”

  1. 先搭全局异常处理骨架

    • 写好 ApiResponseBusinessExceptionGlobalExceptionHandler,保证“任何异常都能有一个统一 JSON 返回”。
  2. 新代码统一用“好写法”

    • 新增接口、改需求时:用自定义业务异常 + 全局异常处理器,不再在 Controller 堆 try-catch
  3. 逐步改造老代码

    • 挑一条关键链路(比如“下单”),从底层客户端开始:
      • 技术异常 → Checked;
      • 服务层 → 业务异常;
      • Controller → 去掉多余 try-catch,交给全局处理器。

九、异常处理最佳实践速查表

场景异常类型处理方式日志规范
请求参数不合法(负数、必填为空)Validation 异常 / IllegalArgumentException由全局异常处理器返回 VALIDATION_FAILED级别 WARN,记录字段名+值
余额不足、库存不足等业务失败自定义业务异常(继承 BusinessException由全局异常处理器返回对应业务错误码WARN,记录 userId、orderId、商品信息
调用支付/库存超时在客户端转为 Checked(如 PaymentFailedException)再包成 BusinessException返回业务错误码(如 PAYMENT_FAILEDERROR,记录外部接口名、耗时、请求参数
不可预期的系统错误(NPE 等)RuntimeException兜底捕获,返回 INTERNAL_ERRORERROR,完整堆栈 + 关键上下文
Controller 内部显式抛出的业务异常BusinessException全局异常处理器统一转换为 JSONWARN,控制台和日志系统一致
批处理/定时任务自定义任务异常或直接 RuntimeException任务框架日志 + 告警必须有任务名、批次号、处理条数

十、小结:从“顺手 try-catch”到“有设计的异常处理”

回顾一下这条“电商下单”的异常链路:

  • 合理划分 Checked / Unchecked:IO、网络这类外部依赖失败,用 Checked 异常在边界层转换为业务可理解的异常;编程错误用 RuntimeException 表达“不该发生”。
  • 用自定义业务异常表达业务语义:余额不足、库存不足、风控拦截,都有自己明确的异常类,而不是一把梭的“下单失败”。
  • 通过全局异常处理器统一收口:让 Controller 只关心业务流程,异常由 @RestControllerAdvice 在一个入口统一转换为标准响应。
  • 为异常提供有用信息与日志:既不吞异常,也不过度暴露内部细节,用恰到好处的上下文帮助排查问题。

真正成熟的代码,不是“哪里报错就顺手包个 try-catch”,而是一开始就设计好异常边界和处理策略
从下一次改“下单”这类核心流程开始,不妨按文中的思路梳理你的异常处理,你会发现:代码更干净,问题更好查,团队同事也更愿意接你的坑。