我用等号判断 Go 的错误类型一直好好的,下游一改成用 %w 包装错误,我的判断就全失效、走错了分支,我对着错误链排查了大半天的复盘
这是一个让我对 Go 的"错误包装与错误链"彻底搞懂的故事。我有段代码,调用下游函数后,要根据错误类型走不同分支,比如:if err == ErrNotFound { ... 返回404 }。一直用 == 比较哨兵错误(sentinel error),也一直工作得很好。可有一天,下游团队改了一下他们的错误处理——为了让错误信息更丰富,他们把原来直接返回的 ErrNotFound,改成了 fmt.Errorf("查询用户失败: %w", ErrNotFound) 这种包装过的错误。从那以后,我的代码就出问题了:我那个 if err == ErrNotFound 的判断,再也匹配不上了!明明本质上就是 ErrNotFound,可 == ErrNotFound 返回 false,于是代码走进了错误的分支(把"没找到"当成了"未知错误",返回了 500 而不是 404)。同样的错误,只是被"包装"了一下,我的判断怎么就失灵了?
我顺着"== 匹配不上"的线索深挖,才终于揭开真相,补上了我对 Go 错误处理一个最关键的认知漏洞:问题的核心,是 Go 的"错误包装(error wrapping)与错误链(error chain)"机制。我一直想当然地以为,"判断错误类型,用 == 比一下就行";可真相是:当一个错误被 fmt.Errorf("...: %w", err) 用 %w 包装之后,它就不再是原来那个错误了——而是变成了一个全新的、外层的错误,而原来的错误(ErrNotFound),被"包裹"在了这个新错误的内部,形成了一条"错误链"(外层错误 → 内层的 ErrNotFound)。而 == 比较,只比的是最外层那个错误对象;最外层现在是 fmt.Errorf 返回的那个新错误,它当然不等于 ErrNotFound(ErrNotFound 被埋在里面了);所以 == ErrNotFound 返回 false。正确的做法,是用 Go 专门为"错误链"设计的两个函数:errors.Is(err, ErrNotFound)——它会顺着错误链一层层往里找,只要链上的任何一层是 ErrNotFound,就返回 true;以及 errors.As(err, &target)——它会顺着链找到某个特定类型的错误,并把它提取出来(用于自定义错误类型)。我这才痛彻地明白:在现代 Go 里,错误不再是一个"扁平的、单一的"对象,而可能是一条"层层包装的链";判断错误类型,绝不能再用 ==(只比最外层)或直接类型断言,而必须用 errors.Is / errors.As——它们懂得"穿透"包装、沿着错误链去查找。错误包装(%w)是个好东西(它能在保留原始错误的同时,层层添加上下文,让错误信息更有助于排查);但用了它,就必须配套地用 errors.Is/errors.As 来判断,否则,你的错误判断,就会在错误链面前,集体失灵。
故障现场:错误被 %w 包装后,== 匹配不上
我把这个"== 失灵"的现场,摊开给你看:
// ✗ 灾难: 用 == 判断错误, 下游一包装错误就匹配不上
var ErrNotFound = errors.New("not found") // 哨兵错误
// 下游(改之前): 直接返回 ErrNotFound
// return ErrNotFound
// 下游(改之后): 用 %w 包装, 加了上下文信息
func queryUser(id int) error {
// ...
return fmt.Errorf("查询用户 %d 失败: %w", id, ErrNotFound) // ✗ 包装了!
}
// 我的判断(一直用 ==):
err := queryUser(123)
if err == ErrNotFound { // ✗ false! 现在 err 是包装后的新错误
return http.StatusNotFound // ✗ 进不来了
}
return http.StatusInternalServerError // ✗ 错误地走到了这里(把404当成了500)
// 为什么 == 失灵了?
// - %w 包装后, err 是 fmt.Errorf 返回的"新错误对象"(外层)。
// - 原来的 ErrNotFound 被"包"在这个新错误的内部, 形成错误链:
// [外层: "查询用户123失败"] --包裹--> [内层: ErrNotFound]
// - == 只比最外层那个对象 → 最外层不是 ErrNotFound → false。
// ✓ 正解: 用 errors.Is(顺着错误链查找)
if errors.Is(err, ErrNotFound) { // ✓ true! 它会穿透包装, 沿链找到 ErrNotFound
return http.StatusNotFound
}
// 类似的坑: 用类型断言判断自定义错误类型, 包装后也失效
type MyError struct{ Code int }
func (e *MyError) Error() string { return "..." }
// ✗ if e, ok := err.(*MyError); ok → 包装后 ok=false
// ✓ var me *MyError
// if errors.As(err, &me) → 穿透包装, 提取出 MyError
// 根因: 错误被 %w 包装后变成新的外层错误、原错误埋在链内层;
// == 只比最外层故失效; 要用 errors.Is/As 穿透错误链判断。
看着这个"包装后 == 就 false"的现场,我才算彻底想明白了根源。问题的核心,是错误被 %w 包装后,err 变成了 fmt.Errorf 返回的"新错误对象"(外层),原来的 ErrNotFound 被"包"在了内部、形成错误链(外层"查询失败" → 内层 ErrNotFound)。而 == 只比最外层那个对象,最外层不是 ErrNotFound(它埋在里面了),所以返回 false,代码就错误地走到了 500 分支。正解是用 errors.Is:errors.Is(err, ErrNotFound) 会穿透包装、沿错误链找到 ErrNotFound,返回 true。类似的坑还有:用类型断言 err.(*MyError) 判断自定义错误类型,包装后也失效,要用 errors.As(err, &me) 穿透包装提取。归根结底:错误被 %w 包装后变成新的外层错误、原错误埋在链内层;== 只比最外层故失效;要用 errors.Is/errors.As 穿透错误链判断——这,就是根源。
第一件事:搞懂错误包装与错误链
定位到根源,我必须把"错误包装、错误链、Is/As"从根上彻底搞清楚:
错误包装(%w)形成错误链; 判断错误要用 errors.Is/As 穿透链, 而非 ==
# Go 的错误处理基础:
# - error 是个接口; 哨兵错误: var ErrXxx = errors.New("...")。
# - 旧方式判断: err == ErrXxx(只在"没包装"时有效)。
# 错误包装(error wrapping):
# - fmt.Errorf("上下文: %w", err): %w 把 err "包"进一个新错误里。
# - 目的: 既保留原始错误, 又层层加上下文(如 "查DB失败: 查用户失败: 连接超时")。
# - 结果: 形成一条"错误链"(外层 → ... → 最内层的根因错误)。
# 为什么 == 在包装后失效?
# - 包装后, err 是最外层的新错误对象。
# - 原错误(ErrXxx)在链的内层, 不是最外层。
# - == 只比最外层对象 → 不等于内层的 ErrXxx → false。
# 正确的判断工具(会穿透错误链):
# - errors.Is(err, target): 沿链查找, 链上任一层 == target(或匹配 Is)就 true。
# 用于: 判断"是不是某个哨兵错误"(如 errors.Is(err, sql.ErrNoRows))。
# - errors.As(err, &target): 沿链查找某"类型", 找到则提取赋给 target。
# 用于: 判断"是不是某种自定义错误类型", 并拿到它(读它的字段)。
# - errors.Unwrap(err): 剥一层, 拿到被包裹的内层错误(底层 API)。
# 关键认知: 包装是为了"加上下文不丢根因"; 判断必须用 Is/As "穿透"链。
# - 库/下游可能随时给错误加包装; 你的判断要对包装"免疫"(用 Is/As)。
# 经验:
# - 判断错误: 一律 errors.Is / errors.As, 别用 == / 类型断言(会被包装坑)。
# - 传递错误并加上下文: 用 %w(保留链), 别用 %v(丢失链, 变成纯字符串)。
# 核心: %w 包装错误形成错误链、== 只比最外层会失效; 判断错误用 errors.Is(查哨兵)
# /errors.As(查类型)穿透链; 包装用 %w 保留根因, 别用 %v。
原理终于清晰了。Go 错误处理基础:error 是接口、哨兵错误 var ErrXxx = errors.New(...),旧方式 err == ErrXxx 只在"没包装"时有效。错误包装:fmt.Errorf("上下文: %w", err) 用 %w 把 err"包"进一个新错误,目的是既保留原始错误、又层层加上下文,结果形成一条错误链(外层 → … → 最内层根因)。为什么 == 包装后失效?包装后 err 是最外层的新错误对象,原错误在链的内层,== 只比最外层、故不等于内层的 ErrXxx。正确的判断工具(会穿透链):errors.Is(err, target)(沿链查找,链上任一层匹配就 true,用于判断哨兵错误);errors.As(err, &target)(沿链找某类型并提取,用于自定义错误类型);errors.Unwrap(err)(剥一层、拿内层错误)。由此,我刻下一个关键认知:包装是为了"加上下文不丢根因",判断必须用 Is/As 穿透链;库/下游可能随时给错误加包装,你的判断要对包装"免疫";经验是——判断错误一律用 errors.Is/errors.As(别用 ==/类型断言),传递错误加上下文用 %w(保留链)而非 %v(丢链、变纯字符串)。归根结底:%w 包装错误形成错误链、== 只比最外层会失效;判断错误用 errors.Is(查哨兵)/errors.As(查类型)穿透链;包装用 %w 保留根因、别用 %v。
第二件事:正解——errors.Is/As 判断 + %w 包装
搞懂了原理,正解就清晰了:判断错误用 errors.Is(查哨兵)/errors.As(查类型),传递错误加上下文用 %w 保留链。
// ✓ 正解一: 判断哨兵错误用 errors.Is(穿透包装)
var ErrNotFound = errors.New("not found")
err := queryUser(123) // 可能返回 fmt.Errorf("...: %w", ErrNotFound)
if errors.Is(err, ErrNotFound) { // ✓ 沿链查找, 即使被包装也能匹配
return http.StatusNotFound
}
// ✓ 正解二: 判断/提取自定义错误类型用 errors.As
type APIError struct {
Code int
Msg string
}
func (e *APIError) Error() string { return e.Msg }
var apiErr *APIError
if errors.As(err, &apiErr) { // ✓ 沿链找到 *APIError 并提取出来
return apiErr.Code // ✓ 能读它的字段了
}
// ✓ 正解三: 传递错误并加上下文用 %w(保留错误链)
func service() error {
if err := dao(); err != nil {
return fmt.Errorf("service 处理失败: %w", err) // ✓ %w 保留 err 链
// ✗ 别用 %v: fmt.Errorf("...: %v", err) → err 变成纯字符串, 链断了!
}
return nil
}
// ✓ 正解四: 让自定义错误支持 errors.Is/As(实现 Unwrap / Is)
type NotFoundError struct{ ID int }
func (e *NotFoundError) Error() string { return fmt.Sprintf("id %d not found", e.ID) }
func (e *NotFoundError) Unwrap() error { return ErrNotFound } // ✓ 让它"链到" ErrNotFound
// → errors.Is(notFoundErr, ErrNotFound) 也能为 true
// ✓ 正解五: 别过度包装/重复包装
// - 每层加"有意义的上下文", 别每层都裹一句废话。
// - 顶层统一记录日志(打印完整链); 中间层只加上下文不重复记日志。
// 原则速记:
// - 判断错误: errors.Is(哨兵) / errors.As(类型), 永不用 == / 类型断言。
// - 传递错误: %w 保留链, 加有意义的上下文。
// - 自定义错误: 实现 Unwrap 让它能被 Is/As 穿透。
// 核心: 判断哨兵用 errors.Is、判断类型用 errors.As(都穿透包装); 传递用 %w 保链;
// 自定义错误实现 Unwrap; 别用 == / %v。
修复的方向,是"用对 Go 现代错误处理的那套工具"。正解一,判断哨兵错误用 errors.Is:errors.Is(err, ErrNotFound) 会沿链查找、即使被包装也能匹配。正解二,判断/提取自定义错误类型用 errors.As:errors.As(err, &apiErr) 沿链找到 *APIError 并提取出来,就能读它的字段(如 Code)了。正解三,传递错误并加上下文用 %w:fmt.Errorf("...: %w", err) 保留错误链——千万别用 %v(%v 会把 err 变成纯字符串、链就断了,后续 Is/As 也查不到了)。正解四,让自定义错误支持 Is/As:实现 Unwrap() 方法返回它要"链到"的错误,这样 errors.Is 也能穿透它。正解五,别过度包装:每层加"有意义的上下文"、别每层裹废话;顶层统一记完整链日志、中间层只加上下文不重复记。归根结底:判断哨兵用 errors.Is、判断类型用 errors.As(都穿透包装);传递用 %w 保链;自定义错误实现 Unwrap;别用 ==/%v。
第三件事:Go 错误处理的其他常见坑
这次踩坑后,我把 Go 错误处理里其他常见的坑,也一并梳理清楚了:
// Go 错误处理的其他常见坑:
// 1. 忽略错误(最常见)
result, _ := doSomething() // ✗ 用 _ 丢掉了 err! 出错了也不知道
// → 老老实实 if err != nil 处理; 用 errcheck/linter 揪出被忽略的 err。
// 2. 用 %v 而非 %w 传递错误 → 错误链断了
return fmt.Errorf("失败: %v", err) // ✗ 链断, errors.Is/As 查不到根因
// → 加上下文要保留链就用 %w。
// 3. defer 里的错误被吞
func f() (err error) {
defer file.Close() // ✗ Close 的 error 被丢了
// ✓ defer func(){ if cerr := file.Close(); cerr != nil && err == nil { err = cerr } }()
}
// 4. 在循环里只记最后一个错误 / 多个错误丢失
// → Go 1.20+ 用 errors.Join(err1, err2) 合并多个错误。
// 5. 错误信息没上下文, 排查像大海捞针
// return err // ✗ 一路裸传, 最后只看到个 "not found", 不知哪来的
// return fmt.Errorf("处理订单 %d: %w", id, err) // ✓ 带上下文
// 6. panic 当 error 用 / 该 error 的地方用了 panic
// → 可预期的错误用 error 返回; panic 只用于"不可恢复的程序 bug"。
// 7. nil error 的 typed nil 坑(见 typed nil 篇)
// → 返回 error 接口时小心包装了 nil 的具体类型。
// 核心: Go 错误处理坑还有 忽略err、%v断链、defer吞错、多错丢失、缺上下文、
// panic滥用; 处理err要 不忽略+%w保链+加上下文+errors.Is/As判断。
原来 Go 错误处理的坑,远不止包装一个。忽略错误(result, _ := ... 用 _ 丢掉 err,最常见,要用 linter 揪出);用 %v 而非 %w 传递(链断了、Is/As 查不到根因);defer 里的错误被吞(defer file.Close() 丢了 Close 的 error);循环里多个错误丢失(Go 1.20+ 用 errors.Join 合并);错误信息没上下文(一路裸传,最后只看到个 "not found" 不知哪来的,要 fmt.Errorf("处理订单%d: %w", ...) 带上下文);panic 当 error 滥用(可预期错误用 error 返回、panic 只用于不可恢复的 bug);typed nil 坑(见 typed nil 篇)。它们共同指向 Go 错误处理的几条准则:不忽略 err、用 %w 保链、加有意义的上下文、用 errors.Is/errors.As 判断。归根结底:Go 错误处理坑还有忽略 err、%v 断链、defer 吞错、多错丢失、缺上下文、panic 滥用;处理 err 要不忽略 + %w 保链 + 加上下文 + errors.Is/As 判断。
下面这张图,是这次"== 失灵"的成因与解法:
第四件事:错误判断方式速查对照
这次踩坑后,我把 Go 里几种判断/处理错误的方式,整理成一张速查表,以后判断错误时用对。
| 方式 | 做什么 | 能穿透包装吗 | 用于 |
|---|---|---|---|
| err == ErrXxx | 比最外层是否是某哨兵 | ✗ 不能(被包装就失效) | ✗ 别再用(除非确定没包装) |
| err.(*MyError) | 类型断言最外层 | ✗ 不能 | ✗ 别再用 |
| errors.Is(err, ErrXxx) | 沿链找是否有某哨兵 | ✓ 能 | 判断哨兵错误(推荐) |
| errors.As(err, &t) | 沿链找某类型并提取 | ✓ 能 | 判断+提取自定义错误类型 |
| fmt.Errorf("%w", err) | 包装并保留链 | —(用于包装) | 加上下文且保留根因 |
| errors.Unwrap(err) | 剥一层拿内层 | —(手动剥) | 底层 API, 一般用 Is/As |
这张表,把"该用哪个"讲清了。核心结论:判断错误,一律用 errors.Is(查哨兵)和 errors.As(查类型),因为只有它们能"穿透包装";而 == ErrXxx 和 类型断言 err.(*T),都只比最外层、被包装就失效,应该弃用(除非你 100% 确定那个错误不会被包装)。包装错误则用 fmt.Errorf("%w", err)(保留链)。它给我的启发是:Go 在 1.13 引入了 %w 包装和 errors.Is/As 这套"错误链"机制后,判断错误的"正确方式",就已经从 ==/类型断言,整体迁移到了 errors.Is/errors.As;如果你还停留在用 == 判断错误的旧习惯里,就会在任何一个"错误被包装"的地方,悄无声息地踩坑。跟上语言的演进、用对它新提供的工具,本身就是避坑的一部分。
第五件事:好的错误处理,是为了"可排查"
这次踩坑也让我重新思考"为什么要好好处理错误"。我把好的错误处理的几个目标和做法梳理了一下。
| 目标 | 怎么做 |
|---|---|
| 能正确判断错误类型 | errors.Is/As 穿透包装, 别用 == |
| 错误信息能定位问题 | 每层用 %w 加有意义的上下文(谁、做什么、什么参数) |
| 不丢失根因 | 用 %w 不用 %v, 保留完整错误链 |
| 不丢失任何错误 | 不忽略 err(别 _); 多错用 errors.Join |
| 区分"预期错误"和"程序bug" | 预期错误用 error, bug 用 panic |
| 顶层统一记录 | 顶层打印完整链(含上下文), 中间层别重复记日志 |
这张表,让我看清了"好好处理错误"的终极目的——不是为了"程序不报错",而是为了"当它出错时,你能快速、准确地定位和解决问题"。一个好的错误处理体系,要做到:能正确判断错误类型(errors.Is/As)、错误信息能定位问题(每层加有意义的上下文)、不丢失根因(%w 保留链)、不丢失任何错误(不忽略、多错用 errors.Join)、区分预期错误和程序 bug(error vs panic)、顶层统一记录完整链。它给我的最大启发是:错误处理,不是写代码时"顺手应付一下"的边角料,而是决定"系统出问题时,你是几分钟定位,还是抓瞎几小时"的关键工程;那条层层包装、带着完整上下文的错误链,在线上出事时,就是你顺藤摸瓜、直达根因的"救命绳索"。所以,认真对待每一个错误的判断、包装、上下文和记录——你在写代码时为错误处理多花的每一分心思,都会在某个深夜排查线上故障时,加倍地回报给你。好的错误处理,是写给"未来那个正在救火的自己"的一封情书。
第六件事:处理一个错误时,我现在会怎么决策
现在,每当我处理一个错误,脑子里都会过一遍这张决策图——核心两问:我是要"判断"它,还是要"传递"它?
这张图的灵魂,是先分清"判断"还是"传递"。第一问:我要做什么?——判断它是什么错误(再分:判断哨兵用 errors.Is、判断类型并读字段用 errors.As);传递给上层(用 fmt.Errorf("上下文: %w", err) 加有意义的上下文并保留链);当前层能处理(处理掉、别再往上抛)。而贯穿始终的铁律是:判断绝不用 ==/类型断言(会被包装坑)、绝不忽略 err(别用 _)、多个错误用 errors.Join、顶层统一记录完整错误链。这套判断,让我处理错误时,不再被"包装"坑、不再丢失根因、不再让错误悄悄溜走——核心始终是:判断用 Is/As 穿透链,传递用 %w 保留链。
我立下的几条规矩
这场"== 失灵"的事故,换来了我写 Go 时,刻进骨子里的几条铁律:
- 判断错误用 errors.Is/As,绝不用 ==/类型断言。错误一旦被 %w 包装,== 只比最外层就失效;Is/As 能穿透错误链。
- 判断哨兵用 errors.Is,判断类型用 errors.As。Is 查"是不是某个错误",As 查"是不是某类型"并提取它。
- 传递错误用 %w 保留链,别用 %v。%v 把错误变成纯字符串、链就断了,根因和后续 Is/As 都没了。
- 每层加有意义的上下文。"处理订单N失败: %w",让错误链自带排查线索,别一路裸传。
- 自定义错误实现 Unwrap。让它能被 errors.Is/As 穿透,链到它包裹的根因。
- 绝不忽略 err。别用 _ 丢掉错误;多个错误用 errors.Join 合并;用 linter 揪出被忽略的。
- 错误处理是为了可排查。顶层记录完整链;好的错误处理是写给未来救火的自己的。
附:一个完整的错误链 + errors.Is/As 实战示例
把哨兵错误、自定义错误类型、%w 包装、Is/As 判断揉到一起,这是一个能直接参照的完整示例:
package main
import (
"errors"
"fmt"
)
// 1. 哨兵错误
var ErrNotFound = errors.New("not found")
// 2. 自定义错误类型(带字段, 实现 Unwrap 让它能链到哨兵)
type QueryError struct {
Table string
ID int
err error // 包裹的底层错误
}
func (e *QueryError) Error() string {
return fmt.Sprintf("query %s id=%d: %v", e.Table, e.ID, e.err)
}
func (e *QueryError) Unwrap() error { return e.err } // ✓ 让 Is/As 能穿透到 e.err
// 3. dao 层: 返回包装了哨兵的自定义错误
func dao(id int) error {
return &QueryError{Table: "users", ID: id, err: ErrNotFound}
}
// 4. service 层: 再用 %w 加一层上下文
func service(id int) error {
if err := dao(id); err != nil {
return fmt.Errorf("service 获取用户失败: %w", err) // ✓ 又包一层
}
return nil
}
func main() {
err := service(123)
// 错误链: [service上下文] -> [*QueryError] -> [ErrNotFound]
// ✓ errors.Is: 穿透两层包装, 找到链最底层的 ErrNotFound
if errors.Is(err, ErrNotFound) {
fmt.Println("是 NotFound 错误 → 返回 404") // ✓ 命中!
}
// ✓ errors.As: 穿透 service 那层, 提取出中间的 *QueryError, 读它的字段
var qe *QueryError
if errors.As(err, &qe) {
fmt.Printf("出错的表=%s, id=%d\n", qe.Table, qe.ID) // ✓ 拿到字段!
}
fmt.Println(err) // 完整链信息: "service 获取用户失败: query users id=123: not found"
}
// 核心: 哨兵+自定义错误(实现Unwrap)+%w层层包装 形成错误链;
// errors.Is 查根因哨兵、errors.As 提取中间类型读字段, 都能穿透任意层包装。
这个示例,把 Go 现代错误处理的全套要素,串成了一条完整的链。它构建了一条三层的错误链:最底层是哨兵 ErrNotFound → 中间是带字段的自定义 *QueryError(它实现了 Unwrap 链到 ErrNotFound)→ 最外层是 service 用 %w 加的上下文。然后展示了 Is 和 As 的各自威力:errors.Is(err, ErrNotFound) 能穿透两层包装、找到链最底层的哨兵,据此返回 404;errors.As(err, &qe) 能穿透 service 那层、提取出中间的 *QueryError,读到它的 Table、ID 字段;而打印整个 err,得到的是带完整上下文的链信息"service 获取用户失败: query users id=123: not found"——一眼就能看出"在哪、做什么、什么根因"。这,正是我想用这个示例,留给每一个写 Go 的人的最后一课:Go 的错误处理,看似"啰嗦"(到处 if err != nil),实则提供了一套极其强大、可组合的"错误链"机制;用好它(哨兵 + 自定义类型 + %w 包装 + Is/As 判断),你就能构建出"既能精确判断类型、又能层层携带上下文、还能直达根因"的、真正工程级的错误处理。错误处理的功力,藏在这些细节里;而它,正是区分"能跑的代码"和"好维护的系统"的、一道重要的分水岭。
写在最后
回头看,这场由"错误包装"引发的、== 判断失灵的事故,真正教给我的,是一个比"用 errors.Is"本身更深的道理:语言和库,是会"演进"的;它们会引入新的机制(如错误包装)、提供新的最佳实践(如 errors.Is);而如果你停留在旧的习惯里、不跟上这种演进,你曾经"正确"的代码,就可能在新的机制面前,悄无声息地变成"错误"。我的 == ErrNotFound,在 Go 引入错误包装之前,是完全正确、且地道的写法;可一旦整个生态开始普遍使用 %w 包装错误,这个旧写法,就从"正确",滑向了"会踩坑"——而我浑然不觉,直到被下游的一次改动绊倒。这让我深刻地领悟到:作为工程师,不能一次学会、就停止学习;要持续地关注你所用语言和库的演进——它们新增了什么能力?改变了什么最佳实践?废弃了什么旧用法?;因为技术的"正确答案",本身就是随着生态演进而动态变化的。那些"我一直都这么写、一直没问题"的旧习惯,恰恰最值得定期拿出来,对照最新的最佳实践,审视一遍——因为它可能早已悄悄过时、只是还没在你身上爆发而已。持续跟上语言的演进,定期审视"一直没问题"的旧习惯——这,是我用一次"== 失灵"的事故,换来的、关于 Go、也关于"如何保持技术不落伍"的、最朴素也最深刻的领悟。如果这篇复盘,能让你回去把项目里那些 == ErrXxx 都换成 errors.Is,那我对着那个失灵的判断熬的这大半天,就值了。
—— 别看了 · 2026