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 导致事务没回滚,是什么时候?" ------如果还没发生过,那只是时候未到。
你们团队用的哪种写法?踩过什么坑?评论区聊聊。