Table of contents
Open Table of contents
TL;DR
Go 将错误视为普通值(error 是一个只含 Error() string 方法的 interface),通过多返回值强制调用者显式处理,而非像 Java/Python 那样用异常机制隐式传播。Go 1.13 引入 error wrapping(%w、errors.Is、errors.As),Go 1.20 引入 errors.Join 支持多错误合并。这套机制的核心优势是:控制流可见、错误可编程、无隐式传播。代价是 if err != nil 样板代码多,但 Go 团队已于 2025 年 6 月正式宣布不再追求语法层面的错误处理变更。
1. What and Why:Go 的错误处理哲学
”Errors Are Values”
2015 年,Rob Pike 在 Errors are values 博文中提出核心论点:
Errors are values. Values can be programmed, and since errors are values, errors can be programmed.
这意味着 error 不是特殊的控制流构造,而是可以用 Go 的所有常规工具(变量、方法、接口、闭包)来编程的普通值。Pike 用 bufio.Scanner 和 errWriter 模式展示了如何通过抽象消除重复的 if err != nil,而不需要语言层面的特殊语法。
为什么不用 try/catch(异常机制)
Go 团队认为异常机制有几个根本问题:
| 问题 | 异常机制(Java/Python) | Go errors |
|---|---|---|
| 控制流可见性 | 任何函数调用都可能抛异常,调用者无法从签名看出来(unchecked exceptions) | 函数签名明确返回 error,不处理就编译不过(unused variable) |
| 隐式传播 | 异常沿调用栈自动传播,中间层可能完全不知道 | 必须显式 return err,每一层都必须做出决定 |
| 异常 vs 错误的边界 | 模糊。网络超时是 exception 还是正常情况? | 清晰。所有预期的失败场景都是 error 值 |
| 性能 | throw/catch 涉及栈展开,代价高 | error 是普通值,零额外开销 |
| 可组合性 | 难以把异常当数据来操作 | error 可以被累积、转换、存储、传递 |
不用它会怎样
如果 Go 采用异常机制:
- 大型代码库(Google 级别) 中,隐式传播的异常会让控制流变得不可预测,维护者无法确定哪些调用会失败
- 并发场景中,goroutine 里的未捕获异常处理更加复杂(Go 的 panic 在 goroutine 边界的行为就是例证)
- 代码看起来更”干净”,但失败路径被隐藏,导致生产环境出现意料之外的崩溃
来源:Error handling and Go、Go’s Error Handling: Why Explicit Beats Exceptions
2. 核心概念与底层机制
2.1 error interface 的设计
// Go 标准库定义(builtin/builtin.go)
type error interface {
Error() string
}
为什么只有一个方法?
- 最小接口原则:Go 的设计哲学是小接口(
io.Reader也只有一个方法)。越小的接口越容易被实现,适用面越广 - 任何类型都能成为 error:只要实现
Error() string,*os.PathError、*net.OpError、一个简单的stringwrapper 都是 error - 扩展靠组合而非继承:需要更多信息?加字段、加方法、实现
Unwrap()——但基础接口保持最小
2.2 值语义 vs 异常语义
Go (值语义):
result, err := doSomething()
if err != nil {
// 你必须在这里决定怎么处理
}
// 程序继续
Java (异常语义):
try {
Result result = doSomething(); // 可能抛异常,也可能不抛
} catch (SomeException e) {
// 可能在几十层调用之上才 catch
}
关键区别:Go 的错误处理发生在产生错误的那一行,Java 的异常处理可以发生在调用链的任意位置。这不仅是语法差异,是根本不同的心智模型。
2.3 Error Wrapping 链式结构(Go 1.13+)
Go 1.13 引入了 error wrapping 的标准化支持:
原始 error: ErrNotFound
↑ Unwrap()
包装层 1: "query user: not found" (fmt.Errorf("query user: %w", ErrNotFound))
↑ Unwrap()
包装层 2: "handle request: query user: not found" (fmt.Errorf("handle request: %w", err))
核心接口:
// 单 error 包装(Go 1.13+)
interface {
Unwrap() error
}
// 多 error 包装(Go 1.20+)
interface {
Unwrap() []error
}
%w 动词创建的 error 实现 Unwrap() error,返回被包装的原始 error。errors.Is 和 errors.As 会递归调用 Unwrap() 沿链向下查找。
2.4 Sentinel Errors vs Custom Error Types vs Opaque Errors
| 类型 | 定义方式 | 匹配方式 | 适用场景 | 缺点 |
|---|---|---|---|---|
| Sentinel Error | var ErrNotFound = errors.New("not found") | errors.Is(err, ErrNotFound) | 特定的、可预期的错误条件 | 成为 public API 的一部分,难以修改 |
| Custom Error Type | type *NotFoundError struct{...} | errors.As(err, &target) | 需要携带结构化信息(ID、字段名等) | 类型也成为 public API |
| Opaque Error | fmt.Errorf("something failed: %w", err) | 调用者不关心具体类型,只看 err != nil | 内部实现细节,不想暴露给调用者 | 调用者无法做精细处理 |
Dave Cheney 的经验法则(来源:Don’t just check errors, handle them gracefully):
- 优先使用 opaque errors,减少 API 耦合
- 只在调用者确实需要区分不同错误时才暴露 sentinel 或 custom type
2.5 errors.Is 和 errors.As 的匹配机制
errors.Is(err, target):沿 Unwrap 链检查是否有 error 值等于 target。
匹配逻辑(伪代码):
for err != nil {
if err == target || err 实现了 Is(target) 且返回 true {
return true
}
if err 实现 Unwrap() []error { // Go 1.20+: 树形遍历(深度优先前序)
对每个子 error 递归检查
} else if err 实现 Unwrap() error {
err = err.Unwrap()
} else {
return false
}
}
errors.As(err, &target):沿 Unwrap 链检查是否有 error 的类型可以赋值给 target。
var pathErr *os.PathError
if errors.As(err, &pathErr) {
// pathErr 已被赋值,可以访问 pathErr.Path, pathErr.Op 等字段
}
重要细节:自定义类型可以实现 Is(error) bool 和 As(any) bool 方法来自定义匹配逻辑。
2.6 errors.Join(Go 1.20+)
errors.Join 将多个 error 合并为一个。返回的 error 实现 Unwrap() []error。
err := errors.Join(err1, err2, err3)
// err.Error() => "err1 msg\nerr2 msg\nerr3 msg"(换行分隔)
// errors.Is(err, err1) => true
// errors.Is(err, err2) => true
内部实现:
type joinError struct {
errs []error
}
func (e *joinError) Error() string {
// 用 \n 连接所有 error 的 Error() 输出
}
func (e *joinError) Unwrap() []error {
return e.errs
}
注意:errors.Unwrap() 对 joinError 返回 nil(因为它实现的是 Unwrap() []error 而非 Unwrap() error)。要获取子错误列表,需要类型断言:
if uw, ok := err.(interface{ Unwrap() []error }); ok {
for _, e := range uw.Unwrap() {
// 处理每个子错误
}
}
2.7 panic/recover 的定位和使用边界
panic 不是错误处理机制,它是程序 bug 的信号。
| error | panic | |
|---|---|---|
| 语义 | 可预期的失败(文件不存在、网络超时) | 不可恢复的 bug(nil 解引用、数组越界、不可能发生的状态) |
| 控制流 | 显式返回,调用者决定 | 沿调用栈展开,终止 goroutine |
| 跨 goroutine | 通过返回值或 channel 传递 | 不跨 goroutine(goroutine 中未 recover 的 panic 直接 crash 进程) |
| 使用场景 | 业务逻辑的所有错误 | 初始化失败(MustCompile)、不变式被打破 |
recover 的使用边界:
- 唯一正当场景:goroutine 边界的防护(HTTP handler 中间件、worker pool 的 goroutine wrapper)
- 反模式:用 panic/recover 做常规错误处理(把它当 try/catch 用)
3. 核心 API 与数据结构
3.1 API 速查表
| 函数/方法 | 引入版本 | 用途 |
|---|---|---|
errors.New(text) | Go 1.0 | 创建简单 error |
fmt.Errorf("...: %w", err) | Go 1.13 | 创建包装了另一个 error 的新 error |
errors.Unwrap(err) | Go 1.13 | 获取被包装的 error(仅限单 error 包装) |
errors.Is(err, target) | Go 1.13 | 沿 Unwrap 链匹配 error 值 |
errors.As(err, &target) | Go 1.13 | 沿 Unwrap 链匹配 error 类型 |
errors.Join(errs...) | Go 1.20 | 合并多个 error 为一个 |
3.2 自定义 Error Type 最佳实践
一个完整的自定义 error type 可以实现以下方法:
// Go 1.21+
type AppError struct {
Code string // 机器可读的错误码,如 "USER_NOT_FOUND"
Message string // 人类可读的描述
Err error // 被包装的底层 error
}
// 必须实现:满足 error interface
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
// 实现 Unwrap:让 errors.Is/errors.As 能穿透到底层 error
func (e *AppError) Unwrap() error {
return e.Err
}
// 可选:自定义 Is 逻辑(按 Code 匹配而非指针相等)
func (e *AppError) Is(target error) bool {
t, ok := target.(*AppError)
if !ok {
return false
}
return e.Code == t.Code
}
4. 典型使用场景对比
| 场景 | 推荐方式 | 示例 | 理由 |
|---|---|---|---|
| 已知的、特定的错误条件 (如 “未找到”、“已存在”) | Sentinel Error | var ErrNotFound = errors.New("not found") | 调用者需要用 errors.Is 精确匹配 |
| 需要携带结构化信息 (如 HTTP 状态码、字段名) | Custom Error Type | type ValidationError struct{Field, Reason string} | 调用者需要用 errors.As 提取信息 |
| 给 error 添加上下文 (跨层传递时标注来源) | fmt.Errorf + %w | fmt.Errorf("query user %d: %w", id, err) | 保留底层 error 的可检查性,添加调试信息 |
| 不想暴露内部实现 | fmt.Errorf + %v(不 wrap) | fmt.Errorf("operation failed: %v", err) | 切断 Unwrap 链,调用者只知道失败了 |
| batch 操作中多个步骤可能各自失败 | errors.Join | err = errors.Join(err, step1(), step2()) | 聚合所有错误,不丢失任何一个 |
| 真正不可能发生的状态 | panic | panic("unreachable: switch default") | 表示程序 bug,不是业务错误 |
| goroutine 边界保护 | recover | HTTP middleware、worker wrapper | 防止单个 goroutine 的 panic 崩溃整个进程 |
5. 与相关技术的对比
| 维度 | Go error | Java Exceptions | Rust Result<T, E> | Node.js error-first callback |
|---|---|---|---|---|
| 错误表示 | error interface(值) | 异常对象(类层次结构) | Result<T, E> enum(值) | callback(err, data) |
| 传播方式 | 显式 return err | 隐式栈展开(throw) | 显式 ? 操作符 / return Err(e) | 手动传递 err 参数 |
| 编译期强制 | 部分(unused variable 报错,但可以 _ = err) | checked exceptions 强制(但大多数用 unchecked 绕过) | 完全强制(Result 必须被处理,#[must_use]) | 无 |
| 类型安全 | 弱(error 是 interface,需要类型断言) | 强(异常类型层次结构) | 强(泛型 E 类型参数) | 无(err 是 any) |
| 零值问题 | nil 表示无错误 | 不抛异常表示成功 | Ok(T) 表示成功 | null/undefined 表示无错误 |
| 多错误 | errors.Join(Go 1.20+) | suppressed exceptions(不常用) | 自行实现或 anyhow::Error | 不内置 |
| 性能开销 | 几乎为零(值传递) | 高(栈展开 + 栈帧捕获) | 几乎为零(值传递) | 几乎为零 |
| 常见批评 | if err != nil 样板多 | 异常类型泛滥、checked exception 争议 | 学习曲线陡峭(生命周期 + 泛型) | 回调地狱(已被 async/await 取代) |
| 调试体验 | 每个错误点都可以打断点 | 需要设置 exception breakpoint | 每个 ? 都可以打断点 | 每个 callback 都可以打断点 |
关键洞察:
- Rust 的
Result<T, E>是 Go error 的”严格升级版”:同样是值语义,但编译器强制处理,类型更安全,?操作符减少样板。Go 没有泛型(直到 1.18)是不采用此方案的历史原因之一 - Java 的 checked exceptions 是一个失败的实验:理论上好(强制处理),实践中导致
catch (Exception e)泛滥。Go 从中吸取了教训——强制处理,但用简单的if而非复杂的类型层次 - Node.js 的 error-first callback 已经被 Promise/async-await 取代,说明纯回调式的错误处理人体工学太差
6. 可运行代码示例(Go 1.21+)
6.1 基础错误创建和返回
package main
import (
"errors"
"fmt"
"os"
)
// sentinel error:包级别变量,首字母大写导出
var ErrInvalidAge = errors.New("invalid age")
func validateAge(age int) error {
if age < 0 || age > 150 {
return fmt.Errorf("age %d: %w", age, ErrInvalidAge)
}
return nil
}
func main() {
if err := validateAge(-1); err != nil {
fmt.Println(err) // age -1: invalid age
if errors.Is(err, ErrInvalidAge) {
fmt.Println("-> 是年龄校验错误")
}
}
// 标准库的 error 使用示例
_, err := os.Open("/nonexistent")
if err != nil {
fmt.Println(err) // open /nonexistent: no such file or directory
}
}
6.2 Error Wrapping 链
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func findInDB(id int) error {
return fmt.Errorf("record %d: %w", id, ErrNotFound) // 第一层包装
}
func queryUser(id int) error {
err := findInDB(id)
if err != nil {
return fmt.Errorf("query user: %w", err) // 第二层包装
}
return nil
}
func handleRequest(userID int) error {
err := queryUser(userID)
if err != nil {
return fmt.Errorf("handle request: %w", err) // 第三层包装
}
return nil
}
func main() {
err := handleRequest(42)
fmt.Println(err)
// handle request: query user: record 42: not found
// errors.Is 穿透整条链
fmt.Println(errors.Is(err, ErrNotFound)) // true
// errors.Unwrap 逐层剥离
fmt.Println(errors.Unwrap(err))
// query user: record 42: not found
}
6.3 自定义 Error Type 带结构化信息
package main
import (
"errors"
"fmt"
)
// ValidationError 携带结构化信息
type ValidationError struct {
Field string
Value any
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed: field %q value %v: %s", e.Field, e.Value, e.Reason)
}
// NotFoundError 支持 Unwrap
type NotFoundError struct {
Resource string
ID string
Err error // 底层错误(如数据库错误)
}
func (e *NotFoundError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s %q not found: %v", e.Resource, e.ID, e.Err)
}
return fmt.Sprintf("%s %q not found", e.Resource, e.ID)
}
func (e *NotFoundError) Unwrap() error {
return e.Err
}
func getUser(id string) error {
// 模拟数据库返回的底层 error
dbErr := fmt.Errorf("sql: no rows in result set")
return &NotFoundError{
Resource: "user",
ID: id,
Err: dbErr,
}
}
func main() {
err := getUser("abc-123")
// 用 errors.As 提取结构化信息
var nfErr *NotFoundError
if errors.As(err, &nfErr) {
fmt.Printf("资源: %s, ID: %s\n", nfErr.Resource, nfErr.ID)
// 资源: user, ID: abc-123
}
// ValidationError 示例
vErr := &ValidationError{Field: "email", Value: "not-an-email", Reason: "invalid format"}
fmt.Println(vErr)
// validation failed: field "email" value not-an-email: invalid format
}
6.4 errors.Is / errors.As 实战
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
func main() {
// === errors.Is:匹配特定的 error 值 ===
_, err := os.Open("/nonexistent/file.txt")
// 检查是否是 "不存在" 错误(穿透 *os.PathError 的 Unwrap 链)
if errors.Is(err, fs.ErrNotExist) {
fmt.Println("文件不存在") // ✓
}
// 即使被多层包装,errors.Is 依然能穿透
wrapped := fmt.Errorf("loading config: %w", err)
fmt.Println(errors.Is(wrapped, fs.ErrNotExist)) // true
// === errors.As:提取特定类型的 error ===
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
fmt.Printf("操作: %s, 路径: %s\n", pathErr.Op, pathErr.Path)
// 操作: open, 路径: /nonexistent/file.txt
}
// === 自定义 Is 方法 ===
type CodeError struct {
Code int
Msg string
}
// 自定义匹配逻辑:只比较 Code
errA := &CodeError{Code: 404, Msg: "user not found"}
errB := &CodeError{Code: 404, Msg: "order not found"}
// 默认情况下 errA != errB(不同指针)
// 如果实现了 Is 方法按 Code 匹配,就可以 errors.Is(errA, errB) == true
}
6.5 errors.Join 多错误聚合
package main
import (
"errors"
"fmt"
)
var (
ErrPermission = errors.New("permission denied")
ErrTimeout = errors.New("timeout")
)
// cleanup 模拟多步清理,每步都可能失败
func cleanup() error {
var errs []error
if err := closeDB(); err != nil {
errs = append(errs, fmt.Errorf("close db: %w", err))
}
if err := closeCache(); err != nil {
errs = append(errs, fmt.Errorf("close cache: %w", err))
}
if err := flushLogs(); err != nil {
errs = append(errs, fmt.Errorf("flush logs: %w", err))
}
return errors.Join(errs...) // nil errs 会被自动过滤;全部 nil 则返回 nil
}
func closeDB() error { return ErrPermission }
func closeCache() error { return ErrTimeout }
func flushLogs() error { return nil }
func main() {
err := cleanup()
if err != nil {
fmt.Println("cleanup 错误:")
fmt.Println(err)
// close db: permission denied
// close cache: timeout
}
// errors.Is 能穿透 Join 找到每个子错误
fmt.Println(errors.Is(err, ErrPermission)) // true
fmt.Println(errors.Is(err, ErrTimeout)) // true
// 提取子错误列表
if uw, ok := err.(interface{ Unwrap() []error }); ok {
for i, e := range uw.Unwrap() {
fmt.Printf(" 子错误 %d: %v\n", i, e)
}
}
}
6.6 panic/recover 的正确使用(HTTP Handler 恢复)
package main
import (
"fmt"
"log"
"net/http"
"runtime/debug"
)
// RecoveryMiddleware 捕获 handler 中的 panic,防止进程崩溃
func RecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
// 记录完整堆栈,便于定位 bug
log.Printf("PANIC recovered: %v\n%s", rec, debug.Stack())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// SafeGo 封装 goroutine 创建,确保 panic 被 recover
func SafeGo(fn func()) {
go func() {
defer func() {
if rec := recover(); rec != nil {
log.Printf("goroutine PANIC: %v\n%s", rec, debug.Stack())
}
}()
fn()
}()
}
func riskyHandler(w http.ResponseWriter, r *http.Request) {
// 模拟一个 bug
var m map[string]string
_ = m["key"] // panic: assignment to entry in nil map — 不会,这只是读取返回零值
// 换一个真的 panic
panic("unexpected state: this should never happen")
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/risky", riskyHandler)
server := &http.Server{
Addr: ":8080",
Handler: RecoveryMiddleware(mux),
}
fmt.Println("listening on :8080")
log.Fatal(server.ListenAndServe())
}
6.7 分层架构中的错误处理实践
package main
import (
"errors"
"fmt"
"log/slog"
"net/http"
)
// ============ 领域层:定义业务错误 ============
type AppError struct {
Code string // 机器可读:"USER_NOT_FOUND", "VALIDATION_FAILED"
Message string // 用户可见的消息
HTTPStatus int // HTTP 状态码映射
Err error // 底层 error(内部使用,不暴露给客户端)
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
func (e *AppError) Unwrap() error { return e.Err }
func (e *AppError) Is(target error) bool {
t, ok := target.(*AppError)
if !ok {
return false
}
return e.Code == t.Code
}
// 预定义错误(sentinel 风格,按 Code 匹配)
var (
ErrUserNotFound = &AppError{Code: "USER_NOT_FOUND", HTTPStatus: 404}
ErrValidation = &AppError{Code: "VALIDATION_FAILED", HTTPStatus: 400}
ErrInternal = &AppError{Code: "INTERNAL_ERROR", HTTPStatus: 500}
)
// ============ Repository 层 ============
func findUserByID(id int) (string, error) {
// 模拟数据库查询
if id <= 0 {
return "", fmt.Errorf("invalid user id %d", id)
}
if id == 999 {
return "", &AppError{
Code: ErrUserNotFound.Code,
Message: fmt.Sprintf("user %d does not exist", id),
HTTPStatus: ErrUserNotFound.HTTPStatus,
Err: nil, // 这里没有底层错误
}
}
return "Alice", nil
}
// ============ Service 层 ============
func getUserName(id int) (string, error) {
name, err := findUserByID(id)
if err != nil {
// 如果已经是 AppError,直接传递(不重复包装)
var appErr *AppError
if errors.As(err, &appErr) {
return "", err
}
// 非 AppError 的错误 → 包装为 Internal Error
return "", &AppError{
Code: ErrInternal.Code,
Message: "failed to get user",
HTTPStatus: ErrInternal.HTTPStatus,
Err: err,
}
}
return name, nil
}
// ============ Handler 层(HTTP 边界)============
func handleGetUser(w http.ResponseWriter, r *http.Request) {
name, err := getUserName(999)
if err != nil {
// 在 HTTP 边界统一处理错误
var appErr *AppError
if errors.As(err, &appErr) {
// 日志记录完整 error(包含 Unwrap 链),响应只返回安全信息
slog.Error("request failed",
"code", appErr.Code,
"err", appErr.Error(),
"path", r.URL.Path,
)
http.Error(w, fmt.Sprintf(`{"code":"%s","message":"%s"}`, appErr.Code, appErr.Message),
appErr.HTTPStatus)
return
}
// 未知错误,500
slog.Error("unexpected error", "err", err, "path", r.URL.Path)
http.Error(w, `{"code":"INTERNAL_ERROR","message":"unexpected error"}`, 500)
return
}
fmt.Fprintf(w, `{"name":"%s"}`, name)
}
func main() {
http.HandleFunc("/user", handleGetUser)
fmt.Println("listening on :8080")
http.ListenAndServe(":8080", nil)
}
/*
关键原则:
1. Repository 层:翻译数据库错误为领域错误,不返回 sql.ErrNoRows 等基础设施细节
2. Service 层:编排业务逻辑,非 AppError 包装为 Internal Error
3. Handler 层:
- 这里(且仅在这里)记日志
- 把 AppError 映射为 HTTP 响应
- 内部 error 细节不暴露给客户端
*/
7. 常见陷阱(Pitfalls)
陷阱 1:用 == 比较 error 而不是 errors.Is
// ❌ 错误
if err == sql.ErrNoRows {
// 如果 err 被 fmt.Errorf("%w", ...) 包装过,这里永远不会进入
}
// ✅ 正确
if errors.Is(err, sql.ErrNoRows) {
// 穿透 Unwrap 链匹配
}
为什么是坑:Go 1.13 之前 == 是唯一方式,大量旧代码和教程仍在用。一旦中间层开始 wrap error,所有 == 比较都会失效。
陷阱 2:Wrap 了 error 但丢失了上下文
// ❌ 没有上下文
return fmt.Errorf("failed: %w", err)
// ❌ 用 %v 而非 %w,切断了 Unwrap 链
return fmt.Errorf("query user %d: %v", id, err)
// ✅ 有上下文 + 保持 Unwrap 链
return fmt.Errorf("query user %d: %w", id, err)
为什么是坑:%v 只保留 error 的字符串表示,调用者无法用 errors.Is/errors.As 匹配原始 error。除非你有意切断链(不想暴露内部实现),否则应该用 %w。
陷阱 3:panic 被滥用为错误处理
// ❌ 把 panic 当 throw 用
func getUser(id int) *User {
user, err := db.Find(id)
if err != nil {
panic(err) // 调用者根本不知道这里会 panic
}
return user
}
// ✅ 正确做法
func getUser(id int) (*User, error) {
user, err := db.Find(id)
if err != nil {
return nil, fmt.Errorf("get user %d: %w", id, err)
}
return user, nil
}
为什么是坑:panic 会绕过所有正常的错误处理逻辑,让调用者无法优雅地处理失败。唯一的”接口契约”就是函数签名——如果签名不返回 error,调用者有理由假设它不会失败。
陷阱 4:忽略 error(_ = doSomething())
// ❌ 静默吞掉错误
_ = file.Close()
_ = db.Ping()
// ✅ 至少记日志
if err := file.Close(); err != nil {
slog.Warn("failed to close file", "err", err)
}
为什么是坑:file.Close() 在写入场景下可能触发 flush 失败,意味着数据没有真正持久化。_ = 是”我知道有 error 但我选择忽略”的显式声明——如果你真要这么做,至少加注释说明理由。
陷阱 5:Error message 重复前缀
// ❌ 每一层都加 "failed to"
// 最终: "failed to handle request: failed to query user: failed to find record: not found"
return fmt.Errorf("failed to handle request: %w", err)
// ✅ 只说做了什么,不加 "failed to"
// 最终: "handle request: query user: find record: not found"
return fmt.Errorf("handle request: %w", err)
为什么是坑:error 已经表示失败了(它是 error 类型!)。每层加 “failed to” 纯属冗余,还让 error message 又长又难读。Go 惯例是只描述正在做什么(动词 + 宾语),不描述”失败了”。
来源:Go Code Review Comments 建议 error string 不要大写、不要加标点、不要加 “failed to” 前缀
陷阱 6:在 goroutine 中 panic 但没有 recover
// ❌ 致命:这个 goroutine 的 panic 会杀死整个进程
go func() {
result := riskyOperation() // 如果这里 panic...
ch <- result
}()
// 主 goroutine 的 recover 捕获不到子 goroutine 的 panic
// ✅ 每个 goroutine 都要有自己的 recover
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("goroutine panic: %v\n%s", r, debug.Stack())
}
}()
result := riskyOperation()
ch <- result
}()
为什么是坑:Go 的 recover 只能捕获当前 goroutine 的 panic。net/http 的内置 recovery 只保护 handler 所在的 goroutine,handler 里 go func() 新起的 goroutine 不受保护。生产环境中,一个未 recover 的 goroutine panic 会导致整个进程崩溃。
陷阱 7:对 nil interface 和 nil 值的 error 混淆
// ❌ 经典陷阱
func doSomething() error {
var p *MyError // p 是 nil 的 *MyError 指针
// ... 一些逻辑,p 没被赋值 ...
return p // 返回一个"non-nil interface holding a nil pointer"!
}
func main() {
err := doSomething()
fmt.Println(err == nil) // false!因为 interface 的 type 字段是 *MyError
}
// ✅ 正确做法
func doSomething() error {
// ... 一些逻辑 ...
return nil // 显式返回 nil,不要返回类型化的 nil 指针
}
为什么是坑:Go 的 interface 由 (type, value) 两部分组成。(*MyError)(nil) 赋给 error interface 后,type 部分是 *MyError(非 nil),所以 err != nil 为 true。这可能是 Go 中最隐蔽的陷阱之一。
陷阱 8:在错误的层记日志
// ❌ 每层都记日志 → 同一个错误在日志里出现 3 次
func repo() error {
err := db.Query(...)
if err != nil {
log.Error("db query failed", "err", err) // 第 1 次
return fmt.Errorf("repo: %w", err)
}
}
func service() error {
err := repo()
if err != nil {
log.Error("repo failed", "err", err) // 第 2 次
return fmt.Errorf("service: %w", err)
}
}
func handler() {
err := service()
if err != nil {
log.Error("service failed", "err", err) // 第 3 次
}
}
// ✅ 底层 wrap 上下文,只在最顶层(边界)记一次日志
为什么是坑:重复日志浪费存储、干扰排查(搜索到 3 条日志以为是 3 个错误)。正确做法:底层只负责 wrap 上下文,日志在 HTTP handler / CLI entrypoint / worker 的最外层统一记录。
8. 生产环境最佳实践 / Checklist
8.1 错误日志策略
| 原则 | 说明 |
|---|---|
| 只在边界记日志 | HTTP handler、gRPC interceptor、CLI main、worker loop 的最外层 |
| 底层只 wrap 不 log | Repository / Service 层用 fmt.Errorf 添加上下文,不调用 logger |
| 结构化日志 | 用 slog(Go 1.21+ 标准库),输出 JSON 格式,附带 request_id、user_id 等字段 |
| 内部 error 不暴露给客户端 | 日志记录完整 error 链,HTTP 响应只返回安全的 message |
8.2 错误分类与 HTTP 状态码映射
// 推荐:在领域层定义错误码,在 HTTP 层映射状态码
var statusMap = map[string]int{
"NOT_FOUND": 404,
"VALIDATION_FAILED": 400,
"UNAUTHORIZED": 401,
"FORBIDDEN": 403,
"CONFLICT": 409,
"INTERNAL_ERROR": 500,
}
不要在 repository 或 service 层引入 net/http 包。状态码映射属于 HTTP 边界的职责。
8.3 结构化错误(API 响应)
{
"error": {
"code": "VALIDATION_FAILED",
"message": "email format is invalid",
"details": [{ "field": "email", "reason": "must be a valid email address" }]
}
}
原则:
code:机器可读,前端用来做条件判断message:人类可读,可以直接展示给用户details:可选,用于字段级错误(表单验证)- 绝不包含 stack trace 或内部 error 信息
8.4 错误监控与报警
- Sentry / Datadog / Grafana Loki:捕获错误并报警
- 关键指标:5xx 错误率、特定 error code 的出现频率
- 错误采样:高频错误做采样(如 1%),避免日志洪泛
- panic 必须立即报警(说明存在 bug)
8.5 第三方库
| 库 | 适用场景 | 状态 |
|---|---|---|
标准库 errors + fmt | 绝大多数场景 | 推荐首选,Go 1.13+ 功能已足够 |
cockroachdb/errors | 分布式系统、需要网络传输 error、PII 过滤、Sentry 集成 | 活跃维护,CockroachDB 生产使用 |
hashicorp/go-multierror | Go 1.20 之前需要多错误聚合 | Go 1.20+ 后建议用 errors.Join 替代 |
github.com/pkg/errors | 添加 stack trace | 已归档(archived),不再维护。标准库已覆盖其核心功能 |
建议:新项目直接用标准库。只有在需要 stack trace 附加、网络 error 传输、PII 过滤等高级功能时才引入第三方库。
9. 社区争议与演进方向
9.1 if err != nil 的冗余问题
Go Developer Survey 连年显示,错误处理的冗余是开发者的头号抱怨。典型的 Go 函数中,错误处理代码占比可达 30-50%。
Rob Pike 的回应(2015):这不是语言的问题,是程序员没有把 error 当值来编程。用 errWriter、bufio.Scanner 等模式可以大幅减少样板。
社区的反驳:这些模式只适用于特定场景(连续写入、扫描),大多数业务代码仍然是”调用 → 检查 → 返回”的直线逻辑,无法用这些模式优化。
9.2 被拒绝的提案
| 提案 | 年份 | 核心思路 | 被拒原因 |
|---|---|---|---|
| check/handle | 2018 | check 关键字替代 if err != nil,handle 块定义错误处理 | 过于复杂,引入新的控制流概念 |
| try built-in | 2019 | try(f()) 内置函数,自动检查并返回 error | 隐藏返回点、调试困难、~900 条评论压倒性反对 |
| ? operator | 2024 | 借鉴 Rust,f() ? 自动 propagate error | 改善最大但缺乏广泛共识,用户研究表现尚可 |
9.3 Go 团队的最终立场(2025 年 6 月)
Go 官方博客 On/No syntactic support for error handling 明确宣布:
For the foreseeable future, the Go team will stop pursuing syntactic language changes for error handling. We will also close all open and incoming proposals that concern themselves primarily with the syntax of error handling, without further investigation.
核心理由:
- 没有共识:即使 Go 团队内部的资深成员也无法达成一致
- 15 年窗口已过:Go 已有 15 年的错误处理实践,新语法会强制改变所有人的写法(不像泛型是可选的)
- 收益不够大:IDE 辅助(自动补全、折叠)、LLM 代码生成、
cmp.Or等工具链改进可以缓解痛点 - 成本太高:语言变更的维护成本、社区分裂风险、Go 团队资源有限
- 现实反馈:Google Cloud Next 2025 上的 Go 用户普遍表示反对语法变更
这意味着:if err != nil 就是 Go 错误处理的终极形态。与其抱怨,不如掌握好 error wrapping、自定义 error type、errors.Is/errors.As 这套工具,写出清晰、可维护的错误处理代码。
参考来源
- Errors are values - Rob Pike (2015)
- Error handling and Go - Go Blog (2011)
- Working with Errors in Go 1.13 - Go Blog (2019)
- On/No syntactic support for error handling - Go Blog (2025)
- errors package - Go standard library
- Don’t just check errors, handle them gracefully - Dave Cheney
- Proposal: A built-in Go error check function “try” (rejected)
- errors: add support for wrapping multiple errors (Go 1.20)
- Error Handling Draft Design - Go 2