Go 工业边缘错误处理实战:用 errors 构建可追溯错误链

在工业边缘程序里,错误不只是"打印一行日志"。一次设备读取失败,可能来自连接超时、协议响应异常、数据校验失败、权限不足,也可能是下游数据库暂时不可用。如果错误只被转成字符串,后续就很难判断该重试、报警、降级,还是直接返回给上层业务。

Go 标准库 errors 包和 fmt.Errorf 的 %w 动词,是构建错误链的核心工具。本文从 Zenova EdgeOS 在工业边缘工程视角梳理错误创建、包装、识别、分类、分层传递、日志与接口返回的实践。

一、为什么需要错误链

一个可维护的错误处理体系至少要满足四个目标:

目标 典型问题 错误链价值
保留上下文 只知道 failed,不知道哪个站点、设备、操作失败 每层包装补充当前语义
支持识别 上层只能比较错误字符串 使用 errors.Is / errors.As
支持决策 无法区分超时、权限、不存在、冲突 用哨兵错误或错误类型分类
控制暴露面 SQL、固件路径、内部 IP 泄漏到 API 内层保留细节,边界只返回安全信息

简单说,错误链让错误既能"带细节往上传",又能让上层按语义处理,而不是按文案猜。

二、基础:创建、包装与解包

1. 创建错误

go 复制代码
var ErrNotFound = errors.New("device not found")

func findDevice(id string) error {
    // 真实项目中这里通常查缓存、本地表或设备服务。
    return ErrNotFound
}

2. 包装错误

%w 会保留被包装错误,后续 errors.Is、errors.As 和 errors.Unwrap 才能继续工作。

go 复制代码
func getDevice(id string) (Device, error) {
    if err := findDevice(id); err != nil {
        return Device{}, fmt.Errorf("get device %s: %w", id, err)
    }
    // 省略成功路径。
    return Device{ID: id}, nil
}

3. 判断错误

go 复制代码
err := getDevice("dev-001")
if errors.Is(err, ErrNotFound) {
    log.Printf("device not found: %v", err)
}

注意区别:

  • errors.Is 判断错误或错误链中是否包含目标值,适合哨兵错误;
  • errors.As 判断错误链中是否存在某种类型,并提取字段,适合业务错误类型;
  • err == ErrNotFound 只能比较最外层对象,包装后会失效;
  • strings.Contains(err.Error(), "not found") 依赖文案,不应作为程序逻辑。

三、哨兵错误:定义稳定语义

哨兵错误适合表达跨层传递、稳定且数量有限的业务语义。

go 复制代码
var (
    ErrNotFound     = errors.New("not found")
    ErrPermission   = errors.New("permission denied")
    ErrTimeout      = errors.New("timeout")
    ErrUnavailable  = errors.New("unavailable")
    ErrDeviceBusy   = errors.New("device busy")
)

边界层可以按语义转换:

go 复制代码
func statusCode(err error) int {
    switch {
    case errors.Is(err, ErrNotFound):
        return http.StatusNotFound
    case errors.Is(err, ErrPermission):
        return http.StatusForbidden
    case errors.Is(err, ErrTimeout):
        return http.StatusGatewayTimeout
    case errors.Is(err, ErrUnavailable) || errors.Is(err, ErrDeviceBusy):
        return http.StatusServiceUnavailable
    default:
        return http.StatusInternalServerError
    }
}

不要为每种底层故障都创建哨兵错误。语义不稳定、需要携带字段、只有局部模块使用的错误,更适合自定义类型。

四、自定义错误类型:保留业务字段

自定义错误适合表达"同一类失败,但上下文不同"的场景,例如设备读取失败、协议解析失败、采集任务失败。

go 复制代码
type DeviceError struct {
    DeviceID  string
    Op        string
    Retryable bool
    Err       error
}

func (e *DeviceError) Error() string {
    if e == nil {
        return "<nil>"
    }
    return fmt.Sprintf("device %s %s: %v", e.DeviceID, e.Op, e.Err)
}

func (e *DeviceError) Unwrap() error {
    return e.Err
}

使用 errors.As 提取结构化字段:

go 复制代码
err := &DeviceError{
    DeviceID: "dev-001",
    Op:       "read register",
    Retryable: true,
    Err:      ErrTimeout,
}

var de *DeviceError
if errors.As(err, &de) {
    log.Printf(
        "device_id=%s op=%s retryable=%t root=%v",
        de.DeviceID, de.Op, de.Retryable, errors.Unwrap(de),
    )
}

实现建议:

  • Unwrap 返回 Err,不要在 Error() 里丢失底层错误;
  • 字段使用稳定命名,便于结构化日志和指标统计;
  • 敏感值、内部地址、凭据不要直接放入错误字段;
  • 如果错误类型实现 Is 方法,必须保证语义稳定、可测试。

五、多错误:fan-out 与批量任务

工业边缘常见一次任务操作多台设备,例如批量读取、批量下发、批量校验。Go 1.20 之后可以使用 errors.Join 聚合多个错误。

go 复制代码
func collectDevices(ctx context.Context, ids []string) error {
    var results []error

    for _, id := range ids {
        if err := readDevice(ctx, id); err != nil {
            results = append(results, fmt.Errorf("read device %s: %w", id, err))
        }
    }

    return errors.Join(results...)
}

判断聚合错误:

go 复制代码
err := collectDevices(ctx, deviceIDs)
if errors.Is(err, ErrTimeout) {
    // 只要错误链中存在超时,就按超时策略处理。
}

如果成功读取了 90 台设备、失败 10 台,单纯返回一个总错误会丢掉成功结果。工程上可以返回"结果 + 失败列表",让上层决定是否部分成功、继续处理或整体重试。

六、分层错误边界

1. Repository / Driver 层

驱动层的职责是把数据库、协议栈、文件系统错误转换成领域语义,同时保留技术根因。

go 复制代码
func (r *DeviceRepo) Get(ctx context.Context, id string) (Device, error) {
    var d Device
    err := r.db.GetContext(ctx, &d, "SELECT id, name FROM devices WHERE id = ?", id)
    if errors.Is(err, sql.ErrNoRows) {
        return Device{}, fmt.Errorf("device %s: %w", id, ErrNotFound)
    }
    if err != nil {
        return Device{}, fmt.Errorf("query device %s: %w", id, err)
    }
    return d, nil
}

2. Service 层

服务层补充业务动作,不重复堆叠无意义的上下文。

go 复制代码
func (s *GatewayService) GetDevice(ctx context.Context, id string) (Device, error) {
    d, err := s.repo.Get(ctx, id)
    if err != nil {
        return Device{}, fmt.Errorf("get gateway device: %w", err)
    }
    return d, nil
}

3. HTTP Handler 层

接口边界要做两件事:按语义返回状态码,避免把内部细节暴露给外部。

go 复制代码
type ErrorResponse struct {
    Code    string `json:"code"`
    Message string `json:"message"`
}

func (h *Handler) GetDevice(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")

    device, err := h.service.GetDevice(r.Context(), id)
    if err != nil {
        code := statusCode(err)
        if code >= http.StatusInternalServerError {
            slog.Error("get device failed",
                "device_id", id,
                "error", err,
            )
            writeError(w, code, "internal error")
            return
        }

        writeError(w, code, userMessage(err))
        return
    }

    writeJSON(w, http.StatusOK, device)
}

错误文案建议:

  • 对外返回稳定 code 和可行动的 message;
  • SQL、驱动细节、堆栈、内部路径只进入日志;
  • 请求 ID、设备 ID、站点 ID 等上下文用于排障;
  • 不要把原始错误直接返回给浏览器、设备端或第三方调用方。

七、错误与重试策略

错误分类要服务于动作。可以按"是否可重试"和"影响范围"建模:

错误语义 常见来源 建议动作
超时 连接超时、请求截止 有限重试,检查退避
暂不可用 下游服务重启、连接池耗尽 重试或降级缓存
设备忙 采集冲突、指令占用 延迟重试
不存在 ID 错误、设备未注册 不重试,返回业务错误
权限不足 认证、授权、密钥失效 不重试,通知运维或用户
数据校验失败 非法寄存器值、CRC 错误 视协议决定重读或告警

判断上下文超时时,应使用:

go 复制代码
if errors.Is(err, context.DeadlineExceeded) {
    // 请求级超时。
}

如果错误来自 net 包,也可以按接口判断:

go 复制代码
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
    // 网络层超时,可进入有限重试。
}

重试必须有限制,包括最大次数、总截止时间、退避间隔和抖动,避免设备掉线时把任务循环打成高频扫描。

八、堆栈、日志与可观测性

Go 标准库错误默认不携带调用堆栈。runtime.Caller 只能记录创建错误的位置,不等同于完整调用栈。工业边缘系统更稳定的做法是把错误链与结构化日志、trace、指标结合。

go 复制代码
slog.Error("collect device failed",
    "site", site,
    "device", deviceID,
    "op", "read_registers",
    "error", err,
)

如果确实需要堆栈,可以选择第三方库,但要评估维护状态和依赖成本:

  • github.com/pkg/errors 提供过 Wrap / WithStack,但项目已进入冻结状态;
  • github.com/cockroachdb/errors 兼容标准错误链,并提供堆栈与报告能力;
  • 也可以在关键边界自行记录 runtime.Caller,只保留必要位置。

比"每个错误都带堆栈"更重要的是:

  • 日志中有站点、设备、操作、请求 ID;
  • 指标能按错误类型聚合,例如 timeout、permission、not_found;
  • 每个错误只在一个明确边界打印,避免重复刷屏;
  • 失败路径有测试覆盖,而不是只测试成功路径。

九、常见错误处理模式

1. 只在需要决策时处理错误

go 复制代码
if err := syncDevice(ctx, id); err != nil {
    if errors.Is(err, ErrDeviceBusy) {
        scheduleLater(id)
        return nil
    }
    return fmt.Errorf("sync device %s: %w", id, err)
}

能处理就处理,不能处理就带上下文继续上抛。不要到处 if err != nil 却什么也不做。

2. 避免吞错

不推荐:

go 复制代码
_ = closeConn()

如果关闭失败不影响主流程,至少记录 debug 日志;如果可能造成资源泄漏或数据不完整,应进入返回错误或指标。

3. 避免过度包装

go 复制代码
return fmt.Errorf("handle: %w",
    fmt.Errorf("service: %w",
        fmt.Errorf("repo: %w", err),
    ),
)

这种链路会变成 handle: service: repo: ...,可读性差。每层只补充自己拥有的信息即可。

4. goroutine 错误要显式传递

go 复制代码
func worker(ctx context.Context, id string, out chan<- error) {
    result := process(ctx, id)
    if result != nil {
        result = fmt.Errorf("process %s: %w", id, result)
    }

    select {
    case out <- result:
    case <-ctx.Done():
    }
}

不要在 goroutine 里直接 panic 或丢弃失败结果。批处理场景应确保每个任务的错误都有归属和结束状态。

十、常见坑与应对

常见坑 现象 应对
吞掉错误 任务显示成功但状态异常 返回、记录或进入指标,至少显式说明可忽略
字符串比较 文案变化后逻辑失效 使用 errors.Is / errors.As
忘记 %w 错误链断裂,无法识别根因 包装时使用 %w
包装过深 错误信息重复、排障困难 每层只补充本层语义
暴露内部错误 SQL、路径、地址泄漏 边界转换安全响应,细节进日志
把所有错误当 500 上层无法降级或重试 定义领域错误语义
无限重试 放大故障、拖垮设备 限制次数、退避、总超时
日志无上下文 无法定位站点和设备 携带 request、site、device、op
只看成功路径测试 边界行为不可控 覆盖超时、不存在、权限、暂不可用

十一、工程实践清单

  1. 为跨层错误定义少量稳定哨兵错误;
  2. 为携带上下文的业务失败定义自定义错误类型;
  3. 所有包装使用 %w,避免错误链断裂;
  4. HTTP、gRPC、MQTT 等边界统一做错误映射;
  5. 重试策略与错误语义绑定;
  6. 结构化日志携带设备、站点、操作和请求 ID;
  7. 批量任务保留部分成功结果和逐项失败原因;
  8. 对外错误只暴露稳定 code 与安全 message;
  9. 为超时、不存在、权限不足、暂不可用等路径补测试;
  10. 将错误类型接入指标、告警和链路追踪。

十二、TL;DR

  • errors.New 创建错误,fmt.Errorf + %w 包装错误;
  • errors.Is 识别哨兵错误,errors.As 提取错误类型;
  • 自定义错误要实现 Error() 和 Unwrap();
  • errors.Join 适合聚合多任务失败;
  • 分层包装要克制,每层只补充本层语义;
  • API 边界返回安全信息,内部细节进入日志;
  • 重试、告警、降级都应基于错误语义,而不是错误文案。

下一步建议

  1. 盘点当前项目中字符串比较和吞错的位置;
  2. 定义领域级哨兵错误和错误类型;
  3. 统一 HTTP / gRPC / 消息入口的错误映射;
  4. 为超时、权限、不存在、暂不可用补充测试;
  5. 将错误分类接入日志、指标和告警体系。
相关推荐
KKKlucifer21 分钟前
国内数据安全服务的产品智能化转型:大模型赋能下的技术能力重构分析
大数据·数据库·重构
取名真是30 分钟前
Python 算法知识点从基础到进阶
开发语言·python·算法
FRing031 小时前
vue2干货总结3(computed计算属性、watch监听器)
开发语言·前端·vue.js·vue2
字节跳动开源1 小时前
OpenViking 上线原生 3D 内容理解模型
数据库·开源
Fatbobman(东坡肘子)2 小时前
快速消失的硬盘容量 -- 肘子的 Swift 周报 #160
开发语言·人工智能·redis·ios·swiftui·iphone·swift
揽星河..2 小时前
【C语言】底层程序设计
c语言·开发语言
JavaPub-rodert2 小时前
从零搭建通用管理后台:ShiyuAdmin 的 Go、React 与 RBAC 实践
开发语言·react.js·golang
海的透彻3 小时前
dockerfile如何快速构建容器(java服务)
java·开发语言
code2cat3 小时前
Java工具篇之Gradle:用任务图组织构建与交付
java·开发语言·单元测试·gradle