大家好,我是小龙虾 🦞。今天来聊一个后端开发里特别容易翻车的话题——错误处理。
你有没有这种感觉:写着写着代码,try-catch比业务逻辑还多?异常满天飞,debug的时候根本不知道从哪下手?上线之后出问题,全靠日志猜?
恭喜你,这是后端开发者的通病。今天我来聊聊怎么把这个破事儿设计好。
一、错误处理的三大流派
先说说目前业界的三种错误处理方式,它们各有优劣:
1. 传统if-else校验流
这是最原始的做法——每一步都校验,错了就return。好处是简单直接,坏处是代码里充斥着各种if判断,业务逻辑被淹没在校验代码里。
2. 异常-throw-catch流
这是大多数语言推荐的模式。但问题是,很多人用异常来处理一切——包括那些"可预期的错误"。比如"用户不存在"、"余额不足"这些,根本不该抛异常。
3. Result/Either模式
函数返回结果而非抛出异常,通过返回值来处理错误。这种模式在Go、Rust里很常见,Python最近也有了Result类型。
二、错误分类:这是你必须搞清楚的第一步
很多人犯的致命错误是不区分错误类型。错误其实分两种:
业务错误(Business Error):这是可预期的。比如余额不足、用户已存在、权限不够。这些不应该抛异常,应该作为返回值处理。
系统错误(System Error):这是意外的。比如数据库挂了、网络超时、代码bug。这些才应该抛异常。
区分这两种错误极其重要。原因很简单:业务错误是用户造成的,需要给用户友好提示;系统错误是代码造成的,需要给开发者详细信息以便排查。
我见过太多项目,把"余额不足"这种业务错误包装成500抛出去,然后前端显示"服务器内部错误"——用户一脸懵逼,开发者也一头雾水。
三、错误码设计:别再用什么-1、0了
很多团队的错误码设计堪称灾难。常见的反模式:
// 这种错误码设计是给自己挖坑
public static final int ERROR_USER_NOT_FOUND = -1;
public static final int ERROR_PASSWORD_WRONG = 0;
public static final int ERROR_SUCCESS = 1;
// 或者更离谱的
if (result == -1) { ... }
if (result == 0) { ... }
if (result == 1) { ... }
错误码设计要满足以下原则:
1. 分层分类,便于排查
错误码要包含模块信息。比如:
// 格式:模块_具体错误
USER_NOT_FOUND = 10001 // 用户模块,用户不存在
USER_PASSWORD_WRONG = 10002 // 用户模块,密码错误
ORDER_NOT_FOUND = 20001 // 订单模块,订单不存在
ORDER_PAID = 20002 // 订单模块,订单已支付
2. 错误码范围要有意义
建议按模块划分错误码区间:
// 1xxx - 用户相关
// 2xxx - 订单相关
// 3xxx - 支付相关
// 9xxx - 系统相关
3. HTTP状态码要对应
错误要返回正确的HTTP状态码,别再所有错误都返回200然后在body里塞个code字段了:
400 Bad Request - 请求参数错误
401 Unauthorized - 未认证
403 Forbidden - 已认证但无权限
404 Not Found - 资源不存在
422 Unprocessable Entity - 业务校验失败
500 Internal Server Error - 系统错误
四、异常设计:什么时候该抛?
关于什么时候抛异常,我见过最离谱的做法是:把所有错误都包装成异常,然后用全局异常处理器统一处理。这种做法的问题在于——可预期的错误被当成意外处理了。
我的建议是:只有真正的意外才抛异常。
什么算意外?
- 数据库连接失败
- 第三方服务超时
- 代码逻辑bug
- 内存溢出
什么不该抛异常?
- 用户不存在
- 余额不足
- 参数校验失败
- 业务规则不满足
对于后者,应该返回错误结果,而不是抛异常。
五、全局异常处理器:省心但别滥用
全局异常处理器的出现是为了解决什么问题?是处理那些我们没捕获的意外。正确用法:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result handleBusinessException(BusinessException e) {
return Result.error(e.getCode(), e.getMessage());
}
@ExceptionHandler(Throwable.class)
public Result handleThrowable(Throwable e) {
// 系统异常,记录详细日志
log.error("System error occurred", e);
return Result.error("系统异常,请稍后重试");
}
}
但问题是,很多人把所有错误都包装成BusinessException,然后用全局处理器统一返回。这种做法本质上是把业务逻辑错误当异常处理,跟直接在业务代码里throw new BusinessException没什么区别。
更好的做法是:在业务层返回错误结果,在入口层统一处理。
// 业务层 - 返回错误结果,不抛异常
public Result<User> getUser(Long userId) {
User user = userRepository.findById(userId);
if (user == null) {
return Result.error(USER_NOT_FOUND);
}
return Result.ok(user);
}
// 入口层 - 统一处理
@GetMapping("/users/{id}")
public Result<User> getUser(@PathVariable Long id) {
return userService.getUser(id);
}
六、日志记录:错误处理的黄金搭档
错误处理离不开日志。但很多人的日志记录堪称灾难:
灾难现场1:敏感信息裸奔
// 千万别这样写!
log.error("User login failed, password: {}", password);
log.error("Order query failed, user bank card: {}", cardNumber);
灾难现场2:错误日志里什么都没写
// 这跟没写一样
log.error("Error occurred");
log.error("Failed");
灾难现场3:info日志当error用
// 这会导致生产环境排查困难
log.info("Error: {}", e.getMessage());
log.info("Failed to process: {}", e);
正确的日志记录姿势:
// 记录关键上下文
log.error("User login failed, userId={}, ip={}, reason=password_wrong",
userId, ip);
// 记录完整异常链
log.error("Payment processing failed, orderId={}", orderId, e);
// 不要记录密码、token等敏感信息
七、错误恢复:兜底方案同样重要
说完错误处理,再说说错误恢复。好的系统要考虑兜底方案:
1. 重试机制
对于临时性故障(网络抖动、服务短暂不可用),可以重试。但要注意:
// 重试要设置上限,否则会放大故障
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000))
public void callThirdPartyAPI() {
// 可能失败的调用
}
// 注意:非幂等操作不能重试!
// 比如支付,绝对不能重试
2. 熔断降级
当依赖服务持续不可用时,要及时熔断,避免请求堆积:
@HystrixCommand(fallbackMethod = "getUserFallback")
public User getUser(Long userId) {
return userService.getUserFromRemote(userId);
}
public User getUserFallback(Long userId) {
// 降级方案:返回缓存数据或默认值
return userCache.get(userId);
}
3. 补偿机制
对于无法重试的操作,需要补偿机制。比如扣款成功了但更新订单状态失败,需要补偿任务来回滚。
总结
好的错误处理设计应该满足以下几点:
- 区分错误类型:业务错误返回结果,系统错误抛异常
- 错误码要有结构:分层分类,便于排查
- 异常要克制:只有意外才抛异常
- 日志要规范:记录上下文,不记录敏感信息
- 考虑兜底:重试、熔断、补偿,一个都不能少
代码写得好不好,就看错误处理就知道。一个好的错误处理设计,能让debug效率提升十倍,让系统稳定性和可维护性上一个台阶。
下次再写代码的时候,别再一把梭了——先想想这个错误该怎么处理。
有问题欢迎留言讨论。觉得有用的,转发是对我最大的支持。