Go 错误处理
Go 使用显式错误返回值处理异常情况。相比 try-catch,Go 更鼓励调用方明确判断错误,并在合适层级补充上下文、决定重试、回滚、记录日志或转换成 HTTP 响应。
零基础学习 Go 错误处理时,不要只记住 if err != nil { return err }。真正项目里要理解:错误是值、错误可以包装成链、错误可以分类、业务错误和系统错误要分开、panic 不是普通错误处理手段。
学习目标
学完本页你应该能回答:
- Go 为什么显式返回
error。 error接口是什么。- 什么时候直接返回错误,什么时候包装错误。
%w、errors.Is、errors.As怎么配合使用。- 哨兵错误、自定义错误类型、业务错误码怎么设计。
- API 层如何把内部错误转换成 HTTP 状态码和响应。
- panic/recover 和 error 的边界是什么。
- 日志应该在哪一层记录,为什么不要重复打印同一个错误。
error 是什么
Go 中 error 是一个接口:
type error interface {
Error() string
}任何实现了 Error() string 方法的类型都可以作为错误。
最简单的错误:
err := errors.New("asset not found")带格式的错误:
err := fmt.Errorf("asset %d not found", assetID)显式错误处理流程:
flowchart TD
A["调用函数"] --> B["返回 result 和 err"]
B --> C{"err 是否为 nil"}
C -- "是" --> D["继续处理 result"]
C -- "否" --> E["判断错误类型"]
E --> F["补充上下文或转换"]
F --> G["返回上层 记录日志 或响应用户"]为什么 Go 不用 try-catch
Go 设计上把错误当普通值返回,而不是使用异常作为主要控制流。
好处:
- 调用方必须显式看见错误。
- 控制流清楚。
- 文件、网络、数据库这类高频失败场景处理更直接。
- 不容易出现异常从深层一路冒泡但没人知道在哪里处理。
代价:
if err != nil会比较多。- 初学者容易机械返回错误,不补上下文。
- 如果错误分类设计不好,上层不知道如何处理。
所以重点不是抱怨 if err != nil,而是学会设计错误边界。
直接返回还是包装错误
底层错误如果直接返回,上层可能只看到:
not found不知道是查用户、查订单还是查资产。
推荐补充上下文:
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 表示包装错误,并保留原始错误链。
错误链:
flowchart TD
A["Repository: sql.ErrNoRows"] --> B["Service: find asset by id 1001"]
B --> C["Handler: create response"]
C --> D["日志或 HTTP 响应"]有上下文后日志会更清楚:
find asset by id 1001: sql: no rows in result set%v 和 %w 区别
fmt.Errorf("query asset: %v", err)%v 只是把错误文本拼进去,错误链断了。
fmt.Errorf("query asset: %w", err)%w 会包装错误,后续可以用 errors.Is 和 errors.As 判断。
示例:
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
}如果用 %v,errors.Is 就无法识别原始错误。
errors.Is:判断哨兵错误
哨兵错误是提前定义好的错误值:
var ErrAssetNotFound = errors.New("asset not found")
var ErrPermissionDenied = errors.New("permission denied")使用:
if errors.Is(err, ErrAssetNotFound) {
// 返回 404 或业务码
}适合场景:
- 资源不存在。
- 权限不足。
- 状态冲突。
- 幂等重复提交。
注意:哨兵错误是公共契约,定义后不要随便改变语义。
errors.As:提取错误类型
如果错误需要携带结构化信息,使用自定义错误类型。
type AppError struct {
Code string
Message string
}
func (e *AppError) Error() string {
return e.Message
}判断:
var appErr *AppError
if errors.As(err, &appErr) {
fmt.Println(appErr.Code, appErr.Message)
}errors.As 会沿着错误链查找是否存在目标类型。
业务错误和系统错误
商业项目要区分业务错误和系统错误。
| 类型 | 示例 | 给用户的提示 | 日志级别 |
|---|---|---|---|
| 业务错误 | 参数非法、资产不存在、无权限 | 明确可理解 | INFO/WARN |
| 系统错误 | 数据库连接失败、panic、下游超时 | 系统繁忙或稍后重试 | ERROR |
业务错误通常是可预期的,不一定要打 ERROR。系统错误才需要重点报警。
业务错误类型:
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:
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 不应该把内部错误原样返回给前端。
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 状态 | 响应码 |
|---|---|---|
| 参数非法 | 400 | BAD_REQUEST |
| 未登录 | 401 | UNAUTHORIZED |
| 无权限 | 403 | FORBIDDEN |
| 资源不存在 | 404 | NOT_FOUND |
| 状态冲突 | 409 | CONFLICT |
| 超时 | 504 | TIMEOUT |
| 未知系统错误 | 500 | INTERNAL_ERROR |
前端看到稳定错误码,后端日志保留完整错误链。
日志应该在哪一层打
不要每一层都打印同一个错误,否则日志会重复。
常见策略:
- 底层返回错误并补上下文,不打日志。
- Service 根据业务语义转换错误。
- Handler 或中间件统一记录最终错误。
- 如果某层做了重试、降级、补偿,可以记录关键日志。
错误日志示例:
log.Printf(
"request failed request_id=%s path=%s err=%+v",
requestID,
r.URL.Path,
err,
)日志要包含 request_id、业务 ID、错误链、耗时,但不要记录密码、Token、医疗隐私等敏感信息。
panic 和 recover
panic 不是普通错误处理方式。
适合 panic 的场景:
- 程序启动时关键配置缺失,无法继续运行。
- 不应该发生的编程错误。
- 初始化失败且无法降级。
不适合 panic 的场景:
- 用户参数错误。
- 数据库查询不到数据。
- 下游接口超时。
- 业务状态不允许。
Web 服务中一般用 Recovery 中间件兜底:
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 清理和错误
资源使用后要关闭。
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()文件写入时,Close 也可能失败。如果 close 错误很重要,要处理:
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 | 可以,但要限制次数 |
| 超时 | 视业务而定 |
| 数据库唯一键冲突 | 否,通常要做幂等处理 |
重试要注意:
- 设置最大次数。
- 设置退避时间。
- 尊重 Context 取消。
- 保证操作幂等。
示例:
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)
}商业场景:资产创建错误链
创建资产可能涉及:
- 参数校验。
- 权限校验。
- 数据库写入。
- 审计日志。
- 外部系统同步。
错误处理建议:
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 能让每一层决定是补上下文、重试、回滚、转换响应还是继续向上返回。
%w、errors.Is、errors.As 有什么用?fmt.Errorf("%w", err) 用来包装错误并保留错误链;errors.Is 用来判断错误链里是否包含某个哨兵错误;errors.As 用来从错误链中提取某种错误类型。它们配合可以既保留上下文,又能在上层分类处理。
业务错误和系统错误怎么区分?
业务错误是可预期的,例如参数非法、资源不存在、无权限,应该返回明确业务码;系统错误是不可预期或基础设施问题,例如数据库连接失败、下游超时、panic,前端通常只返回通用提示,日志记录详细错误链。
panic 和 error 怎么选?
普通业务失败、参数错误、数据库查不到、下游超时都应该返回 error。panic 只适合无法继续运行的启动失败或不应该发生的编程错误。Web 服务可以用 recover 中间件兜底,但 recover 不是替代错误处理。
关联知识点
小结
Go 错误处理的核心不是机械写 if err != nil,而是让错误可分类、可追踪、可转换、可排查。底层补上下文,上层按类型处理,API 层转换响应,日志在边界统一记录。业务错误给用户明确提示,系统错误保留完整错误链供排查。
