在Go项目里,错误处理往往是从最底层的数据访问函数一路返回到HTTP网关。如果每一层都只是把err原样往上抛,到了最上层就只剩一条database connection failed或者context canceled,排查时连是哪个服务、哪个方法出的问题都分不清。要在多层系统里做好错误传递,核心是设计一个稳定的错误封装结构,让错误在返回链路中既能保留上下文,又不会被每一层的包装信息淹没。

一、从一次错误丢失看封装结构的作用
假设订单服务调用库存服务,库存服务在查询数据库时返回了sql.ErrNoRows。订单服务拿到err后做了一次fmt.Errorf("查询库存失败: %v", err),然后继续往上抛。网关层只拿到一条字符串,无法判断这是库存记录不存在还是数据库连接异常,只能给客户端返回500。事实上sql.ErrNoRows应该被识别为业务上的商品不存在,返回明确的业务错误码和404状态码。问题就在于err没有被结构化,原始错误在字符串拼接后失去了类型身份。
要解决这个问题,最常用的做法是定义一个带错误码、消息和原始错误的AppError类型。它既能通过Error()方法输出可读信息,又能通过Unwrap()方法把底层错误暴露给标准库的errors.Is和errors.As。下面是一个基础结构:
type AppError struct {
Code int
Msg string
Err error
Stack string
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Msg, e.Err)
}
return e.Msg
}
func (e *AppError) Unwrap() error {
return e.Err
}
func NewAppError(code int, msg string, err error) *AppError {
return &AppError{Code: code, Msg: msg, Err: err}
}
这个结构的核心思路是:Code用于程序内部判断和网关映射,Msg用于说明当前层的业务上下文,Err保留原始错误,Stack可以在创建时选择性捕获。每一层拿到错误后,都可以通过errors.As判断它是不是*AppError,再决定是否继续包装。
如果只依赖标准库的errors.New或fmt.Errorf,虽然也能返回错误,但错误码无法传递,调用方只能靠字符串匹配来判断错误类型。字符串匹配非常脆弱,一旦文案调整就会漏判。结构化错误让错误处理从字符串比较升级为类型和字段判断,这是多层系统里可维护性的基础。
二、错误链的构建与Wrap边界控制
Go 1.13引入的%w格式化动词可以保留错误链,让errors.Is和errors.As穿透多层包装。比如底层返回sql.ErrNoRows,中间层用fmt.Errorf("查询用户: %w", err)包装后,网关层仍然可以用errors.Is(err, sql.ErrNoRows)判断出来。如果误用了%v,错误链就断了,后续的类型断言全部失效。
但也不是每一层都需要包装。错误包装应该发生在服务边界、RPC调用外层、数据库访问层这些关键位置,而不是每个函数都加一句fmt.Errorf。过度包装会让错误信息变得很冗长,例如连续包装三次后,客户端日志里可能出现查询用户: 调用订单服务: 调用库存服务: sql: no rows in result set,信息价值并没有增加。一个更好的做法是封装一个统一的Wrap函数,只在必要时追加业务上下文:
func Wrap(err error, code int, msg string) error {
if err == nil {
return nil
}
var appErr *AppError
if errors.As(err, &appErr) {
return &AppError{Code: appErr.Code, Msg: msg + ": " + appErr.Msg, Err: err}
}
return &AppError{Code: code, Msg: msg, Err: err}
}
这个Wrap函数不会改变已经存在的业务错误码,只在Msg上追加当前层的说明,同时保持Unwrap链路不断。如果一个错误从底层传到网关,最终Msg可能变成查询库存: 商品服务调用失败: sql: no rows in result set,但Code仍然是底层设置的业务码,网关可以直接映射。
边界控制的另一个好处是避免敏感信息泄露。内部错误常常包含SQL语句、主机名、端口号,如果每层都把自己的上下文原样拼进Msg,最上层返回给客户端时就可能暴露系统细节。在服务边界统一包装,可以过滤掉这些内部信息,只保留对客户端安全的提示。
三、在网关层用errors.As做分类响应
网关层收到错误后,需要根据Code或错误类型决定返回什么HTTP状态码。使用errors.As可以安全地提取*AppError,不用手动做类型断言:
func HandleError(err error) (int, string) {
var appErr *AppError
if errors.As(err, &appErr) {
switch appErr.Code {
case 1001:
return http.StatusNotFound, "商品不存在"
case 1002:
return http.StatusConflict, "库存不足"
default:
return http.StatusInternalServerError, "服务器内部错误"
}
}
return http.StatusInternalServerError, "服务器内部错误"
}
这段代码把内部错误码和HTTP状态码分离。内部错误码可以按业务域细分,例如1001表示商品不存在、1002表示库存不足、1003表示价格变动。网关只负责映射,不关心错误是怎么产生的。这样做的好处是,后续新增业务错误码时不需要修改底层逻辑,只扩展网关映射表即可。
需要特别注意的是,不能把appErr.Msg直接返回给客户端。Msg里可能包含表名称、SQL片段或内部服务名。应该像上面那样,根据Code返回预定义的客户端文案,而完整错误链通过日志记录下来,方便开发人员排查。
四、堆栈捕获与日志输出策略
堆栈信息是排查分布式系统问题的关键,但不是每一层都要捕获。推荐在NewAppError或NewWithStack创建错误时捕获一次堆栈,后续的Wrap不再重复捕获。否则一条错误经过五个服务后,可能携带五段几乎相同的堆栈,反而把真正的事发点淹没。
func NewWithStack(code int, msg string, err error) *AppError {
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
return &AppError{Code: code, Msg: msg, Err: err, Stack: string(buf[:n])}
}
这个函数在创建错误时调用runtime.Stack获取当前goroutine的调用栈,并存入Stack字段。中间层继续用Wrap包装时,不会覆盖这个堆栈。最上层输出日志时,可以同时打印Msg、Stack和Err,一次就能定位到最初的错误位置。
日志输出同样要克制。不要在每一层都打log.Printf,否则同一个错误在日志平台会出现几十条。建议只在最上层的统一错误处理中间件里记录一次,中间层只返回错误,不打印。这样既能减少噪音,也能避免日志服务压力过大。
最后用一张表格对比三种错误传递方式的差异,方便根据项目规模选择方案:
| 传递方式 | 错误码 | 原始错误保留 | 堆栈信息 | 排查效率 |
|---|---|---|---|---|
| 直接透传原始err | 无 | 有 | 无 | 低 |
| 字符串拼接包装 | 无 | 丢失 | 无 | 较低 |
| 自定义结构化错误 | 有 | 有 | 可按需捕获 | 高 |
从表中可以看出,直接透传虽然简单,但无法区分错误类别;字符串拼接会让errors.Is失效;自定义结构化错误虽然前期需要多写一些代码,但能够支撑多层系统长期演进。
Golang错误处理错误封装多层错误传递修改时间:2026-09-25 12:09:01