Milvus 错误哨兵约定(Error Sentinel Convention):typed merr 与内部哨兵的两层体系与 gRPC 边界硬性不变量
2026/9/10 20:04:34 网站建设 项目流程

Milvus 错误哨兵约定(Error Sentinel Convention):typed merr 与内部哨兵的两层体系与 gRPC 边界硬性不变量

【免费下载链接】milvusMilvus is a high-performance, cloud-native vector database built for scalable vector ANN search项目地址: https://gitcode.com/GitHub_Trending/mi/milvus

本篇文章系统讲解 Milvus 服务端错误处理的两层体系——携带数字错误码、可跨 gRPC 传输的 typed merr(wire-protocol errors),与仅用于单进程内控制流的内部哨兵(internal sentinels),并给出二者之间必须遵守的errors.Is硬性不变量、命名约定、错误码段分区、仓库现状审计结果以及未来的静态检查(linter)规划。读完本文,你将掌握在 Milvus 源码中"何时该创建merr.WrapErrXxx、何时该定义errors.New哨兵、何时必须用返回值携带信号"的完整决策依据,并能在提交代码前自行对照审计。

本文是 Milvus 错误处理规范三部曲的核心:规则与决策树见 docs/dev/error_handling_guide.md(日常 how-to),真实正反例见 docs/dev/error_handling_casebook.md(那些能通过 review 的典型错误)。

一、Milvus 错误处理的两层体系

Milvus 的错误处理存在两个截然不同的层次,本规范的核心目标就是把它们彻底分开:

1. Typed merr:wire-protocol 错误

定义于 pkg/util/merr/errors.go(ErrCollectionNotFoundErrParameterInvalid等)。这些错误携带一个数字错误码,会被序列化进commonpb.Status{ErrorCode, Reason},并通过 gRPC 传给客户端。它们是客户端(以及任何处于 RPC 接收端的 Milvus 组件)唯一能看到的东西。

创建方式为merr.WrapErrXxxMsg(...)merr.WrapErrXxxErr(cause, ...)。注意:这些工厂函数只用于错误发源地(origination)——绝不用于给一个已存在的 typed merr 追加上下文(追加上下文请用merr.Wrap,详见下文"两条易混淆的规则")。

2. Internal sentinels:单进程内控制流

使用包作用域的errors.New(...)创建(例如errIgnoredAlterAliaserrReleaseCollectionNotLoadederrNodeNotEnough)。它们是单个 Go 进程内部的"信号词汇":被调用方通过它们告诉调用方"这是一次幂等空操作"或"队列为空",调用方据此分支、重试或忽略。它们不属于wire 协议。

捕获方总是位于调用栈的某个边界处,通过errors.Is(err, sentinelX)判断,该边界随后二选一:

  • 翻译为merr.Success()(幂等语义:例如 drop 一个不存在的东西 → 视为成功);
  • 翻译为 typed merrmerr.WrapErrXxxMsg(...)(例如用户已存在 →WrapErrParameterInvalid)。

二、硬性不变量:哨兵绝不允许越过任何 gRPC 边界

任何内部哨兵必须在越过 gRPC handler 边界之前,被errors.Is捕获并翻译成 typed merr(或Success)——无论是面向客户端的边界,还是组件与组件之间的边界。

为什么是"任何 gRPC 边界"而不只是客户端边界?因为 gRPC 会把错误序列化为commonpb.Status{ErrorCode, Reason}errors.New哨兵赖以工作的 Go 指针身份(pointer identity)不会穿过网络。对端用merr.Error(status)从数字码重建 typed merr 时,哨兵链已经永远消失。因此,一个从内部 coord RPC 逃逸的哨兵,与一个从用户 RPC 逃逸的哨兵一样是缺陷——只是更隐蔽,因为不会有客户看到由此产生的Code=65535 (unexpected)。从源码看,pkg/util/merr/utils.go 的Error(status)只依据Status.Code重建milvusError,并只恢复Retriableis_input_error标记,这正是"哨兵链不可复原"的机制根源。

什么会破坏errors.Is

errors.Is链可以安全地穿过return errerrors.Wrap(err, "...")/merr.Wrap(err, "...")(cockroachdb 薄封装),以及merr.WrapErrServiceInternalErr(err, "...")——因为milvusError.Unwrap()返回内部错误,所以errors.Is(outer, innerSentinel)在这些情况下始终为 true(见 pkg/util/merr/errors.go 中milvusErrorinner字段与Unwrap()实现)。

会在以下情况被摧毁:

  • 把 cause 塞进格式化参数而不是错误链merr.WrapErrXxxMsg("...: %s", err),或%w错误用法(WrapErr*Msgfmt.Sprintf格式化,%w,会渲染成%!w(...))。内部错误进入了消息文本,但无法通过Unwrap()触达,于是errors.Is(outer, innerSentinel)返回 false。配套文档 docs/dev/error_handling_casebook.md 的 Pattern 4 专门记录了此类缺陷:Status.Reason里的审计痕迹看起来一切正常,这正是它能屡屡通过 review 的原因。
  • 任何未实现Unwrap()的自定义包装器

不要与"码/可重试性"规则混淆

merr.Wrap(err, ...)merr.WrapErrXxxErr(err, ...)都能保住errors.Is,但它们在边界上报的内容不同:

  • merr.Wrap(err, ...)保留内层错误的Code()IsRetryable——链最终解析到内层的*milvusError
  • merr.WrapErrXxxErr(err, ...)上报外层哨兵的码与可重试性:As()解析到ErrServiceInternal(Code 5,不可重试),掩盖了内层的分类。

所以存在两条经常被混为一谈的规则:

  1. 要让errors.Is持续有效:绝不要把 cause 塞进格式字符串,而要作为 error 参数传入。
  2. 要加上下文且不改分类(保留内层码 + 可重试性):用merr.Wrap,而不是merr.WrapErr*Err。只有当你有意断言一个新的分类(例如这确实就是 service-internal 错误)时才用merr.WrapErrXxxErr

从 pkg/util/merr/errors.go 的wrapInner实现可以看到机制:它把外层哨兵的msg/detail替换为上下文消息并挂上inner,因此Code()IsRetryableErr均报告外层哨兵,而Unwrap()保留内层——这正是"掩盖分类但保留链"的底层原因。

三、命名约定:两层错误,两套命名

Wire 层(typed merr)——大写Err*,只能存在于pkg/util/merr

所有可能越过任何 gRPC 边界(客户端或组件间)的错误,都必须是定义在 pkg/util/merr/errors.go 中的*merr.milvusError。它们具备:

  • 传入newMilvusError(...)的数字码;唯一性由 init 期代码注册表强制(在同一码上重复定义第二个哨兵会在包初始化时 panic,因为milvusError.Is仅凭码匹配,见下文);
  • 在 pkg/util/merr/errors.go 中的var ErrXxx = newMilvusError(...)声明;
  • 导出的WrapErrXxxMsg/WrapErrXxxErr工厂函数。

如果某个错误需要出现在 wire 上,它就必须住在这里。没有例外。

每个哨兵还带有一个 Input-vs-System 分类(责任在谁),它驱动Status.Retriablecause指标标签、proxy 的 lb_policy 故障转移与retry.Do。分类的判定规则、误分类的代价与边界标记机制(WrapErrAsInputErrorWhen),详见 docs/dev/error_handling_guide.md 的 "Input vs System: who is to blame?" 一节。

码段分区

错误码按家族分配。新增哨兵前,先扫描注册表(grep -nE "= newMilvusError\(" pkg/util/merr/errors.go),把新码放进所属家族区间内——init 期注册表只会在重复时 panic,它无法告诉你 1305 属于 MQ 家族。不要为一个一次性错误开新区间;多数"新"错误都能装进现有家族或现有哨兵(标准化工作中ErrSegcoreErrMqInternal都险些被重复发明)。

区间家族区间家族
1–99服务级(NotReady1、Unavailable2、Internal5、…)1300–1399MQ
100–199collection1400–1499privilege / RBAC
200–299partition1600–1699alias
300–399resource group1700–1799field
400–499replica1800–1899HTTP / REST gateway
500–599channel1900–1999replicate / CDC
600–699segment2000–2099segcore + knowhere(cgo;表驱动,见下)
700–799index2100–2199import
800–899database2200–2299query / requery plan
901–999node2300–2399compaction
1000–1099io / storage / serialization / data integrity2400–2499function pipeline(ErrFunctionFailed2400)——但ErrDataNodeSlotExhausted是 2401,添加前先查占用
1100–1199request parameter2500–2599KMS
1200–1299metrics2600–2699snapshot
3000+misc(ErrOperationNotSupported3000、ErrOldSessionExists3001)

65535(1<<16)-1)是errUnexpected——没有 merr 码的错误落网时的 wire 回退码。它是保留值,绝不允许故意起源(源码中它被注释为 "Do NOT export this, never allow programmer using this",见 pkg/util/merr/errors.go)。

2000–2099 的 segcore 区间由 cgo 转换表拥有:必须走merr.SegcoreError(pkg/util/merr/utils.go),它查询 pkg/util/merr/segcore.go 中的码/可重试性表——不要手工挑选区间内的数字(见 casebook 的 Pattern 7)。该表同时定义了 C++ 码到 Go 哨兵的映射、InputError 标记(2020/2023/2025/2026/2028/2031/2032/2042/2007/2021/2022 等)与可重试系统错误(2012/2014/2015/2018/2027/2034/2036/2043/2045 等),未注册码回退到非可重试的ErrSegcore

milvusError.Is按码匹配——两个后果

milvusError.Is的实现仅比较errCode(见 pkg/util/merr/errors.go),由此产生两个推论:

  1. 一个码只能有一个哨兵。共享一个码的两个哨兵将errors.Is相等;init 期注册表 panic 就是为了让这种状态不可表达(newMilvusError中的registeredCodes查重)。
  2. 把内部errors.New哨兵提升为 merr 会放宽所有 guard。裸哨兵按指针身份匹配;merr 按码匹配。转换后,errors.Is(err, thatSentinel)会静默匹配任何携带同码的错误。转换前必须执行grep -rn "errors.Is(.*<sentinelName>"并审计每一个命中(casebook 的 Pattern 6 记录了这个规则来源的>// errFull / errNoSuchElement are INTERNAL sentinels: caught by errors.Is // inside the compaction inspector / scheduler loop and never serialized // across any gRPC boundary. var ( errFull = errors.New("compaction queue is full") errNoSuchElement = errors.New("compaction queue has no element") )

    跨包幂等性:用返回值标志,而不是导出哨兵

    旧代码导出了meta.ErrResourceGroupOperationIgnored,让父包querycoordv2能通过errors.Is捕获并翻译为merr.Success()。这是一个位于internal/...中的导出Err*——与 typed wire 错误merr.ErrXxx视觉上无法区分,极易误用。

    当前代码改为在返回值中编码该信号(见 internal/querycoordv2/meta/resource_manager.go 与 internal/querycoordv2/ddl_callbacks_alter_resource_group.go):

    // meta/resource_manager.go func (rm *ResourceManager) CheckIfResourceGroupAddable(...) (ignored bool, err error) { if proto.Equal(rm.groups[rgName].GetConfig(), cfg) { return true, nil // idempotent no-op } ... } // querycoordv2/ddl_callbacks_alter_resource_group.go (broadcaster) func (s *Server) broadcastCreateResourceGroup(...) (ignored bool, err error) { if ignored, err := s.meta.CheckIfResourceGroupAddable(...); err != nil || ignored { return ignored, err } ... } // querycoordv2/services.go (RPC handler) ignored, err := s.broadcastCreateResourceGroup(ctx, req) if err != nil { return merr.Status(err), nil } if ignored { return merr.Success(), nil }

    没有任何哨兵跨过包边界;信号通过结构化返回值传递。这是任何新的跨包幂等性场景的首选模式。

    四、现状审计:internal/{datacoord,rootcoord,querycoordv2}中的 28 个哨兵

    审计基于 err-std-04-coord 分支(2026-05-19)。28 个errors.New(...)哨兵的命运:

    种类数量示例状态
    幂等:被捕获 →merr.Success()13 个捕获点,约 12 个不同哨兵errIgnoredAlterAliaserrIgnoredCreateCollectionerrReleaseCollectionNotLoadederrUserNotFound、…✅ 合规
    被捕获 → 翻译为 typed merr3 个捕获点(errUserAlreadyExistserrRoleAlreadyExistserrRoleNotExists客户端收到WrapErrParameterInvalidMsg(...)WrapErrServiceInternalMsg(...)✅ 合规(1100 / 5)
    仅后台使用(从不进入 RPC handler)5 个(errFullerrNoSuchElementerrNodeNotEnougherrDisposederrTypeNotFoundcompaction 队列 / resource observer / session 生命周期 / checker 注册表✅ 合规
    通过(ignored bool, err error)签名实现跨包幂等1 个(resource group 创建/删除)meta 层返回ignored=true;querycoordv2 RPC handler 翻译为merr.Success()——没有哨兵跨包✅ 合规(本分支从导出的ErrResourceGroupOperationIgnored重构而来)
    死代码(0 个调用者的函数)3 个(errNilResponseerrNilStatusResponseerrUnknownResponseType——仅被datacoord/util.goVerifyResponse使用)可安全删除🪦 清理候选
    违规:未捕获就逃逸出 RPC handler(已解决)3 个(errEmptyUsernameerrEmptyRoleNameerrEmptyPrivilegeGroupNamemeta_table.go中的起源点现在直接发出WrapErrParameterInvalidMsg;裸哨兵已消失✅ 已解决
    语义误分类:用错误的 typed merr 码捕获并包裹(已解决)1 个(ops_services.go:87,101中的errTypeNotFound客户端传入的非法CheckerID现在包装为WrapErrParameterInvalidMsg(码 1100),原来是WrapErrServiceInternal(码 5)✅ 已解决

    从当前仓库源码可以印证表中"已解决"项:internal/rootcoord/meta_table.go中的errIgnoredAlterAliaserrIgnoredCreateCollectionerrIgnoredDropPartition仍为裸哨兵,并分别被 internal/rootcoord/root_coord.go 的errors.Isguard(第 1010/1644/1936/1997 行附近)捕获翻译为成功;internal/rootcoord/meta_rbac.go定义errUserAlreadyExists/errRoleAlreadyExists/errRoleNotExists,由 internal/rootcoord/root_coord.go 翻译为 typed merr;而 internal/querycoordv2/ops_services.go 的ActivateChecker/DeactivateChecker已改为merr.WrapErrParameterInvalidMsg("invalid checker type %d: %v", req.CheckerID, err)(码 1100)。

    清理状态

    1. ✅ 已完成——errEmptyUsername/errEmptyRoleName/errEmptyPrivilegeGroupName在 internal/rootcoord/meta_table.go 的起源点现在直接发出merr.WrapErrParameterInvalidMsg("username is empty")等;裸哨兵已不存在。
    2. ✅ 已完成——internal/querycoordv2/ops_services.go 第 87、101 行的errTypeNotFound捕获点现包装为merr.WrapErrParameterInvalidMsg("invalid checker type %d: %v", req.CheckerID, err)
    3. 未完成——删除VerifyResponse及其三个死代码哨兵(errNilResponseerrNilStatusResponseerrUnknownResponseType)。

    五、未来 linter 规划

    三个候选方案,按实现成本与强制难度排序。Tier 1.5 的 return 形态现已实现(见下);Tier 1 与 Tier 2 仍在设计队列中。

    Tier 1——导出哨兵禁令(约 1 小时编写)

    最简单的规则:internal/...包不得声明导出的var Err\w+ = errors.New(...)扫描所有internal/...*.go文件,命中即 CI 失败。修复违规有两条路径:

    1. 改成小写(var errXxx = errors.New(...))——仅同包可调用。如果 lint 失败是因为跨包调用者需要该信号,见修复 2。
    2. 重构 API,让信号通过返回值传递(例如在返回元组中加ignored bool)并删除哨兵。

    这使 Go 可见性本身成为强制机制:任何需要看起来像merr.ErrXxx的东西只能存在于pkg/util/merr。内部哨兵在其所属包中安静地保持小写。

    internal/...内的小写哨兵(var errXxx = errors.New(...))仍建议带上INTERNAL: ...文档注释以提供 reviewer 上下文,但这不是强制的——可见性规则已经防止了最坏情况(与merr.ErrXxxErr*冲突)。

    Tier 1.5——裸使用禁令(1 小时 grep;AST 需半天达到 100% 精度)

    状态——return 形态已实现。该禁令的return形态现已由rules.go中的gocritic/ruleguard规则(rawmerrerror)强制执行,随make verifiers运行:它拒绝在函数体里return errors.New / fmt.Errorf / errors.Errorf(包级哨兵、cmd/tests/、codegen 与 walimpls 测试框架豁免)。日常指南见 docs/dev/error_handling_guide.md。下述无例外形态(局部:=panic(...)、函数实参)未被覆盖:ruleguard 的 DSL 无法表达"函数体内任意位置的调用但不在ValueSpec中",所以完整禁令仍需基于 AST 的 Tier 2 linter。

    最严格的强制,无任何例外。internal/...包不得在函数体内内联使用errors.New(...)errors.Errorf(...)。这些调用唯一合法的位置是包级var <Name> = errors.New(...)(哨兵声明)。

    允许/禁止矩阵:

    形态位置判定
    var errInvalid = errors.New("invalid")包级(文件顶部 /var块)✅ 允许
    var ( errA = ...; errB = ... )包级var✅ 允许
    return errors.New(...)函数体❌ 禁止
    x := errors.New(...)函数体局部❌ 禁止
    panic(errors.New(...))函数体❌ 禁止
    foo(errors.New(...))函数体实参❌ 禁止

    为什么不允许例外(即使是今天看起来无害的"局部 break 信号"/"仅日志"/"panic 绑定"场景):

    • 今天的局部变量可能被明天的重构提升为包级,然后静默开始跨越边界。
    • 带例外的 linter 需要 AST 级的 wire 可达性分析(昂贵);无例外 linter 只是一次 grep。
    • 迫使作者使用正确的原语,而不是把errors.New当作万能的逃生舱:
      • 回调中的 break 信号→ 定义一个实现了errortype doneSignal struct{}并使用errors.As。意图体现在类型中,而不是字符串键控的哨兵中。
      • "不可达"断言→ 直接panic(...)。如果调用方已经写了if err != nil { panic(err) },就把它折叠进被调用方。
      • 输入校验 / 配置校验→ 按层次使用merr.WrapErrParameterInvalidMsg(...)status.NewInvalidArgument(...)

    实现:grep 版本约 1 小时覆盖 ~95% 的真实违规。AST 版本(go/analysis)覆盖边缘情况(例如init()体内给包变量赋值),但需约半天。先用 grep,若误报率超过 5% 再升级。

    # grep 骨架 grep -rnE 'errors\.(New|Errorf)\(' internal/ --include='*.go' \ | grep -v _test.go \ | grep -vE ':[0-9]+:\s*(var\s+)?[A-Za-z_]+\s*=\s*errors\.(New|Errorf)' \ | grep -vE ':[0-9]+:\s*[A-Za-z_]+\s+(\w+\s+)?=\s*errors\.(New|Errorf)' # 任何剩余行 = 违规

    一个//nolint:err-bare逃生阀(带必需的理由注释)用来处理真正的例外(少数init()模式、嵌入式errors.Mark用法等)。

    Tier 2——逃逸路径 linter(约 1 天,基于 go/analysis)

    对每个 gRPC handler 方法(匹配internal/{rootcoord,datacoord,querycoordv2}/services.go,root_coord.go,*_handler.go模式,返回类型为(*proto.XxxResponse, error)(*commonpb.Status, error)),追踪 err-return 数据流。任何错误:

    • 传递性地起源于INTERNAL:标记的哨兵,
    • 在没有经过errors.Is(err, internalSentinelX) { ... }分支的情况下到达return Status{Code: merr.Status(err)}return err

    即为违规。报告泄漏处的 file:line。

    这能捕获真正的不变量违规(那 3 个 RBAC 空值哨兵本应被标记出来),而不只是命名卫生。需要 AST 分析;如果再一次向客户端静默输出Code=1的代价很高,就值得做。

    Tier 3(若 Tier 2 已就位则不再必要)——wrap 规则 linter

    扫描merr\.WrapErr[A-Za-z]+Err\(err,(注意:第一个实参是err,不是新的纯字符串起源),并要求 causeerr本身不是 typed merr。这就是feedback_merr_wrap_rule(见下文)按约定强制的内容;Tier 2 会作为副作用捕获其症状(哨兵逃逸)。

    六、相关规则

    • feedback_merr_wrap_rule(本仓库的协作记忆):"给已有 err 加上下文用merr.Wrap/merr.Wrapf,绝不用merr.WrapErr*Err——后者会掩盖内层 typed code 与可重试性(errors.Is链本身通过Unwrap()保留)。"该规则已沉淀为 rules.go 中的merrsentinel检查:禁止把 merr 类型错误存进形似哨兵的变量——因为errors.Is对 merr 是按码比较而非按身份比较,任何同码错误都会命中(真实事故:stale-meta / already-loaded guard 曾把 etcd 写失败当作成功吞掉)。
    • project_errstd_autogen_defects:本分支系列中自动生成的errors.Wrap → merr转换存在三个系统性缺陷;其中缺陷 #2 与 #3 正是违反本文规则造成的直接后果。

    七、与其他文档的分工

    • docs/dev/error_handling_guide.md——日常 how-to:三种错误类型的心智模型、"绝不返回裸错误"的决策树、Input vs System 分类、三种正确写法(起源 / 加上下文 / 哨兵)与边界回退(Code=65535安全网)。
    • docs/dev/error_handling_casebook.md——真实正反例:七个反复出现的错误模式("看起来像校验"并非用户输入、WrapErrXxxErr是重贴标签而非加上下文、cause 进 error 实参而非格式串、InputError 会中止retry.Do、转换哨兵前先 greperrors.Isguard、边界转换是契约等),以及常用易混码速查表。
    • docs/dev/error_sentinel_convention.md(本文主题)——两层体系的分界规则、命名约定、码段分区与 lint 规划,是前两者的规则根基。
    • 权威数字码清单——pkg/util/merr/errors.go 中的哨兵定义(init 期注册表对重复码直接 panic)。注意 docs/archive/milvus-2.0/developer_guides/appendix_d_error_code.md 附录早于 merr,列出的是已废弃commonpb.ErrorCode枚举,而非 merr 码。

    对开发者而言,动手前只需记住三件事:任何要越过边界的错误必须是携带数字码的 typed merr;任何单进程内控制流信号必须是同包可见的小写哨兵并在边界前被errors.Is翻译;任何跨包信号都优先走返回值标志而非导出哨兵。这三条贯穿 Milvus 全部服务端组件,也是本规范全部细节的浓缩。

    【免费下载链接】milvusMilvus is a high-performance, cloud-native vector database built for scalable vector ANN search项目地址: https://gitcode.com/GitHub_Trending/mi/milvus

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询