错误处理的工程实践:包装、分类与日志怎么配合?

一句话结论:Go 的错误处理要遵循“每层最多处理一次”:内层用 %w 包装并附加上下文向上抛,最外层(handler/worker 边界)统一做分类决策并记录日志;根因通过错误链保留,用 errors.Is 判哨兵、errors.As 取类型、领域错误码做对外契约,避免每层都打日志造成重复噪音,也避免吞错与拿 panic 当流程控制。

三层职责划分

该做什么不该做什么
底层(repo/网络)返回原始错误或轻包装打完整日志
中层(service)%w 包装并加业务上下文决定“是否 404/500”
边界(handler/入口)Is/As 分类、转状态码、日志一次忽略 err

代码示例

var ErrNotFound = errors.New("not found")

type BizError struct {
	Code int
	Msg  string
	Err  error
}

func (e *BizError) Error() string { return e.Msg }
func (e *BizError) Unwrap() error { return e.Err }

// 中层:只包装,不记录
func getUser(ctx context.Context, id int) (*User, error) {
	u, err := repo.FindByID(ctx, id)
	if err != nil {
		return nil, fmt.Errorf("get user %d: %w", id, err)
	}
	return u, nil
}

// 边界:分类 + 日志只打一次
func handleGetUser(w http.ResponseWriter, r *http.Request) {
	u, err := getUser(r.Context(), 1)
	switch {
	case errors.Is(err, ErrNotFound):
		http.Error(w, "not found", http.StatusNotFound)
	case errors.As(err, &be):
		http.Error(w, be.Msg, be.Code)
	default:
		log.Error("get user failed", "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
	}
}

注意:errors.As 前要先声明 var be *BizError,目标必须是指向错误类型的指针。

配套习惯

  • 错误分类三件套:哨兵(Is)、类型(As 带数据)、错误码(对外契约)。对外接口的错误码放响应体里,HTTP 状态码只管大类。
  • 可恢复 vs 致命:网络抖动类可重试/降级,数据不一致类直接失败并告警。
  • recover 只在 goroutine 顶层兜底防崩,业务错误一律 return error,不用 panic。
  • 日志要带请求上下文:request id、用户、耗时,配合 middleware 统一注入。

常见追问 / 记忆点

  • 追问:为什么不让每层都 log.Error?——同一错误会被打 N 遍,上下文碎片化,排查时反而更难串联。
  • 追问:底层错误直接 return 不包装行不行?——行,但上层会丢失“在哪一层、哪个参数”的上下文。
  • 记忆点:包装一次、分类一次、记录一次;%w 保根因、Is/As 做决策、边界层统一收口。
笔记加载中…