登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go 回调接口为什么不该统一返回 error:同步确认、异步投递与错误所有权

来源:17golang原创

时间:2026-08-11 13:00:43 382浏览 收藏

订单状态从“待支付”变成“已支付”时,业务代码往往会顺手挂一个回调:校验库存、写审计日志、通知下游。最初把所有回调都定义成 func(Event) error 很省事,几周后却会出现一个难排的误判:同步校验返回的错误必须阻断订单,后台通知返回的错误却只能进入重试队列,两个调用方看着同一个签名,却拥有完全不同的责任。

要点速览

  • 回调是否返回 error,先看调用方是否需要在当前请求内做决定。
  • 同步确认应把失败返回给上层;异步投递应返回“已接收/未接收”,不要伪装成业务执行结果。
  • 后台任务的错误由任务系统负责记录、重试和告警,不能让提交方替它背锅。
  • 接口拆分的核心不是少写一个 error,而是把错误所有权放回真正能处理它的组件。

先看调用关系:谁拥有这次错误

判断一个回调要不要返回 error,可以先问一句:这个错误发生后,当前调用方还能不能改变结果?如果答案是“能”,它就是同步确认;如果只能把事件交给另一个执行单元,返回值表达的应是接收状态,而不是最终业务结果。

下面这个最小事件模型故意保留两种调用关系。Validator 参与订单提交决策,Publisher 只负责把事件交给队列:

type OrderPaid struct {
    OrderID string
    Amount  int64
}

type Validator interface {
    Validate(context.Context, OrderPaid) error
}

type Publisher interface {
    Publish(context.Context, OrderPaid) (Receipt, error)
}

type Receipt struct {
    Accepted bool
    Message  string
}

Validate 的错误可以直接转成支付流程的失败原因;Publish 的返回值只说明消息有没有被当前组件接收。即使消息已经接收,消费者仍可能在几秒后因为下游不可用而失败。

同步校验错误阻断订单,异步投递只返回接收状态的回调错误所有权示意图

统一返回 error,最容易制造哪种误判

问题通常不在接口声明本身,而在调用者把所有错误都当成同一种错误。比如下面的代码把通知投递失败当成订单支付失败:

func Pay(ctx context.Context, p Publisher, event OrderPaid) error {
    if err := p.Publish(ctx, event); err != nil {
        return fmt.Errorf("支付失败: %w", err)
    }
    return nil
}

如果 Publish 只是把消息写入本地缓冲区,那么返回的错误最多表示“这次交接没有完成”。它不应该让支付接口回滚一个已经完成的扣款;正确动作可能是标记“待通知”,交给投递任务重试。

相反,库存预占属于同步确认。库存组件返回 ErrOutOfStock 时,订单仍处在可改变的窗口内,支付流程就应该停止后续动作,并把这个业务结果反馈给调用方。把这两类回调合并成一个接口,调用方只能靠注释猜测错误含义。

把接口拆成两条,调用方才不会误判

接口拆分不是为了追求更多类型,而是让返回值和时间边界对齐。同步路径表达“这次操作是否通过”,异步路径表达“消息是否已交接”:

type OrderRules interface {
    Validate(context.Context, OrderPaid) error
}

type OrderEvents interface {
    Enqueue(context.Context, OrderPaid) (Receipt, error)
}

func CompletePayment(ctx context.Context, rules OrderRules, events OrderEvents, e OrderPaid) error {
    if err := rules.Validate(ctx, e); err != nil {
        return err
    }

    receipt, err := events.Enqueue(ctx, e)
    if err != nil {
        return fmt.Errorf("事件未交接: %w", err)
    }
    if !receipt.Accepted {
        return fmt.Errorf("事件未接收: %s", receipt.Message)
    }
    return nil
}

这里仍然有 error,但它的含义变了:只表示队列不可用、上下文取消或消息没有被接收,不表示消费者已经完成审计。审计任务的执行错误应该出现在任务日志、重试次数和死信记录里。

Go 回调接口拆成 Validate 与 Enqueue 后,同步结果和异步任务结果分开的流程图

三个反例:什么时候不该强行拆接口

只存在一个调用方,而且结果必须立刻决定

例如密码校验、额度检查和权限判断,它们天然属于当前请求的同步决策。此时使用 func(...) error 直接、清楚,不需要为了形式再包一层结果对象。

组件本身就是可靠投递层

如果一个客户端的职责是把数据写入已经确认成功的事务日志,那么它返回的 error 就是写入是否完成。不要因为调用名叫“回调”就把它误判成异步接口,关键要看它是否把责任交给了后续消费者。

需要携带可恢复的业务分支

当调用方要区分“库存不足”“稍后重试”和“参数错误”时,error 仍然适合承载分支,但应使用稳定的哨兵错误或自定义类型,让 errors.Iserrors.As 有明确契约,不要让字符串比较承担协议职责。

落地前的判断清单

  • 错误发生时,当前调用方能否取消、回滚或改写业务结果?能:优先同步返回 error
  • 回调是否已经把工作交给队列、goroutine 或其他服务?是:返回接收凭证,执行失败交给后续任务记录。
  • 返回的 error 是“没有接收”还是“消费失败”?如果文档说不清,接口边界还没拆干净。
  • 是否需要重试?把重试次数、退避和死信放在真正执行任务的一侧,不要塞进每个调用方。

常见问题

回调接口返回 error,会不会让代码更容易测试?

会,但前提是 error 的语义稳定。同步校验可以直接断言错误类型;异步投递则应测试“是否接收”和后续任务的失败记录,不能只断言一个 nil。

异步投递还需要 context 吗?

需要。提交动作仍然可能受超时、取消和连接关闭影响;但不要把请求 context 直接带进一个必须独立完成的后台消费任务,交接成功后应由任务系统创建自己的生命周期。

能不能只保留一个通用 Callback 接口?

只有在所有调用方对返回值、时限和失败处理拥有同样预期时才适合。只要一个调用方把错误当成“业务失败”,另一个把它当成“稍后重试”,就应该拆成语义更具体的接口。

好的回调接口会把时间边界写进类型和返回值:同步确认负责当前决定,异步投递负责可靠交接,后台任务负责执行结果。先确认错误由谁处理,再决定签名,通常比给所有函数统一加上 error 更稳。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>