14 错误处理:为什么不用异常,error 的演进与 wrap
开篇提问
Go 有一个让所有从 Java/C++/Python 转过来的人都”不习惯”的设计:它没有异常(exception),错误处理靠显式地返回 error 值。
f, err := os.Open(path)
if err != nil {
return err
}这段 if err != nil { return err } 的样板代码,你写了五年,可能已经写到”肌肉记忆”了。但请你停下来问自己一个更深的问题:
为什么 Go 的作者们——这些设计了 Plan 9、Unix、B 语言、C 语言的人——会做出”不用异常”这个决定?他们是不知道异常的好处,还是他们经历过什么,让他们对异常深恶痛绝?
答案藏在 Go 设计者的哲学里,也藏在他们对”错误到底是不是一种异常”这个问题的根本认知里。这一章,我们把 Go 的错误处理设计彻底讲透。
子主题一:为什么不用异常——设计者的理由
Go 团队拒绝异常,最核心的理由可以浓缩成三条:
理由一:异常把”控制流”变成了”隐式的、难以追踪的东西”。
异常的问题在于:一个函数 throw 出来的异常,可能被调用栈上任意一层的任意一个 catch 接住。你读一段代码,无法从代码本身看出”这段代码可能在哪、被谁捕获异常”。控制流变成了一张隐式的、跨越函数边界的网,可读性和可维护性都受损。
而显式返回 error,让”错误”成为控制流的一部分:错误就写在返回值里,一眼可见,逐层显式传递。 这符合 Go”显式优于隐式”的核心哲学。
理由二:异常让”正常路径”和”错误路径”混在一起。
异常的一大卖点是”正常逻辑不用被打断”。但 Go 团队认为,错误处理也是程序逻辑的一部分,不该被”特殊化”。一个函数处理错误的代码,和它处理正常逻辑的代码,应该同样显式、同样重要。把错误用异常”隔开”,反而容易让人忽视错误路径(尤其是不自觉吞掉异常时)。
理由三:异常的成本和复杂度。
异常机制(栈展开、try/catch 的运行时支持)在语言和运行时层面都有成本。Go 追求简单、可预测,不愿意为一个”可以被 error 值替代”的机制,引入整套异常运行时。
一句话总结:Go 的作者们认为,错误是”预期内的、常见的、需要显式处理的结果”,不是”预期外的、罕见的、需要特殊机制的异常”。 所以错误用普通值表达,而不是用异常。
子主题二:error 的本质——一个再普通不过的接口
Go 的 error,底层简单到令人发指:
type error interface {
Error() string
}就这一个方法。任何实现了 Error() string 的类型,都可以作为 error 使用。
这个设计的好处:
第一,error 是普通值。 它可以被比较、被存储、被传递、被包装——享受 Go 值类型的一切待遇。没有”异常对象”的特殊地位。
第二,error 是接口,极度灵活。 你可以定义自己的 error 类型,携带任意字段(错误码、错误分类、原始错误……),通过类型断言取回这些信息。
第三,error 的零值是 nil,天然表示”没有错误”。 if err != nil 成了自然的判断。
但”error 是普通接口”也带来一个著名的坑:nil 接口陷阱。
func f() error {
var p *MyError = nil
return p // 返回一个"类型为 *MyError、值为 nil"的接口,而不是 nil error
}
func main() {
if f() == nil {
// 这里不会执行!因为 f() 返回的接口不是 nil(它的类型信息非空)
}
}因为 error 是接口,一个”类型为 *MyError、值为 nil”的指针赋给 error 接口后,接口本身不是 nil(它带着类型信息)。这就是”返回 nil 指针却判不出 nil”的经典 bug。解决方法是:返回 error 时,直接 return nil,而不是 return 一个值为 nil 的具体类型变量。
子主题三:错误包装——从 “errors.New” 到 “fmt.Errorf + %w”
早期的 Go,错误处理有个痛点:错误在层层传递中丢失了上下文。
// 底层
err := db.Query(...) // err = "connection refused"
// 中层
if err != nil {
return err // 只是原样返回,不知道是哪一步出错
}当这个 error 最终到达顶层时,你只看到一个”connection refused”,却不知道是”查询用户”时出的错,还是”查询订单”时出的错。错误缺少”发生位置和上下文”的信息。
Go 1.13 引入了错误包装(error wrapping),核心是 fmt.Errorf 的 %w 动词:
if err != nil {
return fmt.Errorf("query user: %w", err) // 包装:加上上下文,同时保留原始错误
}%w 把原始错误”包”进新错误里,形成一条错误链。配合两个新函数:
errors.Is(err, target):沿着错误链,判断 err 是否”是”或”包含”某个目标错误(支持==比较和自定义Is方法)。errors.As(err, target):沿着错误链,判断 err 是否”是”某个具体类型,并提取出来(支持类型断言和自定义As方法)。
这样,你既能给错误加上”查询用户失败”的上下文,又能在上层用 errors.Is(err, sql.ErrNoRows) 精确判断”是不是没查到数据”。上下文和类型判断,两者兼得。
子主题四:错误处理的正确姿势
结合 error 的演进,现代 Go 错误处理的最佳实践:
1. 错误要”包装”而不是”吞掉”。 每层返回错误时,尽量加上本层的上下文(fmt.Errorf("...: %w", err)),让最终的错误信息像一条”调用栈”一样清晰。
2. 用 errors.Is/As 做类型判断,而不是字符串比较。 不要 if err.Error() == "...",因为包装后字符串就变了;用 errors.Is 判断”是不是某个错误”,用 errors.As 提取”某种错误类型”。
3. 区分”错误”和”正常分支”。 sql.ErrNoRows 是”没有结果”,属于预期内的正常分支,不应该当”异常”层层上报,而应该在合适的层处理掉。
4. 谨慎定义自定义 error 类型。 自定义 error 类型要小心”值语义”(用值类型而非指针,避免 nil 接口陷阱),并考虑实现 Is/Unwrap 方法,让 errors.Is/As 能正确穿透。
子主题五:panic/recover——“真正的异常”该用在哪
Go 不是完全不能”崩溃+恢复”,它有 panic 和 recover。但它们的定位和异常截然不同:
panic 是”程序性错误,无法继续”的信号。 比如数组越界、nil 指针解引用、panic("impossible") 断言。panic 意味着”状态已经不可信,继续跑只会更糟”。
recover 是”最后的抢救”。 在 defer 里 recover,可以在 panic 把整个程序炸掉之前,做最后的清理、记录日志、或把 panic 转成 error 返回。
关键的原则:panic 用于”不可恢复的程序 bug”,不应该用于”可预期的业务错误”。 业务错误(文件不存在、网络超时、参数非法)应该用 error 值正常返回;只有”代码逻辑本身错了”(数组越界、类型断言失败、违反不变量)才该 panic。
这条边界,是 Go 错误处理哲学的最后一块拼图:error 处理”预期内的失败”,panic 处理”预期外的 bug”。两者定位清晰、互不越界,和”异常包打天下”的模型形成鲜明对比。
但”边界清晰”不等于”用得好”。真正难的是:包装了五层之后,怎么还能认出原始错误?一个结构体到底该怎么建错误模型?多个失败同时发生怎么办?panic 到底”不可恢复”的判据是什么?一个真实的分层服务,错误码该怎么落地?
下面十个小节,我们一个一个拆。
子主题六:errors.Is 的底层机制——一条错误链上的”寻亲”
先抛个问题:
err := repo.Get(ctx, 42) // 里面是 fmt.Errorf("query user: %w", sql.ErrNoRows)
errors.Is(err, sql.ErrNoRows) // 为什么是 true?err 早就不是 sql.ErrNoRows 了,它是个 *fmt.wrapError,err == sql.ErrNoRows 必然是 false。那 Is 凭什么认得出来?
答案:%w 让错误变成了一条单向链表,errors.Is 做的就是沿链表逐个问”你是不是”。
fmt.Errorf("...: %w", err) 返回的是一个 *fmt.wrapError(Go 1.20 起若有多个 %w 则是 *fmt.wrapErrors),它内部存着 msg 和 err,并实现:
func (e *wrapError) Unwrap() error { return e.err }于是 Is 的逻辑几乎是”人写出来就该是这样”的(下面是贴近标准库实现的伪代码):
func Is(err, target error) bool {
if err == nil || target == nil {
return err == target // 只有两个都 nil 才 true
}
comparable := reflect.TypeOf(target).Comparable()
for {
// 1) 先比身份:==
if comparable && err == target {
return true
}
// 2) 再问它自己:你有没有自定义的 Is 语义?
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
return true
}
// 3) 都不行,剥一层继续
switch x := err.(type) {
case interface{ Unwrap() error }:
err = x.Unwrap()
if err == nil {
return false
}
case interface{ Unwrap() []error }: // Go 1.20+,errors.Join 的多错误树
for _, e := range x.Unwrap() {
if e != nil && Is(e, target) {
return true
}
}
return false
default:
return false // 没有 Unwrap,链到头了
}
}
}这里有几个必须刻进肌肉记忆的细节:
第一,target 不可比较时不会 panic。 标准库先用 reflectlite.TypeOf(target).Comparable() 探一下,不可比较(比如一个含 slice 字段的结构体,且它没实现 Is)就跳过 ==,只走 Is 方法和 Unwrap。所以 errors.Is(err, someUncomparableError) 是安全的。
第二,Is 方法优先于 Unwrap,但 == 又在 Is 之前。 顺序是:身份比较 → 自定义 Is → 剥链。这意味着自定义 Is 不是”覆盖”,而是”补充”:== 能命中的,轮不到它。
第三,Unwrap() []error 让错误链从”链”变成”树”。 这是 Go 1.20 为了 errors.Join 引入的能力,Is/As 都会做深度优先遍历。代价是遍历成本不再是 O(链长),而是 O(节点数)。
那什么时候该给自己的错误类型实现 Is(error) bool?当你需要”语义相等”而不是”身份相等”时:
type Error struct {
Code int
Message string
Detail map[string]any // 含 slice/map,本身不可比较,也没法用 ==
}
func (e *Error) Error() string { return fmt.Sprintf("code=%d: %s", e.Code, e.Message) }
func (e *Error) Unwrap() error { return nil }
func (e *Error) Is(target error) bool {
t, ok := target.(*Error)
if !ok {
return false
}
// 只要错误码相同,就认为"是同一个错误"
return e.Code == t.Code
}于是:
var ErrQuotaExceeded = &Error{Code: 42901, Message: "配额不足"}
// 链上任何一个 Code==42901 的 *Error 都能被认出来,哪怕指针不同、Detail 不同
if errors.Is(err, ErrQuotaExceeded) { ... }这就是自定义 Is 的价值:把”判断相等”的权力从编译器(==)手里拿回来,交给领域语义。
写 Is 方法有三个雷:
- 不要在
Is里递归调用errors.Is(err, target)。 你的Is会被errors.Is反复调用,再调回去就是无限递归。 - 不要在
Is里做昂贵或有副作用的事(打日志、查库、改状态)。Is在链上每个节点都会调一次。 - 接收者要匹配链上的实际类型。 你定义
func (e *Error) Is(...)却在链上放了个Error值(而非*Error),那这个方法根本不会被调到。这类”方法集不匹配”的 bug 排查起来极其恶心。
最后留个反问:既然 Is 这么灵活,为什么标准库不给所有错误都配一个 Is? 因为 Is 是给”需要被程序分流判断”的错误准备的。绝大多数错误只该被打印和被上报,它们只需要 Error() string。给每个错误都加语义判断,等于给每个错误都发了一张身份证——管理成本远高于收益。
子主题七:errors.As 的底层机制——沿链做类型断言
Is 回答的是”是不是这个错误”。但现实里更常见的需求是:“我知道链上某处有个 *os.PathError,我要把它拿出来,读它的 Path 字段。”
var pe *os.PathError
if errors.As(err, &pe) {
log.Println("出问题的文件:", pe.Path)
}注意这个 &pe——As 的第二个参数是”指向错误类型的指针”,也就是指针的指针。 这是新手最高频的翻车点:写 errors.As(err, pe) 编译能过(因为参数是 any),运行时直接 panic。
As 的内部逻辑,同样值得看一眼:
func As(err error, target any) bool {
if err == nil {
return false
}
// —— 下面三段是 panic 校验 ——
if target == nil {
panic("errors: target cannot be nil")
}
val := reflect.ValueOf(target)
typ := val.Type()
if typ.Kind() != reflect.Ptr || val.IsNil() {
panic("errors: target must be a non-nil pointer")
}
targetType := typ.Elem()
if targetType.Kind() != reflect.Interface && !targetType.Implements(errorType) {
panic("errors: *target must be interface or implement error")
}
// —— 沿链做类型断言 ——
for {
if reflect.TypeOf(err).AssignableTo(targetType) {
val.Elem().Set(reflect.ValueOf(err))
return true
}
if x, ok := err.(interface{ As(any) bool }); ok && x.As(target) {
return true
}
// 和 Is 一样:Unwrap() error 或 Unwrap() []error,剥一层/遍历树
...
}
}三条 panic 规则,背下来:
| 错误写法 | 后果 |
|---|---|
errors.As(err, nil) | panic: target cannot be nil |
errors.As(err, pe)(pe 是 *os.PathError,忘了取址) | panic: target must be a non-nil pointer |
var s string; errors.As(err, &s) | panic: *target must be interface or implement error |
第三条是关键约束:target 指向的类型,要么实现 error,要么本身是接口。
第二条约束意味着 As 支持”提取成接口”,这是它最强大的用法之一:
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
// 不管这个超时来自 HTTP、MySQL 驱动还是 gRPC,只要它实现了 net.Error,就能认出来
retry()
}这是标准的行为探测(capability detection):我不关心你具体是谁,我只关心你会不会 Timeout()。对比一下 errors.Is(err, context.DeadlineExceeded)——那是身份判断,要求链上真的包了这个哨兵。两者互补:
// 判据一:是不是"我们主动取消/超时"的上下文错误(身份)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { ... }
// 判据二:底层网络错误是不是超时(行为)
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() { ... }
// 判据三:标准库现成的哨兵(Go 1.21 起还有 errors.ErrUnsupported)
if errors.Is(err, os.ErrNotExist) { ... }As 的一个隐蔽陷阱:它可能给你赋值一个 nil 指针。 如果链上某个节点是 (*os.PathError)(nil)(typed-nil),As 的类型匹配会成功,然后 pe 就被赋成了 nil。紧接着你 pe.Path —— 直接 panic。这是”nil 接口陷阱”在 As 上的变体。防御方式是:库作者永远不要往错误链里塞 typed-nil;使用者在拿到底层错误字段前,如果来源不可信,加一次 nil 判断。
另外一个容易混淆的语义:As 命中的是链上”最外层”的第一个匹配。 它是深度优先、从外到内。如果外层包装和内层都满足类型,你拿到的是外层那个。这通常正是想要的(最具体的上下文在外层)。
那自定义 As(target any) bool 方法呢?它比自定义 Is 罕见得多,一般只在”需要把错误投影成另一种视图”时用,比如某些 ORM 让错误既能被识别为 *Error,也能被投影成 interface{ SQLState() string }。日常写业务代码,你几乎永远不需要写自定义 As——需要携带数据就定义类型 + 字段,然后用 As 提取即可。
子主题八:三种错误建模——哨兵、类型、值,到底该用哪个
现在问一个设计层面的问题:
你要表达”用户不存在”,有多少种写法?该选哪一种?
Go 世界里其实只有三种基本建模方式(外加一个派生技巧)。选错模型,后期就是”改不动”的技术债。
模式一:哨兵错误(sentinel error)
// 包级变量,导出,调用方用 errors.Is 判断
var ErrUserNotFound = errors.New("user not found")优点: 极简;可比较;errors.Is 天然支持;调用方零成本分流;错误信息稳定不变。
缺点:
- 零上下文。 你不知道是哪个
user_id没找到。 - 零载荷。 带不动任何数据。
- 它一旦导出就成了 API 契约。 别人已经在
errors.Is(err, yourpkg.ErrUserNotFound)了,你改个名字就是 breaking change;你把两个哨兵合并,别人的分支就静默失效了。
标准库案例: io.EOF、sql.ErrNoRows、context.Canceled、os.ErrNotExist、crypto/rsa.ErrVerification。注意它们的共性:错误种类有限、语义稳定、需要被分流、不需要额外数据。
命名与文档纪律: 一律 ErrXxx(或 ErrXxxNotFound 之类),并在文档里写清”这个错误在什么情况下返回、调用方应该怎么处理”。不要导出一大堆哨兵——哨兵爆炸是”错误常量地狱”,最后没人记得该判哪个。经验值:一个包对外暴露的哨兵控制在个位数。
模式二:错误类型(custom error type)
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("invalid field %q: %s", e.Field, e.Reason)
}调用方:var ve *ValidationError; if errors.As(err, &ve) { ... }
优点: 能携带结构化数据(字段名、重试时间、上游状态码、traceID);能带行为(Timeout()、Temporary());As 可以精确提取。
缺点:
- API 表面积变大。 你暴露了一个类型,别人就会依赖它的字段;字段改名就是破坏性变更。
- 有 nil 陷阱。 调用方很容易写出
return (*ValidationError)(nil)这种定时炸弹。 - 值语义 vs 指针语义要提前决定。 用值类型(
ValidationError)则要小心不可比较字段;用指针类型(*ValidationError)则要小心 typed-nil。
何时用: 需要”分流判断”且需要”携带数据或行为”。典型场景:校验错误(要告诉前端哪个字段错了)、限流错误(要带 Retry-After)、上游错误(要带原始状态码)。
模式三:错误值(fmt.Errorf 动态构造)
return fmt.Errorf("query user %d: %w", id, err)它不承载”语义”,只承载”上下文”。 你无法用 errors.Is 判断一个纯 fmt.Errorf 出来的错误是不是”某类错误”——除非它用 %w 包了一个哨兵或类型。
何时用: 中间层加上下文。它是三种模式里成本最低、也最容易被滥用的一种:滥用表现为 fmt.Errorf("failed: %w", err) 这种什么都没说的包装。
派生技巧:判定函数(predicate function)
既然”暴露哨兵/类型”会让 API 变硬,那能不能既能分流,又不暴露实现?能——这是标准库自己走过的路:
// os 包:先有哨兵,再有判定函数
var ErrNotExist = fs.ErrNotExist
func IsNotExist(err error) bool { return underlyingErrorIs(err, ErrNotExist) }
func IsPermission(err error) bool { ... }os.IsNotExist(err) 在 os.ErrNotExist 演化成 fs.ErrNotExist 的过程中,对调用方保持了兼容。这就是判定函数的价值:把”错误的实现”和”错误的判定”解耦。
你自己的包也可以这么写:
var errQuotaExceeded = &Error{Code: 42901} // 不导出,随时可改
func IsQuotaExceeded(err error) bool { return errors.Is(err, errQuotaExceeded) }决策流程
放在你手边,每次建错误模型时过一遍:
- 上层需不需要对这个错误做程序化分流?
- 不需要 →
fmt.Errorf加上下文,完事。别发明类型。
- 不需要 →
- 需要分流,但不携带任何数据? → 哨兵错误(
var ErrX = errors.New(...))。 - 需要分流,且要带数据/行为? → 自定义错误类型 +
As提取。 - 需要分流,但不想把类型暴露成契约? → 私有哨兵/私有类型 +
IsXxx(err) bool判定函数。 - 需要分流,且错误种类多、要按类别聚合? → 带
Kind/Code字段的错误类型 + 自定义Is方法按码判等。
最后一句狠话:错误的可见性,和函数的可见性一样,是设计决策,不是随手写的。 你 export 一个哨兵的那一刻,就等于跟全世界签了一份永久合同。
子主题九:errors.Join(Go 1.20):一个错误装不下的失败
再来一个问题:
你写了个
Close(),里面要关三个资源,前两个关失败了,第三个成功了。返回一个错误,能表达”两个都失败了吗?”
在 Go 1.20 之前,答案是:不能,你只能二选一——要么丢掉前面的错误,要么丢掉后面的。 这是 Go 错误模型长期存在的一个真实缺口。
Go 1.20 补上了 errors.Join:
func Join(errs ...error) error它的语义,精确版:
- 全部为 nil → 返回 nil(所以你可以无脑 Join,不用先判空)。
- 跳过 nil,只收集非 nil 的。
- 返回的错误的
Error()是各错误字符串用"\n"拼接。 - 它实现
Unwrap() []error,因此errors.Is/errors.As会深度优先遍历整棵错误树。
err := errors.Join(errA, errB, nil, errC)
errors.Is(err, errB) // true
errors.Is(err, sql.ErrNoRows) // 只要这棵树上任何一支包含它,就是 true场景一:defer 里的 Close 错误不再被吞(最经典的用法)
func CopyFile(dst, src string) (err error) {
s, err := os.Open(src)
if err != nil {
return fmt.Errorf("open src: %w", err)
}
defer func() {
err = errors.Join(err, s.Close()) // 关失败不能被丢掉
}()
d, err := os.Create(dst)
if err != nil {
return fmt.Errorf("create dst: %w", err)
}
defer func() {
err = errors.Join(err, d.Close())
}()
if _, err := io.Copy(d, s); err != nil {
return fmt.Errorf("copy: %w", err)
}
return nil
}注意这里的要求:函数必须是命名返回值,否则 defer 里改的是副本,白搭。这个模式和 recover 转 error 是同一个套路,记住一次,受用终身。
场景二:一次性收集所有校验错误
func Validate(u User) error {
var errs []error
if u.Name == "" {
errs = append(errs, &ValidationError{Field: "name", Reason: "不能为空"})
}
if u.Age < 0 || u.Age > 150 {
errs = append(errs, &ValidationError{Field: "age", Reason: "不在合法区间"})
}
if !strings.Contains(u.Email, "@") {
errs = append(errs, &ValidationError{Field: "email", Reason: "格式不正确"})
}
return errors.Join(errs...) // 全对时自动返回 nil,不用判 len
}用户体验上的差别是巨大的:一个是”你的 name 错了”→ 改了 → “你的 age 错了”→ 改了 → “你的 email 错了”;另一个是一次告诉你三条全错。这是”错误处理质量”直接转化为”产品体验”的典型例子。
场景三:并行/批量任务的错误聚合
g, ctx := errgroup.WithContext(ctx) // golang.org/x/sync/errgrouperrgroup 只能拿到第一个错误(它用 sync.Once 存第一个)。如果你想”全都要”,就自己收集:
var mu sync.Mutex
var errs []error
for _, task := range tasks {
eg.Go(func() error {
if err := run(task); err != nil {
mu.Lock()
errs = append(errs, fmt.Errorf("task %s: %w", task.ID, err))
mu.Unlock()
return nil // 不让 errgroup 提前取消,全部跑完
}
return nil
})
}
_ = eg.Wait()
return errors.Join(errs...)三个必须知道的坑
坑一:Join 出来的 Error() 是多行字符串。 直接塞进单行日志里,日志会被换行切成好几条;直接塞进 JSON 响应里,会带一堆 \n。边界层要么展开、要么只取摘要:
if u, ok := err.(interface{ Unwrap() []error }); ok {
for _, e := range u.Unwrap() {
log.Warn("sub error", "err", e) // 逐条结构化记录
}
}坑二:错误树会变大。 每层都 Join、每层都包装,最后是一棵深且宽的树,Is/As 每次调用都要走一遍。不要在热路径上无节制地 Join。
坑三:多个 %w 的版本差异。 fmt.Errorf("%w; %w", a, b) 在 Go 1.20 起会返回实现 Unwrap() []error 的错误,两个都能被 Is/As 找到;而 Go 1.20 之前,fmt.Errorf 只记住最后一个 %w。如果你在维护老代码库,这是个必须知道的暗坑——别在老版本上指望多 %w 生效,老老实实用 errors.Join。
子主题十:错误包装的最佳实践与反模式清单
理论讲完,落到手上。下面这份清单,是”能直接拿去做 code review 标准”的。
✅ 1. 每层加自己的上下文,且要说清”动作 + 关键参数”
// ❌ 什么都没说
return fmt.Errorf("failed: %w", err)
// ✅ 能当调用栈读
return fmt.Errorf("query user id=%d: %w", id, err)判据很简单:最终打出来的那条错误串,你能不能在没看代码的情况下还原出错路径? 能,就是好包装;不能,就是噪音。
✅ 2. 只在跨越”语义边界”时包装,不要层层无脑包
如果你在一个 20 行的私有函数里包三次,最后会得到:
service.Pay: handle payment: call charge: do request: post https://api.pay/charge: dial tcp: i/o timeout
看着挺详细,其实中间那几层(handle payment / call charge / do request)毫无信息量——它们只是把函数名重复了一遍。判断标准:这一层加的前缀,是否提供了调用栈本身无法提供的信息(比如 user_id、order_no、第几次重试)? 是,就包;不是,就别包。
✅ 3. 用 %w,不要用 %v / %s / err.Error()
fmt.Errorf("x: %v", err) // ❌ 链断了,errors.Is 永远 false
fmt.Errorf("x: " + err.Error()) // ❌ 更糟,还容易拼出 nil panic
fmt.Errorf("x: %w", err) // ✅唯一的例外是你故意要断链:当底层错误含敏感信息(SQL 语句、连接串、内网 IP),而你要把它抛给不可信的调用方时,断链是一种安全策略。但这种情况应该配一句注释说明为什么用 %v,否则下一个人会以为是笔误改回 %w,然后重新引入信息泄露。
✅ 4. 不要”既记日志又往上抛”
if err != nil {
log.Error("query failed", "err", err) // 记了
return fmt.Errorf("query user: %w", err) // 又抛了
}结果:顶层再记一次,同一个错误在日志里出现 N 遍,告警被放大 N 倍,排障时你根本分不清到底发生了几次。要么处理掉(并终止传播),要么加上下文往上抛(不记录)。 这就是 Go 社区俗称的 “handle once” 原则。
✅ 5. 向上翻译,别让实现细节泄漏
repository 层的 pq: duplicate key value violates unique constraint "users_email_key" 直接进 HTTP 响应,是 simultaneously 三宗罪:泄漏 schema、暴露技术栈、用户看不懂。
跨边界要翻译: 底层错误 → 领域错误码/文案,原始错误留在链里供内部排障,只把安全的文案给外面。
✅ 6. 不要把 err.Error() 当结构化数据
// ❌ 反模式:字符串匹配判定错误类型,包装一变就崩
if strings.Contains(err.Error(), "timeout") { ... }用 errors.Is / errors.As,或者让底层直接提供可判定的信号。
✅ 7. 吞掉错误必须留下痕迹
defer f.Close() // 隐式吞掉 Close 的错误如果真的不关心(比如只读文件的 Close),就明确写出来并说明:
defer func() { _ = f.Close() }() // 只读场景,Close 的返回值无意义errcheck 这类 linter 会逼你面对每一个被忽略的错误——这不是洁癖,这是在消除”C 语言式静默失败”。
✅ 8. goroutine 里的错误必须显式传回
go func() {
if err := do(); err != nil {
log.Println(err) // ❌ 只打了日志,调用方永远不知道它失败了
}
}()用 channel、errgroup、或者结果 struct 把错误传回主流程。“在 goroutine 里 log 一下”等于”假装处理了”。
✅ 9. 出错时不要碰其他返回值
v, err := f()
if err != nil {
// 除非文档明确说明(如 io.Reader.Read 允许部分读),不要用 v
return err
}✅ 10. if err != nil 的三个层次
同样是判空,高手写的和低手写的差别在这:
// 第一层:直接透传(这一层没有任何上下文可加时)
if err != nil {
return err
}
// 第二层:加上下文后抛(本层知道"我在干嘛")
if err != nil {
return fmt.Errorf("load config %s: %w", path, err)
}
// 第三层:翻译成本层语义后抛(跨边界时)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, apperr.New(CodeNotFound, op, "配置文件不存在")
}
return nil, apperr.Wrap(CodeInternal, op, "加载配置失败", err)
}能分清这三种场景该用哪一种,就算把 Go 错误处理从”会写”升级到”会设计”了。
子主题十一:panic 的边界——到底什么算”不可恢复的 bug”
“panic 用于不可恢复的错误”——这句话几乎人人会背。但什么叫”不可恢复”? 判据是什么?
给你一条可操作的判据:
问一句:这是”程序员写错了”,还是”世界违背了程序”? 程序员写错了 → panic(继续跑没有意义,代码必须改)。 世界违背了程序(磁盘满了、网断了、用户输了乱码、上游挂了)→ error(这是运行时环境的一部分,程序必须能应对)。
该 panic 的四类情况
1. 不变量被破坏。 你的数据结构内部状态自相矛盾了,说明代码有 bug。
func (c *Cache) Get(k string) []byte {
c.mu.RLock()
defer c.mu.RUnlock()
if c.items == nil {
panic("Cache.Get: items map is nil, invariant violated")
}
...
}2. 逻辑上不可能到达的分支。
switch op {
case Add: ...
case Sub: ...
default:
panic(fmt.Sprintf("unreachable: unknown op %v", op))
}3. 初始化/装配失败(fail fast)。 程序启动阶段,配置缺失、必要依赖没注入、证书加载不了——这时候崩溃是最负责任的行为,总好过带着半残状态上线跑三小时然后在半夜以更诡异的方式挂掉。
func init() {
flag.StringVar(&dsn, "dsn", "", "database dsn")
}
func main() {
flag.Parse()
if dsn == "" {
panic("missing required flag -dsn") // 或 log.Fatal
}
}4. MustXxx 系列:把”必然无错”的输入变成编译期可接受的写法。
regexp.MustCompile、template.Must、sqlx.MustConnect——它们的存在前提是:输入是代码里的常量,错了就是 bug。
var emailRe = regexp.MustCompile(`^[^@]+@[^@]+\.[^@]+$`) // ✅ 常量,错了是 bug
func Validate(email string) bool {
return regexp.MustCompile(email).MatchString("x") // ❌ 用户输入!这是拿 panic 当验证器
}记住一条铁律:Must 后面的参数里,永远不能出现任何来自外部的数据。
不该 panic 的(哪怕看起来很”严重”)
网络超时、连接被拒、DNS 失败、文件不存在、权限不足、磁盘写满、上游返回 500、用户输入非法、JSON 解析失败、数据库约束冲突——全部是 error。
它们有个共同点:程序没写错,只是这次没成功。 下一次可能就成功了,重试是有意义的。而 panic 的问题是:panic 隐含”程序状态已不可信”的断言,你用一个表达”我写错了”的机制,去表达”这次运气不好”,语义就错位了。
三个必须知道的 panic 运行时语义
语义一:panic 跨 goroutine 不传播。
func main() {
defer func() {
if r := recover(); r != nil {
log.Println("recovered") // ❌ 永远执行不到
}
}()
go func() {
panic("boom") // 这个 panic 会崩掉整个进程,main 的 recover 救不了
}()
time.Sleep(time.Second)
}这是线上最常见的”整个服务莫名其妙重启”的原因之一。推论:每一个你自己 go 出来的 goroutine,尤其是长期运行的 worker、消息消费者、定时器任务,入口第一行就该是 defer recover()。
语义二:os.Exit 不执行 defer。
defer cleanup() // os.Exit 时不会执行
os.Exit(1)所以”优雅退出”要用 signal + context 取消,而不是在任意地方 os.Exit。
语义三:panic 会执行 defer 并逐层冒泡,没人 recover 就打印栈、以退出码 2 终止。
所以 panic 不是”内存泄漏”,也不是”进程直接消失”——它是一次受控的、带栈追踪的、逐层执行清理的崩溃。 这个设计恰恰说明 panic 的定位:给程序员看证据用的。
子主题十二:recover 的正确写法与陷阱
既然 panic 是”给程序员看的证据”,那 recover 什么时候用?
答案:只在”进程边界”用。 HTTP/RPC 框架的请求入口、goroutine 入口、任务调度入口。业务代码的中间层 recover,基本都是错的——因为它把”程序有 bug”这个信号,伪装成了”这次请求失败”。
正确写法一:把 panic 转成 error(必须用命名返回值)
func SafeCall(fn func()) (err error) { // ← 注意 (err error)
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
fn()
return nil
}为什么必须命名返回值? 因为匿名返回值在函数返回时已经被赋值到调用方的栈帧了,你在 defer 里改的是局部变量副本,改不动。命名返回值让 err 成为一个可被 defer 修改的变量——这是 Go 里 defer 唯一能”影响返回值”的通道。
正确写法二:recover 必须直接写在 defer 的函数里
// ❌ 无效:recover 被包了一层,它看到的是"当前没有 panic"
defer func() {
handlePanic() // 里面调 recover() → 返回 nil
}()
func handlePanic() {
if r := recover(); r != nil { ... } // 永远拿不到
}recover 的生效条件是:当前 goroutine 正处于 panicking 状态,且 recover 是被 panic 直接调用的 defer 函数调用的。 一旦多一层函数调用,这个上下文就断了。
正确写法三:中间件里的 recover,要记栈、要上报、要返回正确响应
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
rec := recover()
if rec == nil {
return
}
// 1) 拿证据:没有 debug.Stack() 的 recover 等于把证据烧了
stack := debug.Stack()
log.ErrorContext(r.Context(), "panic recovered",
"panic", fmt.Sprint(rec),
"path", r.URL.Path,
"trace_id", middleware.TraceID(r.Context()),
"stack", string(stack),
)
metrics.PanicCounter.Inc()
// 2) 对外只给安全的响应,绝不把 panic 内容吐给客户端
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 50000,
"msg": "服务内部错误",
})
// 3) 关键决策:runtime 错误要不要重新抛出去?
if _, ok := rec.(runtime.Error); ok {
panic(rec) // 见下方讨论
}
}()
next.ServeHTTP(w, r)
})
}最硬核的一问:该不该 recover 掉 runtime.Error?
recover() 拿到的是什么?可能是你 panic("xxx") 的字符串,也可能是运行时错误:runtime.Error 接口的实现——*runtime.TypeAssertionError、数组越界、nil 指针解引用(*runtime.Error 的具体类型如 *runtime.boundsError、*runtime.TypeAssertionError)。
nil 指针解引用意味着:你的程序写错了,而且它可能已经改坏了别处的状态。 这时候 recover 掉、返回 500、继续服务下一个请求,会发生什么?
答案是:进程带着一个未知的、被破坏的内部状态继续跑。 它可能第 100 个请求才暴露出诡异的数据错乱,那时候的栈和日志早就没了,排查成本是现在的 100 倍。
打个比方:recover 掉一个 nil 指针 panic 然后继续服务,就像飞机驾驶舱的告警灯亮了,你拿黑胶布把它贴住继续飞。 短期看”服务没中断”,长期看你失去了唯一的早期预警。
所以务实的策略是分层决策:
if _, ok := rec.(runtime.Error); ok {
// 程序 bug:证据已留,主动崩溃,让 supervisor/k8s 拉起干净进程
// (前提是:你已经通过 graceful shutdown 保护了正在进行的重要操作)
panic(rec)
}
// 业务代码的 panic("xxx"):可视为"这个请求无法完成",转成 500 继续服务很多团队会选择更保守的做法:runtime 错误也 recover,但立刻触发优雅退出(先摘流量、再 os.Exit)。这不矛盾——核心原则是**“别让带病进程长期无感知地跑下去”**,具体实现可以商量。
另外三个陷阱
陷阱一:Go 1.21 起 panic(nil) 不再是 nil。 以前 panic(nil) 会让 recover() 返回 nil,导致 if r := recover(); r != nil 这个标准写法漏判。Go 1.21 把它改成 recover 返回 *runtime.PanicNilError。但为了兼容老版本和语义清晰,显式 panic 一个 error 值更好:panic(fmt.Errorf("..."))。
陷阱二:net/http 自带 recover,但不是你想要的那种。 每个连接的 server goroutine 都有 recover,它会记日志、关闭连接。这意味着单个 handler panic 不会打挂整个 server。但它不会给你返回结构化 JSON,也不会带 trace_id。所以框架层还是要加自己的 recover 中间件(注意:如果你已经写了响应头,w.WriteHeader 会报 superfluous 警告,需要先判断)。
陷阱三:recover 后不要”假装没事”。
defer func() {
recover() // ❌ 吞掉 panic,函数正常返回 nil
}()这比不 recover 更糟:错误消失了,调用方以为成功了,数据不一致从此埋下。 要么转成 error 返回,要么记日志 + 重新 panic,绝不静默。
子主题十三:实战——一个分层服务的错误码、包装与顶层统一转换
讲了一堆原则,现在做一次完整的落地。
需求
一个 HTTP 订单服务,三层:handler → service → repository。要求:
- 对外永远返回统一 JSON:
{"code": <业务码>, "msg": "<用户可读文案>"}; - 内部错误链完整,出问题时能还原路径、能聚合告警;
- 内部细节(SQL、内网地址、依赖名)绝不外泄;
- 日志只打一次,且带 trace_id;
- panic 不穿透到客户端。
第一步:定义领域错误类型(错误码 + 包装 + 自定义 Is)
package apperr
import (
"errors"
"fmt"
)
// Code 是业务错误码。前三位对齐 HTTP 语义,便于映射,但它在领域上是独立的。
type Code int32
const (
CodeInternal Code = 50000
CodeInvalidArgument Code = 40000
CodeNotFound Code = 40400
CodeConflict Code = 40900
CodeUpstream Code = 50200
)
// Error 是唯一的领域错误类型:对外给 Code+Msg,对内保留完整 Err 链。
type Error struct {
Code Code
Msg string // 面向用户的安全文案,不含任何内部细节
Op string // 出错位置,形如 "service.order.Pay"
Err error // 底层原因,只进日志,绝不进响应
}
func New(code Code, op, msg string) *Error {
return &Error{Code: code, Op: op, Msg: msg}
}
func Wrap(code Code, op, msg string, err error) *Error {
return &Error{Code: code, Op: op, Msg: msg, Err: err}
}
// Error 输出"人类排障用"的完整文本:位置 + 码 + 原因。
func (e *Error) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: code=%d: %s: %v", e.Op, e.Code, e.Msg, e.Err)
}
return fmt.Sprintf("%s: code=%d: %s", e.Op, e.Code, e.Msg)
}
func (e *Error) Unwrap() error { return e.Err }
// Is 按错误码判等:调用方可以用一个"只填了 Code 的哨兵"去匹配链上任意同类错误。
func (e *Error) Is(target error) bool {
t, ok := target.(*Error)
if !ok {
return false
}
return e.Code == t.Code
}
// CodeOf 是顶层统一转换的入口:拿不到领域错误,一律按内部错误处理。
func CodeOf(err error) Code {
var e *Error
if errors.As(err, &e) {
return e.Code
}
return CodeInternal
}几个设计决策值得单独说:
- 为什么
Is按Code判等而不是按指针? 因为调用方想表达的是”这是不是’没找到’“,而不是”这是不是那个具体的结构体实例”。按码判等后,errors.Is(err, &Error{Code: CodeNotFound})就能命中链上任何一个 404,调用方不需要 import 一堆哨兵。 - 为什么
Op单独一个字段而不是拼进 Msg? 因为Msg可能对外,Op只对内(用于聚合告警、定位代码位置)。两者生命周期不同,必须分开。 - 为什么
Error()不带堆栈? 标准库 error 本来就不带。堆栈交给日志层(见下一节)。
第二步:repository 层——只暴露哨兵,不暴露驱动细节
package repo
var ErrOrderNotFound = errors.New("order not found")
func (r *orderRepo) Get(ctx context.Context, id int64) (*Order, error) {
const op = "repo.order.Get"
var o Order
err := r.db.QueryRowContext(ctx, `SELECT id, user_id, amount, status FROM orders WHERE id = ?`, id).
Scan(&o.ID, &o.UserID, &o.Amount, &o.Status)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 驱动细节消失,换成领域哨兵;%w 保留了原始 sql.ErrNoRows 供内部排障
return nil, fmt.Errorf("%s: id=%d: %w", op, id, ErrOrderNotFound)
}
return nil, fmt.Errorf("%s: id=%d: %w", op, id, err)
}
return &o, nil
}第三步:service 层——把底层错误翻译成领域错误码
func (s *orderService) Pay(ctx context.Context, orderID int64) error {
const op = "service.order.Pay"
o, err := s.repo.Get(ctx, orderID)
if err != nil {
switch {
case errors.Is(err, repo.ErrOrderNotFound):
return apperr.Wrap(apperr.CodeNotFound, op, "订单不存在", err)
case errors.Is(err, context.DeadlineExceeded):
return apperr.Wrap(apperr.CodeUpstream, op, "系统繁忙,请稍后重试", err)
default:
return apperr.Wrap(apperr.CodeInternal, op, "查询订单失败", err)
}
}
if o.Status == StatusPaid {
return apperr.New(apperr.CodeConflict, op, "订单已支付,请勿重复操作")
}
if err := s.pay.Charge(ctx, o.UserID, o.Amount); err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, os.ErrDeadlineExceeded) {
return apperr.Wrap(apperr.CodeUpstream, op, "支付网关超时,请稍后重试", err)
}
return apperr.Wrap(apperr.CodeUpstream, op, "支付失败,请稍后重试", err)
}
return nil
}注意这里的翻译动作:repo.ErrOrderNotFound 被翻译成 CodeNotFound + 用户文案,原始错误留在 Err 字段里。上层再也不需要知道下层用的是 MySQL 还是 Postgres。
第四步:顶层统一转换(只在这一处决定 HTTP 语义)
func writeError(w http.ResponseWriter, r *http.Request, err error) {
var ae *apperr.Error
if !errors.As(err, &ae) {
// 没被分类的错误:内部错误,绝不把原文吐出去
ae = apperr.Wrap(apperr.CodeInternal, "handler", "服务内部错误", err)
}
// 日志:只在这里打一次,带 trace_id、结构化字段
log.ErrorContext(r.Context(), "request failed",
"code", ae.Code,
"op", ae.Op,
"cause", ae.Err, // 完整内部链,进日志/进追踪系统
"trace_id", TraceID(r.Context()),
"path", r.URL.Path,
)
metrics.ErrorCounter.WithLabelValues(strconv.Itoa(int(ae.Code))).Inc()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(httpStatusOf(ae.Code))
_ = json.NewEncoder(w).Encode(map[string]any{
"code": ae.Code,
"msg": ae.Msg, // 只给安全文案
})
}
func httpStatusOf(c apperr.Code) int {
switch c / 100 {
case 400:
return http.StatusBadRequest
case 404:
return http.StatusNotFound
case 409:
return http.StatusConflict
case 502:
return http.StatusBadGateway
default:
return http.StatusInternalServerError
}
}第五步:handler 只做参数校验和转发,不打日志
func (h *OrderHandler) Pay(w http.ResponseWriter, r *http.Request) {
const op = "handler.order.Pay"
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, r, apperr.Wrap(apperr.CodeInvalidArgument, op, "订单 ID 非法", err))
return
}
if err := h.svc.Pay(r.Context(), id); err != nil {
writeError(w, r, err) // 不打日志,交给 writeError 统一处理
return
}
w.WriteHeader(http.StatusNoContent)
}这套设计解决了什么
| 问题 | 解法 |
|---|---|
| 内部细节外泄 | Msg 与 Err 分离,响应只用 Msg |
| 日志重复/告警放大 | handle once,只在 writeError 打一次 |
| 错误类型判断靠字符串 | 用 errors.Is 判哨兵、errors.As 取领域错误 |
| HTTP 状态码散落各处 | 只在 httpStatusOf 一处映射 |
| 新增错误类型要改很多地方 | 加一个 Code 常量 + service 里一行 Wrap |
| panic 打到客户端 | Recover 中间件兜底 |
| 排障时找不到出错位置 | Op 字段 + 完整 %w 链 |
最后一个反问:为什么不直接把 apperr.Error 序列化成响应体返回? 因为 Error() 里可能有 SQL、有内网地址、有用户手机号。错误的”对内视图”和”对外视图”必须是两个东西,这是安全边界,不是格式问题。
子主题十四:错误的可观测性——日志、堆栈与”只处理一次”
代码写对了,错误链也完整了,还有一个工程问题没解决:这条错误,最后是怎么被看见的?
缺口:标准库 error 不带堆栈
这是个历史遗留。Go 的 error 只有 Error() string,没有创建位置、没有调用栈。于是社区搞出了 github.com/pkg/errors:
// pkg/errors 时代(Go 1.13 之前的事实标准)
if err != nil {
return errors.Wrap(err, "query user") // 带堆栈
}
// 顶层
log.Printf("%+v", err) // %+v 打印完整堆栈Go 1.13 的 %w + Is/As 出来后,pkg/errors 基本退场了——除了堆栈这一项,它至今仍是标准库的缺口。 现在的选项是:
- 接受它。 依靠
Op字段 + 结构化参数(id=%d、trace_id)定位。对绝大多数服务够用,成本为零。 - 在错误首次产生处附加堆栈: 自定义错误类型里存
stack []uintptr(runtime.Callers),顶层用runtime.CallersFrames展开。适合内部基础库。 - 用带堆栈的错误库(如
cockroachdb/errors),它同时兼容errors.Is/As/Join语义。
选择建议:业务代码用方案 1,基础库/公共 SDK 用方案 2 或 3。 不要给每个错误都挂堆栈——runtime.Callers 有成本,而且错误对象会变重。
原则一:Handle Once(只处理一次)
这条前面提过,这里给出工程化的表述:
一个错误,在其生命周期中,应当恰好被”处理”一次。 “处理” = 记录日志 / 上报指标 / 转换为对外响应 / 触发补偿动作。 “不处理” = 加上下文后继续抛。
违反它的代价是告警风暴:一次 DB 抖动,在 repo 记一条、service 记一条、handler 记一条、中间件记一条——4 条日志、4 次计数,值班的人以为挂了 4 次。
原则二:日志打在”边界”,且必须结构化
边界 = 请求入口/出口、任务开始/结束、外部调用前后。在这些地方打,天然满足”一次”。
// ❌ 字符串拼接:无法查询、无法聚合
log.Printf("failed to pay order %d: %v", id, err)
// ✅ 结构化:可以按 code / op 聚合,可以按 trace_id 关联
log.ErrorContext(ctx, "pay order failed",
"code", apperr.CodeOf(err),
"op", "service.order.Pay",
"order_id", id,
"cause", err,
"trace_id", traceID,
)原则三:按”错误指纹”聚合,不要按错误文本聚合
// ❌ 高基数:每个不同的 order_id 都是一个独立时间序列,指标直接爆掉
metrics.Inc("error." + err.Error())
// ✅ 低基数 + 可聚合
metrics.Inc("error", "code", strconv.Itoa(int(apperr.CodeOf(err))), "op", OpOf(err))错误文本是高基数维度(含 ID、含时间、含地址),错误码是低基数维度。 这是监控领域的基本纪律,但在错误处理上被违反得最多。
原则四:不要把外部数据拼进 Error()
// ❌ 三重风险:日志注入(换行伪造日志行)、PII 泄露、高基数
return fmt.Errorf("user input: %s", r.FormValue("q"))外部输入进错误时,要么截断、要么转义、要么只放一个哈希/长度。这也是”对外视图/对内视图”分离的一个延伸。
原则五:为错误链设计”出口”
一条精心包装的错误链,最后必须有一个地方把它整体消费掉。这个出口通常长这样:
func handleError(ctx context.Context, err error) Response {
code := apperr.CodeOf(err) // 1) 分类
log.Error(ctx, err, code) // 2) 记录(唯一一次)
metrics.Observe(code) // 3) 度量
return renderForClient(code) // 4) 翻译给外部
}如果你说不出你的错误链的”出口”在哪,那这条链就是白建的。 这是判断一个项目错误处理是否成熟的试金石。
子主题十五:业界对照补强——Java 受检异常、Rust Result、Python EAFP
最后,把 Go 的选择放到更大的坐标系里看。不是为站队,是为了理解每种取舍的代价由谁承担。
Java:受检异常的实验与溃败
Java 做了一个大胆的实验:把”可能失败”写进类型系统。
// checked exception:编译器强制你处理或声明
public void read() throws IOException { ... }初衷极好: 错误不会被遗忘,因为编译不过。
现实极痛:
- 签名污染。 你改一个底层方法的异常类型,
throws像瘟疫一样沿着调用链向上蔓延,几十个方法签名跟着改。 - lambda 不兼容。 函数式接口(如
Runnable)不能抛受检异常,于是只能try { ... } catch (IOException e) { throw new RuntimeException(e); }——讽刺地,强制机制逼出了最差的写法。 - 为了过编译而吞异常。
try {
doSomething();
} catch (Exception e) {
// TODO: handle later ← 这个 TODO 一挂就是五年
}- 工业界的集体叛逃。 Spring 全家桶几乎清一色 unchecked:它把各种持久化技术的异常(
SQLException、HibernateException)统一翻译成DataAccessException层次,且是 unchecked(继承RuntimeException)。Hibernate 也在 3.x 把受检异常改成了 unchecked。
结论:强制的代价由”每一个调用方”承担,而收益由”原作者”获得。 这种成本/收益错配,让受检异常在大规模协作中必然溃败。
对 Go 的启示: Go 选择了”编译器不强制,但约定强制 + 工具辅助”(errcheck、golangci-lint)。代价是确实会有人忘记检查 error;收益是API 演化不会因为错误类型而断裂。这是一笔清楚的账:Go 用”少量漏检”换”大规模可演化”。
顺便补一段史实:Go 团队在 Go 2 时期认真尝试过 check/handle 语法糖来消灭 if err != nil,最终因为社区争议过大而撤回。真正落地的是 2018 年的 Go 1.13 错误值提案(%w/Is/As)——它不改变”必须显式处理”,只让它更好用。 这个取舍本身就是 Go 哲学的缩影:宁可啰嗦,不引入隐式。
Rust:类型系统强制,但用语法糖抹平成本
Rust 和 Go 在”显式优于异常”上是一致的,但实现路径几乎是镜像:
fn read(path: &str) -> Result<String, std::io::Error> { ... }
fn main() -> Result<(), Box<dyn Error>> {
let content = read("a.txt")?; // ? 出错即提前返回
Ok(())
}三个关键差异:
1. 强制性来源不同。 Rust 的 Result<T, E> 是返回类型的一部分,不处理就编译不过(#[must_use]);Go 的 error 只是第二个返回值,不处理也编译得过。所以 Rust 拿到了 Java 受检异常想要的”不被遗忘”,却没付”签名污染”的代价——因为 Result 是一个类型,而不是一个需要单独声明的 throws 子句。
2. 语法糖的程度不同。 ? 把”判空 + 提前返回 + 类型转换”压缩成一个字符;Go 要三行。这是 Go 最被吐槽的地方,也是 Rob Pike 那句”errors are values”的代价。Go 1.22 之后社区仍在讨论 ?/or 语法,但目前没有落地。
3. 错误类型转换是自动的。 ? 会自动调用 From::into 做错误类型转换,所以底层 io::Error 能自动变成上层 MyError。Go 完全没有这个能力——你必须手写 fmt.Errorf("...: %w", err),一行一行地转换。这是 Go 啰嗦的根源,也是”显式”的必然代价:编译器不替你做任何隐式的事。
生态的分层也很有意思,几乎可以一对一映射到 Go:
| Rust | Go | 适用场景 |
|---|---|---|
thiserror(定义具体错误枚举) | 哨兵错误 + 自定义错误类型 | 库:调用方需要精确分流 |
anyhow::Error(动态错误 + 上下文) | fmt.Errorf("%w") 包装链 | 应用:只需要打印和上报 |
? | if err != nil { return err } | 错误传播 |
同一个取舍,两种语法。 这反过来印证了 Go 的错误模型本身并不原始——它缺的是语法糖,不是设计。
Python:EAFP——把异常当控制流
Python 走的是第三条路,也是最相反的一条:
# EAFP: Easier to Ask for Forgiveness than Permission
try:
value = d["key"]
except KeyError:
value = default
# 对比 LBYL: Look Before You Leap
if "key" in d:
value = d["key"]
else:
value = defaultPython 甚至用异常实现正常控制流:StopIteration 用来终止迭代器,for 循环的本质就是不断捕获它。这在 Go 里完全不可想象(io.EOF 虽然是哨兵,但 for 循环是靠显式判断,不是靠”抛异常”)。
EAFP 成立的前提是”异常很便宜”,而 Python 本来就是解释执行、性能余量充足,所以这笔账划算。它的收益是代码短、意图集中;代价是:
- 控制流隐式化——
try块里任何一行都可能跳走,你无法从代码看出”这会走到哪里”; except Exception: pass泛滥——静默吞掉错误的门槛太低;finally满天飞——因为要在隐式跳转下保证清理;- 无法静态检查——你不知道一个函数会不会抛、抛什么。
Go 的立场恰好是这四条的镜像。 用一句 Go 社区的老话总结:“errors are values”——错误是普通的、可传递的、可组合的值,不是一条隐式的跳转指令。
一张对照表
| 语言 | 机制 | 是否强制处理 | 错误是否在签名里 | 上下文携带 | 核心代价 |
|---|---|---|---|---|---|
| Go | error 值 + %w + Is/As/Join | 否(约定 + linter) | 是(第二个返回值) | 手工包装 | 啰嗦,可能漏检 |
| Java | checked / unchecked 异常 | checked 强制,unchecked 不强制 | checked 在(throws) | 异常链 + 栈 | 签名污染、吞异常 |
| Rust | Result<T,E> + ? | 是(类型系统) | 是(Result 就是类型) | ? 自动转换 | 心智负担(泛型、生命周期) |
| Python | 异常(EAFP) | 否 | 否 | 异常对象 | 控制流隐式、无法静态检查 |
| C++ | 异常(可 -fno-exceptions 关闭) | 否 | 部分(noexcept) | 异常对象 | ABI 复杂、栈展开成本 |
没有哪一种是”正确答案”,只有”代价由谁承担”。
- Java 受检异常:代价由所有下游调用方承担 → 溃败。
- Rust:代价由编译器的复杂度承担,用户只付出学习成本 → 成功。
- Go:代价由每个写业务代码的人承担(多写三行)→ 换来任何人都能读懂控制流。
- Python:代价由运行期和维护者承担 → 换来开发速度。
理解了这张表,你就能回答一个常被问到的问题:“为什么 Go 不学 Rust 加个 ?”——因为 Go 的答案是:代价已经付了,就不再假装它不存在。 if err != nil 的啰嗦,是一笔显式的、诚实的税;而异常和语法糖,是把这笔税变成隐式的、延后支付的债。
业界对照
Java 异常: 受检异常(checked exception)强制调用者处理,非受检异常(runtime exception)不强制。Java 的异常体系庞大,但”吞异常""catch 空”泛滥,异常栈追踪成本高。Java 社区也在反思”异常滥用”(如用异常控制正常流程)。
C++ 异常: 有异常机制,但大量 C++ 代码(尤其系统级、嵌入式)禁用异常(-fno-exceptions),因为异常的栈展开成本、ABI 复杂性、以及和 C 接口的兼容问题。C++ 社区对异常的态度是分裂的。
Rust Result: 和 Go 最像——用 Result<T, E> 枚举显式表达”成功或失败”,用 ? 操作符做错误传播,没有异常。Rust 的 ? 比 Go 的 if err != nil 更简洁(语法糖),且 Result 是枚举、模式匹配更强。Rust 和 Go 在”显式错误优于异常”上高度一致。
Python 异常: 异常是主流错误处理方式(EAFP,先做了再说、错了再捕),风格和 Go 完全相反。Python 追求开发效率,用异常做控制流在 Python 里很常见。
版本演进小结
- 早期:error 只是一个接口 +
errors.New+fmt.Errorf(%v),上下文在逐层传递中丢失。 - Go 1.13:错误包装(
%w)、errors.Is、errors.As、errors.Unwrap落地(源自”错误值”提案),同时fmt.Errorf产出的错误实现Unwrap() error,错误链成为标准能力。 - Go 1.20:
errors.Join支持合并多个错误;fmt.Errorf支持多个%w;Unwrap() []error让Is/As从遍历”链”升级为遍历”树”。 - Go 1.21:
errors.ErrUnsupported哨兵落地(errors.Is(err, errors.ErrUnsupported)成为判断”不支持”的官方方式);panic(nil)变为*runtime.PanicNilError,recover()不再返回 nil。 - 始终没有变的部分:没有异常,
error就是一个只有一个方法的接口,错误必须显式处理。
主线:Go 的错误处理,始终坚持”错误是普通值、要显式处理”的哲学,从不引入异常;它的演进方向,是让”显式传递错误”这件事越来越不痛苦——用包装补上下文、用 Is/As 补类型判断、用 Join 补多错误合并。 这是”坚持一个简单哲学,然后用工具慢慢把它的体验做顺”的典型路径。
本章思考题
【思考题】
-
Go 不用异常的三个核心理由是什么?“错误是预期内的结果,不是预期外的异常”这句话怎么理解?
-
什么是”nil 接口陷阱”?为什么
return一个值为 nil 的具体类型变量,调用方却判不出 nil? -
错误包装(%w)+ errors.Is/As 解决了什么问题?为什么不能用字符串比较来判断错误类型?
-
panic 和 error 的边界在哪?什么情况该 panic,什么情况该返回 error?
-
请写出
errors.Is的判定顺序(至少三步),并说明:为什么target是不可比较类型时errors.Is不会 panic?如果自定义类型实现了Is(error) bool方法,它和==比较的优先级关系是怎样的? -
errors.As要求第二个参数是什么?请列举三种会导致它 panic 的写法。另外,errors.As(err, &pe)成功返回 true 之后,pe有可能是 nil 吗?什么情况下会? -
哨兵错误、自定义错误类型、
fmt.Errorf动态错误值,这三种错误建模方式各自的优缺点和适用场景是什么?“导出一个哨兵错误”在 API 演化上意味着什么? -
你的包里有个”配额不足”的错误,调用方需要判断它,但你不想把这个错误的类型暴露成永久契约。有几种做法?分别的取舍是什么?
-
errors.Join是 Go 哪个版本引入的?它返回的错误在Error()、Unwrap()上的语义分别是什么?请举出至少两个真实适用场景,并说出一个使用陷阱。 -
下面这段代码的错误在哪里?如何修复?
func CopyFile(dst, src string) error {
s, err := os.Open(src)
if err != nil {
return fmt.Errorf("open src: %w", err)
}
defer s.Close()
d, err := os.Create(dst)
if err != nil {
return fmt.Errorf("create dst: %w", err)
}
defer d.Close()
if _, err := io.Copy(d, s); err != nil {
return fmt.Errorf("copy: %w", err)
}
return nil
}-
为什么”既记录日志又向上返回错误”是反模式?请说明它在线上会造成什么具体后果,以及正确的替代做法。
-
recover()生效需要满足什么条件?为什么必须写在defer的函数体里(而不是被再包一层函数)?为什么”把 panic 转成 error”必须使用命名返回值? -
你的 HTTP 服务里,一个 handler 因为 nil 指针解引用而 panic。你在中间件里 recover 了它,记了日志,返回 500,然后继续服务后续请求。这样做有什么隐患?更稳妥的策略是什么?
-
一个分层服务(handler/service/repository)要实现”对外统一错误码、对内保留完整错误链”。请描述你的错误类型设计(至少包含哪些字段、为什么),并说明:日志应该在哪一层打?HTTP 状态码应该在哪一层决定?为什么对外响应不能直接用
err.Error()? -
为什么做错误监控时,不能用
err.Error()作为指标的标签维度?应该用什么?什么是”错误指纹”? -
对比 Go 的
error值、Java 的受检异常、Rust 的Result<T, E>+?、Python 的 EAFP 四种方案,从”是否强制处理""错误是否在签名里""核心代价由谁承担”三个维度说明各自的取舍。为什么 Java 的受检异常在工业界大规模溃败了?
【参考答案】
-
三个理由:异常让控制流变成隐式、难以追踪的网(一个 throw 可能被任意层的任意 catch 接住);异常把正常路径和错误路径割裂,让人忽视错误处理;异常的运行时成本高。Go 认为错误是”预期内的、常见的、需要显式处理的结果”,不是”预期外的、罕见的、需要特殊机制的异常”,所以用普通值(error 接口)表达,显式逐层传递。
-
nil 接口陷阱:error 是接口,一个”类型为 *MyError、值为 nil”的指针赋给 error 接口后,接口的”类型信息”字段非空,所以接口整体不是 nil。
f() == nil判不出。根因是接口有两个字段(类型 + 值),值 nil 但类型非 nil 时,接口不等于 nil。解法:返回 error 时直接return nil,而不是返回值为 nil 的具体类型变量。 -
包装(%w)解决了”错误层层传递丢失上下文”的问题——每层加上自己的上下文,形成错误链;errors.Is/As 解决了”包装后还能精确判断错误类型”的问题——沿错误链查找目标错误或类型。不能用字符串比较,是因为包装后 Error() 字符串变了,
==或字符串匹配会失效;Is/As 沿链穿透、且支持自定义语义,才可靠。 -
边界是”预期内的失败 vs 预期外的 bug”。业务错误(文件不存在、网络超时、参数非法)是预期内的,用 error 值正常返回;程序 bug(数组越界、类型断言失败、违反不变量)是预期外的、状态已不可信的,用 panic。recover 只做”最后的抢救”(清理、记日志、转 error),不用于常规错误处理。
-
判定顺序:①
err == target(身份比较);② 若当前err实现了Is(error) bool则调用它;③ 否则Unwrap()剥一层,重复 ①②③,直到Unwrap返回 nil 或不再实现Unwrap(返回 false)。另有前置短路:err == nil || target == nil时直接返回err == target。不会 panic 的原因:标准库先用reflectlite.TypeOf(target).Comparable()判断target是否可比较,不可比较就跳过==,只走Is方法和Unwrap链。优先级:==在Is方法之前——==能命中的轮不到Is,所以自定义Is是对==的补充(提供”语义相等”),不是覆盖。 -
第二个参数必须是”指向某个实现
error的类型、或指向某个接口的非 nil 指针”,典型写法是var pe *os.PathError; errors.As(err, &pe)(指针的指针)。三种 panic 写法:①target为 nil(panic: target cannot be nil);②target不是指针或是指针但为 nil,例如忘了取址写成errors.As(err, pe),或var p **os.PathError = nil; errors.As(err, p);③*target的类型既不实现error也不是接口,例如var s string; errors.As(err, &s)。另外,规范的As实现在err == nil时直接返回 false,不 panic。pe有可能是 nil:如果错误链上存在 typed-nil 节点(如(*os.PathError)(nil)),类型匹配会成功并把 nil 赋给pe,随后访问pe.Path就会 panic。防御方式:库作者绝不往链里塞 typed-nil;使用方对不可信来源的错误取值前做一次 nil 判断。 -
① 哨兵错误(
var ErrX = errors.New(...)):优——极简、可比较、errors.Is天然支持、调用方零成本分流;缺——无上下文、无载荷,一旦导出就成为永久 API 契约(改名/合并都是 breaking change)。适用于错误种类有限、语义稳定、需被分流、不需携带数据的场景(io.EOF、sql.ErrNoRows、context.Canceled)。② 自定义错误类型:优——能携带结构化数据与行为(Timeout()、RetryAfter),可用As提取;缺——API 表面积变大,要处理 nil 陷阱与值/指针语义选择。适用于需要分流且要带数据/行为的场景(校验错误、限流、上游错误)。③fmt.Errorf动态错误值:只承载上下文,不承载语义(除非用%w包了前两者),无法被程序化判定。适用于中间层加上下文。导出哨兵在 API 演化上的含义:你和所有调用方签了一份永久合同——名字不能改、语义不能并、返回值里必须继续包含它,否则调用方的errors.Is分支会静默失效(编译不报错,运行时行为改变,是最危险的一类 breaking change)。 -
三种做法。① 导出哨兵 +
errors.Is:最简单,但把”存在性”暴露为契约。② 导出错误类型 +errors.As:能带数据,但把整个类型的字段都暴露为契约,改字段即 breaking。③ 不导出错误(私有哨兵/私有类型)+ 导出判定函数(func IsQuotaExceeded(err error) bool { return errors.Is(err, errQuotaExceeded) }):把”错误的实现”和”错误的判定”解耦,内部实现可以任意演化,调用方 API 保持不变。标准库自己就是这么演进的——os.IsNotExist(err)在os.ErrNotExist迁移到fs.ErrNotExist的过程中对调用方保持了兼容。取舍:③ 的维护成本略高(多写一层函数),但换来的是可演化的 API,是公共库的首选。此外还有一种变体:错误类型私有 + 导出一个”取值函数”(如func QuotaOf(err error) (limit, used int64, ok bool))。 -
Go 1.20 引入。语义:① 全部入参为 nil 时返回 nil(可以无脑 Join,无需先判空);② 跳过 nil,只收集非 nil 的错误;③
Error()把各错误的字符串用"\n"拼接,因此是多行文本;④ 实现Unwrap() []error,errors.Is/errors.As会对它做深度优先的树形遍历。适用场景:a)defer中把Close()的错误合并进主错误(defer func() { err = errors.Join(err, f.Close()) }(),要求命名返回值);b) 一次性收集全部字段校验错误,避免用户”改一次报一个”;c) 并行/批量任务聚合所有失败(自己收集 + Join,比errgroup只返回第一个错误更全)。陷阱:Error()是多行字符串,直接进单行日志会被切成多条、直接进 JSON 响应会带一堆\n,边界层需要展开(interface{ Unwrap() []error })或只取摘要;另外错误树过深过宽会让Is/As变慢,不要在热路径上无节制 Join。补充:fmt.Errorf支持多个%w(也是 Go 1.20 起),Unwrap()返回[]error;Go 1.20 之前多个%w只有最后一个能被 Unwrap 拿到。 -
问题:
defer s.Close()/defer d.Close()的返回错误被静默丢弃(尤其写文件时Close可能返回”数据未真正落盘”的错误,这类丢失最危险);而且defer d.Close()在io.Copy成功后才执行,若关闭失败,函数仍返回 nil,调用方以为成功。修复:改为命名返回值,并用errors.Join把关闭错误合并进主错误:
func CopyFile(dst, src string) (err error) {
s, err := os.Open(src)
if err != nil {
return fmt.Errorf("open src: %w", err)
}
defer func() { err = errors.Join(err, s.Close()) }()
d, err := os.Create(dst)
if err != nil {
return fmt.Errorf("create dst: %w", err)
}
defer func() {
err = errors.Join(err, d.Close())
}()
if _, err := io.Copy(d, s); err != nil {
return fmt.Errorf("copy: %w", err)
}
return nil
}注意 io.Copy 里的 err 用了 := 声明的是内部变量,靠 return 显式赋值到命名返回值上(或改成 if _, err = io.Copy(...))。
-
后果:同一个错误在日志里出现 N 遍(每层一次),告警被放大 N 倍,值班同学无法判断”到底发生了 1 次故障还是 4 次”;指标基数失真;排障时要在重复日志里人肉去重。根因是违反了 handle once(只处理一次) 原则:一个错误在其生命周期中应当恰好被”处理”一次(记录/上报/转换/补偿),其余层只负责加上下文后继续抛。正确做法:中间层只
fmt.Errorf("...: %w", err),不记录;只在”边界层”(请求入口/出口、任务开始/结束、外部调用前后)统一记录一次,且带trace_id、code、op、结构化字段。 -
生效条件:① 当前 goroutine 正处于 panicking 状态;②
recover()必须是被 panic 直接触发的defer函数直接调用的——多包一层函数,那个上下文就断了,recover()返回 nil。所以正确写法是defer func() { if r := recover(); r != nil { ... } }(),绝不能写成defer func(){ handlePanic() }()再在handlePanic里 recover。必须用命名返回值的原因:匿名返回值在函数返回时已经拷贝到调用方栈帧,defer 里改的是局部副本,改不动返回给调用方的值;命名返回值让该变量可被 defer 赋值,从而把 panic 转成err。补充:panic 跨 goroutine 不传播,父 goroutine 的 recover 救不了子 goroutine 的 panic(会崩掉整个进程),所以每个go出来的入口都要自己 defer recover。 -
隐患:nil 指针解引用属于
runtime.Error,意味着程序写错了且内部状态可能已经被破坏。recover 掉它并返回 500 继续服务,等于让进程带着未知脏状态长期运行——真正的故障可能在几百个请求之后才以”数据莫名错乱”的形式暴露,那时栈和日志早已丢失,排查成本成百倍上升。类比:把驾驶舱的告警灯用黑胶布贴住继续飞。更稳妥的策略(按团队风险偏好选一):① 记完整日志 +debug.Stack()+ 上报指标,然后重新 panic,让 supervisor/K8s 拉起干净进程;② recover 后触发优雅退出(先摘流量、处理完在途请求、再退出),而不是继续服务;③ 只对业务主动 panic 的值做”转 500 继续服务”,对runtime.Error走 ① 或 ②。核心原则是一致的:别让带病进程长期无感知地跑下去。 另外注意net/http每个连接自带 recover(不会打挂整个 server,但只记日志、关闭连接,不返回结构化响应),所以框架层仍需自己的 recover 中间件。 -
错误类型设计(如
apperr.Error)建议包含四个字段:Code(业务错误码,低基数、可聚合、是契约)、Msg(面向用户的安全文案,不含内部细节)、Op(出错位置,如service.order.Pay,用于定位与告警聚合)、Err(底层原始错误,只进日志不进响应)。实现Error() string、Unwrap() error(接入错误链)、以及按Code判等的Is(error) bool(让调用方用一个只填了 Code 的哨兵就能匹配同类错误)。日志:只在顶层统一出口打一次(writeError或中间件),避免 handle once 被破坏;中间层不打。HTTP 状态码:只在顶层一处映射(httpStatusOf(code)),散落在各层会导致同一个业务错误在不同接口返回不同状态码。不能用err.Error()的原因:Error()可能包含 SQL 语句、内网地址、连接串、用户手机号等内部或敏感信息,直接吐给客户端既是信息泄露也是安全事件;同时它的文本会随包装层变化,不稳定,不适合做契约。正确做法是”对内视图”(完整链 + 结构化字段进日志)与”对外视图”(code+msg进响应)严格分离。 -
因为
err.Error()是高基数维度:错误文本里通常包含 ID、时间、地址、用户输入等变量,每个不同取值都会生成一条独立时间序列,指标基数爆炸(Prometheus 直接被打挂或成本暴涨),而且无法聚合(“同一类错误”被拆成上万条)。应该用低基数、稳定、有语义的维度做标签:code(业务错误码)、op(出错位置/操作名)、service、method、是否可重试等;把err.Error()和完整链放进日志/链路追踪系统(那里按文本检索是合适的)。“错误指纹”就是这类能把同一次故障的所有实例归为一类的稳定标识——通常是code+op的组合,用于在监控告警里把 N 条日志聚合成”1 个正在发生的故障”。配套纪律:不要把外部输入拼进Error()(日志注入、PII、高基数三重风险)。 -
三维度对照:① 是否强制处理——Go:否(靠约定 +
errcheck/linter);Java:checked 强制、unchecked 不强制;Rust:是(类型系统 +#[must_use],不处理编译不过);Python:否。② 错误是否在签名里——Go:是(第二个返回值error,但不是独立的类型约束,不处理也能编译过);Java:checked 在(throws子句),unchecked 不在;Rust:在(Result<T, E>本身就是返回类型);Python:不在(无静态声明)。③ 核心代价由谁承担——Go:由每个写业务代码的人承担(多写三行),换来任何人都能读懂控制流;Java:由所有下游调用方承担(签名蔓延),收益却归原作者所有,成本/收益错配;Rust:由编译器/语言复杂度承担,用户付学习成本;Python:由运行期性能和维护者承担(隐式控制流、无法静态检查),换来开发速度。Java 受检异常溃败的原因:a) 签名污染,底层改异常类型会沿调用链向上蔓延数十处;b) 与 lambda/函数式接口不兼容,逼出catch { throw new RuntimeException(e) }这种最差写法;c) 为了过编译而catch { /* TODO */ },静默吞异常比不检查更糟;d) 工业界集体叛逃——Spring 把各持久化异常统一翻译成 unchecked 的DataAccessException层次,Hibernate 3.x 也把受检异常改为非受检。结论:强制的代价若由”每一个调用方”承担、而收益只归”原作者”,在大规模协作中必然溃败;Rust 之所以能强制而没溃败,是因为Result是一个普通返回类型而非需要单独声明的throws子句,不产生签名污染,且有?自动做错误类型转换(Go 完全没有这个能力,必须手写%w转换,这是 Go 啰嗦的根源,也是”显式”的代价)。Go 的取舍是:用”少量漏检”换”API 可演化 + 控制流可读”,宁可付一笔显式的税,也不引入隐式的债。