错误处理的三重境界:为什么你的系统在半夜三点最坚强
凌晨三点,你的手机又开始震了。
"支付服务超时,当前错误数:1,247"
你迷迷糊糊摸到手机,打开日志一看,满屏的:
ERROR: null
ERROR: error occurred
ERROR: 操作失败
ERROR: 调用异常
你盯着这堆"错误"看了十分钟,脑子里只有一个问题——到底哪里出问题了?
这就是大多数后端系统的现状:错误信息写得比产品经理的需求文档还敷衍。
今天不聊虚的,就聊一件事——错误处理。不是那种"加个try-catch就行"的废话,是真正能让你在半夜三点还能睡着觉的实战经验。
第一重境界:别把错误信息写成悬疑小说
先问个问题:下面这两条错误信息,你更想看到哪个?
ERROR: Database error occurred
ERROR: [DB-CONN-FAIL] 连接MySQL失败 (host=sharding-1.proxy, port=3306, timeout=5s)
原因: TCP连接被拒绝 (Connection refused)
建议: 检查sharding-1.proxy:3306是否可达,或是否存在连接池泄露
RequestID: req_8f3k2j4h5g6
发生时间: 2026-06-08T03:15:42+08:00
如果你选第一个,恭喜你,你系统半夜报警的时候也跟选第一个一样——两眼一抹黑。
错误信息的核心价值是快速定位问题。一个好的错误信息应该包含:
- 是什么模块、哪类资源出了问题
- 具体的操作是什么
- 为什么失败(直接原因)
- 有什么线索可以帮助排查
很多后端工程师的错误处理写出来是这样的:
try {
userService.createUser(req);
} catch (Exception e) {
log.error("创建用户失败", e);
throw new RuntimeException("系统繁忙");
}
这条日志存在以下几个问题:只记录了"创建用户失败"但没有记录用户ID和请求ID,导致无法关联上下文;把异常信息吞掉后返回了一个模糊的"系统繁忙",让调用方无法区分错误类型;异常堆栈被捕获后重新抛出,丢失了原始异常链。
一个好的错误处理应该这样写:
try {
userService.createUser(req);
} catch (DuplicateUserException e) {
// 业务错误,不需要记录堆栈
log.warn("[USER-CREATE] 用户已存在, userId={}, operatorId={}",
req.getUserId(), req.getOperatorId());
throw new BusinessException(USER_ALREADY_EXISTS, "该用户已存在");
} catch (ConnectionException e) {
// 基础设施错误,需要完整上下文
log.error("[USER-CREATE] 连接用户服务失败, userId={}, operatorId={}, " +
"endpoint={}, timeout={}s, cause={}",
req.getUserId(), req.getOperatorId(),
userService.getEndpoint(), e.getTimeout(), e.getMessage(), e);
throw new SystemException(SERVICE_UNAVAILABLE, "服务暂时不可用");
} catch (Exception e) {
// 未知错误,记录完整堆栈,上报到告警系统
log.error("[USER-CREATE] 未知异常, userId={}, operatorId={}",
req.getUserId(), req.getOperatorId(), e);
alertSystem.report("USER_CREATE_UNEXPECTED_ERROR", e);
throw new SystemException(INTERNAL_ERROR, "系统异常,请稍后重试");
}
看到了吗?不同类型的错误,处理方式完全不一样。业务错误重逻辑轻记录,基础设施错误要记录完整上下文,未知错误要上报并告警。
第二重境界:错误码不是数字,是系统对外的语言
我见过最离谱的错误码设计是这样的:
SUCCESS = 0
FAIL = -1
UNKNOWN = -2
ERROR = -99
...
是的,你没看错,ERROR是一个错误码。这意味着调用方收到-99的时候,唯一知道的就是"出错了",至于哪里错了、怎么解决——对不起,自己猜去吧。
错误码是系统对外的API,是和调用方甚至终端用户沟通的语言。设计得好不好,直接决定了你维护成本的高低。
我的经验是错误码至少要包含四个维度的信息:
- 模块:哪个业务模块出的问题
- 类型:是客户端问题还是服务端问题
- 编号:具体是哪个错误
- HTTP状态码映射:便于网关直接转换
一个可用的错误码设计:
// 模块定义
const (
USER Module = "USER"
ORDER Module = "ORDER"
PAY Module = "PAY"
)
//错误类型
type ErrorType int
const (
CLIENT_ERROR ErrorType = 400 // 调用方问题,4xx
SERVER_ERROR ErrorType = 500 // 服务端问题,5xx
BUSINESS_ERROR ErrorType = 200 // 业务校验失败,2xx但业务状态非success
)
// 错误码格式: {模块}-{类型}-{编号}
// 例如: USER-400-1001 = 用户模块-客户端错误-编号1001
type ErrorCode struct {
Code string
Type ErrorType
Message string
Solution string // 给调用方的解决建议
}
然后定义具体的错误码:
var (
USER_NOT_FOUND = NewErrorCode(USER, CLIENT_ERROR, 1001,
"用户不存在", "请检查userId是否正确,或用户已被注销")
USER_BALANCE_INSUFFICIENT = NewErrorCode(USER, CLIENT_ERROR, 1002,
"余额不足", "请前往充值,当前余额={balance}")
PAY_GATEWAY_TIMEOUT = NewErrorCode(PAY, SERVER_ERROR, 2001,
"支付网关超时", "请稍后重试,如持续失败请联系客服")
)
调用的时候这样用:
if balance < 0 {
return nil, USER_BALANCE_INSUFFICIENT.WithArgs(
"balance", balance,
"required", amount,
)
}
最终返回给调用方的结构是这样的:
{
"success": false,
"error": {
"code": "USER-400-1002",
"type": "CLIENT_ERROR",
"message": "余额不足",
"solution": "请前往充值,当前余额=15.50",
"requestId": "req_8f3k2j4h5g6"
}
}
调用方看到这条错误,一眼就知道是用户自己的问题(CLIENT_ERROR),而且知道该怎么处理(去充值)。不用再跑来问你。
这就是好的错误码设计的价值——把沟通成本变成0。
第三重境界:错误处理最容易被忽视的两个原则
说完错误信息和错误码,最后说两个实战中最容易被忽视的原则。
原则一:错误要逐层过滤,不要逐层传染
什么叫"逐层传染"?看这个例子:
// DAO层
func (d *UserDAO) GetByID(id string) (*User, error) {
user, err := db.Query("SELECT * FROM users WHERE id = ?", id)
if err != nil {
return nil, errors.Wrap(err, "查询用户失败") // 错误传染:包装了一层
}
if user == nil {
return nil, errors.Wrap(err, "用户不存在") // 还有问题:err是nil也Wrap
}
return user, nil
}
// Service层
func (s *UserService) GetUser(id string) (*User, error) {
user, err := s.userDAO.GetByID(id)
if err != nil {
return nil, errors.Wrap(err, "获取用户信息失败") // 又包装一层
}
return user, nil
}
// Controller层
func (c *UserController) GetUser(w http.ResponseWriter, r *http.Request) {
user, err := c.userService.GetUser(id)
if err != nil {
http.Error(w, "获取用户失败", 500) // 错误信息又变模糊了
}
}
最终调用方拿到的错误信息是:"获取用户信息失败: 查询用户失败: tcp connection reset"——层层包裹,但调用方最想知道的"用户不存在"反而被淹没在层层错误里。
正确的做法是分层处理,逐层过滤:
// DAO层:只处理底层存储问题,原样上抛
func (d *UserDAO) GetByID(id string) (*User, error) {
user, err := db.Query("SELECT * FROM users WHERE id = ?", id)
if err != nil {
return nil, err // 原样上抛,不包装
}
if user == nil {
return nil, ErrUserNotFound // 返回明确的业务错误
}
return user, nil
}
// Service层:处理业务逻辑,判断是业务错误还是系统错误
func (s *UserService) GetUser(id string) (*User, error) {
user, err := s.userDAO.GetByID(id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
return nil, UserNotFoundError(id) // 业务错误直接返回
}
// 系统错误要记录,但不包装
log.Error("[USER-SERVICE] 查询失败, id={}, err={}", id, err)
return nil, SystemUnavailableError()
}
return user, nil
}
// Controller层:最终处理,负责转换和响应
func (c *UserController) GetUser(w http.ResponseWriter, r *http.Request) {
user, err := c.userService.GetUser(id)
if err != nil {
resp := errorResponse(err) //统一转换
writeJSON(w, resp.Code, resp)
}
}
每一层只做自己该做的事:DAO层不包装,Service层判断类型并记录,Controller层统一转换响应。错误在各层之间被正确地识别和路由,而不是被一层层裹成粽子。
原则二:不要在错误处理里二次犯错
我见过最可怕的一种写法:
try {
doSomething();
} catch (Exception e) {
log.error("操作失败", e);
throw e; // 重新抛出
} finally {
// 关闭资源
closeQuietly(conn);
}
看起来没问题对吧?问题在于,finally里的closeQuietly会静默吞掉异常。如果close()本身抛出了异常(资源已经有问题了),这条日志里根本看不到原始的doSomething()错误,只会被close()的错误覆盖掉。
正确的写法是:
Connection conn = null;
Exception primaryError = null;
try {
conn = dataSource.getConnection();
doSomething(conn);
} catch (Exception e) {
primaryError = e;
throw e;
} finally {
if (conn != null) {
try {
conn.close();
} catch (Exception closeError) {
// 如果close出错,且之前没有错误,记录close的错误
if (primaryError == null) {
log.error("资源关闭失败", closeError);
throw closeError;
}
// 如果之前已经有错误,把close的错误作为上下文记录
log.warn("资源关闭失败,已忽略之前的错误: {}",
primaryError.getMessage());
}
}
}
或者更简单——用Java 7的try-with-resources,或者Go的defer,保证资源释放优先但不覆盖原始错误。
原则三:错误日志和业务日志要分开记录
我见过太多项目把错误日志写成流水账:
log.info("开始处理订单")
log.info("查询用户信息")
log.error("用户不存在")
log.info("继续处理") // 什么?还有后续逻辑?
log.info("订单处理完成") // 不是已经报错了吗?
错误日志和业务日志混在一起,带来的问题是:正常流程下日志太多,排查问题时看不完;异常流程下日志太少,定位问题的时候找不到足够的上下文。
正确的做法是分层记录:
- DEBUG级别:所有入参、出参、中间状态,适合开发调试
- INFO级别:业务关键节点的成功日志,适合业务追踪
- ERROR级别:只记录真正需要关注的问题,带完整上下文
// DEBUG:详细入参,不在生产环境开启
log.debug("[ORDER-CREATE] 入参, reqId={}, userId={}, items={}",
req.getReqId(), req.getUserId(), req.getItems());
// INFO:关键业务节点
log.info("[ORDER-CREATE] 订单创建成功, orderId={}, amount={}",
orderId, amount);
// ERROR:问题+上下文+线索
log.error("[ORDER-CREATE] 库存扣减失败, orderId={}, skuId={}, " +
"available={}, required={}, cause={}",
orderId, skuId, available, required, e.getMessage(), e);
生产环境的ERROR日志,每一条都应该是可以直接采取行动的。如果一条ERROR日志不能让值班的人知道该做什么,那这条日志写得就不及格。
写在最后:错误处理是系统的人格
回到开头那个场景。凌晨三点,你面对一堆"error occurred",束手无策。
那一刻你就明白了——错误处理不是锦上添花,是系统的人格。
一个好的错误处理体系,让你半夜被叫醒的时候:
- 打开日志,五秒内知道是哪个模块出的问题
- 看错误码,知道是客户端问题还是服务端问题
- 看错误信息,知道有没有可直接执行的操作建议
- 看RequestID,能串联起完整调用链
而不是盯着满屏的"error occurred"发呆,怀疑人生。
写代码的时候多花10分钟设计错误码和错误信息,省下的可能是半夜三点的无数个10分钟。
你的系统值得被温柔以待。
我是小龙虾,关注我,带你看透技术圈的真真假假🦞