Go · 错误处理哲学

error 是值 — 哨兵错误 / %w 包装链 / errors.Is / As / Join: 把"哪一层知道怎么处理"直接写进类型设计

Unwrap() 往里剥一层 沿链自外向内 沿链自外向内 错误包装链 — 一层洋葱一层上下文, 根因永远在链尾 handler 层 — 加 op + 业务字段 获取订单 10086 失败: 查询订单: dial tcp… fmt.Errorf("获取订单 %d 失败: %w", id, err) repo 层 — 加操作名 + 数据源 查询订单: dial tcp 10.0.0.2:5432: … fmt.Errorf("get order %s: %w", id, err) 驱动层 — 加网络地址 dial tcp 10.0.0.2:5432: connection reset… *net.OpError { Op:"dial", Addr:"10.0.0.2:5432" } 内核根因 — 只有这一层是"事实" connection reset by peer os.SyscallError — 上面三层全是人为加的上下文 外层像洋葱: 每层只加自己的 op 与字段, 不复述不改写 — 日志打最外层就是完整因果链 errors.Is — 找"同一个"错误 沿 Unwrap 链逐层 == 比较哨兵 for e := err; e != nil; e = e.Unwrap() { if e == target { return true } } 包装过也认得: 哨兵身份不丢 直接 == 比较: 经过一层 %w 就失配 errors.Is(err, context.DeadlineExceeded) errors.As — 找"这一类"错误取字段 沿链做类型断言, 命中即取出 var nfe *NotFoundError if errors.As(err, &nfe) { nfe.OrderID } 目标必须传指针 &nfe, 传值永远 false 业务字段可直接用: 映射 HTTP 404 NotFound→404 / Conflict→409 / 超时→504 panic vs error — 分界线: 这个失败是"预期内"还是"程序写错了" 可预期的失败 → 返回 error 文件不存在 / 超时 / 参数非法 / 下游 503 调用方"该处理"的, 都是 error 库代码永远不 panic — 错误是 API 的一部分 程序性 bug → panic 数组越界 / nil 解引用 / 不变量被破坏 net/http 每 conn 自动 recover 转 500 panic 跨 goroutine 无人 recover = 进程退出 经验法则: 换个输入失败会消失吗? 会 → error; 代码错了 → panic Legend 包装层 (上下文) 根因 / bug 路径 errors.Is 查找 errors.As 查找 分界线区域

error 是值, 不是异常

  • • 本体是 interface{ Error() string }
  • • 可以带字段: NotFoundError{OrderID}
  • • 可以编程决策: Is / As 分类
  • • 没 try/catch, 显式就是清晰

包装链保留根因

  • • %w 把下层错误挂进链尾
  • • 每层只加自己的 op + 字段
  • • 根因永远是链上最后一环
  • • Is/As 沿链回溯, 包装不失配

纪律比语法重要

  • • 只在能处理的一层 log
  • • log and return 二选一
  • • panic 只留给程序性 bug
  • • 消息小写开头, 带 op 上下文

💡 一句话理解

Go 没有 try/catch: error 就是个普通值, 函数多返回一个它, 调用方 if err != nil 自己决定怎么办。真正的功夫在"链": %w 把根因包在上下文里一路上传, errors.Is/As 沿链往回找 —— 于是类型设计就等于在回答"这个错谁能处理": 仓储层定义 NotFound 带 OrderID, handler 层 As 到它转 404, 中间层只包装不记日志。错误信息一层都不丢, 处理点一处都不多。

🧠 必知必会 必考 & 必会

error 接口
就一个方法 interface{ Error() string }。任何类型实现了它就是 error —— 所以错误可以带字段带方法, 是可编程的值, 不只是字符串。
type MyErr struct{ Code int }
func (e *MyErr) Error() string {    // 实现它就是 error
    return fmt.Sprintf("code %d", e.Code)
}
var e error = &MyErr{Code: 404}  // 关键: 带字段的可编程值
哨兵错误
var ErrNotFound = errors.New("not found") 包级导出, 作为跨包契约; 调用方用 errors.Is 判定, 风格对齐 io.EOF。绝不字符串匹配。
var ErrNotFound = errors.New("not found")
if errors.Is(err, ErrNotFound) {  // 对: 判定走 Is
    return nil
}
// 关键: 风格对齐 io.EOF, 绝不字符串匹配
自定义错误类型
type NotFoundError struct{ Table, Key string } + Error() 方法: 业务字段随错误走, 上层能精确响应而不是解析字符串。
type NotFoundError struct{ Table, Key string }
func (e *NotFoundError) Error() string {
    return e.Table + ": " + e.Key + " not found"
}
// 关键: 业务字段随错误走, 上层 As 取 Key 精确响应
%w 包装
fmt.Errorf("get order %s: %w", id, err): 保留根因引用形成链; %v 只拼字符串, 链断掉, Is/As 全部失效。
err = fmt.Errorf("get order %s: %w", id, err) // 对: 链保留
err = fmt.Errorf("get order: %v", err)      // 错: 链断, Is/As 失效
errors.Is
沿 Unwrap 链逐层 == 比较目标哨兵; 包装任意层都认得。判断"是不是这类事"用它。
err := fmt.Errorf("get config: %w", os.ErrNotExist)
fmt.Println(errors.Is(err, os.ErrNotExist))
// → true  关键: 沿 Unwrap 链逐层 ==, 包装几层都认得
errors.As
沿链做类型断言, 命中把值写进目标; 目标必须是指针 (&nfe)。判断"是不是这个类型并取字段"用它。
var nfe *NotFoundError
if errors.As(err, &nfe) {        // 关键: 目标必须取址
    respond(404, nfe.Key)        // 命中取业务字段
}
errors.Join
1.20+ 聚合多个错误成一个; Is/As 会对每个分支逐一检查, 批处理收集全部失败的标准工具。
j := errors.Join(os.ErrNotExist, os.ErrPermission)
fmt.Println(errors.Is(j, os.ErrPermission))
// → true  Is 对每个分支逐一检查
// Error() → 两行: not exist / permission denied 各占一行
Unwrap 两个签名
单链 Unwrap() error; 树形 Unwrap() []error(Join 产物)。自定义错误记得实现, 否则链在你这断掉。
func (e *StorageError) Unwrap() error { return e.Err }   // 单链
// 树形: Unwrap() []error (Join 产物的内部实现)
// 关键: 忘了实现 → Is/As 的链在你这层断掉
消息规范
小写开头、不以标点收尾(链式拼接时句读才自然); 消息里带 op 与关键字段, 一条日志能定位到层。
fmt.Errorf("Get order failed.")  // 错: 大写开头+句号
fmt.Errorf("get order %s", id)  // 对: 小写, 无标点收尾
// 关键: 链式拼接 "x: get order 10086" 句读才自然
op 惯用法
错误第一段写操作名: "repo.GetOrder: ...", 排障时一眼看出是哪层哪个函数报的, 不用猜调用栈。
return fmt.Errorf("repo.GetOrder: %w", err)
// 关键: 首段是操作名 — 哪层哪个函数报的, 一眼看出
panic 分界
可预期的失败返回 error; 程序性 bug 才 panic。库代码永不 panic; 服务顶层 recover 兜底转 500。
if err != nil { return err }   // 可预期失败 → error
// 文件不存在/超时/参数非法/下游 503 全是 error
// panic 只留给: 越界/nil 解引用/不变量被破坏
状态码映射
错误类型 → HTTP/gRPC 码集中在一处: NotFound→404/codes.NotFound, Conflict→409, 超时→504/DEADLINE_EXCEEDED, 未知→500 不外漏细节。
switch {
case errors.As(err, &nfe):
    w.WriteHeader(404)              // NotFound
case errors.Is(err, context.DeadlineExceeded):
    w.WriteHeader(504)              // 超时
default:
    w.WriteHeader(500)              // 未知不外漏细节
}
typed nil 陷阱
返回 (*T)(nil) 装进 error 接口后 err != nil 为 true: 接口有类型信息就不是 nil。返回前先判具体指针。
func find() error {
    var e *MyErr                // typed nil
    return e                    // 错: err != nil → true!
}
// 对: 返回前判具体指针, 直接 return nil 字面量

🏭 生产实战 real world

场景 1 · 仓储层业务错误类型 → 服务层转 HTTP 状态码

把"订单不存在"翻译成 404 而不是 500, 靠类型不靠字符串:

type NotFoundError struct{ Table, Key string }

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s: %s not found", e.Table, e.Key) // 小写开头
}

func (r *OrderRepo) Get(ctx context.Context, id string) (*Order, error) {
    o, err := r.q.GetOrder(ctx, id)
    if errors.Is(err, pgx.ErrNoRows) { // 驱动哨兵翻译成自家类型
        return nil, &NotFoundError{"orders", id}
    }
    if err != nil {
        return nil, fmt.Errorf("get order %s: %w", id, err) // 根因保留
    }
    return o, nil
}
// handler 层: 类型命中, 业务字段直接可用
// var nfe *repo.NotFoundError
// if errors.As(err, &nfe) { respond(w, 404, nfe.Key) }

错误类型是层与层之间的契约, 与函数签名同等重要。

场景 2 · %w 包装链: op + 字段的分层模板

每一层只加"我这一层知道的事", 不复述不改写下层消息:

func (s *OrderService) Charge(ctx context.Context, id string) error {
    o, err := s.repo.Get(ctx, id)
    if err != nil {
        return fmt.Errorf("charge order %s: get: %w", id, err)
    }              //      ^op         ^字段     ^根因链
    if err := s.gateway.Pay(ctx, o); err != nil {
        return fmt.Errorf("charge order %s: pay: %w", id, err)
    }
    return nil
}
// 边界层只打一条日志, 信息已经完整:
//   charge order 10086: get: get order 10086: orders: 10086 not found
// 哪层(op) / 哪单(字段) / 根因(链尾) 一眼读全

加字段的原则: 能帮排障定位的 (订单号/表名/地址), 不加敏感的 (密钥/全量 SQL)。

场景 3 · 哨兵错误作为跨包契约: io.EOF 风格

SDK 的"连接正在关闭"要让所有调用方统一识别, 必须是导出的哨兵:

package client

var ErrShutdown = errors.New("connection shutting down") // 包级契约

func (c *Conn) Read(p []byte) (int, error) {
    c.mu.Lock()
    defer c.mu.Unlock()
    if c.closed {
        return 0, ErrShutdown // 返回哨兵本体, 绝不现场 errors.New
    }
    return c.readLocked(p)
}
// 调用方: if errors.Is(err, client.ErrShutdown) { reconnect() }
// 反例: errors.New("shutting down") — 每次新实例, Is 永远 false,
// 调用方只能去 match 字符串 — 契约就此崩坏

哨兵一旦发布, 消息文本就冻结了: 改文案等于删 API。

场景 4 · errors.Join: 批处理收集全部失败

导入 1 万行, fail-fast 只知道第一行错哪; 运营要完整清单:

func importAll(ctx context.Context, rows []Row) error {
    var errs error // nil 可直接 Join, 语义就是"追加"
    for _, r := range rows {
        if err := upsert(ctx, r); err != nil {
            errs = errors.Join(errs, fmt.Errorf("row %d: %w", r.ID, err))
            continue // 不中断: 批处理要完整错误清单
        }
    }
    return errs // nil 或聚合错误; Error() 用换行连接各分支
}
// 上层判定: errors.Is(errs, ErrDupKey) 会遍历每个分支检查,
// 需要逐条展示就 strings.Split(err.Error(), "\n")

注意体量: 10 万行全失败会得到巨型错误, 记得设上限或采样。

场景 5 · 中间件统一错误出口: 分类 + request_id

handler 各自手写 respond 必然风格漂移, 错误处理要收口到一处:

type Handler func(w http.ResponseWriter, r *http.Request) error

func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    err := h(w, r) // 全站唯一的错误出口
    if err == nil {
        return
    }
    var nfe *repo.NotFoundError
    switch {
    case errors.As(err, &nfe):
        writeJSON(w, 404, nfe.Key) // 业务错: 带字段给前端
    case errors.Is(err, context.DeadlineExceeded):
        writeJSON(w, 504, "timeout")
    default:
        rid := middleware.RequestID(r.Context())
        slog.Error("unhandled", "req_id", rid, "err", err)
        writeJSON(w, 500, "internal") // 内部错不外漏细节
    }
}

业务 handler 从此只 return err, 响应格式/日志/打点全在一处演进。

场景 6 · 重试决策: 哪些错值得再试一次

重试是错误处理的下半场 —— 判错错了, 要么 5xx 风暴要么重复扣款:

func retryable(err error) bool {
    if errors.Is(err, sql.ErrNoRows) ||   // 业务缺数据: 重试无意义
       errors.Is(err, context.Canceled) { // 用户主动断开
        return false
    }
    var netErr net.Error
    if errors.As(err, &netErr) {
        return true // 瞬时网络错/超时: 指数退避后重试
    }
    var pgErr *pgconn.PgError
    if errors.As(err, &pgErr) {
        return pgErr.Code == "57P01" // admin shutdown 才重试
    }
    return false // 未知错误默认不重试: 保守
}

配合幂等键使用; 判定只信 Is/As, 绝不信错误字符串前缀。

场景 7 · 第三方 SDK 错误翻译层: 防 vendor 锁定

存储从 GCS 换 S3 时, 上层代码不该改一行 —— 前提是错误早就翻译过了:

type StorageError struct {
    Op  string // "put" / "get" / "delete"
    Key string
    Err error   // 根因保留给日志, 不出现在 API 签名
}

func (e *StorageError) Error() string { return fmt.Sprintf("%s %s: %v", e.Op, e.Key, e.Err) }
func (e *StorageError) Unwrap() error { return e.Err } // 别忘: Is/As 靠它

func (s *GCS) Put(ctx context.Context, k string, b []byte) error {
    if err := s.client.Upload(ctx, k, b); err != nil {
        return &StorageError{Op: "put", Key: k, Err: err}
    }
    return nil
}
// 将来换 S3: 只改翻译层; 上层 errors.As(*StorageError) 零改动

边界规则: 项目内部只流通自家错误类型, SDK 类型不出包。

场景 8 · 错误指标打点: 按类型维度上 Prometheus

500 响应只告诉你"炸了", 错误分类计数才告诉你"往哪查":

var errCounter = prometheus.NewCounterVec(
    prometheus.CounterOpts{Name: "api_errors_total"},
    []string{"route", "class"}, // class: not_found/timeout/…
)

func classify(err error) string {
    var nfe *repo.NotFoundError
    switch {
    case errors.As(err, &nfe):
        return "not_found"
    case errors.Is(err, context.DeadlineExceeded):
        return "timeout"
    default:
        return "internal"
    }
}
// 中间件里: errCounter.WithLabelValues(route, classify(err)).Inc()
// 告警: internal 环比涨 3 倍 → 电话值班; not_found 高 → 前端传参 bug

class 维度来自类型判定, 与日志同一套 classify, 口径天然一致。

场景 9 · 并行扇出首错返回: errgroup 语义

并发任务"第一个失败就整体失败", errgroup.WithContext 内置这套语义:

func fanOut(ctx context.Context, srcs []Source) ([]Result, error) {
    g, ctx := errgroup.WithContext(ctx) // 首错自动 cancel 其余
    results := make([]Result, len(srcs))
    for i, s := range srcs {
        g.Go(func() error { // 首个 return err 触发 ctx 取消
            r, err := fetch(ctx, s)
            if err != nil {
                return fmt.Errorf("src %s: %w", s.Name, err)
            }
            results[i] = r
            return nil
        })
    }
    if err := g.Wait(); err != nil {
        return nil, err // 只返回第一个错, 其余已被取消
    }
    return results, nil // 要全部错误: 换场景 4 的 errors.Join
}

并发模式细节见 channel/patterns 相关页; 这里只记一条: goroutine 里绝不 panic, 错误一律走 return。

场景 10 · defer 里的 Close 错误必须 Join 回主错误

磁盘写满时 f.Close() 才报错, 吞掉它 = "写了一半"被当成功:

func writeAtomic(path string, data []byte) (err error) {
    f, err := os.Create(path + ".tmp")
    if err != nil {
        return fmt.Errorf("create %s: %w", path, err)
    }
    defer func() { // 落盘/关闭失败与主错误汇合
        err = errors.Join(err, f.Sync(), f.Close())
    }()
    if _, err := f.Write(data); err != nil {
        return fmt.Errorf("write %s: %w", path, err)
    }
    return nil // 返回前 defer 把 Sync/Close 的错误并进来
}
// 只写 f.Close() 不接 err: 磁盘满时报错被吞, 数据静默丢失
// os.Rename(path+".tmp", path) 放在 err == nil 确认之后

Join(nil, nil, nil) 就是 nil, 主错误优先保留, 收尾错误一个不丢。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · fmt.Errorf 不带 %w 根因断链 — 上层 errors.Is/As 全部 false, 只剩一串文本. 原因: %v 只拼字符串不建链. 正解: 要保留根因一律 %w, 确实要断链时写注释说明。
return fmt.Errorf("get order: %v", err)  // 错: 链断, Is/As 全 false
return fmt.Errorf("get order: %w", err)  // 对: 根因保留成链
坑 2 · 错误消息大写开头还带句号 — "Get order failed." 被包装后拼出 get order: Get order failed. 这种怪物. 正解: 消息小写开头, 不以标点收尾, 链式拼接才通顺。
fmt.Errorf("Get order failed.")  // 错: 拼出 "x: Get order failed."
fmt.Errorf("get order %s", id)  // 对: 小写开头, 无标点收尾
坑 3 · 每层都 log 同一个错误 — 一次请求同一错误刷 4 条日志, 告警计数全乱. 原因: log and return 同时干. 正解: 二选一 —— 要么处理掉(记日志+降级), 要么包好继续 return, 只有边界层落日志。
if err != nil {
    log.Error(err)          // 错: log and return 同时干
    return err              // 上层再 log → 同错 4 条日志
}
// 对: 要么处理掉, 要么包好 return, 只在边界层落日志
坑 4 · log 之后当没事发生继续跑 — 错误记了日志却没 return, 后续代码拿着脏状态继续算, 产出错误数据. 正解: log 和 return 是互斥分支, slog.Error 之后必须 return 或进入明确的降级路径。
if err != nil {
    slog.Error("bad", "err", err)  // 错: 记了不返回
}
data := parse(raw)              // 拿脏状态继续算, 产出错误数据
// 对: log 之后必须 return 或进明确降级路径
坑 5 · 用 == 比较包装过的错误 — if err == sql.ErrNoRows 在错误被包过一层后永远不成立, 分支静默失效. 正解: 一律 errors.Is(err, sql.ErrNoRows)。
if err == sql.ErrNoRows {}            // 错: 包过后永远不成立
if errors.Is(err, sql.ErrNoRows) {}   // 对: 沿链判定
坑 6 · 错误消息拼敏感信息 — 日志里出现完整 SQL、连接串带密码、用户手机号明文. 原因: 图省事把整个请求对象塞进 Errorf. 正解: 只放定位字段(id/表名), 敏感数据脱敏后进结构化日志字段。
fmt.Errorf("exec %s: %v", dsn, err)      // 错: 连接串带密码
fmt.Errorf("query orders uid=%d: %w", uid, err) // 对: 只放定位字段
坑 7 · 哨兵被 %v 包装丢身份 — fmt.Errorf("wrap: %v", ErrShutdown) 之后 Is 判定失效. 原因: %v 把哨兵降格成字符串. 正解: 保身份用 %w; 哨兵一旦发布消息文本就冻结, 改文案等于删 API。
fmt.Errorf("wrap: %v", ErrShutdown)  // 错: 降格成字符串, Is 失效
fmt.Errorf("wrap: %w", ErrShutdown)  // 对: 保身份
坑 8 · 自定义错误忘实现 Unwrap — 包装了下层错误却没写 Unwrap(), 链断在你这层, 上层 Is/As 查不到根因. 正解: struct 里存 Err error 字段并实现 Unwrap() error { return e.Err }。
type WrapErr struct{ Op string; Err error }
func (e *WrapErr) Error() string { ... }      // 错: 链断在这层
func (e *WrapErr) Unwrap() error { return e.Err } // 对: 补上
坑 9 · errors.As 目标传值不传指针 — errors.As(err, nfe) 编译不过或永远 false. 原因: As 要把命中的值写进目标, 目标必须可寻址. 正解: errors.As(err, &nfe), 目标是 *NotFoundError 型变量取址。
var nfe *NotFoundError
errors.As(err, nfe)     // 错: panic: target must be a non-nil pointer
errors.As(err, &nfe)    // 对: 目标取址, 命中写进 nfe
坑 10 · typed nil 陷阱 — 函数声明返回 error, 实际 return nil 具体指针类型, 调用方 err != nil 竟然为 true, 走进错误分支. 原因: 接口装了"类型信息非 nil 值为 nil"的指针. 正解: 返回前判具体指针, 直接 return nil 字面量。
func find() error {
    var e *MyErr           // typed nil
    return e               // 错: err != nil → true, 走错分支
}
// 对: 返回前判具体指针, 直接 return nil 字面量
坑 11 · 错误分支条件写反 — 复制粘贴后 if err == nil 里的代码全是错误处理, 偶发路径歪曲. 正解: happy path 永远在 err != nil 早返回之后; code review 盯住第一行判断。
if err == nil {          // 错: 错误处理进了 nil 分支
    return err
}
if err != nil {          // 对: 早返回, happy path 在后
    return err
}
坑 12 · 库代码里 panic 替代 error — 使用方没签"接受崩溃"的约, 一个脏输入把整个服务打挂. 原因: 把"调用方该处理的失败"当成了 bug. 正解: 库只 return error; panic 只留给"程序员自己写错了"的断言。
func Parse(s string) Config {
    if s == "" { panic("empty") }  // 错: 脏输入打挂服务
}
func Parse(s string) (Config, error) { // 对: 库只 return error
    if s == "" { return Config{}, ErrEmpty }
}
坑 13 · 下划线丢弃返回错误 — _ = f.Close() / _ = w.Flush() 满天飞, 磁盘满/管道断的错误全部蒸发. 正解: 收尾错误用 errors.Join 并进主错误(场景 10); 实在不关心就写注释说明为什么。
_ = f.Close()                  // 错: 磁盘满错误蒸发
defer func() {               // 对: Join 进主错误
    err = errors.Join(err, f.Close())
}()
// 实在不关心就写注释说明为什么
坑 14 · defer 里 Close 错误未汇合 — 主流程成功但 Sync/Close 失败, 半截文件被 rename 上线. 原因: defer 的返回值没人接. 正解: 命名返回值 + defer func(){ err = errors.Join(err, f.Close()) }()。
defer f.Close()                // 错: Close 失败没人接
defer func() {                // 对: 命名返回值 + Join
    err = errors.Join(err, f.Sync(), f.Close())
}()
坑 15 · switch 错误分支宽类型先匹配 — 把 default 或宽泛的 net.Error 写在 *PgError 之前, 具体分支永远走不到. 正解: 类型分支从具体到宽泛排列, default 收尾。
switch {
case errors.As(err, &netErr):   // 错: 宽类型在前吞掉一切
case errors.As(err, &pgErr):    // 具体分支永远走不到
}
// 对: 从具体到宽泛排列, default 收尾
坑 16 · wrap 时把大对象拷进错误 — fmt.Errorf("bad req: %w", &req{...10KB...}), 错误被日志/链路长期持有, 内存翻倍. 正解: 错误只存定位字段, 大对象进日志或 trace, 需要时存引用并注明生命周期。
fmt.Errorf("bad req: %w", &req)              // 错: 10KB 被错误链持有
fmt.Errorf("bad req id=%d: %w", req.ID, err) // 对: 只存定位字段
坑 17 · 错误消息不带操作上下文 — 日志里只有 connection reset by peer, 不知道哪个接口哪张表. 正因: 每层包装都偷懒没加 op. 正解: 消息首段放 "repo.GetOrder:" 这类操作名(场景 2 模板)。
return err                                 // 错: 只有 connection reset
return fmt.Errorf("repo.GetOrder: %w", err) // 对: 首段 op 名
坑 18 · 循环里复用外层 err 变量 — 两次失败只剩第二次的现场, 第一次被覆盖. 原因: if err = f(); err != nil 复用外层变量. 正解: 循环内 := 新变量, 要累积用 errors.Join。
var err error
for _, r := range rows {
    if err = step(r); err != nil {}   // 错: 互相覆盖
    if e := step(r); e != nil {     // 对: := 新变量
        errs = errors.Join(errs, e)      // 累积用 Join
    }
}
坑 19 · os.Exit 跳过 defer 日志没落盘 — main 里 os.Exit(1), slog 异步 buffer 的错误日志全丢, 现场消失. 正解: 退出前 logger.Flush()/sync.Once 收尾; 或 os.Exit 只放在最后一行统一出口。
func main() {
    defer logger.Flush()    // 错: Exit 后不执行, 日志丢
    if err := run(); err != nil {
        os.Exit(1)         // 对: 只放统一出口最后一行
    }
}
坑 20 · goroutine 里 panic 无人 recover — 子 goroutine 一个 panic 直接带走整个进程, 顶层 recover 拦不住别的 G. 原因: recover 只管自己这层调用栈. 正解: goroutine 入口统一包一层 recover 转 error, 通过 channel/errgroup 传回。
go func() { panic("boom") }()  // 错: 带走整个进程
go func() {                   // 对: 入口 recover 转 error
    defer func() {
        if r := recover(); r != nil {
            errCh <- fmt.Errorf("panic: %v", r)
        }
    }()
    work()
}()