我在 Go 里用 == 判断一个错误是不是"记录不存在",一直好好的,直到某层代码用 %w 把这个错误包装了一下再往上抛,我的 == 判断就突然失效了,把"记录不存在"当成了未知错误:一次没用 errors.Is 穿透错误包装链、只看最外层身份的深度复盘
那个"明明是记录不存在,却被当成系统错误返回 500"的 bug,根子在我对 Go 错误处理的一个盲区。我有段代码,要区分"记录不存在(正常,返回 404/空)"和"真的出错了(返回 500)",写成了 if err == sql.ErrNoRows { ... }。一直跑得好好的。直到某次重构,中间一层 DAO 代码在错误往上传时,用 fmt.Errorf("query user failed: %w", err) 把原始错误包装(wrap)了一层再返回。从那以后:我那个 err == sql.ErrNoRows 的判断突然失效了——明明底层就是 sql.ErrNoRows,可 == 返回 False,于是"记录不存在"被当成了"未知错误",该返回 404 的接口返回了 500。复盘 Go 的错误包装,我才彻底搞懂,后背发凉:问题出在我用 == 直接比较错误,但错误被 %w 包装后,最外层已经是一个新的 error 了,== 比不上里面被包裹的原始错误。Go 1.13 引入了"错误包装(error wrapping)":fmt.Errorf("...: %w", err) 会创建一个新的 error,它"包裹"着原始的 err(像套了一层壳),保留了错误链;而 == 比较的是最外层那个 error 对象——包装后,最外层是 fmt.Errorf 新造的 error,它当然不等于里面那个 sql.ErrNoRows;我想判断的是"这个错误链里,本质上是不是 sql.ErrNoRows",可 == 只看最外层、不会穿透包装去看里面;正确的做法,是用 errors.Is(err, sql.ErrNoRows):它会沿着错误包装链一层层往里查,只要链上任何一层是目标错误,就返回 true——它能穿透包装看到本质。根本原因是:错误被 fmt.Errorf 的 %w 包装后,最外层是新 error,== 只比最外层、比不上被包裹的原始哨兵错误;判断错误本质应该用 errors.Is(沿包装链查找)、用 == 会在错误被包装后失效。问题的根,是用 == 比较可能被包装的错误——== 只看最外层身份,错误一旦被 %w 包装就比不上里面的原始错误;应该用 errors.Is 穿透包装链查找。这篇就把这次"错误包装与 errors.Is"的坑,从头到尾复盘一遍。
故障现场:错误被包装后,== 比不上了
问题在于 == 只比最外层,穿不透包装链:
// 我的写法: 用 == 直接比较错误
row := db.QueryRow("SELECT ... WHERE id=?", id)
err := row.Scan(&user)
if err == sql.ErrNoRows { // 一开始能用; 但错误被包装后就失效!
return nil, ErrNotFound // 记录不存在, 返回404
}
if err != nil {
return nil, err // 其他错误, 返回500
}
// 某次重构, 中间一层这样包装了错误:
func (d *DAO) GetUser(id int) (*User, error) {
user, err := d.query(id)
if err != nil {
return nil, fmt.Errorf("GetUser(%d) failed: %w", id, err) // %w 包装! 加了上下文
}
return user, nil
}
/*
为什么 == 失效了:
- fmt.Errorf("...: %w", err) 创建了一个【新的error】, 它"包裹"着原始的err(保留错误链);
- 包装后, 最外层是 fmt.Errorf 造的新error(类型是 *fmt.wrapError), 不再是 sql.ErrNoRows;
- == 比较的是【最外层那个error对象】 → 新error == sql.ErrNoRows → false!
- 所以底层明明是 sql.ErrNoRows, 我的 == 判断却返回false → "记录不存在"被当成未知错误。
错误包装(Go 1.13+)是怎么回事:
- fmt.Errorf 里用 %w (而非 %v) 包装一个error → 创建保留错误链的新error;
- 目的: 在错误往上传时, 逐层加上下文("在哪一步失败的"), 又不丢失原始错误;
- 形成一条"错误链": 最外层(新加的上下文) → ... → 最内层(原始错误如sql.ErrNoRows)。
判断包装后的错误, 要用 errors 包(能穿透包装链):
- errors.Is(err, target): 沿链查找, 链上任一层 == target(或其Is方法匹配) → true;
- errors.As(err, &target): 沿链查找, 找到能赋给target类型的error → 提取出来;
- errors.Unwrap(err): 取出被包装的下一层error。
★ 核心: == 只比【最外层】error; 错误被 %w 包装后, 最外层是新error, == 比不上里面的原始错误;
判断错误本质(是不是某个哨兵错误)用 errors.Is(穿透包装链查找), 别用 ==。
看着 errors.Is(err, sql.ErrNoRows) 是 True、而 err == sql.ErrNoRows 是 False 的现场,我又懊恼又恍然:"我一直用 == 比错误,因为之前没人包装它、最外层就是原始错误,碰巧一直对……谁知道有人用 %w 包了一层,最外层就变了,== 只看最外层、根本不知道里面裹着的还是那个错误。"这个坑最隐蔽的地方在于:它在错误"没被包装"时一直正确(最外层就是原始错误,== 和 errors.Is 表现一样);它只在错误链上某处被 %w 包装后才失效;而"给错误加包装"是 Go 推荐的好习惯(加上下文便于排查),所以越是规范地包装错误的代码库,这个 == 的坑越容易被踩——你的好习惯反而触发了别处的隐藏 bug。下面就来拆解,Go 的错误判断到底该怎么写。
第一件事:搞懂错误包装与 errors.Is/As
我顺着这次事故,把 Go 的错误包装和判断方式彻底理清了。
Go 错误包装是怎么回事? 怎么正确判断错误?
【核心: fmt.Errorf("%w",err)包装出新error、保留错误链; ==只比最外层、包装后比不上里面的原始错误;
判哨兵错误用errors.Is(穿透链查找)、提取类型用errors.As、包装用%w保留链; 别用==比可能被包装的错误】
1. 错误包装(error wrapping, Go 1.13+):
- fmt.Errorf("上下文: %w", err): 用 %w 创建一个"包裹着err"的新error, 保留错误链;
- 目的: 错误往上传时逐层加"在哪步失败"的上下文, 又不丢原始错误;
- 形成链: 最外层(新上下文) → 中间层 → 最内层(原始错误)。
2. == 为什么不够用:
- == 比较的是"最外层那个error对象本身";
- 错误被包装后, 最外层是新造的error, 不再 == 里面的原始哨兵错误;
- 所以 == 只在"错误没被包装"时碰巧对, 被包装后就失效。
3. 正确的判断工具(errors包, 能穿透包装链):
- errors.Is(err, target): 沿错误链逐层查找, 任一层等于target(或其Is匹配)就返回true;
→ 判断"是不是某个哨兵错误"(sql.ErrNoRows、io.EOF、context.Canceled、自定义哨兵)用它;
- errors.As(err, &targetVar): 沿链查找能赋给targetVar类型的error, 找到就提取出来;
→ 判断"是不是某个错误类型"并取出它(如 *os.PathError、自定义错误结构)用它;
- errors.Unwrap(err): 取出被包装的下一层(一般不直接用, Is/As内部用它遍历)。
4. 怎么写规范的错误:
- 往上传时加上下文用 %w 包装(保留链, 便于上层用Is/As判断、便于排查);
return fmt.Errorf("read config %s: %w", path, err)
- 定义可被判断的哨兵错误: var ErrNotFound = errors.New("not found"); 上层用 errors.Is 判;
- 定义可携带信息的错误类型: type MyErr struct{...}; 实现Error(); 上层用 errors.As 取;
- 不想暴露内部错误链给调用方时, 用 %v(不保留链, 只格式化文本)——但就没法Is/As了, 看需求。
5. 本质: 判断一个"可能被层层包装的东西"的本质, 不能只看最外层, 要能穿透包装
- 错误被包装是为了加上下文, 但它的"本质身份"藏在链的内层;
- == 只看最外层(表面身份), errors.Is/As 才穿透包装看本质——判断本质要用后者。
一句话: fmt.Errorf的%w包装出保留链的新error、==只比最外层、包装后比不上里面的原始错误; 判哨兵错误用
errors.Is、提取类型用errors.As(都穿透包装链)、包装用%w保留链; 别用==比可能被包装的错误。
这套认知,是整个坑的根。错误包装:fmt.Errorf("...: %w", err) 创建包裹原始 err 的新 error、保留错误链,目的是逐层加上下文又不丢原始错误。== 为什么不够:== 比最外层 error 对象,包装后最外层是新 error、不再等于里面的原始哨兵错误,只在没被包装时碰巧对。正确工具:errors.Is(沿链查找哨兵错误)、errors.As(沿链查找并提取错误类型)、errors.Unwrap(取下一层)。怎么写规范错误:往上传用 %w 包装加上下文、定义哨兵错误(errors.New)供 Is 判、定义错误类型供 As 取。本质:判断"可能被层层包装的东西"的本质,不能只看最外层,要能穿透包装——== 看表面身份,errors.Is/As 穿透看本质。一句话:fmt.Errorf 的 %w 包装出保留链的新 error、== 只比最外层、包装后比不上里面的原始错误;判哨兵错误用 errors.Is、提取类型用 errors.As(都穿透包装链)、包装用 %w 保留链;别用 == 比可能被包装的错误。
第二件事:正解——用 errors.Is/As 穿透包装链判断
知道了 == 穿不透包装,正解就清楚了:判断错误本质用 errors.Is/As,包装用 %w。
// 正解1: 判断"是不是某个哨兵错误"用 errors.Is(穿透包装链)——本次该做的
err := row.Scan(&user)
if errors.Is(err, sql.ErrNoRows) { // ✓ 沿链查找, 不管包装几层都能识别
return nil, ErrNotFound // 记录不存在, 404
}
if err != nil {
return nil, err // 其他错误, 500
}
// 正解2: 提取"某个错误类型"并取出它用 errors.As
var pathErr *os.PathError
if errors.As(err, &pathErr) { // ✓ 沿链找到 *os.PathError 类型, 提取出来
log.Printf("路径出错: %s", pathErr.Path)
}
// 正解3: 往上传错误时用 %w 包装, 保留链(便于上层Is/As + 排查)
func (d *DAO) GetUser(id int) (*User, error) {
user, err := d.query(id)
if err != nil {
return nil, fmt.Errorf("GetUser(%d): %w", id, err) // ✓ %w保留链, 上层仍能Is出sql.ErrNoRows
}
return user, nil
}
// 正解4: 定义可被判断的哨兵错误 / 错误类型
var ErrNotFound = errors.New("not found") // 哨兵: 上层用 errors.Is(err, ErrNotFound)
type ValidationError struct{ Field string } // 错误类型: 上层用 errors.As 取出 Field
func (e *ValidationError) Error() string { return "invalid: " + e.Field }
// 正解5: 什么时候用 %v 而非 %w
// - %w: 保留错误链(上层能Is/As、能看到完整链), 默认推荐;
// - %v: 只格式化文本、不保留链(故意不暴露内部错误给调用方时用); 但上层就没法Is/As了。
// 反例(别这样):
// if err == sql.ErrNoRows { } // ✗ 错误一旦被%w包装就失效
// if err.Error() == "not found" { } // ✗ 比较错误字符串! 脆弱(文案一改就失效), 别这么干
// 核心: 判哨兵错误用 errors.Is、提取错误类型用 errors.As(都穿透包装链); 往上传用 %w 保留链;
// 定义哨兵错误(errors.New)和错误类型供上层判断; 别用 == 或比字符串判可能被包装的错误。
这套正解的关键,是用能"穿透包装链"的 errors.Is/As 判断错误本质,而非只看最外层的 == 。判哨兵错误用 errors.Is:沿链查找,不管包装几层都能识别 sql.ErrNoRows——这正是本次该改的。提取错误类型用 errors.As:沿链找到目标类型并取出来用。往上传用 %w 包装:保留链、加上下文,上层仍能 Is/As、也便于排查。定义哨兵错误和错误类型:供上层判断。%w vs %v:%w 保留链(推荐),%v 不保留(故意不暴露内部错误时用,但上层就没法 Is/As)。而要避开的反例,是 err == sql.ErrNoRows(被包装就失效)和 err.Error() == "..."(比字符串,文案一改就崩)。
第三件事:其他几个"只看表层、没穿透包装"的坑
顺着这次错误包装,我把"对被包装/嵌套的东西只看表层"的几类坑也一并理了:
几类"只看表层、没穿透包装/嵌套"的坑:
坑1: 比较错误字符串(err.Error() == "xxx")——文案一改就失效, 还可能被包装的上下文污染;
正解: 用哨兵错误 + errors.Is, 别依赖错误文本。
坑2: panic/recover 跨包装——recover拿到的可能是被包装/重新抛的, 要看清原始;
正解: 规范地传递错误信息, 别丢失原始。
坑3: 类型断言只断最外层——接口值被装饰器/包装类型套了一层, 直接断言原始类型失败;
正解: 沿装饰链解包, 或用支持解包的接口。
坑4: HTTP/RPC错误码只看最外层——下游真实错误被网关/中间层包了一层通用错误码, 丢了原因;
正解: 透传或保留原始错误码/原因, 别一律包成500。
坑5: 异常被catch后重新抛、丢了cause(Java的 throw new XxxException(e) vs 不传e)——
正解: 包装异常时带上cause(初始化cause), 保留异常链, 便于定位根因。
坑6: 日志/监控只记最外层错误信息——丢了内层根因, 排查时只看到"GetUser failed"看不到sql.ErrNoRows;
正解: 用 %+v / 打印完整错误链, 记录根因。
共同的根: 一个东西在传递/处理过程中常被"包装、装饰、转换"层层套上外壳(加上下文、转格式、换类型);
它的"本质/根因"被藏在了内层; 如果判断/处理时只看"最外层的表面", 就会错判、丢失根因;
要用"能穿透包装、沿链/沿层查找本质"的方式去判断和处理, 而非只认最外面那一层。
这些坑看似不同,根却是同一个:一个东西在传递/处理过程中常被"包装、装饰、转换"层层套上外壳(加上下文、转格式、换类型),它的本质/根因被藏在了内层;判断/处理时只看"最外层的表面",就会错判、丢失根因。认清这个根("对被包装的东西要穿透外壳看本质/根因"),才不会被层层包装迷惑。
第四件事:错误判断方式 / 包装动词——两张对照表
我把几种错误判断方式、以及格式化动词,整理成对照表,贴在了团队的 Go 规范里:
| 方式 | 能穿透包装链吗 | 用途 |
|---|---|---|
| err == target | ✗ 只比最外层 | 只适合确定没被包装时 |
| errors.Is(err, target) | ✓ 沿链查找 | 判是不是某哨兵错误 |
| errors.As(err, &v) | ✓ 沿链查找 | 判类型并提取出来 |
| errors.Unwrap(err) | 取下一层 | 手动遍历(少用) |
| err.Error() == "xxx" | ✗ 且脆弱 | 别用(比字符串) |
| fmt.Errorf 动词 | 是否保留错误链 | 影响 |
|---|---|---|
| %w | 保留(包装) | 上层可 Is/As,推荐 |
| %v | 不保留(只格式化) | 上层无法 Is/As,故意隐藏内部时用 |
| %s | 不保留 | 同 %v(取 Error() 文本) |
这两张表的核心,第一张是只有 errors.Is/As 能穿透包装链,== 和比字符串都只看表层、错误一旦被包装就失效;第二张是往上传错误想让上层能判断,就用 %w 保留链。记住一条:判可能经过多层传递的错误,一律用 errors.Is/As;包装错误一律用 %w。
第五件事:关于 Go 错误处理的几组容易想当然的认知
这次事故也让我厘清了几组关于错误判断的、容易想当然的概念:
| 直觉以为 | 实际上 |
|---|---|
| err == sql.ErrNoRows 一直可靠 | 错误被 %w 包装后就比不上了 |
| == 和 errors.Is 差不多 | == 只比最外层,Is 穿透整条链 |
| 包装错误是多此一举 | 加上下文便于排查,是好习惯 |
| %w 和 %v 包装效果一样 | %w 保留链可 Is/As,%v 不保留 |
| 比 err.Error() 字符串也行 | 脆弱,文案/上下文一变就失效 |
| == 一直没出错说明它对 | 是错误恰好没被包装,被包装就错 |
| 判错误只看最外层就够 | 本质/根因藏在链的内层,要穿透 |
这张表里,我栽的是第一行和第六行:用 == 比 sql.ErrNoRows、且"一直没出错"让我以为它可靠,没意识到那只是因为错误一直没被包装;一旦有人规范地用 %w 加了上下文,== 就失效了。厘清这些,核心是一个意识:Go 的错误可能被层层 %w 包装(为了加上下文,这是好习惯);判断一个错误"本质上是什么",必须用能穿透包装链的 errors.Is/As,而非只看最外层的 ==——别让自己(或同事)规范的包装,触发了 == 的隐藏失效。
第六件事:判断 / 传递错误时,我现在的自检习惯
现在每当我要判断或往上传一个 error,我都会先按这张图问自己:
这张图的精髓,是"判错误用 errors.Is/As 别用==、传错误用 %w 保留链"。先分判断还是传递、判断就用 Is/As 穿透包装、传递就用 %w 保留链。这套习惯,让我从"随手 == 比错误"变成了"用 errors.Is 穿透包装判本质"——核心始终是:fmt.Errorf 的 %w 包装出保留链的新 error、== 只比最外层、包装后比不上原始错误;判哨兵用 errors.Is、提取类型用 errors.As、包装用 %w 保留链;别用 == 比可能被包装的错误。
我立下的几条规矩
这场"错误被包装后 == 失效、404 变 500"的事故,换来了我写 Go 错误处理时,刻进骨子里的几条铁律:
- fmt.Errorf 的 %w 会创建包裹原始错误的新 error、保留错误链;== 只比最外层。
- 错误被 %w 包装后,== 就比不上里面的原始哨兵错误了(只在没被包装时碰巧对)。
- 判断"是不是某个哨兵错误"用 errors.Is(穿透包装链查找),别用 ==。
- 判断"是不是某错误类型"并取出它用 errors.As。
- 往上传错误加上下文用 %w 包装,保留链(便于上层 Is/As 和排查)。
- 定义哨兵错误(errors.New)和错误类型供上层判断;别用比 err.Error() 字符串(脆弱)。
- %w 保留链(推荐)、%v 不保留(故意隐藏内部错误时用,但上层就没法 Is/As)。
附:打印完整错误链、定义可判断的错误
借这次的坑,我整理了团队"错误处理"的一套小约定:用 %+v 打印完整链便于排查,定义清晰的哨兵错误和错误类型供上层判断。
// 1. 定义可被 errors.Is 判断的哨兵错误(包级别导出)
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
)
// 2. 定义可被 errors.As 提取信息的错误类型
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Msg)
}
// 3. 往上传时用 %w 包装、加上下文(保留链)
func getUser(id int) (*User, error) {
u, err := repo.Find(id)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("getUser(%d): %w", id, ErrNotFound) // 转成业务哨兵, 仍可Is
}
if err != nil {
return nil, fmt.Errorf("getUser(%d): %w", id, err) // 其他错误包装上下文
}
return u, nil
}
// 4. 上层统一判断 + 记录完整链
func handler(id int) {
u, err := getUser(id)
switch {
case errors.Is(err, ErrNotFound):
respond(404) // 穿透包装, 识别业务错误
case err != nil:
log.Printf("internal error: %+v", err) // %+v(配合带堆栈的错误库)打印完整链/根因
respond(500)
default:
respond(200, u)
}
}
// 原则: 定义清晰的哨兵错误/错误类型供上层 Is/As 判断; 传递用 %w 保留链;
// 排查时打印完整错误链(根因), 而非只看最外层的一句上下文 —— 让"判断"和"排查"都能直达本质。
这套约定的核心,是让错误从"产生"到"判断"到"排查"全程都能直达本质:产生时定义清晰的哨兵/类型,传递时用 %w 保留链,判断时用 errors.Is/As 穿透包装,排查时用 %+v 打印完整链看根因。无论中间被包装了多少层上下文,我永远有办法穿透它们、看到那个最里面的、真正的错误。
写在最后
回头看,这场由"错误被包装后 == 失效"引发的、把记录不存在误判成系统错误的事故,真正教给我的,远不止"判错误用 errors.Is"这一个技巧。它让我对"一个东西在传递的过程中, 常常会被'层层包装'(加上外壳、加上上下文); 而它'本质上是什么', 被藏在了这些外壳的里面; 如果你判断它时只看最外面那层壳, 就会被外壳骗了, 看不到里面真正的它",有了一次刻骨的体会。我栽跟头,是因为我用"看最外层"的方式(==), 去判断一个"本质藏在内层"的东西——错误被 %w 包了一层上下文后, 它最外面那层壳已经不是 sql.ErrNoRows 了, 而是 "GetUser failed" 这个新壳;我的 == 只认这层壳, 一看"不是 sql.ErrNoRows 啊", 就判它不是——可它的'内核'明明还是 sql.ErrNoRows, 只是被一层善意的上下文给包住了;我把"表面的那层包装"当成了"它的全部身份", 而没有去穿透包装、看它内在的本质。这让我领悟到一个关于"包装与本质、表层与内核"的深刻认知:一个事物在被传递、转述、处理的过程中, 往往会被不断地包装、转换、加上外层的修饰和上下文(这常常是善意且有用的——比如给错误加上"在哪一步出错"的线索); 但这也意味着, 它的"本质/根源"会被一层层外壳包裹起来、离表面越来越远;所以判断一个'可能被层层包装过的东西'到底是什么, 绝不能只看它最外层的样子, 而要有能力'穿透包装、层层解开、直达内核';这对一切"经过多手传递/封装"的东西都成立——错误的根因、一条转述了好几手的消息、一个被多层中间商加价的商品、一个被层层汇报粉饰过的真实情况: 看表层会被层层包装误导, 看本质要有穿透包装、追溯根源的耐心和方法。这给了我一种面对"被包装之物"时的清醒:每当我要判断或依赖一个"可能经过多层传递/包装"的东西时,要意识到"我看到的最外层, 可能只是层层包装中最新的那一层, 不是它的本质"——用"能穿透包装、沿着链条追溯到根源"的方式(errors.Is/As 之于错误)去看它的本质, 而不是只认最外面那层壳;"对被层层包装的东西, 穿透外壳判本质、沿链追溯根因, 而非只看最外层",是避免被表层包装误导的关键。认清事物在传递中会被层层包装、本质藏在内层、判断要穿透包装追溯根源而非只看最外层——这,是我用一次 Go 错误包装的事故,换来的、关于 Go、也关于如何看透包装直达本质的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次判断一个 error 时,把那个 == 换成 errors.Is、让判断能穿透同事善意加上的那层包装,那我对着那个"记录不存在却返回 500"的 bug 排查的这段时间,就值了。
—— 别看了 · 2026