Service 层到底该 throw 异常还是 return 错误码?我在两个团队各推行过一种,最后被一个事务 Bug 说服了

Service 层到底该 throw 异常还是 return 错误码?我在两个团队各推行过一种,最后被一个事务 Bug 说服了

这可能是 Java 后端 Code Review 里吵得最多的问题之一:业务失败了,Service 是抛一个 BusinessException 让全局异常处理器兜底,还是老老实实 return Result.fail("余额不足")?两派谁也说服不了谁。这篇文章不站队开喷,我把两种写法的真实代价都摆出来------包括一个 90% 的人没意识到的事务回滚大坑------看完你自己就有答案了。

一、先看两种写法长什么样

需求很简单:下单接口,要校验用户存在、库存充足、余额够扣。

写法一:Service 返回错误码(Result 派)

java 复制代码
@Service
public class OrderService {

    public Result<OrderVO> createOrder(CreateOrderCmd cmd) {
        User user = userMapper.selectById(cmd.getUserId());
        if (user == null) {
            return Result.fail(ErrorCode.USER_NOT_FOUND);
        }

        Result<Void> stockResult = stockService.deduct(cmd.getSkuId(), cmd.getCount());
        if (!stockResult.isSuccess()) {
            return Result.fail(stockResult.getCode(), stockResult.getMessage());
        }

        Result<Void> payResult = accountService.debit(cmd.getUserId(), cmd.getAmount());
        if (!payResult.isSuccess()) {
            return Result.fail(payResult.getCode(), payResult.getMessage());
        }

        Order order = buildOrder(cmd);
        orderMapper.insert(order);
        return Result.ok(OrderVO.from(order));
    }
}

写法二:抛异常 + 全局捕获(异常派)

java 复制代码
@Service
public class OrderService {

    @Transactional(rollbackFor = Exception.class)
    public OrderVO createOrder(CreateOrderCmd cmd) {
        User user = userMapper.selectById(cmd.getUserId());
        if (user == null) {
            throw new BizException(ErrorCode.USER_NOT_FOUND);
        }

        stockService.deduct(cmd.getSkuId(), cmd.getCount());   // 失败内部抛异常
        accountService.debit(cmd.getUserId(), cmd.getAmount()); // 失败内部抛异常

        Order order = buildOrder(cmd);
        orderMapper.insert(order);
        return OrderVO.from(order);
    }
}

配一个全局异常处理器:

java 复制代码
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BizException.class)
    public Result<Void> handleBiz(BizException e) {
        return Result.fail(e.getCode(), e.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public Result<Void> handleUnknown(Exception e) {
        log.error("系统异常", e);
        return Result.fail(ErrorCode.SYSTEM_ERROR);
    }
}

直观感受:写法二的主流程干净得多------正常路径一眼读到底,失败路径全部交给异常通道。但 Result 派会反驳:异常性能差、异常不该做流程控制、阿里手册也这么说。这些说法对不对?往下看。

二、Result 派的三个隐藏代价

代价 1:错误处理传染,调用链越深越丑

Result 一旦进入 Service 层,就会沿着调用链传染 。A 调 B,B 调 C,每一层都要写 if (!result.isSuccess()) 的搬运代码,三层嵌套之后,一半的代码行数都在做"错误转发"这件没有业务价值的事。异常则是自动冒泡的,中间层根本不用感知。

代价 2:错误可以被"无声吞掉"

java 复制代码
stockService.deduct(cmd.getSkuId(), cmd.getCount()); // 忘了接返回值
accountService.debit(cmd.getUserId(), cmd.getAmount());

Result 模式下,调用方忘记检查返回值,编译器不会有任何意见 ------库存扣减失败了,代码继续往下走,钱照扣、单照下。这种 Bug 上线前极难发现。而异常想被吞掉,得有人显式写 try { ... } catch (Exception ignored) {},至少 Code Review 一眼能看见。

代价 3:最致命的------事务不会回滚

这是我真正从 Result 派转向的原因。看这段代码,你能看出 Bug 吗?

java 复制代码
@Transactional(rollbackFor = Exception.class)
public Result<Void> createOrder(CreateOrderCmd cmd) {
    orderMapper.insert(order);                    // ① 订单已落库

    Result<Void> payResult = accountService.debit(...);
    if (!payResult.isSuccess()) {
        return Result.fail(payResult.getMessage()); // ② 扣款失败,return
    }
    return Result.ok();
}

扣款失败时,方法正常 return 了。而 Spring 的声明式事务只在方法抛出异常 时才回滚------正常返回意味着事务正常提交。结果就是:扣款失败了,订单却成功落库,产生一笔永远支付不了的脏订单。

我们当年就是靠这个 Bug 在生产环境刷出一堆脏数据后,才真正统一了规范。Result 派当然可以在每个 return 前手动加 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(),但你能保证团队里每个人、每个 return 点都记得吗?抛异常的写法天然不存在这个问题

三、异常派也别得意:这三个坑你踩过吗

坑 1:异常真的有性能开销,但开销在"堆栈"不在"抛"

异常慢的元凶是构造时的 fillInStackTrace()------它要遍历整个调用栈,栈越深越慢。JMH 压测的量级大概是:正常 return 纳秒级,抛一个带完整堆栈的异常在深调用栈下是它的几百到上千倍。

但注意:业务异常根本不需要堆栈。"余额不足"是一个明确的业务事实,你不需要知道它经过了哪 40 层调用。JDK 早就留了口子:

java 复制代码
public class BizException extends RuntimeException {

    private final String code;

    public BizException(ErrorCode errorCode) {
        // 第 4 个参数 writableStackTrace = false,跳过 fillInStackTrace
        super(errorCode.getMessage(), null, false, false);
        this.code = errorCode.getCode();
    }
}

关掉堆栈后,抛业务异常的成本和 new 一个普通对象基本在同一量级。对于本来就要走一次数据库(毫秒级)的业务请求,这点开销完全可以忽略。"异常性能差"这个理由,在业务系统里基本不成立。

坑 2:别用异常做真正的流程控制

阿里《Java 开发手册》说"不要用异常做流程控制",这句话是对的,但经常被引用错场景。它反对的是这种:

java 复制代码
// 反例:用异常判断字符串是不是数字
try {
    Long.parseLong(input);
    return true;
} catch (NumberFormatException e) {
    return false;
}

"输入不是数字"在校验场景是大概率、预期内 的正常分支,应该用判断而不是异常。但"下单时余额不足"是小概率的业务失败 ,走异常通道恰恰是异常的本职工作。区分标准就一条:这个失败是不是主流程的正常分叉?是,就返回值表达;不是,就抛异常。

所以"根据手机号查用户,可能查不到"这种场景,返回 Optional<User> 比抛 UserNotFoundException 更合适------查不到是查询的正常结果之一。

坑 3:异常出不了线程和进程

两个天然边界,异常派必须认:

  • 异步/线程池 :子线程抛的异常,主线程的 @RestControllerAdvice 接不住。CompletableFuture 里要用 exceptionally / handle 显式接。
  • RPC 边界 :跨服务调用时,异常对象要跨进程序列化,消费方还得有同样的异常类才认识它。所以 Dubbo / Feign 的接口层,业界主流就是返回 Result<T> ------ 这也是阿里手册"跨模块用 Result"的真正语境。

四、结论:不是二选一,是分层各司其职

吵了这么多年,其实答案早就收敛了,一张表说清:

位置 推荐做法 理由
Controller → 前端 统一返回 Result<T>(全局处理器包装) HTTP 边界没有异常,只有报文
Service 层内部 业务失败抛 BizException(无堆栈) 主流程干净、错误不会被吞、事务正确回滚
查询类"未命中" 返回 Optional / null 查不到是预期内的正常结果
RPC / 跨服务接口 返回 Result<T> 异常跨进程序列化不可靠
异步任务内部 异常 + handle/exceptionally 显式收口 全局处理器接不住子线程异常

一句话总结:

进程内,用异常表达业务失败,让全局处理器在出口统一翻译成 Result;进程外(HTTP / RPC),用 Result 表达一切。 争论"抛异常还是返回错误码",就像争论"函数内部该用 return 还是网络包"------它们本来就工作在不同的边界上。

最后

如果你的团队还在为这个问题打架,把这篇文章甩到群里,然后只问一个问题:"我们上一次因为 return Result.fail 导致事务没回滚,是什么时候?" ------如果还没发生过,那只是时候未到。

你们团队用的哪种写法?踩过什么坑?评论区聊聊。

相关推荐
小月土星1 小时前
吞噬星空 RAG 项目 MySQL 实战:从硬编码登录到数据库鉴权(Day 1)
后端
Heo1 小时前
怎么保证缓存和数据库一致性?
前端·后端·面试
延凡科技2 小时前
从 “人管机器“ 到 “数据管人“:智慧矿山综合管控落地实践
java·后端·struts
ServBay2 小时前
2026 年值得关注的 8 款 AI 智能体工具
后端·aigc·ai编程
QX_hao2 小时前
【Go】--Cobra-cli的用法
开发语言·后端·golang
大厂码农老A2 小时前
177K star,扒开DeepSeek Harness的营销,我看到了什么
人工智能·后端·deepseek
别看我只是一直狼2 小时前
补充:证书自动续约检查、故障诊断与重新签发
后端
Lear2 小时前
Java 实现多步骤异步任务编排
后端
小强19882 小时前
告别 "if-else 地狱":PHP 中的策略模式、管道模式与责任链模式实战
后端