Go 错误处理:error、defer、panic 与 recover
Go 将错误视为普通值。调用方显式检查错误,能够清楚看到失败路径、补充上下文并决定是否重试、降级或终止请求。
一套稳定的错误处理策略需要回答四个问题:错误在哪里产生、如何保留原因、由谁记录日志,以及何时才允许 panic。
error 的基本用法
error 是只有一个方法的接口:
type error interface {
Error() string
}
使用 errors.New 创建固定错误,使用 fmt.Errorf 创建带变量信息的错误。
func ParsePort(raw string) (int, error) {
port, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("parse port %q: %w", raw, err)
}
if port < 1 || port > 65535 {
return 0, fmt.Errorf("port out of range: %d", port)
}
return port, nil
}
错误文本通常使用小写开头且不加句号,因为上层可能继续包装它。
包装错误并保留原因
%w 会保留原始错误链,调用方可以使用 errors.Is 和 errors.As 判断原因。%v 只会格式化文本,无法参与错误链判断。
var ErrUserNotFound = errors.New("user not found")
func (r *Repository) FindUser(ctx context.Context, id int64) (User, error) {
user, err := r.query(ctx, id)
if err != nil {
return User{}, fmt.Errorf("find user %d: %w", id, err)
}
return user, nil
}
调用方不要比较错误字符串:
if errors.Is(err, ErrUserNotFound) {
// 返回 404 或执行替代逻辑
}
字符串可能变化,错误链仍能保持稳定语义。
哨兵错误与自定义错误类型
哨兵错误适合调用方只关心类别的情况:
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
)
需要携带结构化信息时,定义错误类型:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return e.Field + ": " + e.Message
}
通过 errors.As 提取类型:
var validationErr *ValidationError
if errors.As(err, &validationErr) {
fmt.Println(validationErr.Field)
}
对外暴露的哨兵错误和错误类型会成为包的 API。只有调用方确实需要分支处理时才公开它们。
errors.Join:合并多个失败
清理多个资源或并行任务可能同时失败。Go 1.20 起可以使用 errors.Join:
func closeAll(closers ...io.Closer) error {
var errs []error
for _, closer := range closers {
if err := closer.Close(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
合并后的错误仍支持 errors.Is 和 errors.As。
用提前返回保持主流程清晰
Go 代码通常先处理失败路径,再继续主流程:
func Register(ctx context.Context, store UserStore, input Input) (User, error) {
if err := input.Validate(); err != nil {
return User{}, fmt.Errorf("validate input: %w", err)
}
user, err := NewUser(input)
if err != nil {
return User{}, fmt.Errorf("create user: %w", err)
}
if err := store.Save(ctx, user); err != nil {
return User{}, fmt.Errorf("save user: %w", err)
}
return user, nil
}
避免连续多层 if err == nil。提前返回能让成功路径保持线性。
defer:可靠释放资源
defer 在当前函数返回前执行,多个 defer 按后进先出顺序运行。
func ReadConfig(path string) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open config: %w", err)
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
return data, nil
}
需要处理 Close 错误时,可以使用命名返回值:
func WriteFile(path string, data []byte) (err error) {
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("create file: %w", err)
}
defer func() {
if closeErr := file.Close(); closeErr != nil {
err = errors.Join(err, fmt.Errorf("close file: %w", closeErr))
}
}()
if _, err := file.Write(data); err != nil {
return fmt.Errorf("write file: %w", err)
}
return nil
}
defer 参数何时求值
defer 语句的函数参数会在注册时求值:
func example() {
value := 1
defer fmt.Println(value)
value = 2
}
输出为 1。如果 defer 使用闭包并读取外部变量,则在执行时获取值。
不要在无限循环中堆积 defer
defer 在函数返回时才执行。处理大量循环项时,应把单次操作提取为函数:
func processFile(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
return consume(file)
}
panic 的合理边界
panic 会停止当前函数的正常流程并沿调用栈展开。它适合程序无法继续满足内部不变量的情况,而不适合普通输入错误、网络失败或数据库错误。
可以考虑 panic 的场景:
- 应用启动时关键配置缺失,继续运行没有意义。
- 包初始化中的静态模板或正则表达式必定正确,否则属于程序缺陷。
- 检测到理论上不可能出现的内部状态。
不应 panic 的场景:
- 用户提交了无效参数。
- 下游服务超时。
- 文件不存在或权限不足。
- 数据库返回无记录。
库代码通常应返回错误,让应用决定策略。
recover:在边界处隔离崩溃
recover 只有在同一 Goroutine 的 defer 中调用才有效。
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
log.Printf("panic: %v\n%s", recovered, debug.Stack())
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
恢复后应记录堆栈并返回明确失败,不能静默忽略 panic。recover 也不能替代正常错误处理。
新启动的 Goroutine 需要自己的恢复边界:
go func() {
defer func() {
if recovered := recover(); recovered != nil {
log.Printf("worker panic: %v\n%s", recovered, debug.Stack())
}
}()
runWorker()
}()
错误应该在哪里记录日志
同一个错误不要在每一层都记录,否则一次故障会产生大量重复日志。常见策略是:
- 底层函数补充操作上下文并返回错误。
- 业务层将底层错误转换为稳定的领域错误。
- HTTP、RPC、任务消费者等系统边界统一记录日志和指标。
- 面向客户端返回安全信息,不泄露 SQL、文件路径或内部地址。
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrNotFound):
http.Error(w, "resource not found", http.StatusNotFound)
case errors.Is(err, ErrConflict):
http.Error(w, "resource conflict", http.StatusConflict)
default:
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}
测试错误行为
测试应验证错误类别,而不是绑定完整错误文本。
func TestFindUser_NotFound(t *testing.T) {
_, err := findUser(context.Background(), 404)
if !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
对于自定义类型,使用 errors.As 验证结构化字段。
实践检查清单
- 每个错误是否包含失败动作和必要标识,但不泄露敏感信息。
- 包装错误时是否使用
%w保留原因。 - 调用方是否使用
errors.Is或errors.As,而不是比较字符串。 - 文件、响应体、事务等资源是否通过 defer 正确释放。
- 同一个错误是否只在合适的系统边界记录一次。
- panic 是否只用于不可恢复的程序错误。
- HTTP、RPC 和后台任务入口是否具备恢复与堆栈记录机制。
- 测试是否覆盖关键错误类别和资源清理路径。
好的错误处理不是让错误消失,而是保留足够上下文,让上层能够做出正确决策,同时保证资源始终被释放、服务边界不会因单个请求而崩溃。