简介
Go 代码里最常见的错误处理大概是这样:
1result, err := doSomething() 2if err != nil { 3 return err 4} 5
这几行代码不难,真正容易出问题的是后面的选择:
1应该新建错误,还是包装原错误? 2应该使用 ==,还是 errors.Is? 3什么时候需要自定义错误类型? 4错误应该在哪一层记录日志? 5多个清理操作同时失败,应该返回哪一个错误? 6普通错误、panic 和 recover 到底怎么分工? 7
Go 没有把错误处理藏进异常机制,而是把错误当成普通值显式传递。
这套设计看起来重复,却带来一个直接好处:函数签名会明确说明操作可能失败,调用方也能在失败发生的位置决定返回、重试、降级还是终止。
一句话概括:
1error 不只是错误文本,它还是可以分类、包装、传递和组合的值。 2
error 到底是什么
error 是 Go 内置的接口类型,定义非常简单:
1type error interface { 2 Error() string 3} 4
任何类型只要实现了 Error() string,就实现了 error 接口。
1package main 2 3import "fmt" 4 5type ValidationError struct { 6 Field string 7 Message string 8} 9 10func (e *ValidationError) Error() string { 11 return fmt.Sprintf("字段 %s:%s", e.Field, e.Message) 12} 13 14func validateName(name string) error { 15 if name == "" { 16 return &ValidationError{Field: "name", Message: "不能为空"} 17 } 18 return nil 19} 20 21func main() { 22 err := validateName("") 23 fmt.Println(err) 24} 25
输出:
1字段 name:不能为空 2
ValidationError 没有声明实现某个接口。Go 使用隐式接口实现,只要方法集合满足要求,就可以作为 error 返回。
nil 表示操作成功
函数通常把 error 放在最后一个返回值:
1func divide(a, b int) (int, error) { 2 if b == 0 { 3 return 0, errors.New("除数不能为 0") 4 } 5 return a / b, nil 6} 7
约定很明确:
err == nil:操作成功,其他返回值可以使用err != nil:操作失败,先处理错误
完整示例:
1package main 2 3import ( 4 "errors" 5 "fmt" 6) 7 8func divide(a, b int) (int, error) { 9 if b == 0 { 10 return 0, errors.New("除数不能为 0") 11 } 12 return a / b, nil 13} 14 15func main() { 16 result, err := divide(12, 3) 17 if err != nil { 18 fmt.Println("计算失败:", err) 19 return 20 } 21 22 fmt.Println("计算结果:", result) 23} 24
输出:
1计算结果: 4 2
错误分支尽早返回,可以减少嵌套:
1data, err := loadData() 2if err != nil { 3 return err 4} 5 6result, err := parseData(data) 7if err != nil { 8 return err 9} 10 11return saveResult(result) 12
正常流程保持在左侧,错误处理紧跟在可能失败的调用后面,这就是 Go 项目里常见的写法。
创建错误的三种常见方式
errors.New:固定错误文本
errors.New 适合创建不需要动态参数的简单错误:
1err := errors.New("用户名不能为空") 2
完整示例:
1package main 2 3import ( 4 "errors" 5 "fmt" 6) 7 8func checkAge(age int) error { 9 if age < 0 { 10 return errors.New("年龄不能小于 0") 11 } 12 return nil 13} 14 15func main() { 16 if err := checkAge(-1); err != nil { 17 fmt.Println(err) 18 } 19} 20
fmt.Errorf:带动态信息
错误信息需要带上文件名、用户 ID 或参数值时,使用 fmt.Errorf:
1return fmt.Errorf("用户 %d 不存在", userID) 2
这里仅仅是格式化文本,还没有形成错误链。
1package main 2 3import "fmt" 4 5func findUser(id int64) error { 6 return fmt.Errorf("用户 %d 不存在", id) 7} 8 9func main() { 10 fmt.Println(findUser(1001)) 11} 12
输出:
1用户 1001 不存在 2
自定义错误:携带结构化信息
如果调用方需要读取错误码、字段名、重试时间等信息,应定义错误类型,而不是解析错误字符串。
1type RateLimitError struct { 2 RetryAfter time.Duration 3} 4 5func (e *RateLimitError) Error() string { 6 return fmt.Sprintf("请求过于频繁,%s 后重试", e.RetryAfter) 7} 8
错误文本适合阅读,结构化字段适合程序判断。
错误文本不是错误身份
下面两个错误的文本相同,但不是同一个错误值:
1first := errors.New("not found") 2second := errors.New("not found") 3 4fmt.Println(first == second) 5
输出:
1false 2
因此,不能在判断时临时创建一个同文本错误:
1if errors.Is(err, errors.New("not found")) { 2 // 通常匹配不到 3} 4
需要稳定识别的错误,应复用同一个变量:
1var ErrNotFound = errors.New("not found") 2
这种包级错误值通常称为哨兵错误。
哨兵错误:表示稳定的错误类别
哨兵错误适合表达调用方需要识别的固定状态:
1var ( 2 ErrNotFound = errors.New("not found") 3 ErrConflict = errors.New("conflict") 4 ErrPermission = errors.New("permission denied") 5) 6
完整示例:
1package main 2 3import ( 4 "errors" 5 "fmt" 6) 7 8var ErrUserNotFound = errors.New("user not found") 9 10func findUser(id int64) error { 11 if id != 1001 { 12 return ErrUserNotFound 13 } 14 return nil 15} 16 17func main() { 18 err := findUser(2002) 19 if errors.Is(err, ErrUserNotFound) { 20 fmt.Println("返回 404") 21 return 22 } 23 24 if err != nil { 25 fmt.Println("返回 500") 26 } 27} 28
导出的哨兵错误会成为包的公开契约。调用方一旦依赖 ErrUserNotFound,后续修改时就要继续维护这个语义。
只想返回一段说明,不希望调用方依赖错误类别时,普通错误文本通常已经够用。
为什么要包装错误
底层函数返回的错误往往缺少业务上下文。
例如:
1file does not exist 2
只看这句话,不知道读取了哪个文件,也不知道发生在哪个业务流程。
可以使用 %w 包装原错误:
1return fmt.Errorf("读取配置 %q: %w", filename, err) 2
包装后同时保留两部分信息:
1外层上下文:读取配置 "app.json" 2底层原因:file does not exist 3
完整示例:
1package main 2 3import ( 4 "errors" 5 "fmt" 6 "os" 7) 8 9func readConfig(filename string) ([]byte, error) { 10 data, err := os.ReadFile(filename) 11 if err != nil { 12 return nil, fmt.Errorf("读取配置 %q: %w", filename, err) 13 } 14 return data, nil 15} 16 17func main() { 18 _, err := readConfig("missing.json") 19 if err == nil { 20 return 21 } 22 23 fmt.Println(err) 24 if errors.Is(err, os.ErrNotExist) { 25 fmt.Println("配置文件不存在,加载默认配置") 26 } 27} 28
输出类似:
1读取配置 "missing.json": open missing.json: no such file or directory 2配置文件不存在,加载默认配置 3
%w 和 %v 的差别非常重要:
1fmt.Errorf("读取配置失败: %v", err) // 只拼接文本 2fmt.Errorf("读取配置失败: %w", err) // 包装错误,保留错误链 3
两者打印出来可能很像,但只有 %w 能让 errors.Is、errors.As 继续识别底层错误。
错误链是怎么形成的
使用 %w 包装后,外层错误会提供:
1Unwrap() error 2
可以把单链错误理解成:
1HTTP 层错误 2 ↓ Unwrap 3Service 层错误 4 ↓ Unwrap 5Repository 层错误 6 ↓ Unwrap 7ErrNotFound 8
示例:
1package main 2 3import ( 4 "errors" 5 "fmt" 6) 7 8var ErrRecordNotFound = errors.New("record not found") 9 10func queryUser(id int64) error { 11 return fmt.Errorf("查询 user_id=%d: %w", id, ErrRecordNotFound) 12} 13 14func loadProfile(id int64) error { 15 if err := queryUser(id); err != nil { 16 return fmt.Errorf("加载用户资料: %w", err) 17 } 18 return nil 19} 20 21func main() { 22 err := loadProfile(1001) 23 fmt.Println(err) 24 fmt.Println(errors.Is(err, ErrRecordNotFound)) 25 fmt.Println(errors.Unwrap(err)) 26} 27
输出:
1加载用户资料: 查询 user_id=1001: record not found 2true 3查询 user_id=1001: record not found 4
errors.Unwrap 只拆一层。业务判断通常不需要手动循环解包,直接使用 errors.Is 或 errors.As 即可。
errors.Is:判断错误值和错误类别
直接使用 == 只能比较当前错误值:
1err == ErrNotFound 2
错误被包装后,外层错误不再等于哨兵错误:
1wrapped := fmt.Errorf("查询失败: %w", ErrNotFound) 2 3fmt.Println(wrapped == ErrNotFound) // false 4fmt.Println(errors.Is(wrapped, ErrNotFound)) // true 5
errors.Is 会沿错误树向下检查:
- 当前错误是否等于目标错误
- 当前错误是否实现自定义的
Is(error) bool - 当前错误能否通过
Unwrap继续展开
因此,只要错误可能被包装,就优先使用:
1if errors.Is(err, ErrNotFound) { 2 // 按未找到处理 3} 4
不要比较错误文本:
1if err.Error() == "record not found" { 2 // 文本稍有变化就失效 3} 4
错误文本用于展示和日志,errors.Is 用于程序分支。
errors.As:提取错误链中的具体类型
哨兵错误适合表达固定类别,自定义错误类型适合携带额外字段。
1package main 2 3import ( 4 "errors" 5 "fmt" 6) 7 8type ValidationError struct { 9 Field string 10 Value any 11 Message string 12} 13 14func (e *ValidationError) Error() string { 15 return fmt.Sprintf("字段 %s 的值 %v 不合法:%s", e.Field, e.Value, e.Message) 16} 17 18func validateAge(age int) error { 19 if age < 0 || age > 150 { 20 return &ValidationError{ 21 Field: "age", 22 Value: age, 23 Message: "必须在 0 到 150 之间", 24 } 25 } 26 return nil 27} 28 29func register(age int) error { 30 if err := validateAge(age); err != nil { 31 return fmt.Errorf("注册校验失败: %w", err) 32 } 33 return nil 34} 35 36func main() { 37 err := register(200) 38 39 var validationErr *ValidationError 40 if errors.As(err, &validationErr) { 41 fmt.Printf("field=%s, value=%v, message=%s\n", 42 validationErr.Field, 43 validationErr.Value, 44 validationErr.Message, 45 ) 46 } 47} 48
输出:
1field=age, value=200, message=必须在 0 到 150 之间 2
直接类型断言只检查最外层动态类型:
1validationErr, ok := err.(*ValidationError) 2
如果错误已经被 %w 包装,通常会断言失败。
errors.As 会遍历错误链,更适合提取可能被包装的自定义错误。
目标变量的写法要与错误类型一致:
1var target *ValidationError 2if errors.As(err, &target) { 3 // target 的类型是 *ValidationError 4} 5
自定义错误同时保留底层原因
自定义错误不仅可以保存业务字段,也可以实现 Unwrap 保留底层错误:
1package main 2 3import ( 4 "errors" 5 "fmt" 6) 7 8var ErrInsufficientBalance = errors.New("insufficient balance") 9 10type PaymentError struct { 11 OrderID int64 12 Code string 13 Err error 14} 15 16func (e *PaymentError) Error() string { 17 return fmt.Sprintf("订单 %d 支付失败,code=%s: %v", e.OrderID, e.Code, e.Err) 18} 19 20func (e *PaymentError) Unwrap() error { 21 return e.Err 22} 23 24func pay(orderID int64) error { 25 return &PaymentError{ 26 OrderID: orderID, 27 Code: "BALANCE_NOT_ENOUGH", 28 Err: ErrInsufficientBalance, 29 } 30} 31 32func main() { 33 err := pay(9001) 34 35 if errors.Is(err, ErrInsufficientBalance) { 36 fmt.Println("提示余额不足") 37 } 38 39 var paymentErr *PaymentError 40 if errors.As(err, &paymentErr) { 41 fmt.Printf("order_id=%d, code=%s\n", paymentErr.OrderID, paymentErr.Code) 42 } 43} 44
输出:
1提示余额不足 2order_id=9001, code=BALANCE_NOT_ENOUGH 3
这时同一个错误同时支持两种判断:
errors.Is判断底层错误类别errors.As读取外层结构化信息
errors.Join:合并多个错误
有些操作可能同时产生多个错误。
例如关闭多个资源、批量校验多个字段、并行执行多个任务。只返回最后一个错误,会把前面的错误丢掉。
Go 1.20 增加了 errors.Join:
1joined := errors.Join(firstErr, secondErr) 2
它有几个特点:
- 忽略传入的
nil - 所有参数都是
nil时返回nil - 错误文本通常按换行连接
errors.Is和errors.As可以遍历每个分支
批量校验示例:
1package main 2 3import ( 4 "errors" 5 "fmt" 6 "strings" 7) 8 9var ( 10 ErrNameRequired = errors.New("name is required") 11 ErrPasswordTooShort = errors.New("password is too short") 12 ErrEmailInvalid = errors.New("email is invalid") 13) 14 15type RegisterRequest struct { 16 Name string 17 Email string 18 Password string 19} 20 21func validate(request RegisterRequest) error { 22 var errs []error 23 24 if strings.TrimSpace(request.Name) == "" { 25 errs = append(errs, ErrNameRequired) 26 } 27 if !strings.Contains(request.Email, "@") { 28 errs = append(errs, ErrEmailInvalid) 29 } 30 if len(request.Password) < 8 { 31 errs = append(errs, ErrPasswordTooShort) 32 } 33 34 return errors.Join(errs...) 35} 36 37func main() { 38 err := validate(RegisterRequest{ 39 Name: "", 40 Email: "invalid-email", 41 Password: "123", 42 }) 43 44 if err == nil { 45 fmt.Println("校验通过") 46 return 47 } 48 49 fmt.Println(err) 50 fmt.Println("邮箱错误:", errors.Is(err, ErrEmailInvalid)) 51 fmt.Println("密码错误:", errors.Is(err, ErrPasswordTooShort)) 52} 53
输出:
1name is required 2email is invalid 3password is too short 4邮箱错误: true 5密码错误: true 6
errors.Join 形成的是错误树,不再只是单链。
它实现的是:
1Unwrap() []error 2
而 errors.Unwrap 只处理 Unwrap() error,不会返回 errors.Join 的子错误。判断合并错误时,应直接使用 errors.Is 和 errors.As。
一个 fmt.Errorf 可以包装多个错误
现代 Go 允许一个 fmt.Errorf 格式串包含多个 %w:
1err := fmt.Errorf("保存失败,写入错误: %w,关闭错误: %w", writeErr, closeErr) 2
这同样会形成多分支错误树,errors.Is 和 errors.As 可以检查其中任意分支。
如果只是把多个独立错误汇总起来,errors.Join 通常更直接。
如果还需要在一条错误文本中说明每个错误对应的操作,多个 %w 更有表达力。
实战一:读取并解析配置
下面的 demo 串起文件读取、JSON 解析、错误包装和错误分类。
1package main 2 3import ( 4 "encoding/json" 5 "errors" 6 "fmt" 7 "os" 8) 9 10type Config struct { 11 Address string [`json:"address"`](https://xplanc.org/primers/document/zh/03.HTML/EX.HTML%20%E5%85%83%E7%B4%A0/EX.address.md) 12 Port int `json:"port"` 13} 14 15func loadConfig(filename string) (Config, error) { 16 data, err := os.ReadFile(filename) 17 if err != nil { 18 return Config{}, fmt.Errorf("读取配置文件 %q: %w", filename, err) 19 } 20 21 var config Config 22 if err := json.Unmarshal(data, &config); err != nil { 23 return Config{}, fmt.Errorf("解析配置文件 %q: %w", filename, err) 24 } 25 26 if config.Address == "" { 27 return Config{}, errors.New("配置 address 不能为空") 28 } 29 if config.Port <= 0 || config.Port > 65535 { 30 return Config{}, fmt.Errorf("配置 port 超出范围: %d", config.Port) 31 } 32 33 return config, nil 34} 35 36func main() { 37 config, err := loadConfig("app.json") 38 if err != nil { 39 switch { 40 case errors.Is(err, os.ErrNotExist): 41 fmt.Println("配置文件不存在") 42 case errors.Is(err, os.ErrPermission): 43 fmt.Println("没有读取配置文件的权限") 44 default: 45 var syntaxErr *json.SyntaxError 46 if errors.As(err, &syntaxErr) { 47 fmt.Printf("JSON 第 %d 字节附近存在语法错误\n", syntaxErr.Offset) 48 return 49 } 50 fmt.Println("加载配置失败:", err) 51 } 52 return 53 } 54 55 fmt.Printf("配置加载成功:%s:%d\n", config.Address, config.Port) 56} 57
这个例子体现了分层处理方式:
1底层负责返回具体原因。 2中间层使用 %w 增加操作上下文。 3边界层使用 Is 或 As 决定最终动作。 4
实战二:统一 HTTP 错误响应
业务层不应该到处拼 HTTP JSON,也不应该把数据库错误原文直接返回给客户端。
可以定义应用错误,在 HTTP 边界统一映射:
1package main 2 3import ( 4 "encoding/json" 5 "errors" 6 "fmt" 7 "net/http" 8 "net/http/httptest" 9) 10 11var ErrUserNotFound = errors.New("user not found") 12 13type AppError struct { 14 Status int 15 Code string 16 Message string 17 Err error 18} 19 20func (e *AppError) Error() string { 21 return fmt.Sprintf("%s: %v", e.Code, e.Err) 22} 23 24func (e *AppError) Unwrap() error { 25 return e.Err 26} 27 28func getUser(id string) error { 29 if id == "" { 30 return &AppError{ 31 Status: http.StatusBadRequest, 32 Code: "INVALID_ARGUMENT", 33 Message: "id 不能为空", 34 Err: errors.New("empty user id"), 35 } 36 } 37 38 return fmt.Errorf("查询用户 id=%s: %w", id, ErrUserNotFound) 39} 40 41func writeError(w http.ResponseWriter, err error) { 42 status := http.StatusInternalServerError 43 code := "INTERNAL_ERROR" 44 message := "服务暂时不可用" 45 46 var appErr *AppError 47 switch { 48 case errors.As(err, &appErr): 49 status = appErr.Status 50 code = appErr.Code 51 message = appErr.Message 52 case errors.Is(err, ErrUserNotFound): 53 status = http.StatusNotFound 54 code = "USER_NOT_FOUND" 55 message = "用户不存在" 56 } 57 58 w.Header().Set("Content-Type", "application/json") 59 w.WriteHeader(status) 60 _ = json.NewEncoder(w).Encode(map[string]string{ 61 "code": code, 62 "message": message, 63 }) 64} 65 66func handler(w http.ResponseWriter, r *http.Request) { 67 if err := getUser(r.URL.Query().Get("id")); err != nil { 68 writeError(w, err) 69 return 70 } 71 w.WriteHeader(http.StatusNoContent) 72} 73 74func main() { 75 request := httptest.NewRequest(http.MethodGet, "/user?id=1001", nil) 76 recorder := httptest.NewRecorder() 77 78 handler(recorder, request) 79 80 fmt.Println("status:", recorder.Code) 81 fmt.Print("body: ", recorder.Body.String()) 82} 83
输出:
1status: 404 2body: {"code":"USER_NOT_FOUND","message":"用户不存在"} 3
这里把两类信息分开了:
- 内部错误链:用于日志和排查
- 对外错误码与消息:用于稳定 API 契约
数据库地址、SQL、文件路径和调用栈等内部信息不应该直接暴露给客户端。
实战三:只重试可恢复错误
重试不能只看“发生了错误”。参数错误、权限错误等永久性错误,重复执行不会变好。
可以定义带重试信息的错误类型:
1package main 2 3import ( 4 "errors" 5 "fmt" 6 "time" 7) 8 9type TemporaryError struct { 10 After time.Duration 11 Err error 12} 13 14func (e *TemporaryError) Error() string { 15 return fmt.Sprintf("临时错误,%s 后可重试: %v", e.After, e.Err) 16} 17 18func (e *TemporaryError) Unwrap() error { 19 return e.Err 20} 21 22func retry(maxAttempts int, operation func() error) error { 23 var lastErr error 24 25 for attempt := 1; attempt <= maxAttempts; attempt++ { 26 err := operation() 27 if err == nil { 28 return nil 29 } 30 lastErr = err 31 32 var temporaryErr *TemporaryError 33 if !errors.As(err, &temporaryErr) { 34 return err 35 } 36 37 fmt.Printf("第 %d 次执行失败:%v\n", attempt, err) 38 if attempt < maxAttempts { 39 time.Sleep(temporaryErr.After) 40 } 41 } 42 43 return fmt.Errorf("重试 %d 次后仍然失败: %w", maxAttempts, lastErr) 44} 45 46func main() { 47 attempts := 0 48 err := retry(3, func() error { 49 attempts++ 50 if attempts < 3 { 51 return &TemporaryError{ 52 After: time.Millisecond, 53 Err: errors.New("远端服务超时"), 54 } 55 } 56 return nil 57 }) 58 59 fmt.Println("最终错误:", err) 60 fmt.Println("执行次数:", attempts) 61} 62
输出:
1第 1 次执行失败:临时错误,1ms 后可重试: 远端服务超时 2第 2 次执行失败:临时错误,1ms 后可重试: 远端服务超时 3最终错误: <nil> 4执行次数: 3 5
真实项目还应考虑指数退避、随机抖动、上下文取消、请求幂等性和最大总耗时。
实战四:保留关闭资源时的错误
很多代码会这样写:
1defer file.Close() 2
读取文件时通常可以接受,但写文件、刷新缓冲区或提交数据时,关闭阶段也可能失败。完全忽略 Close 错误,可能把写入不完整误判成成功。
可以使用命名返回值和 errors.Join 合并主流程错误与关闭错误:
1package main 2 3import ( 4 "errors" 5 "fmt" 6) 7 8type Writer struct { 9 writeErr error 10 closeErr error 11} 12 13func (w *Writer) Write([]byte) error { 14 return w.writeErr 15} 16 17func (w *Writer) Close() error { 18 return w.closeErr 19} 20 21func save(writer *Writer, data []byte) (err error) { 22 defer func() { 23 err = errors.Join(err, writer.Close()) 24 }() 25 26 if writeErr := writer.Write(data); writeErr != nil { 27 return fmt.Errorf("写入数据: %w", writeErr) 28 } 29 30 return nil 31} 32 33func main() { 34 writeErr := errors.New("磁盘空间不足") 35 closeErr := errors.New("刷新缓冲区失败") 36 37 err := save(&Writer{writeErr: writeErr, closeErr: closeErr}, []byte("data")) 38 fmt.Println(err) 39 fmt.Println("包含写入错误:", errors.Is(err, writeErr)) 40 fmt.Println("包含关闭错误:", errors.Is(err, closeErr)) 41} 42
输出:
1写入数据: 磁盘空间不足 2刷新缓冲区失败 3包含写入错误: true 4包含关闭错误: true 5
是否必须处理 Close 错误取决于资源语义。只读文件和内存缓冲区的风险不同,持久化写入、压缩流和网络连接更值得关注关闭阶段的结果。
typed nil:看起来是 nil,返回后却不为 nil
error 是接口,接口值由动态类型和动态值组成。
下面的函数有隐藏问题:
1type QueryError struct{} 2 3func (*QueryError) Error() string { 4 return "query failed" 5} 6 7func bad() error { 8 var err *QueryError 9 return err 10} 11
err 指针虽然是 nil,返回到 error 接口后,接口中仍然保存了动态类型 *QueryError,所以接口本身不等于 nil。
完整示例:
1package main 2 3import "fmt" 4 5type QueryError struct{} 6 7func (*QueryError) Error() string { 8 return "query failed" 9} 10 11func bad() error { 12 var err *QueryError 13 return err 14} 15 16func good() error { 17 return nil 18} 19 20func main() { 21 fmt.Println("bad() == nil:", bad() == nil) 22 fmt.Println("good() == nil:", good() == nil) 23} 24
输出:
1bad() == nil: false 2good() == nil: true 3
没有错误时应直接返回字面量 nil,不要把 nil 具体指针装进 error 接口。
应该包装,还是直接返回
不是每一层都必须包装。
包装适合补充有价值的上下文:
1return fmt.Errorf("读取订单 %d: %w", orderID, err) 2
直接返回适合当前函数没有新增信息的情况:
1return repository.Save(order) 2
无意义的层层包装会产生噪声:
1handler failed: service failed: use case failed: repository failed: query failed 2
更实用的判断标准是:
1当前层能否补充操作名、关键标识或业务阶段? 2
能补充有效上下文就包装,不能就直接返回。
还有一个重要边界:包装底层错误等于允许调用方通过 errors.Is 或 errors.As 观察它。
公开包如果不希望暴露底层实现细节,可以转换成包自己的错误契约,而不是直接 %w 暴露数据库驱动错误。
错误应该在哪里记录日志
常见问题是每一层都记录一次:
1Repository 记录一次 2Service 记录一次 3Handler 再记录一次 4
同一个故障最终产生三条甚至更多重复日志。
更清楚的分工是:
1底层:返回错误,必要时增加上下文。 2中间层:分类、转换或继续包装。 3系统边界:记录一次完整日志,并决定响应、退出或重试。 4
HTTP 服务通常在中间件或 Handler 边界记录;命令行程序通常在 main 附近记录;后台任务通常在任务执行器边界记录。
如果中间层已经真正处理了错误,例如降级成功、忽略了某个可接受错误,记录一条有业务意义的日志也合理。
错误文本怎么写
Go 标准库和常见项目通常使用小写开头、不加句号的错误文本:
1errors.New("user not found") 2fmt.Errorf("read config %q: %w", filename, err) 3
原因是错误经常会被继续包装:
1start server: load config: read config "app.json": file does not exist 2
每一层都写完整句子和句号,组合后会显得断裂。
中文项目不受大小写影响,但仍适合保持短语风格,并包含必要上下文:
1fmt.Errorf("查询订单 order_id=%d: %w", orderID, err) 2
不要把密码、令牌、完整身份证号等敏感数据写进错误文本,因为错误很可能进入日志和监控系统。
error 和 panic 的边界
error 用于调用方可以预期并处理的失败:
- 参数不合法
- 文件不存在
- 用户不存在
- 余额不足
- 请求超时
- 数据库暂时不可用
panic 更适合程序内部不变量被破坏,或者初始化阶段已经无法继续运行:
- 必需的静态模板无法加载
- 程序内部状态违反不变量
- 数组越界和 nil 指针等编程错误
普通业务失败不应该使用 panic:
1if balance < amount { 2 return ErrInsufficientBalance 3} 4
而不是:
1if balance < amount { 2 panic("余额不足") 3} 4
recover 不是通用错误处理
recover 只能在同一个 goroutine 的延迟函数中捕获 panic。
1func runSafely(task func()) (err error) { 2 defer func() { 3 if value := recover(); value != nil { 4 err = fmt.Errorf("任务发生 panic: %v", value) 5 } 6 }() 7 8 task() 9 return nil 10} 11
它适合放在进程边界或任务边界,例如 HTTP 中间件、消息消费者和任务执行器,防止单个任务的 panic 直接拖垮整个服务。
recover 之后不能假装什么都没发生。通常还需要记录堆栈、终止当前请求或任务,并确保共享状态没有处于半完成状态。
不要用 panic 加 recover 模拟 try-catch。可预期失败继续使用 error。
常见错误
忽略 error
不推荐:
1data, _ := os.ReadFile("config.json") 2
如果确实允许忽略,应写清楚原因,例如尽力而为的清理操作。普通业务流程直接丢弃错误,很容易把根因变成后续的空值、脏数据或 panic。
使用 %v 破坏错误链
1return fmt.Errorf("保存用户失败: %v", err) 2
文本还在,但错误身份丢失。
需要保留底层原因时使用:
1return fmt.Errorf("保存用户失败: %w", err) 2
使用 == 判断包装错误
1if err == ErrNotFound { 2 // 包装后匹配不到 3} 4
更稳妥的写法:
1if errors.Is(err, ErrNotFound) { 2 // 可以沿错误链匹配 3} 4
比较 err.Error()
1if err.Error() == "user not found" { 2} 3
错误文本一旦增加 ID、操作名或本地化内容,判断就会失效。稳定分类应使用哨兵错误、错误类型或明确错误码。
返回 nil 具体指针
1func load() error { 2 var err *LoadError 3 return err 4} 5
返回后的接口不等于 nil。成功分支直接 return nil。
到处记录同一个错误
错误每向上传一层就记录一次,会造成重复告警和日志噪声。通常在真正处理错误的边界记录一次即可。
把内部错误直接返回给客户端
下面的做法可能泄露 SQL、路径和内部结构:
1http.Error(w, err.Error(), http.StatusInternalServerError) 2
对外返回稳定的错误码和安全消息,内部日志保留完整错误链。
工程实践建议
先定义错误契约
包的调用方需要区分哪些失败,应在设计 API 时明确:
1var ErrNotFound = errors.New("not found") 2
或者:
1type ValidationError struct { 2 Field string 3} 4
不需要调用方识别的内部细节,不必全部导出。
增加能定位问题的上下文
好的错误信息通常包含:
- 做了什么操作
- 操作对象的非敏感标识
- 底层原因
例如:
1return fmt.Errorf("更新订单 order_id=%d 状态为 %s: %w", orderID, status, err) 2
用 Is 分类,用 As 取字段
1if errors.Is(err, ErrNotFound) { 2 // 判断稳定错误类别 3} 4 5var validationErr *ValidationError 6if errors.As(err, &validationErr) { 7 // 读取字段名等结构化信息 8} 9
在边界完成错误翻译
数据库错误不应该一路原样变成 HTTP 响应。
常见转换关系:
| 内部错误 | HTTP 状态 | 对外错误码 |
|---|---|---|
| 参数校验失败 | 400 | INVALID_ARGUMENT |
| 资源不存在 | 404 | NOT_FOUND |
| 数据冲突 | 409 | CONFLICT |
| 权限不足 | 403 | PERMISSION_DENIED |
| 未知系统错误 | 500 | INTERNAL_ERROR |
同样的业务错误也可以在 gRPC、消息任务或命令行边界翻译成各自的协议结果。
测试错误语义,不要锁死完整文本
脆弱测试:
1if err.Error() != "load user: user not found" { 2 // 文案调整就失败 3} 4
更稳定的测试:
1if !errors.Is(err, ErrUserNotFound) { 2 // 错误类别不符合预期 3} 4
自定义错误可以结合 errors.As 检查关键字段。只有错误文本本身就是公开契约时,才需要完整字符串比较。
常见问题
errors.New 和 fmt.Errorf 怎么选
固定文本使用 errors.New:
1errors.New("invalid state") 2
需要插入变量使用 fmt.Errorf:
1fmt.Errorf("invalid state %q", state) 2
需要保留底层错误链时使用 %w:
1fmt.Errorf("update state: %w", err) 2
errors.Is 会比较错误文本吗
不会。
errors.Is 主要根据错误值身份、错误链和自定义 Is 方法判断。两个文本相同的 errors.New 错误通常不会匹配。
errors.As 和类型断言有什么区别
类型断言只看当前接口值的动态类型:
1target, ok := err.(*ValidationError) 2
errors.As 会沿错误树查找:
1var target *ValidationError 2ok := errors.As(err, &target) 3
错误可能被包装时使用 errors.As。
errors.Unwrap 能拆开 errors.Join 吗
不能。
errors.Unwrap 只调用 Unwrap() error,而 errors.Join 返回的错误实现 Unwrap() []error。合并错误应使用 errors.Is、errors.As,或者在确实需要遍历时断言 interface{ Unwrap() []error }。
错误包装层数越多越好吗
不是。
包装的价值在于补充上下文和保留原因。没有新增信息的包装只会让文本重复。包边界、业务阶段和外部系统调用通常是比较有价值的包装位置。
自定义错误应该使用值还是指针
两种方式都可以,但需要保持一致。
复杂错误通常使用指针接收者:
1func (e *ValidationError) Error() string 2
这样只有 *ValidationError 实现 error,errors.As 的目标也应写成:
1var target *ValidationError 2errors.As(err, &target) 3
不要一部分代码返回值,一部分代码返回指针,否则匹配逻辑容易混乱。
总结
Go error 的核心可以压缩成几句话:
1error 是只有 Error() string 方法的内置接口。 2nil 表示成功,非 nil 表示失败。 3errors.New 创建固定错误,fmt.Errorf 创建动态错误。 4fmt.Errorf 配合 %w 可以增加上下文并保留错误链。 5errors.Is 用于判断错误值和错误类别。 6errors.As 用于提取错误链中的具体错误类型。 7errors.Join 用于保留多个并列错误。 8错误文本用于阅读,错误值、类型和错误码用于程序判断。 9可预期失败返回 error,内部不变量破坏才考虑 panic。 10日志通常在系统边界记录一次。 11
日常选择可以按下面这张表判断:
| 场景 | 常见写法 |
|---|---|
| 固定错误文本 | errors.New("...") |
| 动态错误文本 | fmt.Errorf("id=%d", id) |
| 包装底层错误 | fmt.Errorf("操作失败: %w", err) |
| 判断哨兵错误 | errors.Is(err, ErrNotFound) |
| 提取自定义错误 | errors.As(err, &target) |
| 合并多个错误 | errors.Join(errs...) |
| 普通业务失败 | 返回 error |
| 内部不变量被破坏 | 视边界决定是否 panic |
| HTTP 或任务边界 | 记录日志并翻译错误 |
if err != nil 只是错误处理的入口。真正稳定的错误体系,需要保留原因、补充上下文、提供可判断的错误语义,并在合适的边界完成日志记录和协议转换。
