Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Wukong 错误治理模型:降服千变万化的错误

一个工业级系统和原型的区别,不只在正确路径上,更在失败路径上。

本文将先梳理核心矛盾,再给出 Wukong 错误治理模型的解决思路,最后看 Rust 落地和工业验证。如果你只关心方法论本身,可以直接跳读到 我们的方案


你有没有见过这样的代码库?

同一个“订单不存在“,A 模块返回一个 enum,B 模块返回一个字符串,C 模块直接 panic。网关层拼 JSON 的时候,每个 handler 各自为政——有的返回 {"error": "not found"},有的返回 {"code": 404, "msg": "resource missing"}。出了故障,你打开日志,满屏的错误信息散落各处,没有一条完整的链路告诉你“这个失败从哪里来、经过了哪些层、根因是什么“。想重构错误类型?不敢动——因为你不知道上游谁在依赖那个错误字符串的内容。

如果你点头了,那你的项目大概率正处于错误处理的无治理状态


错误处理的三条路,同一个问题

C++ 用异常,Unix 用返回值 + errno,Windows 用结构化错误码。表面上是三种语法,实际上在回答同一个问题:失败信息怎么从发生点传递到处理点?

异常选择了隐式长跳转——正常路径和错误路径分离,业务逻辑不再被 if (ret < 0) 打断。这是它真正优雅的地方。但代价也很明确:语言只给了 throwreturn,没有告诉你线划在哪。用户输入不合法,是抛异常还是返回 error?订单库存不足,算业务逻辑还是异常?同一个函数,不同的人写,对”异常”的定义可能完全不同。分离了路径,但没有告诉我们分离的边界。

Rust 做了一个不同的选择:彻底抛弃异常机制。错误回到返回值——Result<T, E>,通过 ? 操作符向上传播。初看像是退回了 C 语言的老路,但深入之后会发现,这是一种重新思考错误本质的姿态。

用一个比喻来说:

异常机制把错误处理搞成了”特区”。 有自己的控制流(throw/unwind)、自己的语法(try-catch)、自己的运行时基础设施(栈展开表、landing pad)——即使异常从没发生,这些成本也在那里。两套规则、两套心智模型、两套代价。开发者在正常逻辑和异常逻辑之间反复切换,”该抛还是该 return”就成了永远的灰色区域。

Rust 的选择是取消特区。 错误就是值——和 i32StringOption<T> 一样,通过返回值传递,通过 match 分派。没有隐式跳转,没有”正常代码”和”错误代码”的地位差别。Result<T, E> 不过是一个泛型枚举,? 不过是一个语法糖。

这恰恰是一种平等对待:错误处理不是寄生在正常逻辑上的附属机制,它本身就是正常逻辑的一部分。Result 写在签名里,调用方必须面对两种可能。代价是错误路径更显眼了,收益是不需要同时掌握两套系统。平等,意味着简单。

每次设计开发一个系统,都要面对同一个问题:错误处理体系怎么设计?从最初的”不管错误”,到在框架里设计异常体系、错误码、错误接口,再到引入日志规范——这些工作总是占据大量时间和精力。

和多位资深开发者、架构师聊到这个话题,有一个共识:

能把代码写得简洁易懂的,是优秀开发者。能把错误处理做得干净漂亮的,才是顶尖程序员。

错误处理排在功能实现、算法性能、代码可读之后——它不是最急迫的,不是 PM 会催的,不是 code review 会重点看的。但正是它,决定了一个系统能不能长期稳定运行,能不能在故障面前快速恢复,能不能在人员更迭后仍然可维护。它是系统进化能力的底座。

Rust 的这个设计引出一个更本质的问题:错误到底是什么?是意外,还是结果? 如果错误是意外,异常是合理的——跳出去,别让它污染正常逻辑。但如果错误也是结果的一种——一个函数可能返回数据,也可能返回失败,两者都是这个函数的合法输出——那错误就应该像返回值一样被显式传递、显式处理。

两种理解,引向两种设计哲学。哪种对?业界对此并没有共识。C++ 保留异常但也支持 std::expected,Java 固守异常但社区也在尝试 Result 式库,Go 用返回值但结构化信息需要额外约束,Rust 用 Result 但诊断链和边界策略仍要工程层面补足。每条路都有各自的取舍——说明这个问题远未被”解决”,大家还在探索。

只能说,在错误处理这件事上,各种语言都还在探索基础能力:异常、返回值、Result、错误码,各有取舍,也都没有彻底解决问题。可从系统构建者的角度看,我们真正关心的到底是什么?

我们关心的,不是语法层面怎么抛、怎么传,而是失败信息在系统中如何被分类、保留、转换、暴露和观测。下面把这套思路展开——先说核心矛盾,再说解决思路,最后看落地和验证。


错误处理,为什么是原型和工业级的分水岭?

原型只需要证明一件事:正确路径能跑通。

但工业级系统面对的是完全不同的现实:输入在变化,依赖在退化,网络在抖动,配置在漂移,数据在积累脏状态,业务规则在迭代。系统不会长期运行在理想条件下。

所以错误不是“正常逻辑之外的意外文本“,它是系统在非理想条件下继续运行、恢复状态、决定对外响应、支持诊断时必须传递的信息

很多项目早期把错误处理当成“每个函数自己的事“。调用链短、边界少的时候,这不是问题。但当错误信息开始跨越团队、子系统、服务边界、协议边界时,失败路径就变得不可治理。

这不是某个函数“写得不好“,而是缺乏统一的信息架构。错误治理,就是这套信息架构,也是系统对抗腐化与脆弱的骨架。


行业一直在探索错误处理

行业从来都知道错误处理重要。C 用返回码,Java 用异常,Go 用显式 error,Rust 用 Result<T, E>。每种设计都有取舍。

但语言机制解决的是“怎么表达失败“,不是“失败信息在系统中如何被分类、保留、转换、暴露和观测“。

来看看几个优秀项目的做法:

  • gRPC 把跨语言 RPC 失败收敛为标准状态码——稳定分类让调用方可重试、降级、告警
  • PostgreSQL 用稳定 SQLSTATE 错误码,不依赖错误文本——机器契约和人类文案应该分离
  • Kubernetes 把就绪状态、失败原因写入 status 字段——错误可以是可查询、可自动化处理的系统状态
  • rustc 用错误码 + 源码位置 + label + note + help 构成诊断链——诊断信息本身是产品体验

它们的形态不同,方向一致:把失败路径设计成稳定的信息系统。


核心矛盾:收敛 vs. 诊断

错误治理要面对一对根本矛盾。

调用方需要稳定的、有限的分类——否则没法做重试、降级、告警、返回给用户的决策。排障方需要完整的、保留细节的信息——否则没法定位根因。

如果你向调用方暴露太多技术细节,上层就会依赖数据库、网络库、第三方 SDK 的具体失败形态,底层一重构,错误契约就崩了。如果你只保留业务分类,排障时就会丢失关键路径:原始失败是什么?经过了哪些层?每层追加了什么上下文?

所以问题不是“要不要包装错误“,而是:如何同时让错误在治理层面收敛,在诊断层面保真?

接下来的问题是:现有语言机制和常见实践,为什么还没有把这件事真正解决?


语言给了你砖块,但没给你建筑

很多人会说:Java 的异常链、Go 的 errors.Is/errors.As、Rust 的 enum + cause chain,不是已经解决了这个问题吗?

它们是砖块,但砖块不等于建筑。问题出在三点:

第一,错误标识不稳定。 Java 里异常类型承担分发角色——catch (OrderNotFoundException e)。但异常类型受继承层次控制,重构时会变。错误码只是异常的附赠字段。

第二,分类空间天然膨胀。 异常机制鼓励“一种失败一个类“——SubmitDependencyUnavailableExceptionInvalidStateException……类数量跟随业务失败模式无限制增长,没有机制强制收敛到有限分类。

第三,治理和诊断共用同一通道,互相牵制。 cause chain 解决的是诊断保留,不解决“该重试还是降级?该映射到哪个 HTTP 状态码?该暴露给用户还是只记日志?“这些治理动作散落在每个 handler、每个 catch 块里。

关键不在于用什么语言机制,而在于你的错误架构有没有这四根承重墙:稳定错误标识、有限分类空间、诊断保留、集中边界策略。


我们的方案:Wukong 错误治理模型

通过多年的实践,我逐渐找到了解决这个问题的方案。它的核心思想很简单:**用稳定契约让错误现形,用可靠诊断看清根因,用适配输出让不同接收端拿到稳定、合适的信息。**我把这套方法叫作 Wukong 错误治理模型

为什么叫 Wukong?借用的正是悟空在西天路上一路降妖除魔的意象。放到软件开发里,我们要降伏、封印的是各种错误:IO 错误、配置错误、网络错误、业务规则错误、数据质量错误。它们不能继续以散乱字符串、隐式包装和边界泄露的形式到处作乱,而必须被纳入一套可命名、可诊断、可约束、可演进的治理体系。

错误内部模型 = 契约通道 + 诊断通道
适配输出 = 对内部模型按策略生成的不同输出视图

契约通道 —— 包含稳定错误标识、稳定分类、category、retryable、暴露等级。服务调用方、网关、监控、运维策略。稳定性要求高,应被文档化和测试约束。

诊断通道 —— 包含原因链、操作上下文、关键字段、动态细节、底层错误。服务开发者、SRE、排障工具。可以动态变化,但必须保真且可追溯。

HTTP response、RPC error、CLI output、log record、metric label 和 debug report 不是错误本体,而是错误到达不同边界后生成的适配输出。同一个错误,通过不同输出视图服务不同角色:调用方看到的是 order.submit_dependency_unavailable 和它的治理属性;排障方看到的是完整的 cause chain:service → repository → database timeout,以及每层的 detail、context 和原始错误。

这里有四个概念要分清:

  • identity 是长期稳定的机器主键,面向协议、监控、告警、文档和兼容性。
  • reason 是代码里的领域分类表达,比如 Rust enum、Java sealed class 或 tagged union。
  • category 是粗粒度治理维度,用来辅助路由、聚合和告警。
  • policy 是边界输出规则,决定 HTTP 状态码、visibility、retryable、hints、日志级别等。

外部系统应该依赖 identity.code,而不是 Rust enum 名、Java class 名或错误文案。reason 是进程内代码表达,identity 才是跨边界、跨版本的契约。

这不是一个 API 问题,而是一组围绕失败信息展开的工程约束。

还有一个很实用的判断:不同信息的稳定性不同。identity.code 最稳定,适合作为外部契约;category 通常也稳定;reason 变体主要是代码内契约;detail、context 字段、source chain 都更动态,不应该被外部调用方依赖。所以测试应该断言 identity 和策略结果,而不是断言错误文案。


落地需要五项原则

概念说清楚了,但怎么落地?我在不同项目里反复踩坑之后,把这套模型收敛成五项可操作的原则。

原则一:用统一载体承载契约与诊断信息

自有跨层错误传播路径应使用统一的结构模型。不是要求所有第三方库都改成同一种类型,而是你团队控制的内部传播路径,使用同一种形状。

变化的是分类空间和上下文,不是错误形状本身。

反例:controller 返回 Result<T, Box<dyn Error>>,service 返回 Result<T, MyEnum>,dao 返回 io::Result<T>——三种形状,调用方每层都要重新学习一套规则。

原则二:让契约通道保持稳定

错误分类是契约——调用方依赖它做治理决策。可以新增错误标识,但不应删除已承诺的、不应改变已有语义、不应让同一错误标识在不同边界产生矛盾的治理动作。

稳定错误标识是人和系统之间的共享接口:运维配告警、网关配状态码、API 文档描述错误响应——全部依赖错误标识而非错误消息文本。

反例:把 business.not_found 的语义从“资源不存在“悄悄改成“无权限访问“——所有依赖这个错误标识做 404 映射的上游全部出错。

新增 identity 也要克制。只有当调用方需要不同治理动作、边界需要不同状态码或用户提示、监控需要独立聚合、语义能长期稳定时,才值得新增稳定错误标识。字段名、文件路径、租户、行列号、底层库错误文本这些动态差异,应进入诊断通道,而不是膨胀契约通道。

原则三:让诊断通道跨层保真

错误在内部传播时应追加信息,不应破坏已有诊断链。判断标准是:当前层是否跨越了语义域?

同语义域内(比如 database driver → query executor → repository helper 都在 data access 域),只需分类收敛 + 诊断保留。跨语义域时(比如 data access → order service),建立新语义边界,把下层错误作为 cause 保留。

反例:service 层调用 repository 失败,直接 return Err(ServiceError::DependencyFailed) 而丢弃了下层的 database timeout 根因——排障时只能看到 service 这一层,根因永远丢失。

在 Rust / orion-error 的心智模型里,可以这样判断:只把下层 reason 收敛到上层 reason,用 conv_err();跨语义域、要表达新的业务失败,用 source_err(...);只是补充当前操作路径和字段,用 doing(...) / with_context(...)。不要把每一层都包装成新的错误叙事,也不要把底层技术失败直接暴露给上层。

原则四:在边界集中输出

边界暴露策略应集中定义。HTTP 状态码、RPC 错误码、日志级别、告警触发、重试建议、脱敏规则——这些决策如果在每个 handler 里重新做一次,同一错误标识就会在不同边界产生不同表现。

集中策略让错误标识 → 治理动作的映射变成一处定义、处处一致。举个具体的例子:与其在每个 handler 里 match err { ... } 决定状态码,不如定义一个统一的 exposure 策略,所有边界点都调用同一套规则——handler 只负责传错误、取结果。

反例:两个 handler 对同一个 system.timeout 错误,A 返回 503,B 返回 504——客户端重试逻辑不知道该信谁。

原则五:显式桥接外部生态

进入外部生态(日志系统、标准错误接口、第三方库)应是显式的。每次桥接都要回答:目标消费者是谁?保留什么?丢弃什么?脱敏什么?如何降级?

信息不一定要全带出去,但每次输出都应该是可审计、可测试、可预期的。

反例:调用方在不知情的情况下把 StructuredError 转成字符串传给日志系统——诊断链、错误标识、上下文全部擦除,除了几行文案什么都没留下。

尤其要区分信任域:进程内可以保留完整结构化错误;服务间更适合传递协议快照、identity、category、公开消息、retryable 和 correlation id;用户边界只应该看到稳定错误码、可公开消息和必要修复提示;观测系统则接收脱敏后的诊断摘要和聚合标签。


错误传播的三种模式

一个工业级错误经历三种动作,不是机械向上抛。

首次进入。 原始错误(IO、网络、解析)首次进入结构化体系,同时完成:选择分类、给出当前层解释(detail)、保留原始错误为 source。

跨层转换。 同语义域收敛分类,跨语义域建立新边界包裹。取决于当前层是否是新语义边界。

适配输出。 在系统边界交给集中策略,为不同接收端生成不同输出视图。不再重新解释错误。

三层下来,契约通道给调用方的是稳定错误标识和治理决策,诊断通道保留了完整的 cause chain + context + detail。排障方能追溯根因,调用方不需要知道底层细节。


Rust 落地:五个设计规则

Rust 的类型系统天然适合这套模型——代数类型表达分类,match 提供穷尽检查,泛型参数化载体,无异常机制让错误通过返回值传递。但 Rust 不会自动完成治理,需要在工程层面建立规则。

规则一:按语义域定义 Reason,而非一个全局大枚举。 RepositoryReasonOrderReason 各自在自己的边界内约束分类空间,跨域时显式转换。

规则二:首次进入即结构化。 IO、网络、解析错误第一次进入体系时,同时完成:选择分类 + 给出 detail + 保留 source。不把底层错误转成字符串。

规则三:跨语义域建立新边界,保留下层为 source。 同域收敛,跨域包裹——判断依据是当前层是否有新的业务含义。只做 reason 收敛时用 conv_err();建立新语义边界时用 source_err(...)

规则四:边界只做输出,不重新解释。 handler 调用 exposure(&policy),策略集中决定 HTTP 状态码、用户消息、日志级别——不在每个 handler 里重复决策。

规则五:测试错误标识,不测试错误文案。

#![allow(unused)]
fn main() {
assert_eq!(err.identity_snapshot().code, "order.submit_dependency_unavailable");
assert_eq!(exposed.decision.http_status, 503);
}

我们在 Rust 下把 Wukong 错误治理模型实现为 orion-error crate(开源),完整示例见 orion-error/examples/order_case.rs。这个示例主要展示 conv_err() 收敛路径;如果上层需要建立新的业务语义边界,应使用 source_err(...) 把下层结构化错误保留为 source。


工业验证:WarpParse

方法论需要真实系统检验。WarpParse 是 Orion 体系中的高吞吐日志解析引擎,在 benchmark 中对比主流方案取得了显著领先。但吞吐量本身不证明错误治理质量,真正被验证的是:

  • 规则错误能否定位到文件、行列和字段。
  • 配置错误能否阻断发布,而不是触发系统故障告警。
  • 数据质量错误能否聚合统计,而不污染系统错误。
  • 运行时错误能否区分可重试、不可重试和需要人工介入。
  • 用户视图、运维视图和调试视图是否来自同一个稳定错误标识。

没有结构化错误时,规则语法错误只是一段字符串 unexpected token at line 12——开发者仍需打开文件、定位行列、猜测问题。引入 Wukong 错误治理后,同一次失败被表达为:

identity : rule.syntax
category : config
context  : { rule_file, line, column, field, expected_token, actual_token }
policy   : block activation, show repair hint, do not page SRE

规则开发者拿到位置和修复线索,运行系统拿到稳定错误标识和策略,运维侧可把配置错误、数据错误、系统错误分开统计和告警。

吞吐越高,失败路径越需要结构化能力。 否则处理能力越强,错误扩散和排障成本也被同步放大。


总结

错误处理排在功能实现、算法性能、代码可读之后——但它决定了系统能不能长期稳定运行。从 1997 年被异常机制吸引,到今天把 Wukong 错误治理模型落地,我花了二十多年。

核心答案就一条:把契约和诊断拆成两条通道,再为不同接收端生成适配输出。 契约通道提供稳定错误标识和分类,让机器做决策;诊断通道保留原因链和上下文,让人能定位。成熟度不取决于选了 Rust 还是 Java,而取决于你的架构有没有四根承重墙——稳定错误标识、有限分类空间、诊断保留、集中边界策略。

当失败路径也具备稳定分类、完整诊断、集中输出和可演进契约时,你的系统才真正从“能跑“走向“可长期运行“。


本文是 Orion 错误治理方法论的主体文档。可运行示例见 orion-error/examples/order_case.rs。方法论、设计原则和迁移规则已沉淀为 engineering skills,见 orion-skills