别再只会 if err != nil:Go error 从错误链到工程实战详解

作者:唐青枫日期:2026/7/4

简介

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.Iserrors.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.Iserrors.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.Iserrors.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.Iserrors.As

一个 fmt.Errorf 可以包装多个错误

现代 Go 允许一个 fmt.Errorf 格式串包含多个 %w

1err := fmt.Errorf("保存失败,写入错误: %w,关闭错误: %w", writeErr, closeErr)
2

这同样会形成多分支错误树,errors.Iserrors.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.Iserrors.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 状态对外错误码
参数校验失败400INVALID_ARGUMENT
资源不存在404NOT_FOUND
数据冲突409CONFLICT
权限不足403PERMISSION_DENIED
未知系统错误500INTERNAL_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.Iserrors.As,或者在确实需要遍历时断言 interface{ Unwrap() []error }

错误包装层数越多越好吗

不是。

包装的价值在于补充上下文和保留原因。没有新增信息的包装只会让文本重复。包边界、业务阶段和外部系统调用通常是比较有价值的包装位置。

自定义错误应该使用值还是指针

两种方式都可以,但需要保持一致。

复杂错误通常使用指针接收者:

1func (e *ValidationError) Error() string
2

这样只有 *ValidationError 实现 errorerrors.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 只是错误处理的入口。真正稳定的错误体系,需要保留原因、补充上下文、提供可判断的错误语义,并在合适的边界完成日志记录和协议转换。


别再只会 if err != nil:Go error 从错误链到工程实战详解》 是转载文章,点击查看原文


相关推荐


Java 虚拟线程实战指南:从 Thread API 到 Spring Boot 高并发应用
唐青枫2026/6/26

简介 虚拟线程的英文名是 Virtual Thread,它是 Project Loom 带来的轻量级线程实现。 虚拟线程在 JDK 19、JDK 20 中经历了两轮预览,到了 JDK 21 正式发布。 简单理解: 平台线程:Java 线程长期绑定操作系统线程 虚拟线程:大量 Java 线程由 JVM 调度到少量操作系统线程上 传统 Java 服务经常采用“一请求一线程”的处理方式。 代码很直观,但平台线程数量有限。当大量请求都在等待数据库、HTTP 接口、文件或消息队列时,线程本身会先成为瓶颈


Java Flyway 实战指南:用 SQL 脚本管理数据库版本
唐青枫2026/6/17

简介 Flyway 是一个数据库迁移工具。 它解决的问题和 Liquibase 类似: 数据库结构怎么跟着项目版本一起演进。 不过 Flyway 的风格更简单直接。 它主要通过 SQL 文件管理数据库变更。 比如: V1__create_users_table.sql V2__add_user_email_column.sql V3__create_orders_table.sql V4__insert_init_data.sql 应用启动或命令执行时,Flyway 会检查哪些脚本已经执行过


AI 代理只会在本地打转?我用 MCP 给它接上手脚,3 步接通第一个外部服务
大鹏AI教育2026/6/10

AI 代理只会在本地打转?我用 MCP 给它接上手脚,3 步接通第一个外部服务 先说结论:很多人觉得自己的 AI 代理"不够聪明",其实它不笨,是够不着外面的世界——能读本地文件、能跑命令,却连不上你的数据库、内部接口、第三方服务。我一开始也卡在这儿,把 MCP 跑通后才明白:问题从来不在模型,在它有没有"手脚"。 这篇我把给 OpenClaw 小龙虾(Claude Code 同款)接第一个 MCP 服务的过程讲一遍,连我踩的三个坑和边界判断一起给你。 1. 真问题:AI 写得出脚本,却发不出请


前端跨域完全指南:从 JSONP 到 Nginx 反向代理,一次性彻底搞懂
不会敲代码12026/6/2

前端跨域完全指南:从 JSONP 到 Nginx 反向代理,一次性彻底搞懂 同源策略是浏览器最坚实的护城河,而跨域方案就是一道道精心设计的城门。 前言 前后端分离开发早已成为标配。前端跑 localhost:5173,后端跑 localhost:3000,端口不同,跨域就来了。再加上调用第三方 API、对接合作商接口,跨域问题几乎是每个前端开发者的必修课。 这篇文章从「为什么会有跨域」出发,一次性梳理 JSONP、CORS、WebSocket、postMessage、Vite Proxy、N


详解MySQL事务(超详细版)
一条泥憨鱼2026/5/25

🌈个人主页:一条泥憨鱼(欢迎各位大佬莅临) 🎬精选专栏:数据结构与算法,JavaSE ,苍穹外卖日记 前言: “事务(Transaction)”是数据库开发里非常重要的知识。 简单来说: 事务就是“一组操作,要么全部成功,要么全部失败”。 它主要用于: 转账 下订单 库存扣减 支付系统 多表更新 这些场景都不能只执行一半,否则数据就会出错。 一、为什么需要事务? 先看一个经典案例: 银行转账 假设: 张三账户:1000 元


OpenClaw梦境系统使用介绍
handsomestWei2026/5/3

OpenClaw梦境系统使用介绍 全文链接:OpenClaw梦境系统使用介绍 本文整理 OpenClaw 2.x / 2.5 路线上围绕 Dream Engine(梦境 / 记忆抽象系统) 的能力划分、工作流、指令与场景示例。安装方式、子命令与频道行为会随版本迭代变化,以当前环境 openclaw --help 与官方文档为准。 一、2.x 新功能概览 功能模块关键改进使用上的直接收益① Dream Engine(记忆 / 梦境系统)引入 Dream 概念:对原始 Memor


DeepSeek-V4-Pro 写代码到底行不行?我拿 GLM-5.1 跟它硬碰硬比了一轮
孟健AI编程2026/4/24

大家好,我是孟健。 DeepSeek-V4-Pro 发了,官方说代码能力大幅升级。这种话我听得多了,每次新模型发布都这么说。 但我确实好奇:V4 在写代码这件事上,到底有没有追上 GLM-5.1? GLM-5.1 是我日常写代码的主力模型,用了几个月了,它什么水平我心里有数。所以这次我不跑 benchmark,不拼跑分,就拿我实际工作中的四个场景,让两个模型正面硬刚。 四个场景:源码分析、功能实现、大文件拆分、项目架构分析。 最后再算笔账,看看成本谁更划算。 场景一:项目分析,分析 Claude


飞书机器人权限批量导入
itmanll2026/4/15

{ "scopes": { "tenant": [ "contact:contact.base:readonly", "im:app_feed_card:write", "im:biz_entity_tag_relation:read", "im:biz_entity_tag_relation:write", "im:chat", "im:chat.access_event.bot_p2p_chat:read",


云计算基础
**Cara**2026/4/7

1.数据中心 数据中心就是用稳定的电力、网络、机房环境,支撑服务器和存储,安全、可靠、不间断地跑业务、存数据。 1.简述 1.数据中心: IDC Internet Data Center(互联网数据中心) 从作用上来看,数据中心就是一个超大号的机房,里面有很多很多的服务器,专门对数据进行集中管理(存储、计算、交换) 定义:一套复杂的设施 能容纳多个服务器及通信设备 2.数据中心级别 3.数据中心选址 地理条件: 海拔高 气温低 非地震带 成本因素 电费 政策导


TRAE Friends@济南第4次活动:100+极客集结,2小时极限编程燃爆全场!
飞哥数智谈2026/3/30

飞哥数智谈,现居于济南,AI提效、AI编程实践者,AI·Spring 社群发起人,同时,担任 TRAE Friends 社区济南 Fellow,致力于AI 提效与AI编程落地,最近长期举办 openclaw 系列活动《养虾记》。 昨天(3.28),100多位 AI 爱好者带着电脑齐聚齐鲁软件园,大家现场编程、热烈讨论、共同路演,活动气氛空前热烈。 活动感受 说实话,活动的热度有点超出预期。 要知道,这次活动不是嘉宾分享会、也不是普通的交流会,而是一次要求每个人带着电脑,现场编程的路演型 Wo

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读