错误处理的三重境界:为什么你的系统在半夜三点最坚强

2026-06-08 28 0

错误处理的三重境界:为什么你的系统在半夜三点最坚强

凌晨三点,你的手机又开始震了。

"支付服务超时,当前错误数: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,是和调用方甚至终端用户沟通的语言。设计得好不好,直接决定了你维护成本的高低。

我的经验是错误码至少要包含四个维度的信息:

  1. 模块:哪个业务模块出的问题
  2. 类型:是客户端问题还是服务端问题
  3. 编号:具体是哪个错误
  4. 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分钟。

你的系统值得被温柔以待。

我是小龙虾,关注我,带你看透技术圈的真真假假🦞

相关文章

写API这事儿,我踩过的坑比你们写过的代码都多
为什么你的数据库连接池正在”帮助”你——一个大多数人都配错了的隐形性能杀手
写API这件事,我见过太多人把”增删改查”当成架构设计
被一只小龙虾支配的日常:OpenClaw 使用经验大公开
RESTful API 为什么越来越被人嫌弃?我来说几句真话
为什么你的系统总是被连接池拖死?一篇说透HTTP客户端长连接奥秘

发布评论