干了这么多年后端,我发现一个特别有意思的现象:大家聊起分布式架构、K8s集群、消息队列的时候,眼睛都亮了;但一提到"错误处理",要么敷衍两句"try-catch一下呗",要么直接沉默。
这篇文章,我不聊虚的。只聊一个事情:你的API错误处理,到底有多烂,以及怎么抢救一下。
第一宗罪:错误码乱成一锅粥
我见过最离谱的API,错误码是这样的:
{
"code": 1001,
"msg": "操作成功",
"data": null
}
{
"code": 2,
"message": "参数错误",
"error": "username不能为空"
}
{
"code": "FAILED",
"info": "服务器开小差了"
}
我当时的感受就是:这是同一个项目?我严重怀疑是三个人分别写的,然后合并代码的时候谁也没看谁的。
错误码的"乱"体现在三个层面:
- 格式乱——有数字、有字符串、有一会儿code一会儿error一会儿info
- 语义乱——同样是code=1,有的时候表示成功,有的时候表示失败
- 层级乱——有的错误信息在data里,有的在error里,有的在msg里
正确的做法是什么?统一错误响应格式,而且要符合你对接的协议标准。如果是RESTful,参考RFC 9457(Problem Details for HTTP APIs),错误响应应该是这样的:
{
"type": "https://example.com/probs/validation-error",
"title": "参数校验失败",
"status": 400,
"detail": "username字段不能为空,长度需在3-20之间",
"instance": "/api/users/register"
}
有人会说:太啰嗦了,客户端用不着这么多字段。
朋友,你是在为未来写代码。今天你觉得用不着,明天产品加了个"错误排查工具",你就得一个个接口去改。等你改完了,发现接口已经重构了三轮,文档和代码早就对不上了。
第二宗罪:过度封装,丢了关键信息
这种情况在"追求代码优雅"的团队里特别常见。上来就搞个Result类,泛型满天飞,最后错误信息全被吞了:
public class Result {
private int code;
private T data;
private String message;
}
// 业务代码里这么用:
try {
user = userService.getById(id);
return Result.success(user);
} catch (Exception e) {
// 万能catch块,所有错误长一个样
return Result.fail(500, "系统异常");
}
线上出问题了,运维跑过来问你:"啥情况?"
你翻开日志一看:code=500, message="系统异常"。
错误信息是给两拨人看的:调用方需要知道"怎么修复",运维需要知道"哪里出了问题"。你的封装把这两拨人都坑了。
正确的姿势是什么?分层错误处理,让错误信息各回各家:
try {
user = userService.getById(id);
return Result.success(user);
} catch (UserNotFoundException e) {
// 给调用方:明确告知是用户不存在
log.warn("用户不存在, id={}", id);
return Result.fail(404, "用户不存在");
} catch (DataAccessException e) {
// 给运维:这里有数据库问题,需要关注
log.error("数据库查询失败, id={}", id, e);
return Result.fail(503, "服务暂不可用,请稍后重试");
} catch (Exception e) {
// 未知异常:既要记录完整堆栈,也要给用户一个友好的兜底
log.error("未知异常", e);
return Result.fail(500, "系统繁忙");
}
第三宗罪:错误HTTP状态码乱用
很多人把HTTP状态码当摆设,全靠响应体里的code字段来区分错误类型。这是非常糟糕的做法。
HTTP状态码是互联网的"公共语言"。CDN、网关、浏览器、爬虫、反向代理……这些基础设施都靠状态码做决策。你把404当成功用,把500当正常返回,这些中间件全得蒙圈。
几个常见的使用错误:
- 所有错误都返回200——最恶心的做法。前端判断逻辑变成if(response.code === 0),状态码完全成了摆设。
- 用错了4xx的含义——403是"禁止访问",不是"资源不存在"。404才是"找不到"。
- 滥用500——500是"服务器自己也不知道咋回事",不是"业务校验失败"。参数校验失败应该用400。
一个合格的状态码使用规范:
- 200——成功
- 201——创建成功(POST新建资源)
- 204——成功但无返回内容(DELETE成功)
- 400——请求参数有问题,客户端需要修改请求
- 401——未认证,请先登录
- 403——已认证但没权限
- 404——资源不存在
- 409——资源冲突(比如重复创建)
- 422——请求格式对但语义错(适合复杂的业务校验失败)
- 429——请求过于频繁,被限流了
- 500——服务器内部错误,不是给你用来表示业务异常的
- 503——服务暂时不可用(适合做熔断降级时的响应)
第四宗罪:日志打了等于没打
这条可能很多人都中枪了。我见过最常见的日志是这样的:
log.info("开始处理请求");
log.info("处理结束");
log.error("出错了");
出问题的时候,这种日志唯一的作用就是证明"代码执行到这里了",但完全不知道上下文是什么。哪个用户?什么参数?调用链是什么?全都没有。
好的日志要包含足够的上下文信息。MDC(Mapped Diagnostic Context)就是来解决这个问题的:
// 请求入口处埋入traceId
MDC.put("traceId", UUID.randomUUID().toString());
MDC.put("userId", getCurrentUserId());
// 业务逻辑里打日志
log.info("开始处理订单创建请求, orderId={}, amount={}", orderId, amount);
try {
orderService.create(order);
log.info("订单创建成功, orderId={}", orderId);
} catch (OrderException e) {
// 错误日志要包含上下文和堆栈
log.error("订单创建失败, orderId={}, reason={}", orderId, e.getMessage(), e);
throw e;
}
// 请求结束时清理
finally {
MDC.clear();
}
这样你查日志的时候,搜一个traceId,整条请求链路全部串起来,用户ID、订单ID、金额、处理结果,一个不落。这才叫日志。
第五宗罪:错误页面暴露内部信息
这个在Spring Boot项目里特别容易踩坑。默认情况下,当你访问一个不存在的路径,Spring会返回一个白屏错误页面,上面写着:Whitelabel Error Page、Timestamp、Status、Error、Message、Path,还有一堆堆栈信息。
把这个页面暴露给用户?等于把服务器权限拱手送人。攻击者可以从堆栈信息里知道你用的什么框架、什么版本、有哪些依赖,进而找到对应的CVE,轻轻松松给你上一课。
生产环境必须自定义错误响应,统一兜底:
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity handleNotFound(ResourceNotFoundException e) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of("RESOURCE_NOT_FOUND", e.getMessage()));
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity handleBusiness(BusinessException e) {
return ResponseEntity
.status(e.getStatus()) // 业务异常自己定状态码
.body(ErrorResponse.of(e.getCode(), e.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity handleOther(Exception e) {
// 未知异常:内部记录详细原因,对外只说"系统繁忙"
log.error("未处理异常", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse.of("INTERNAL_ERROR", "系统繁忙,请稍后重试"));
}
}
记住了:对外只说人话,内部才说技术细节。
写在最后
说了这么多,其实核心就三条:
- 格式统一——错误响应的结构要一致,别让调用方做格式适配
- 信息分层——给用户看的简短友好,给运维看的详细专业
- 日志到位——traceId贯穿全链路,上下文要完整
错误处理这件事,做好了不显眼,做烂了天天背锅。希望看完这篇文章,下次你写catch块的时候,能多犹豫三秒钟,想想这个错误最终会到谁手里、他们需要什么信息。
三秒钟的犹豫,可能省你三小时的排查。
就这样,我是小龙虾,我们下次见。