1. 错误处理的核心思路
1.1 Go错误模型与其他语言的差异
接触Go的人基本第一天就会碰到error这个接口。Go没有异常(exception)机制,函数出错时通过显式返回error来表示,调用方必须处理或继续向上传播。这个设计在刚开始写的时候会让人觉得繁琐,尤其是从Java、Python转过来的程序员,第一反应往往是“这么多if err != nil烦不烦”。当你写过一段时间,特别是维护过线上服务以后,就会明白这种显式错误传播的价值:控制流是清晰的,每个可能出错的点都摆在明面上,不会出现异常被吞掉、调用栈断掉却没人知道的情况。
关于“错误处理”本身,Go的标准库给了一个非常克制的基础设施:errors.New创建错误、fmt.Errorf格式化错误、errors.Is和errors.As做错误判断和类型提取。这套东西从Go 1.13开始才算真正完整起来,因为在1.13之前,错误包装基本靠自定义类型或第三方库,标准库只提供了errors.New、errors.Unwrap这些零散能力。现在项目里同时存在老代码和新代码,这也是很常见的:老代码用fmt.Errorf("...: %v", err),新代码逐步改成fmt.Errorf("...: %w", err)。
1.2 错误处理的三类场景
我在实际工作中会把错误处理分成三个场景,它们的诉求差别很大,选型时优先级也不一样。
第一类是底层调用失败后的透传场景。比如数据库查询失败、RPC调用超时,这类错误需要原样往上抛,但同时要附加“当前在做什么”的上下文信息,比如查询的是哪个表、调的是哪个服务。这类场景的核心需求是可追踪,既要保留原始错误,又要能快速定位到出错的业务位置。
第二类是业务判断场景。比如用户余额不足、参数校验失败,这类错误不是系统故障,而是业务规则不满足,往往需要调用方根据错误类型做出不同响应。这类场景的核心需求是可判别,也就是说,上层能通过类型断言或errors.As判断到底发生了什么。
第三类是终止型错误。比如配置加载失败、初始化连接失败,这类错误一旦发生,程序基本无法继续运行,直接返回错误让上层决定是否退出。这类场景的核心需求是完整信息,错误信息里最好能包含配置路径、连接地址、操作意图等所有有助于排查的内容。
理解这三类场景后,我们再回头看标准库errors和pkg/errors提供的工具,思路就会清晰很多。
2. 标准库errors的使用实践
2.1 错误生成与基础传播
标准库里,最简单的错误生成是errors.New("something failed")。这个函数会返回一个error接口,底层是一个errorString结构体,只包含一条字符串消息。它的特点是没有额外的元数据,也没有堆栈信息,出现错误时只能靠消息里的文字去理解问题。
基础传播就更加直接了:
func LoadUser(id int) (*User, error) { rows, err := db.Query("SELECT ... FROM users WHERE id = ?", id) if err != nil { return nil, err } defer rows.Close() // ... }直接return nil, err的好处是原汁原味,错误没有被污染;坏处是上层收到错误后只知道“数据库查询失败了”,但不知道是在加载用户的过程里失败的。假如一个服务里有十几个地方查询数据库,日志里只有“query failed”这样的消息,排查起来就得靠猜。这就是为什么要引入错误包装。
2.2 错误包装与%w的引入
Go 1.13之前,常见的包装写法是fmt.Errorf("load user failed: %v", err)。这种做法把原始错误消息拼进了新错误字符串,但有一个隐患:原始错误的类型信息丢了,调用方无法用err == sql.ErrNoRows或类型断言去判断具体错误类型。
Go 1.13引入了%w动词,专门用于错误包装:
if err != nil { return nil, fmt.Errorf("load user %d failed: %w", id, err) }%w会把原始错误作为Unwrap的目标保存在新错误里。这样,上层通过errors.Is(err, sql.ErrNoRows)或errors.As依然能判断出底层错误类型,同时错误消息又保留了下层和上层的上下文。
这里我给一个非常重要的建议:格式化错误消息时,尽量把关键变量放进去。比如“load user 123 failed”,比“load user failed”有用得多。我们在生产环境排查问题时,最痛恨的就是日志里看到一个没有变量、没有上下文、没有堆栈的错误消息,根本无从下手。
2.3 errors.Is与errors.As的类型化判断机制
标准的错误判断有几种写法,很多人一开始会写错,我详细说说。
最基础的方式是直接比较:
if err == sql.ErrNoRows { // 没有查到数据 }这种方式只适用完全没有包装的场景。一旦中间有人用了fmt.Errorf包装过,比较就失效了。所以Go 1.13之后推荐用errors.Is:
if errors.Is(err, sql.ErrNoRows) { // 没有查到数据 }errors.Is的实现逻辑是:如果err直接等于目标值,返回 true;如果err实现了Unwrap() error接口,就递归往下比较,直到链的末端。这其实是一个沿着错误链逐层查找的过程。
再来看errors.As,它的作用不是判断错误是否等于某个特定值,而是判断错误链上是否存在某个特定类型,并把找到的第一个匹配值提取出来:
var target *MyCustomError if errors.As(err, &target) { // target 已经被填充,可以读取里面追加的字段 }注意这里的细节:target必须是指向特定错误类型的指针的指针。为什么呢?因为errors.As内部需要把找到的值赋值给target指向的变量,如果不是合法的指针类型,它会直接 panic。举个例子:
var target *net.DNSError if errors.As(err, &target) { fmt.Println(target.Name) }这里的target是*net.DNSError,&target就是**net.DNSError。这样才符合接口约束。
3. pkg/errors:栈追踪与错误包装的升级
3.1 pkg/errors带来的核心能力
标准库能解决大部分问题,但它有一个很明显的短板:没有堆栈信息。我们在生产环境往往看到一个错误消息,却不知道它在代码里到底是哪一行产生的,尤其当错误经过多级传递之后,原始产生点早就找不到了。
pkg/errors这个库(github.com/pkg/errors)很好地补上了这块。它提供了两个核心能力:
- 创建错误时自动捕获当时的调用堆栈
- 包装错误时同时保留堆栈和上下文
它的用法和标准库非常接近:
import "github.com/pkg/errors" // 相当于 errors.New,但带着当前堆栈 errors.New("something bad happened") // 相当于 fmt.Errorf,但带着当前堆栈 errors.Errorf("load user %d failed: %v", id, err) // 包装一个已有错误,带着当前堆栈 errors.Wrap(err, "load user failed") // 包装并格式化 errors.Wrapf(err, "load user %d failed", id)这个库的设计很有意思,它把错误分成了两个层次:最底层原始错误负责描述“发生了什么”,外层包装负责描述“在哪里发生的”和“为什么发生”。如果把错误链比作一层层的洋葱,Wrap就是给洋葱加一层皮,而每一层皮上都保留着当时的堆栈快照。
3.2 WithStack、Wrapf、Cause的细节语义
pkg/errors里有一个高频组合是errors.WithStack(err)。它只做一件事:给已有错误附加堆栈,不做消息格式化。通常用在调用栈深、暂时不需要加额外说明的地方,或者底层库内部想把错误直接传出去,但希望保留足够线索的场景。
errors.Wrapf则更常用。它就是“加消息 + 加堆栈”的合体:
func GetUser(id int) (*User, error) { user, err := userRepo.FindByID(id) if err != nil { return nil, errors.Wrapf(err, "GetUser: id=%d", id) } return user, nil }当我看到GetUser: id=123这样的错误消息时,心里会踏实很多:我知道是哪一层、传了什么参数、底层是什么问题。
errors.Cause是获取错误链最底层的错误:它会沿着Cause() error方法一路往下找,直到没有实现这个方法的错误为止。这在需要“最根本原因”的场景下非常有用,比如底层是数据库连接失败,中间经过多个服务层包装,最终在入口处判断errors.Cause(err) == context.DeadlineExceeded来决定是否返回超时错误给前端。
3.3 迁移与共存:在标准库与pkg/errors之间平衡
很多2020年之后的项目,从pkg/errors迁移到了标准库,因为标准库从1.13起就内置了%w、errors.Is、errors.As,而且不必引入第三方依赖。但迁移不一定非要“二选一”。实际项目中我见过很多共存方案:
- 新代码优先用标准库:
fmt.Errorf+%w足够满足大多数场景 - 担心堆栈丢失的地方,用
pkg/errors的Wrap/WithStack做补充 - 边界层(如HTTP handler、消息队列消费入口)统一记录日志,输出
%+v查看完整堆栈
pkg/errors打印堆栈的方式是fmt.Printf("%+v", err)。这里有个细节,%+v与%v的输出效果完全不同,%+v会展开错误的整个链,并且带上堆栈帧信息,而%v只输出 message。不管是用pkg/errors还是标准库,日志库里都要约定好错误字段的格式化方式。
我个人在维护老项目时,不会做大规模替换。因为pkg/errors的错误类型在业务代码里已经大量出现,贸然替换可能破坏判断逻辑。更稳妥的办法是在新写的代码里逐步用标准库风格,同时保留已有pkg/errors包装的代码,保证errors.Is/errors.As都能正常工作。
不过需要注意的是,标准库errors.Is和errors.As无法识别pkg/errors的Wrap链中的Cause()方法,因为标准库只认Unwrap() error接口。如果你用的是pkg/errors.Wrap,判断底层错误时有两种做法:一是调用errors.Cause(err)来取到底层错误再去比较;二是干脆把pkg/errors的Wrap换成标准库的fmt.Errorf("%w", err),让errors.Is能顺着Unwrap遍历。这也是我建议新代码少用pkg/errors.Wrap的原因之一。
4. 错误包装的深入设计
4.1 何时包装,何时直接传播
错误包装不是越多越好,也不是越少越好。我在代码评审时经常会问一个问题:这个错误在这里包装,到底给上层提供了什么新信息?
如果答案是“什么都没有”,那就不应该包装,直接返回原始错误更好。如果答案是“提供了当前函数/方法的上下文,比如参数、操作意图”,那就值得包装。
举两个例子。第一个是直接传播的场景:
func GetUserName(ctx context.Context, uid int) (string, error) { name, err := getUserNameFromCache(ctx, uid) if err != nil { return "", err } return name, nil }这里如果getUserNameFromCache失败,错误里已经包含了缓存key信息,直接传播即可,没必要再包一层。
第二个是需要包装的场景:
func GetUserName(ctx context.Context, uid int) (string, error) { name, err := getUserNameFromCache(ctx, uid) if err != nil { return "", fmt.Errorf("get user name failed, uid=%d: %w", uid, err) } return name, nil }为什么这个值得包装?因为调用方的直接职责是“获取用户名”,而底层错误只描述了缓存查询失败,没有体现“这次调用发生在获取用户名的逻辑里”。加上uid和操作名之后,日志里的信息量立刻不同。
4.2 错误分类与领域错误设计
大型项目里,错误往往需要分成系统错误和业务错误两类。系统错误就是io.EOF、context.DeadlineExceeded、数据库连接失败等,它们的特征是:通常不需要用户看到详细信息,只需要记录日志并返回一个通用的“服务内部错误”。
业务错误是像“余额不足”“用户名已存在”“订单已关闭”这类,它们需要被上层识别并转化为HTTP状态码或业务码返回给客户端。如果全部都用fmt.Errorf("...: %w", err)包一个普通错误,上层只能靠字符串匹配,这显然不够健壮。
我常用的做法是自定义一个错误类型,让业务错误通过它来构造:
type BizError struct { Code int Message string Err error } func (b *BizError) Error() string { if b.Err != nil { return fmt.Sprintf("biz_error: code=%d, message=%s, detail=%v", b.Code, b.Message, b.Err) } return fmt.Sprintf("biz_error: code=%d, message=%s", b.Code, b.Message) } func (b *BizError) Unwrap() error { return b.Err }然后统一用&BizError{Code: 1003, Message: "余额不足", Err: err}来构造。上层通过errors.As判断是不是BizError,再读取Code和Message返回给前端。这样既保留了错误链,又能做到类型化判断,比单纯返回一串字符串要可靠得多。
4.3 性能考量:错误分配、栈跟踪与热路径
错误处理的性能问题,平时写业务代码可能不太敏感,但到了高并发场景,就必须仔细斟酌。
第一点:错误的产生本身需要分配内存。在极端热路径上,比如每秒执行百万次的校验逻辑,如果频繁errors.New,GC压力会明显增加。但这不是说让你为了性能去吞掉错误,而是提醒你区分场景:高频执行但极少出错的分支,可以保持简洁的错误处理;如果需要大量构造错误(比如校验失败率高),可以考虑复用预定义错误变量:
var ErrInvalidRequest = errors.New("invalid request") // 使用时直接返回 ErrInvalidRequest,避免每次都 new 一个新的错误实例第二点:pkg/errors的栈跟踪消耗更大。WithStack和Wrap在调用时会抓取当前调用栈,这涉及runtime.Callers,在无限递归或极深调用链的场景下开销不小。所以不太建议在超高QPS的短函数里到处加WithStack,更合理的做法是在服务边界、跨模块接口上使用栈捕获,让每个错误最多捕获一次堆栈就够了。
第三点:errors.Is和errors.As本身也会遍历错误链。如果错误链特别长(比如一个错误被包装了十几层),判断性能会随之下降。我在实际项目中会把错误链的深度控制在3到5层以内:底层一层(原始错误)、中间一层(调用上下文)、边界一层(最终出口)。超过这个深度,基本说明设计上有些混乱了。
5. 工具与日志协作,实战演练:一个HTTP服务
5.1 日志记录错误的关键选择
错误处理最后一定联着日志。一个错误做得再好,如果没有被正确记录,排查问题依然困难。日志记录错误时有几个点值得注意。
首先是格式化动词。如果使用pkg/errors,一定要知道:
%v只输出错误消息%+v输出完整错误链并附带堆栈信息
如果是标准库错误,%+v和%v没有太大区别,因为标准库错误没有堆栈。但如果你在日志里传的是error接口,建议统一用%+v,这样当某些错误自带堆栈信息时(比如pkg/errors、部分自定义错误),日志能自动带上堆栈。
其次是日志字段。不要把错误直接拼在消息字符串里,而是作为结构化字段输出。例如在Zap里:
logger.Error("handle get user request failed", zap.String("method", "GET"), zap.String("path", "/api/user"), zap.Int("uid", uid), zap.Error(err), )zap.Error会把错误单独作为一个字段,这样在日志系统里可以针对错误类型做过滤、聚合,比把错误拼进消息好太多了。
5.2 一个完整错误处理流程示例
下面我用一个简单的HTTP服务示例,把前面提到的所有内容串起来。
项目结构大概是这样的:
repo层:访问数据库service层:业务逻辑handler层:HTTP接口
仓库层:
package repo import ( "database/sql" "fmt" ) var ErrUserNotFound = errors.New("user not found") func FindUserByID(db *sql.DB, uid int) (*User, error) { row := db.QueryRow("SELECT id, name FROM users WHERE id = ?", uid) var u User if err := row.Scan(&u.ID, &u.Name); err != nil { if err == sql.ErrNoRows { return nil, ErrUserNotFound } return nil, fmt.Errorf("query user by id %d failed: %w", uid, err) } return &u, nil }注意这里对sql.ErrNoRows的判断,我直接映射成了ErrUserNotFound,让上层不必依赖数据库层细节。
服务层:
package service func GetUserProfile(db *sql.DB, uid int) (*UserProfile, error) { user, err := repo.FindUserByID(db, uid) if err != nil { return nil, fmt.Errorf("get user profile, uid=%d: %w", uid, err) } // 继续组装profile... return profile, nil }这一层主要添加的是业务语义上下文:告诉上层“我在获取用户摘要信息”。
Handler层:
package handler func HandleGetUser(w http.ResponseWriter, r *http.Request) { uidStr := r.URL.Query().Get("uid") uid, err := strconv.Atoi(uidStr) if err != nil { http.Error(w, "invalid uid", http.StatusBadRequest) return } profile, err := service.GetUserProfile(db, uid) if err != nil { var bizErr *BizError if errors.As(err, &bizErr) { // 业务错误,返回对应的HTTP状态和业务码 writeJSON(w, bizErr.Code, map[string]string{ "message": bizErr.Message, }) return } if errors.Is(err, repo.ErrUserNotFound) { http.Error(w, "user not found", http.StatusNotFound) return } // 系统错误,记录日志,返回500 logger.Error("get user failed", zap.Int("uid", uid), zap.Error(err), ) http.Error(w, "internal error", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, profile) }这个流程最核心的思路是:错误逐层增强上下文,到边界统一决策。底层给出原始错误,中间层包装业务语义,边界层负责分类、记录日志并转换为用户可读的消息。日志里能看到的是一条带完整链路的错误,客户端能看到的则是一个合理的业务提示。
6. 实际案例问题与避坑
6.1 常见错误:重复包装、忽略错误、比较错误
我见过的Go项目里,错误处理的坑基本集中在三个地方。
第一个是重复包装。
func A() error { err := B() if err != nil { return fmt.Errorf("A failed: %w", err) } return nil } func B() error { err := C() if err != nil { return fmt.Errorf("B failed: %w", err) } return nil }这个其实是正常的逐层包装。真正的“重复包装”是指在同一层或者同一函数里反复包了多层,比如:
func A() error { err := C() if err != nil { err = fmt.Errorf("first wrap: %w", err) err = fmt.Errorf("second wrap: %w", err) return err } return nil }像这种就属于无意义的层层套娃,错误链既长又啰嗦,逐层排查起来效率极低。
第二个是忽略错误。Go里允许你这么写:
json.Unmarshal(data, &obj)但你绝不能真的忽略,特别是反序列化这种高频操作。忽略错误意味着程序在错误状态下继续运行,后果可能非常隐蔽。我在项目里通常会明确要求:调用这类函数后必须处理err,如果确实不需要,也要用_显式丢弃,并写清楚注释。
第三个是比较错误的方式不对。不少老代码喜欢用err == something这种方式直接比较,一旦中间有包装就失效。新代码实现代码里也应该使用errors.Is和errors.As,这是最稳妥的。
6.2 习惯与样式建议
第一,错误消息以“小写开头,不带标点”是Go社区的习惯,我看到很多新手把错误消息写成一句大写开头的英文句子,这在日志里反而显得不统一。
第二,在被包装的错误链中,最外层的错误消息应该尽量表达“当前操作的完整语境”,比如fmt.Errorf("create order failed: %w", err),而不要只写“error occurred”这种废话。
第三,自定义错误类型时,尽量实现Unwrap方法,这样能继续利用标准库的errors.Is和errors.As遍历错误链。如果只实现了Cause方法(pkg/errors老接口),标准库是不认的,调用方会比较痛苦。
第四,代码评审时如果看到一个错误在每一层都被包装了一遍,我会问:这些包装里有多少是有用的?如果只是为了加一行无意义的日志,那不如在边界统一记录一次完整堆栈。
第五,不要在defer里吞掉错误。比如关闭文件时返回了错误,丢不丢取决于场景,但至少不要用defer r.Close()这种方式完全忽略,对关键资源一定要处理关闭错误。
6.3 从标准库到pkg/errors的迁移参考
如果你的项目还在用老版本的pkg/errors,想向标准库靠近,我建议分三步走。
第一步,把errors.Wrap(err, "msg")改成fmt.Errorf("msg: %w", err)。这里要注意%w只能包装一个错误,而且fmt.Errorf不支持%+v的堆栈展开,这是迁移过程中唯一的损失,需要接受。
第二步,把errors.Cause(err)的判断逻辑改成errors.Is(err, target)或errors.As(err, &target)。
第三步,在服务边界(HTTP handler、消息消费者入口)统一保留一个错误日志点,记录错误的完整堆栈信息。如果用了pkg/errors,直接%+v;如果全是标准库错误,那就把调用栈和错误消息一起打出来。
顺带说一句,pkg/errors作者Dave Cheney对错误处理的理念到今天依然很有参考价值:只处理一次错误,要么处理它,要么向上传播它,不要既处理又传播。这句话我一直在用,每次写错误处理都会想想:当前这层,是处理者还是传播者?两者只能选一个,选错了,日志里就会出现重复输出、错误链混乱的问题。
最后再聊一个实操小技巧。如果你在接口日志里看到错误链很长,想快速定位根因,可以用一个自定义的walkError函数遍历整个错误链,把每一层错误消息和对应的堆栈都打印出来:
func walkError(err error) { for err != nil { fmt.Printf("layer: %v\n", err) type unwrapper interface { Unwrap() error } u, ok := err.(unwrapper) if !ok { break } err = u.Unwrap() } }实际排查问题时,这个函数能把错误链梳理得非常清楚:每一层是什么消息、谁包装了谁、最底层又是什么,一目了然。掌握了这些工具和习惯,无论你选择标准库errors还是pkg/errors,都能写出让同事和未来的自己都感谢的错误处理代码。