如何在Go中处理错误
本文最初由社区贡献者 Christoph Berger 发布在 JetBrains Go Guide 中,后来迁移到了 JetBrains Go 博客。我们还于 2026 年 8 月对其进行了更新,以反映 Go 语言的最新变化。
错误处理是 Go 与 Java、C++、JavaScript、Python 等其他流行语言的区别之一。在 Go 中,错误就是值。其他语言往往把错误处理移出代码流程之外,而 Go 则将错误视为程序流程中的自然组成部分。如果函数遇到错误,它会将该错误与其他返回值一起返回。调用方有责任检查这个错误并进行相应处理。
一个典型的 Go 包或应用在运行时可能会遇到各种类型的错误,包括逻辑错误、I/O 错误、网络错误、数据校验错误等等。每一类错误都可能需要特定的处理方式。Go 提供了一系列工具和技术来处理不同类型的错误。
本文将探讨 Go 错误处理的几个方面。你将学到错误处理技巧与最佳实践、如何应对特定类型的错误,以及如何避免错误处理中的常见误区。
在开始之前
本指南中的所有示例都直接内嵌在文中,因此仅阅读代码片段就足以理解内容。不过,如果你想边读边自己动手试验和修改代码,我们有一个 代码示例仓库,其中收录了 GoLand 博客上多篇文章的代码。本指南的代码位于 error-handling 目录中。
你可以使用自己选择的 IDE,也可以安装 GoLand IDE。GoLand 提供免费试用;如果你刚开始接触 GoLand,这正是一个体验它的好机会!
接下来,fork 或 clone 包含本指南代码的仓库。
请按照以下步骤在 GoLand 中打开代码:
- 启动 GoLand。
- 如果是全新安装,你将看到欢迎界面。点击 Open 按钮。
- 在打开的文件选择器对话框中,导航到你之前克隆的仓库,选择
error-handling文件夹,然后点击 Open。
一切就绪!在按本指南学习时,请将 IDE 放在手边。## Go 中常见的错误处理技术
如前所述,Go 中所有错误处理都基于“错误即值”的概念。在 Go 中,错误是一种值,和其他任何值一样。错误值的类型是内建类型 error。但这个类型到底是什么?幸运的是,GoLand 让检查 Go 自身源码变得很容易。
在 Project 窗格中,向下滚动到 External Libraries 部分。展开 Go SDK <installed version>,然后展开 builtin.go(因为 error 是内建类型):
[LOADING...]
如果无法展开 builtin.go,请选择 Project 窗格中的三点菜单,然后选择 Tree Appearance,并确保 Show Members 已勾选:
[LOADING...]
向下滚动直到在 builtin.go 下方看到 error 类型,然后点击它。builtin.go 文件会在编辑区中打开并显示 error 类型:
error 类型是一个只包含单个函数 Error() string 的接口。在这里使用接口类型,可以让自定义类型通过实现 error 接口轻松创建自定义错误类型。
下面来看看如何处理错误。
返回错误
大多数情况下,当函数遇到错误时,它自身并不具备正确处理错误所需的上下文,因此必须将错误返回给调用方。
例如,示例代码(readfile.go)中的 func ReadFile():
ReadFile() 会检查接收到的路径;如果路径为空,它会创建一个新的错误并返回。由于 ReadFile() 本应返回的数据不存在,所以它返回 nil 值:
按照惯例,如果函数返回错误值,它总是返回值列表中的最后一个(最右侧)值:
调用 ReadFile() 时,它会返回文件内容以及一个错误值:成功时为 nil,失败时为非 nil。通常,返回的错误值会赋给名为 err 的变量(参见随附仓库中的 main.go):
由于本指南专门探讨错误处理,此处不需要 ReadFile() 返回的文件内容,因此返回值被赋给空白标识符(_)。
现在,调用方可以检查错误是否为非 nil,并据此处理错误。
Panic 与 recover
刚接触 Go 的开发者可能会怀念其他语言提供的 try...catch 机制。不过,Go 中确实有满足类似目的的机制:panic 和 recover。但请注意!与 try...catch 不同,panic 和 recover 不是(也不应该是)处理错误的标准方式。只有在错误确实出乎意料且无法处理时,才应该使用 panic。在这种情况下,最好让应用尽早崩溃并重启。稍后的最佳实践部分会介绍更多相关内容。
一个“不应该发生”的错误的例子是:将正则表达式作为字面量字符串进行编译却失败了。由于正则表达式在编译时是已知的,开发者本应确保它是有效表达式,这样在运行时就不会编译失败。为了强制执行这一点,regexp 包提供了一个名为 MustCompile() 的函数。前缀 Must 表示:如果函数无法编译给定的正则表达式,它就会 panic。
为了演示这一点,verifypath.go 文件中有一个函数,用于验证给定路径是否有效。然而,开发者输入的正则表达式不正确——缺少一个右括号:
如果不加任何预防措施就调用此函数,应用会立即崩溃:
堆栈跟踪显示,verifypath.go 的第 6 行是 panic 的源头。
在某些情况下,应用崩溃是不可接受的。例如,HTTP 服务器必须持续运行而不能中断。如果在处理某个请求时发生 panic,其他所有请求仍应尽可能继续处理。为此,net/http 包使用了 Go 的恢复技术。
下面是针对会 panic 的 isValidPath() 函数,恢复机制可行的两种场景。
它向调用方添加一个延迟函数调用
isValidPath() 的调用方在函数体开头附近设置一个延迟函数调用:
无论包含函数是通过正常的 return 退出,还是因 panic 而退出,延迟函数都会自动执行。
在延迟函数中调用 recover()
延迟函数可以确认自己是被正常返回调用还是被 panic 调用。它只需要调用 recover() 并检查返回的错误即可(参见 func main() 末尾的 main.go):
如果错误为 nil,则延迟函数是由于正常返回而被调用的,因此无需恢复。
如果延迟函数是由 panic 触发的,recover() 会返回导致 panic 的错误。此时延迟函数可以做任何从 panic 中恢复所需的操作。
记录错误
如果函数能够处理从被调用函数收到的错误,它可能希望将错误信息写入日志文件。
在 Go 中记录错误非常简单,这要归功于标准库中的 log 包以及从 Go 1.21 开始提供的 slog 包。
下面是在上一节的延迟函数中使用 log 包的示例:
log.Printf() 是 fmt.Printf() 的直接替代品,它会写入标准日志记录器的输出。如需格式化错误类型,可使用 %v 动词,它按默认格式打印值。
一个附注: 如果你在为库编写代码,请考虑不要记录任何日志。库的使用者对使用哪个日志记录器以及向 stdout 或 stderr 输出什么内容会有不同的看法。因此,几乎总是只返回错误,让库的使用者自行记录他们想要的日志会更好。
使用错误包装
错误经常会在多个函数组成的调用链中“向上冒泡”。换句话说,一个函数收到错误后,通过返回值将其传回调用方;调用方可能同样如此,依此类推,直到调用链上游的某个函数处理或记录该错误。在“冒泡”过程中,每个涉及的函数都可以在把错误交还给调用方之前,向错误添加有价值的上下文信息。在传递错误时保留这条链的做法称为“错误包装”。你在添加上下文的同时,把原始错误保留在新错误的内部,之后可以解开包装,以检查或匹配底层错误。
只有在无法添加任何有用信息时,函数才应该原样传递错误:
在所有其他情况下,它应添加适当的上下文信息。但是,简单地将新错误消息与原始错误消息拼接在一起是不可行的:
这样做只会保留原始错误消息,而将错误本身压平为一个普通字符串。类型和结构化细节丢失后,调用方将无法再解开包装并检查它。
相反,你应该使用错误包装。可以使用 fmt.Errorf() 和特殊的格式化动词 %w 将一个错误“包装”到另一个错误上。参见 readfile.go 文件中的 ReadFile() 函数:
如稍后所示,os.Open() 返回的错误类型包含额外信息。包装错误会保留所有这些额外信息。
解开被包装的错误
函数返回的错误可能包含一个或多个被包装的错误。打印或记录收到的错误时,也会包含所有被包装错误的错误消息。然而,有时你需要知道某种特定类型的错误是否嵌套在多层的错误之中。
例如,看一下如何在 func main() 中处理 ReadFile() 的错误:
这段代码会输出以下内容:
被包装的错误消息是 open failed: open no/file: no such file or directory,而解开包装后的错误只包含 open no/file: no such file or directory,不包含添加到被包装错误上的 open failed: 消息。
这样,你可以逐个解开错误,直到到达错误链的末端。
测试特定错误类型
偶尔,你需要知道被包装的错误链中是否有某个错误属于特定类型。
例如,os.Open 返回一个类型为 fs.PathError 的错误,它不仅记录错误本身,还记录了导致错误的操作和路径。如果你能发现错误链中包含这个错误,就可以利用这些附加信息进行故障排除。
为实现这一点,errors 包提供了三个函数:Is()、As(),以及 Go 1.26 中引入的 AsType()。
errors.Is()
函数 func Is(err, target error) bool:如果错误 err 与 target 相同,或其包装链中包含 target,则返回 true。
在 ReadFile() 的例子中,你可以验证返回的错误是 fs.ErrNotExist,或包装了 fs.ErrNotExist:
这会输出:
errors.As()
你可能还想访问路径信息。为此,你不仅要确保错误包装了 fs.PathError,还要能访问这个 PathError 及其所有方法。
为此,可以使用函数 As(err error, target any) bool。和 Is() 类似,As() 在 err 是或包装了一个与 target 同类型的错误时返回 true;同时,它会解开该错误并将其赋值给 target。
这需要定义一个类型为 fs.PathError 的变量,并将指向该变量的指针传递给 As():
这将记录出错时的路径和操作:
errors.AsType()
Go 1.26 新增了 AsType(),这是 As() 的泛型、类型安全替代方案。其函数签名为 func AsType[E error](err error) (E, bool)。
与预先声明 target 变量并传递指针不同,AsType() 将你要查找的错误类型作为类型参数,并返回两个值:匹配的错误(类型为 E)和一个表示是否找到匹配项的布尔值。这样可以将匹配到的错误整洁地限定在 if 块作用域内:
与 As() 的示例一样,这会记录出错时的路径和操作:
AsType() 相比 As() 有一些优势。由于你在调用中直接指定错误类型,编译器会帮你检查,因此“在需要指针的地方传值”这类错误会在编译期被捕获,而不会像 As() 在收到不合适的目标时那样触发运行时 panic。AsType() 还避免了 As() 内部依赖的反射,因此速度稍快。
As() 并未废弃,因此现有代码仍然可以正常工作。不过,对于新代码,推荐使用 AsType()。当你需要依次测试多个错误类型时,它尤其方便,因为每个匹配到的错误都保持在自己的分支作用域内:
合并错误
通常,错误在返回给各自调用方的过程中被逐一包装。有时,一个函数需要收集多个错误并将它们合并为一个。
以 readfiles.go 中的 ReadFiles()(注意是复数)函数为例。这个函数读取多个文件,并返回成功读取到的所有文件内容。如果有一个或多个文件读取失败,ReadFiles() 会收集这些错误并将它们合并为一个。
为此,errors 包提供了 Join() 函数(Go 1.20 中引入)。下面看看 ReadFiles() 如何使用 Join() 函数:
如果在 for 循环内部发生错误,循环不会中断。相反,该错误会被合并到变量 errs 中,循环继续执行,并在出现更多错误时继续合并。
最后,ReadFiles() 既返回成功读取的内容,也返回合并后的错误消息。
处理合并后的错误
你可能会认为,合并后的错误可以像单个错误一样解开。可惜并非如此。合并错误实际上是一个错误切片,即 []error。而 Unwrap() 函数返回的是单个错误;如果对合并错误调用 Unwrap(),它会返回 nil:
第二行日志输出:
幸运的是,有一种方法可以解开合并错误中的错误切片。合并错误类型本身就提供了返回错误切片的 Unwrap() []error 方法,可以帮助你做到这一点。
要访问这个 Unwrap() 方法,只需使用类型断言确认错误变量实现了该方法。然后就可以安全地调用它:
这会打印完整的合并错误集合:
基于 context 的错误处理
context 包常用于控制请求超时,或在请求到达时取消多个 goroutine。如果使用可取消的 context,你可以检查并处理导致取消的错误。
从 Go 1.20 开始,你可以使用 WithCancelCause context 在取消 context 时发送自定义错误消息。下面是一个基本示例:
(构造 goroutine 和取消场景会很快变得复杂。完整示例见 readfiles_concurrent.go。)
context 函数 WithCancelCause() 返回一个 context 和一个接收 error 类型参数的 cancel 函数。调用 cancel 时,可以传入自定义错误消息作为参数。所有能访问该 context 的相关方都可以通过 context.Cause(ctx) 获取这个自定义错误。## Go 错误处理的最佳实践
了解了上述错误处理技巧后,我们来看一下在 Go 中使用错误时的一些最佳实践。
使用 defer 函数
函数可以通过多条路径退出,既包括 return 语句,也包括 panic。当函数分配了文件、网络连接或 goroutine 等资源时,应使用 defer() 函数在函数退出时清理所有打开的資源。
ReadFile() 函数包含一个延迟调用,用于关闭已打开的文件:
请注意,defer f.Close() 位于错误检查之后。如果 os.Open() 失败,它会返回一个 nil 文件和不为 nil 的错误,因此没有需要关闭的对象。如果在错误检查之前就 defer close,则可能会在 nil 文件上调用它。
提供明确的错误信息
没有什么比在日志文件中看到诸如 ERROR: EPIC FAIL 这样莫名其妙的错误信息、却完全不知道错误发生时的上下文更让人沮丧的了。
你可能会好奇:是的,这类消息在现实世界中确实存在。这类消息的问题在于,即使是应当最了解自己代码的开发者,也可能无法说出某条特定消息出现的具体原因:
“你看,这段特定的代码被很多地方调用,我们真的无法判断是什么精确触发了这个错误。日志文件中没有足够的上下文。”
因此,当函数遇到错误时,不应将该错误原封不动地向上传递。相反,如果存在有助于排查错误的任何上下文信息,都应该通过将该错误包装到一个新错误中,把信息附加到错误上。(参见前面关于使用错误包装的章节。)
仅在必要时使用 panic 和 recover
Go 新手常常不喜欢 Go 那种冗长的错误处理方式,想通过让函数 panic 而不是处理错误来减少代码输入。在顶层,panic 会被 recover 并处理。然而,这种方法不符合 Go 的习惯用法,而且有很多缺点。首先,也是最关键的,通过这种方式无法添加有用的上下文信息(见上一节)。此外,由于 panic 在正常的调用/返回流程之外展开调用栈,因此在顶层函数与引发 panic 的函数之间调用链上的任何函数都不会包含错误处理代码。读者如何能看出这些函数中的某一个可能会观察到错误?作为比较,Java 提供了 throws 关键字,用来列出函数可能抛出的所有异常。Go 没有这种特性。无法判断一个函数调用的某个子函数是否 panic。Go 标准的错误处理方式能让错误流清晰可见。
Go 将错误视为程序流程的正常组成部分,因为它们确实如此。如果发生错误,就应当处理该错误或将其传递给调用者,直到调用链上某一层函数处理该错误,或将其写入日志文件用于排查。
在检查函数时,你希望能立刻看到它可能遇到哪些错误,以及它如何将错误沿调用链向上传递。
调用 panic 应仅限于那些本不该发生的意外错误。例如,在“Panic and recover”一节中看到的硬编码正则表达式字符串就是一个例子。硬编码的正则表达式应是经过精心编写和验证的,绝不能在运行时失败。
还有一些类别的错误根本无法处理,例如内存不足(out-of-memory)的情况。如果所需内存无法分配,应用程序没有有意义的继续运行方式,此时应该 panic。
另一方面,运行时的用户输入通常并不可靠。由用户输入、无效或缺失文件、网络超时或其他可预见的失败来源造成的任何错误,都可以且应当作为错误来处理。
使用遵循错误处理最佳实践的库和包
当多个第三方包提供相同或类似的功能时,请选择遵循错误处理最佳实践的那个包。
如果你选择了一个 API 看起来很花哨、但错误处理却很脆弱的包,这对你没什么好处。任何掩盖错误而不是正确向上返回错误的包,或者任何不为错误提供上下文的包,都会使排查问题变成撞运气式的调试噩梦。
所以,不妨看一看包内部的代码,确认它是否包含健壮且具备妥善错误处理的代码。这种预防性措施从长远来看会得到回报。
在适当的地方创建自定义错误类型
因为 error 是一个接口,因此只要实现 Error() string,就可以构建带有额外功能的自定义错误类型。你在“测试特定错误类型”一节中已经看到了一个示例:os.Open 返回了 fs.PathError。
该错误是一个结构体,实现了 Error()、Unwrap() 和 Timeout() 方法,并提供 Path、Op 和 Err 字段来捕获详细的错误信息:
同样,你也可以创建自己的错误类型。唯一必须实现的方法是 Error(),但如果同时实现 Unwrap() 方法,包函数 errors.Unwrap() 就可以解包你的错误。
处理特定类型的错误
由于特定性质,某些类型的错误需要特殊处理。这些类型包括网络错误、I/O 错误和系统错误。
网络错误
连接失败的网络需要特殊处理。网络错误可能由永久性故障或临时性问题引起。处理网络错误的代码需要区分这两种情况。
以打开新的 TCP 连接为例。该任务可能会因为网络暂时中断而失败,也可能因为连接另一端的系统正在重启或过载、暂时无法接受新连接而失败。
在这种情况下,你会希望稍后重试连接。例如,net.Dial() 函数就支持这一点:它会返回一个特定的错误类型 net.OpError,该类型提供了一个名为 Temporary() 的方法,用于测试错误是否预计会最终消失。
借助 Temporary() 方法,你可以实现像下面这样简单的重试算法,或使用更复杂的策略,如指数退避:
I/O 错误
在读取或写入大量数据之后发生的 I/O 错误,从中恢复的代价可能很高。从开始直到错误发生点为止已处理的所有数据,可能都需要重新读取或写入。
为支持更高效的恢复,标准库中大多数与 I/O 相关的函数和方法不仅会返回错误,还会返回成功处理的字节数。一个典型例子是 io.Reader 的 Read() 函数:
错误恢复流程可以利用这一信息,从中断的位置继续执行 I/O 操作。
重要提示:io 包提供了哨兵错误值 io.EOF(定义为 errors.New("EOF")),用于表示输入流的读取已成功(!)结束。每个实现 io.Reader 接口的类型都应当遵循已文档化的返回错误语义:
……一个 Reader 如果在输入流末尾返回非零字节数,可能会返回
err == EOF或err == nil。下一次Read应当返回0, EOF。
Go 错误处理中应避免的常见错误
虽然 Go 的错误处理乍看之下可能有些不同寻常,但它使用起来既合逻辑又直接。不过,这并不意味着你在处理错误时不会犯错。下面是一些需要避免的错误。
忽略错误
开发者在任何编程语言中能犯的最大错误就是忽略错误。没有及早捕获错误,很容易导致后续错误,而这些后续错误可能比原始错误在得到妥善处理的情况下更难排查。
因此,避免错误处理失误的第一条规则是:绝不把返回的错误值赋给空标识符。
此外,要留意那些唯一返回值就是错误值的函数。Go 并不阻止你完全忽略单个返回值,但你可以使用 linter 来检测被忽略的错误返回值。(GoLand 甚至会在编辑器中直接高亮未处理的错误,方便你避免这类错误。)
趣味事实:你知道吗,fmt.Println() 也会返回一个错误值?
要点是,不要这样写:
而应该这样写:
传播错误时未添加额外的上下文
通常,即使不是总是,一个函数在从另一个函数处收到错误时,也可以为该错误添加有价值的上下文信息。
所以,每当你发现自己写出下面这样的代码:
退一步,看看是否可以加入上下文信息。在大多数情况下,你都可以做到。即使是函数名本身也可能是很有价值的信息,因为这样你可以追溯到导致错误的函数调用链:
现在你只是需要在键盘上多敲几下,但将来却可以为你节省大量时间。
过度泛化错误
在编写错误信息时,尽可能具体。把你能获得的所有上下文信息都包含进去。
像 “database error” 这样的错误信息可能有无数种不同的成因。这样的信息实际上毫无意义,也没有任何帮助。
在错误信息中尽可能多地加入信息。可以考虑创建能携带附加信息的自定义错误类型;可以参考 os.PathError 类型。
使用了不正确的错误类型
特定错误值的类型可能看起来像是一个无关紧要的细节。毕竟,每个错误都实现了 type error interface{ Error() string },所以归根结底,错误不过是被美化了的字符串类型,对吗?
错。自定义错误类型可以包含额外信息,并通过 errors.Is()、errors.As() 和 errors.AsType() 进行高级错误检查。
因此,当你要将错误返回给调用方时,务必使用适合给定错误上下文的错误类型。
不记录错误
错误信息对于排查问题必不可少。无论应用是能处理某个错误,还是该错误会迫使应用终止,应用都应记录该错误,以便事后分析。
一般来说,如果某个函数注意到了错误,它要么处理该错误,要么将错误返回给调用方。
如果函数能够处理该错误,或者由于某种原因无法返回该错误(也许因为它是 main() 函数),那么该函数应当始终记录错误及其所有上下文信息。
每个出现的错误都代表着一个修复 bug 或改进代码的机会;不要让它在不知不觉中溜走。
使用 log.Fatal() 记录错误
如果你的应用遇到不可恢复的错误,你可能会很自然地想通过调用 log.Fatal() 来记录这个错误,因为它可以便捷地记录一条消息并立即退出进程。
然而,这里有一个陷阱。log.Fatal() 会调用 os.Exit()。与调用 panic() 不同,os.Exit() 不可恢复,并且会跳过所有延迟(deferred)函数。
一个好的做法是让 func main() 不 defer 任何函数,并且只在 main() 中调用 log.Fatal() 或 os.Exit()。
不考虑错误恢复
“尽早崩溃”在许多情况下都是个好建议。让应用崩溃可以使其从干净状态重新启动。然而,崩溃并不总是最佳选择。
- 如果错误很容易恢复,那么让整个应用崩溃就属于反应过度。
- 如果一个进程要保证最大可用时间,那么最好尽力从错误中恢复,而不是用一次重启来干扰系统。
- 如果一个进程会启动多个 goroutine,通常只需要退出观察到错误状态的单个 goroutine 就足够了。
http.ListenAndServe()就是这种策略的一个例子。所有传入请求都在各自的 goroutine 中处理,如果某个 goroutine panic,ListenAndServe()会从该 panic 中恢复,从而使所有其他并发处理器不受影响地继续运行。
归根结底:精心设计的错误恢复可能对应用大有裨益,尤其是在“尽早崩溃”会带来很高的重新启动应用成本的情况下。
结论
Go 的错误处理涉及的“活动部件”非常少,因此学起来很快。错误处理的真正艺术在于知道如何针对特定错误场景做出最佳响应,以及如何在错误沿调用链向上传递的过程中管理它们。
在本指南中,你学习了有用的错误处理技术、最佳实践、特定错误类型以及需要避免的常见错误。你获得的知识和技能将帮助你编写可维护且易于排查的代码。但是,你知道如何安全地处理 Go 错误吗?请阅读我们的下一篇错误处理指南来一探究竟!