Skip to content

Go 错误处理

Go 使用显式错误返回值处理异常情况。相比 try-catch,Go 更鼓励调用方明确判断错误,并在合适层级补充上下文、决定重试、回滚、记录日志或转换成 HTTP 响应。

零基础学习 Go 错误处理时,不要只记住 if err != nil { return err }。真正项目里要理解:错误是值、错误可以包装成链、错误可以分类、业务错误和系统错误要分开、panic 不是普通错误处理手段。

学习目标

学完本页你应该能回答:

  1. Go 为什么显式返回 error
  2. error 接口是什么。
  3. 什么时候直接返回错误,什么时候包装错误。
  4. %werrors.Iserrors.As 怎么配合使用。
  5. 哨兵错误、自定义错误类型、业务错误码怎么设计。
  6. API 层如何把内部错误转换成 HTTP 状态码和响应。
  7. panic/recover 和 error 的边界是什么。
  8. 日志应该在哪一层记录,为什么不要重复打印同一个错误。

error 是什么

Go 中 error 是一个接口:

go
type error interface {
    Error() string
}

任何实现了 Error() string 方法的类型都可以作为错误。

最简单的错误:

go
err := errors.New("asset not found")

带格式的错误:

go
err := fmt.Errorf("asset %d not found", assetID)

显式错误处理流程:

mermaid
flowchart TD
    A["调用函数"] --> B["返回 result 和 err"]
    B --> C{"err 是否为 nil"}
    C -- "是" --> D["继续处理 result"]
    C -- "否" --> E["判断错误类型"]
    E --> F["补充上下文或转换"]
    F --> G["返回上层 记录日志 或响应用户"]

为什么 Go 不用 try-catch

Go 设计上把错误当普通值返回,而不是使用异常作为主要控制流。

好处:

  1. 调用方必须显式看见错误。
  2. 控制流清楚。
  3. 文件、网络、数据库这类高频失败场景处理更直接。
  4. 不容易出现异常从深层一路冒泡但没人知道在哪里处理。

代价:

  1. if err != nil 会比较多。
  2. 初学者容易机械返回错误,不补上下文。
  3. 如果错误分类设计不好,上层不知道如何处理。

所以重点不是抱怨 if err != nil,而是学会设计错误边界。

直接返回还是包装错误

底层错误如果直接返回,上层可能只看到:

text
not found

不知道是查用户、查订单还是查资产。

推荐补充上下文:

go
asset, err := repo.FindByID(ctx, id)
if err != nil {
    return Asset{}, fmt.Errorf("find asset by id %d: %w", id, err)
}
return asset, nil

%w 表示包装错误,并保留原始错误链。

错误链:

mermaid
flowchart TD
    A["Repository: sql.ErrNoRows"] --> B["Service: find asset by id 1001"]
    B --> C["Handler: create response"]
    C --> D["日志或 HTTP 响应"]

有上下文后日志会更清楚:

text
find asset by id 1001: sql: no rows in result set

%v%w 区别

go
fmt.Errorf("query asset: %v", err)

%v 只是把错误文本拼进去,错误链断了。

go
fmt.Errorf("query asset: %w", err)

%w 会包装错误,后续可以用 errors.Iserrors.As 判断。

示例:

go
var ErrAssetNotFound = errors.New("asset not found")

func findAsset(id int64) error {
    return fmt.Errorf("query db: %w", ErrAssetNotFound)
}

func main() {
    err := findAsset(1)
    fmt.Println(errors.Is(err, ErrAssetNotFound)) // true
}

如果用 %verrors.Is 就无法识别原始错误。

errors.Is:判断哨兵错误

哨兵错误是提前定义好的错误值:

go
var ErrAssetNotFound = errors.New("asset not found")
var ErrPermissionDenied = errors.New("permission denied")

使用:

go
if errors.Is(err, ErrAssetNotFound) {
    // 返回 404 或业务码
}

适合场景:

  1. 资源不存在。
  2. 权限不足。
  3. 状态冲突。
  4. 幂等重复提交。

注意:哨兵错误是公共契约,定义后不要随便改变语义。

errors.As:提取错误类型

如果错误需要携带结构化信息,使用自定义错误类型。

go
type AppError struct {
    Code    string
    Message string
}

func (e *AppError) Error() string {
    return e.Message
}

判断:

go
var appErr *AppError
if errors.As(err, &appErr) {
    fmt.Println(appErr.Code, appErr.Message)
}

errors.As 会沿着错误链查找是否存在目标类型。

业务错误和系统错误

商业项目要区分业务错误和系统错误。

类型示例给用户的提示日志级别
业务错误参数非法、资产不存在、无权限明确可理解INFO/WARN
系统错误数据库连接失败、panic、下游超时系统繁忙或稍后重试ERROR

业务错误通常是可预期的,不一定要打 ERROR。系统错误才需要重点报警。

业务错误类型:

go
type BizError struct {
    Code    string
    Message string
}

func (e *BizError) Error() string {
    return e.Message
}

func NewBizError(code, message string) *BizError {
    return &BizError{Code: code, Message: message}
}

Service:

go
func (s *AssetService) Get(ctx context.Context, id int64) (Asset, error) {
    asset, err := s.repo.FindByID(ctx, id)
    if errors.Is(err, ErrAssetNotFound) {
        return Asset{}, NewBizError("ASSET_NOT_FOUND", "资产不存在")
    }
    if err != nil {
        return Asset{}, fmt.Errorf("find asset by id %d: %w", id, err)
    }
    return asset, nil
}

API 层错误转换

Handler 不应该把内部错误原样返回给前端。

go
func writeErrorByErr(w http.ResponseWriter, err error) {
    var bizErr *BizError
    if errors.As(err, &bizErr) {
        writeError(w, http.StatusBadRequest, bizErr.Code, bizErr.Message)
        return
    }

    if errors.Is(err, context.DeadlineExceeded) {
        writeError(w, http.StatusGatewayTimeout, "TIMEOUT", "请求处理超时")
        return
    }

    writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "系统异常")
}

映射原则:

内部错误HTTP 状态响应码
参数非法400BAD_REQUEST
未登录401UNAUTHORIZED
无权限403FORBIDDEN
资源不存在404NOT_FOUND
状态冲突409CONFLICT
超时504TIMEOUT
未知系统错误500INTERNAL_ERROR

前端看到稳定错误码,后端日志保留完整错误链。

日志应该在哪一层打

不要每一层都打印同一个错误,否则日志会重复。

常见策略:

  1. 底层返回错误并补上下文,不打日志。
  2. Service 根据业务语义转换错误。
  3. Handler 或中间件统一记录最终错误。
  4. 如果某层做了重试、降级、补偿,可以记录关键日志。

错误日志示例:

go
log.Printf(
    "request failed request_id=%s path=%s err=%+v",
    requestID,
    r.URL.Path,
    err,
)

日志要包含 request_id、业务 ID、错误链、耗时,但不要记录密码、Token、医疗隐私等敏感信息。

panic 和 recover

panic 不是普通错误处理方式。

适合 panic 的场景:

  1. 程序启动时关键配置缺失,无法继续运行。
  2. 不应该发生的编程错误。
  3. 初始化失败且无法降级。

不适合 panic 的场景:

  1. 用户参数错误。
  2. 数据库查询不到数据。
  3. 下游接口超时。
  4. 业务状态不允许。

Web 服务中一般用 Recovery 中间件兜底:

go
func recoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("panic path=%s err=%v", r.URL.Path, err)
                writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "系统异常")
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Recover 是防止服务崩溃,不是替代错误处理。出现 panic 仍然要修根因并补测试。

defer 清理和错误

资源使用后要关闭。

go
resp, err := http.DefaultClient.Do(req)
if err != nil {
    return err
}
defer resp.Body.Close()

文件写入时,Close 也可能失败。如果 close 错误很重要,要处理:

go
func writeFile(path string, data []byte) (err error) {
    file, err := os.Create(path)
    if err != nil {
        return err
    }
    defer func() {
        closeErr := file.Close()
        if err == nil && closeErr != nil {
            err = closeErr
        }
    }()

    _, err = file.Write(data)
    return err
}

这个写法使用命名返回值,确保写入没错但关闭失败时也能返回错误。

重试和错误分类

不是所有错误都应该重试。

错误是否适合重试
参数非法
无权限
资源不存在通常否
网络临时失败可以
下游 5xx可以,但要限制次数
超时视业务而定
数据库唯一键冲突否,通常要做幂等处理

重试要注意:

  1. 设置最大次数。
  2. 设置退避时间。
  3. 尊重 Context 取消。
  4. 保证操作幂等。

示例:

go
func retry(ctx context.Context, times int, fn func() error) error {
    var lastErr error
    for i := 0; i < times; i++ {
        if err := fn(); err != nil {
            lastErr = err
            select {
            case <-ctx.Done():
                return ctx.Err()
            case <-time.After(time.Duration(i+1) * 100 * time.Millisecond):
            }
            continue
        }
        return nil
    }
    return fmt.Errorf("retry failed after %d times: %w", times, lastErr)
}

商业场景:资产创建错误链

创建资产可能涉及:

  1. 参数校验。
  2. 权限校验。
  3. 数据库写入。
  4. 审计日志。
  5. 外部系统同步。

错误处理建议:

mermaid
flowchart TD
    A["Handler 解析请求"] --> B["Service 校验业务"]
    B --> C{"业务错误"}
    C -- "是" --> D["返回 BizError"]
    C -- "否" --> E["Repository 写库"]
    E --> F{"系统错误"}
    F -- "是" --> G["包装上下文返回"]
    F -- "否" --> H["返回成功"]
    D --> I["Handler 转换 HTTP 响应"]
    G --> I

比如资产名称为空,这是业务错误,应返回 ASSET_NAME_REQUIRED;数据库连接失败是系统错误,前端只看到“系统异常”,日志里记录完整错误链。

常见坑

问题后果正确做法
吞掉错误问题被隐藏返回或记录
只返回 err 不补上下文日志看不出失败场景fmt.Errorf("xxx: %w", err)
%v 包装错误错误链断开%w
字符串判断错误脆弱errors.Is / errors.As
业务错误打 ERROR报警噪音业务错误用明确错误码
把内部错误返回前端泄露实现细节API 层转换
到处 panic服务不稳定普通失败返回 error
每层都打日志日志重复边界层统一记录

面试标准回答

Go 为什么显式返回 error?
Go 把错误当普通值返回,调用方必须显式判断,使控制流清晰。文件、网络、数据库这类操作失败很常见,显式 error 能让每一层决定是补上下文、重试、回滚、转换响应还是继续向上返回。

%werrors.Iserrors.As 有什么用?
fmt.Errorf("%w", err) 用来包装错误并保留错误链;errors.Is 用来判断错误链里是否包含某个哨兵错误;errors.As 用来从错误链中提取某种错误类型。它们配合可以既保留上下文,又能在上层分类处理。

业务错误和系统错误怎么区分?
业务错误是可预期的,例如参数非法、资源不存在、无权限,应该返回明确业务码;系统错误是不可预期或基础设施问题,例如数据库连接失败、下游超时、panic,前端通常只返回通用提示,日志记录详细错误链。

panic 和 error 怎么选?
普通业务失败、参数错误、数据库查不到、下游超时都应该返回 error。panic 只适合无法继续运行的启动失败或不应该发生的编程错误。Web 服务可以用 recover 中间件兜底,但 recover 不是替代错误处理。

关联知识点

小结

Go 错误处理的核心不是机械写 if err != nil,而是让错误可分类、可追踪、可转换、可排查。底层补上下文,上层按类型处理,API 层转换响应,日志在边界统一记录。业务错误给用户明确提示,系统错误保留完整错误链供排查。