服务目录元数据:稳定身份、所有权与生命周期
凌晨告警写着“支付网关错误率升高”,值班同学在目录里搜到三个 payment-api:一个来自旧仓库清单,一个来自 Kubernetes 自动发现,另一个由平台团队手工登记。三者的 owner、仓库地址和运行环境互不相同。电话打给清单里的团队后,对方说服务早已移交;真正的生产工作负载没有值班关系,旧条目却仍显示 production。目录没有减少排障时间,反而制造了第四个事实版本。
这类事故并非少填几个字段。根因通常是实体没有跨来源稳定身份、字段没有权威规则、关系被当成任意字符串、发现与人工修改相互覆盖,而且退役只删页面不处理依赖。服务目录真正保存的是一张持续重算的软件知识图:来源产生候选实体,处理器校验并生成关系,目录存储当前投影,搜索和插件再消费投影。任何一步缺少来源、版本或状态,漂亮页面都会把不确定性伪装成事实。
先把目录启用成可验证的系统
如果团队已经运行 Backstage,最短入口是启用 Software Catalog 后端与前端插件,并把第一个受控 catalog-info.yaml 注册为 location。当前稳定主线 1.53.0 支持 Node.js 22 与 24;目录数据在本地试验可使用生成项目的默认数据库,团队共享环境应配置外部 PostgreSQL、备份和连接池。Backstage 本身是 Apache-2.0 许可的开源框架,不附带托管服务的租户隔离、SLA、商业 RBAC 或合规承诺。目录后端、身份提供方和权限策略必须一起启用,因为“能登录页面”不代表“能安全修改目录”。
使用第三方托管发行版时,启用动作通常变成创建组织、配置 SSO、安装 Git App 或 OAuth 集成、授权仓库范围,再选择发现规则。先只开放一个试验仓库和一个只读组织组,不要一开始扫描所有仓库。验收证据是:试验实体能通过完整引用检索,来源地址可追溯,owner 能解析到组织实体,刷新后修改可见,取消来源后进入明确的孤儿或删除流程。多租户隔离、审计导出、细粒度权限、自动发现、备份恢复和 API 配额属于具体供应商套餐与合同边界,不是开源 Catalog 自动具备的商业能力;租户界面的字段名、套餐能力和刷新周期也不能当成长期 API 契约。
Backstage 的实体描述格式采用 Kubernetes 风格信封,常用文件名为 catalog-info.yaml。下面这个入口故意只放最少但足以建立身份和关系的字段:
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
namespace: commerce
name: checkout-api
description: Checkout orchestration service
annotations:
backstage.io/source-location: url:https://example.com/your-org/checkout-api
example.com/runtime-id: k8s:prod:commerce:deployment/checkout-api
spec:
type: service
lifecycle: production
owner: group:default/checkout-team
system: system:commerce/checkout
providesApis:
- api:commerce/checkout-http
dependsOn:
- resource:commerce/orders-dbapiVersion 选择验证和处理契约;kind 决定实体语义,不是展示分类;metadata.namespace + metadata.name 参与逻辑身份;spec.type 是组织内受控词表;spec.lifecycle 表示工程生命周期,而不是最近部署是否健康;spec.owner 是声明输入,处理后的 ownedBy 关系才是插件应读取的规范化结果。Backstage 官方描述格式明确指出,关系是处理器推导的只读结果,消费方不应绕过关系直接猜测 spec.owner 的语义。配置细节可就近对照 Descriptor Format 与 Entity References。
启用后先做五个查询,而不是先装修首页:按完整引用读取实体;读取 relations;查看来源与处理状态;故意写一个不存在的 owner;移除来源并观察实体如何变化。只有这五步都能解释,目录才从网页升级为工程系统。
稳定 ID 不是仓库名或显示标题
目录身份需要区分四个概念。显示标题可以修改;逻辑引用供人和插件使用;来源身份用于判断同一上游对象;内部主键只服务数据库实现。把四者压成 name,仓库改名、服务拆分或跨环境发现时必然冲突。
Backstage 常见逻辑引用是 <kind>:<namespace>/<name>,例如 component:commerce/checkout-api。省略 kind 或 namespace 只是输入简写,不应进入跨系统存储。稳定 ID 设计至少满足:同一对象重复发现得到同一 ID;两个真实对象不会因同名合并;显示名变化不改变依赖;来源迁移时有显式别名;ID 一旦公开便不可静默复用。
环境是否进入实体身份取决于目录抽象层。如果 Component 表示可独立交付的软件单元,开发、预发和生产应是同一组件,环境实例通过 Resource、工作负载实体或注解关联;如果目录直接管理运行时实例,环境必须进入 namespace 或外部键。最危险的折中是有些 provider 按组件建模,有些按部署建模,最后用相同 kind/name 碰运气合并。
Backstage 的 metadata.uid 由数据库在实体首次进入时生成,注销后重新注册同一文件也可能得到新值,官方明确要求不要把它作为外部稳定引用。下面的 governanceId 因而是组织在 Backstage 之外维护的身份注册表字段,不是 Catalog 原生 UID;它可以通过自定义注解投影进实体,但真实主记录、别名唯一约束和迁移审计仍由身份注册表负责:
{
"governanceId": "svc_01H_EXAMPLE",
"entityRef": "component:commerce/checkout-api",
"aliases": ["component:default/checkout-service"],
"externalKeys": {
"git": "repo:your-org/checkout-api",
"kubernetes": "k8s:prod:commerce:deployment/checkout-api",
"cloud": "cloud:account-a:region-a:service/checkout-api"
},
"identityVersion": 3
}governanceId 由组织身份注册表分配且不随显示名变化;entityRef 是当前 Backstage 规范引用;aliases 是自建解析能力,Backstage 核心不会自动把旧引用重定向到新实体;externalKeys 由对应 provider 管理;identityVersion 让合并算法升级可审计。没有外部身份注册表时,应把完整实体引用视为公开契约,并用“新建实体、迁移关系、修复消费者、退役旧实体”完成改名,不能承诺旧 UID 保持不变。
字段权威表决定谁能改什么
多来源目录不能采用“最后写入者获胜”。Backstage 核心用实体引用和 locationKey 阻止不同来源静默接管同一实体,并不会原生地把多个 Provider 的同名实体按字段合并;下面的字段权威表是组织自建聚合 Provider、Processor 或外部治理服务的设计。Git 适合维护设计意图,组织目录适合维护团队身份,值班系统适合维护轮值,运行平台适合维护已部署实例,安全扫描器适合维护带时效的结果。没有实现冲突存储和来源 lineage 前,应让同一实体只有一个根来源,其他系统通过关系或独立实体补充证据。
一张可执行的字段权威表至少包含以下列:
| 字段 | 首要来源 | 可接受备援 | 人工覆盖 | 冲突动作 | 最大年龄 |
|---|---|---|---|---|---|
| 逻辑引用 | Git 注册策略 / 平台 ID 服务 | 无 | 禁止 | 隔离实体 | 不适用 |
description | Git 描述文件 | 人工补充 | 可限时覆盖 | Git 合并后解除 | 随代码评审 |
owner | 组织目录映射 + Git 声明 | 服务交接工单 | 仅紧急限时 | 阻止发布或告警 | 一个同步周期 |
lifecycle | Git 声明 + 发布策略 | 资产流程 | 受审批覆盖 | 取更保守状态 | 一个同步周期 |
| 运行实例 | Kubernetes / 云 provider | 部署系统 | 禁止 | 保留两条证据并告警 | 数个发现周期 |
| 值班 | 值班平台 | 团队联系渠道 | 禁止复制排班 | 显示未知 | 一个值班同步周期 |
| 质量结果 | 原始扫描器 | 已签名缓存 | 不可直接改结果 | 显示 stale/error | 规则自己的窗口 |
“人工覆盖”必须是独立记录,而不是直接修改最终投影。记录应含 field、目标实体、覆盖值、申请人、审批人、原因、创建时刻、到期条件和关联工单。读取时按优先级计算投影;到期后自动回到权威来源,并产生变更事件。永久覆盖等于偷偷创建第二个权威系统,应转成上游修复或正式改变权威表。
字段级 lineage 也不可省略。页面展示 owner 时,应能回答值来自哪个 provider、哪个外部对象、哪次同步、采用了哪条映射规则。否则冲突只会变成“页面错了”,平台团队无法证明是源错、映射错、缓存旧还是人工覆盖未到期。
关系必须解析成有类型的双向边
字符串 dependsOn: orders 对人似乎够用,对图计算却不够。目录需要完整目标引用、关系类型、来源和解析状态。dependsOn 与 dependencyOf、providesApi 与 apiProvidedBy、partOf 与 hasPart 应形成可验证的双向边;目标不存在时保留悬空边证据,但不能伪装成已解析关系。
关系来源也有权威差异。代码描述文件声明“设计依赖”,运行时拓扑发现“观察依赖”,调用链推断“近期通信”。三者不应覆盖成一条无来源的 dependsOn。可以分别使用 declaredDependsOn、observedCalls 或关系属性记录 source、observedAt、confidence。只有经过治理的稳定关系进入架构图,短期观测用于排障叠加层。
owner 是关系,不是权限。Backstage 对 owner 的定义是最终负责且有能力维护该实体的单一 User 或 Group;它主要帮助人找到责任方,不能自动成为生产授权依据。运行时 RBAC、云 IAM 和部署审批必须读取各自权限系统。把 ownedBy 直接映射为生产管理员,会让一次元数据提交变成越权通道。
服务移交时要同时变更:新 owner 关系、代码库审批规则、值班队列、成本中心、告警路由和高风险操作授权。目录可以聚合这些状态,却不能假设改一个字段已完成交接。移交窗口可短暂保留 supportingTeam,但最终责任 owner 仍应唯一;两个 owner 往往等于无人拥有。
发现、处理器与人工登记如何共同工作
自动发现适合覆盖规模,人工登记适合启动和例外,Git 描述文件适合让 owner 在代码评审中维护设计元数据。三条链路可以共存,但必须先定义连接键和覆盖顺序。
Backstage 把 Entity Provider 放在目录边缘:provider 实例拥有自己稳定命名的 bucket,可以做全量或增量 mutation;processor 在处理循环中校验、转换并发射子实体。官方 External Integrations 建议多数外部系统优先使用 provider,并强调 provider 名称必须跨重启稳定。provider 改名会看起来像旧来源消失、新来源重建,可能制造重复和孤儿。
一个稳妥接入顺序是:
Git provider 发出 Component 根实体和设计字段。组织 provider 发出 Group、User 及成员关系。Kubernetes 或云 provider 发出 Resource / 工作负载实体,以外部键关联 Component。
processor 规范化引用、验证词表、生成双向关系。字段解析器按权威表合成投影,并保留冲突。人工登记只能创建临时候选或有限字段覆盖,不能改内部主键与 provider 外部键。
全量刷新适合上游可一次列出所有对象的系统。Provider 应先完成所有分页、校验集合完整性,再提交一次 full mutation;Backstage 会在 bucket 内计算差异,而把半截结果提交为 full 会把缺失项解释成删除并触发 eager deletion。增量刷新适合可靠 webhook 或变更游标,使用 delta 明确 upsert/delete,持久化 cursor、幂等处理 event ID,并定期用全量盘点修复漏事件。扫描频率越高,上游 API 配额、数据库写放大和处理队列成本越高;频率越低,目录新鲜度越差。刷新周期应从目录 SLO 和上游容量倒推,而不是填一个整齐数字。
本地正反实验:看见稳定身份与冲突
下面的 Node.js 程序不依赖第三方包,保存为 catalog-model.mjs 后可直接运行。它把 Git 与运行平台候选归并到稳定 ID,按字段权威合成结果,并故意加入一个同名但外部键不同的实体。正向输入证明重复发现不会重复计数;反向输入证明仅按 name 合并会吞掉真实对象。
const candidates = [
{ source: 'git', externalKey: 'repo:your-org/checkout-api', kind: 'Component', namespace: 'commerce', name: 'checkout-api', owner: 'checkout-team', lifecycle: 'production' },
{ source: 'runtime', externalKey: 'k8s:prod:commerce:deployment/checkout-api', kind: 'Component', namespace: 'commerce', name: 'checkout-api', owner: 'platform-team', lifecycle: 'production', linkTo: 'repo:your-org/checkout-api' },
{ source: 'git', externalKey: 'repo:your-org/legacy-checkout', kind: 'Component', namespace: 'legacy', name: 'checkout-api', owner: 'legacy-team', lifecycle: 'deprecated' },
];
const authority = { owner: 'git', lifecycle: 'git' };
const uidByExternalKey = new Map();
let nextUid = 1;
function uidFor(candidate) {
const canonical = candidate.linkTo || candidate.externalKey;
if (!uidByExternalKey.has(canonical)) uidByExternalKey.set(canonical, `ent-${nextUid++}`);
const uid = uidByExternalKey.get(canonical);
uidByExternalKey.set(candidate.externalKey, uid);
return uid;
}
const entities = new Map();
for (const candidate of candidates) {
const uid = uidFor(candidate);
const current = entities.get(uid) || { uid, sources: [], conflicts: [] };
current.sources.push(candidate.externalKey);
for (const field of ['kind', 'namespace', 'name', 'owner', 'lifecycle']) {
if (current[field] === undefined || authority[field] === candidate.source) {
current[field] = candidate[field];
} else if (candidate[field] !== undefined && current[field] !== candidate[field]) {
current.conflicts.push({ field, kept: current[field], rejected: candidate[field], source: candidate.source });
}
}
entities.set(uid, current);
}
const badMerge = new Map(candidates.map(item => [item.name, item]));
console.log(JSON.stringify({ stableEntities: [...entities.values()], badNameOnlyCount: badMerge.size }, null, 2));
if (entities.size !== 2) throw new Error('stable merge failed');
if (badMerge.size !== 1) throw new Error('negative case did not expose name collision');执行 node catalog-model.mjs 后,预期 stableEntities 有两个元素:commerce 下的实体包含两个 source,owner 保持 checkout-team,并记录 runtime 候选 owner 冲突;legacy 实体独立存在。badNameOnlyCount 为 1,明确暴露按名称合并把两个真实实体压成一个。若第一项 owner 变成 platform-team,说明权威规则失效;若实体数为三,说明外部键关联没有归并;若没有 conflict,说明系统在静默丢证据。
把模型接入项目时,先将 authority 迁移为版本化配置,将 uidByExternalKey 放入有唯一约束的持久化表,再把冲突写入独立队列。唯一约束至少覆盖 (provider, external_key) 和当前 entity_ref;合并事务必须同时写实体、外部键别名、字段 lineage 和审计事件。仅在内存里运行的模型能够证明算法分支,不能证明生产数据库并发、权限和恢复能力。
漂移检测要比较“期望、观察与投影”
漂移不是任意两个来源值不同。Git 声明三副本、运行时短暂出现两副本可能是发布过程;目录 owner 与组织目录不同则可能是尚未完成的移交。每类字段要定义比较对象、容忍窗口和升级动作。
建议保留三层值:desired 表示设计或策略期望,observed 表示运行系统观测,resolved 表示目录当前展示。漂移记录包含字段路径、左右来源、首次发现、最近确认、连续次数和抑制原因。第一次差异进入 pending;超过容忍窗口进入 active;修复后进入 resolved 并保留一段审计历史。这样瞬时发布波动不会淹没真正失配。
典型漂移判据包括:声明 owner 无法解析到有效 Group;生产生命周期实体连续多个发现周期没有任何生产工作负载;运行工作负载没有对应组件;仓库已归档但实体仍为 production;实体声明 API,API 实体却不存在;依赖目标退役后调用关系仍持续出现。每个判据都应产出实体引用、来源、最近成功时间和修复入口,而不是只报“元数据不完整”。
人工覆盖同样参与漂移。上游已修复而覆盖仍存在时,应提示“覆盖可清除”;覆盖到期后上游仍未修复,应恢复权威值并升级工单,不能悄悄续期。覆盖数量、平均年龄和永久覆盖比例是平台债务指标。
孤儿不是立刻删除,而是可解释的中间状态
Backstage 必须先区分三条看似相同的“来源消失”。Processor 不再发射某个子实体且没有其他父边时,子实体才进入 orphan;Provider 通过 full/delta mutation 删除根实体时会触发 eager deletion;远端文件暂时 404、YAML 损坏、网络失败或权限收缩属于处理错误,不会自动把实体标成 orphan,Catalog 会保留最近一次无错误的最终投影并暴露错误。把三者混成“没扫到就孤儿”会让一次分页故障演变成批量删除。
Backstage 的实体处理链会在处理器不再发射子实体时添加 backstage.io/orphan: 'true' 注解。默认 orphanStrategy: delete 会自动清理它;若团队要使用下面的人工状态机,必须显式配置 catalog.orphanStrategy: keep。另一个开关 catalog.orphanProviderStrategy: keep 只处理“曾经注册的 Provider 如今不再配置”的情况;它不是 full/delta 删除的回收站,也不会撤销 Provider 已提交的 eager deletion。其 Catalog 配置、The Life of an Entity 与 External Integrations 分别给出开关、处理图和 mutation 语义。
catalog:
orphanStrategy: keep
orphanProviderStrategy: keep孤儿处理可按以下状态推进:
| 状态 | 进入条件 | 可见行为 | 退出条件 |
|---|---|---|---|
suspected | 一次来源缺失或读取失败 | 保留原投影,标注来源异常 | 来源恢复或连续缺失 |
orphaned | 超过来源容忍窗口 | 搜索降权,通知 owner,阻止新依赖 | 重新绑定来源或批准退役 |
quarantined | 身份冲突或来源不可恢复 | 禁止自动写操作,保留关系快照 | 管理员拆分、合并或退役 |
deleted | 保留期结束且无阻塞引用 | 仅保留审计墓碑 | 不复用旧治理 ID |
孤儿计时必须基于“最近一次成功完整刷新”,不能基于某次失败任务的当前时钟。全量 Provider 只拉到一半时必须放弃 mutation,不能把半截集合提交给 Catalog。删除前要检查入边:仍被生产组件依赖、仍有活跃工作负载、仍接收流量或仍绑定值班的实体不能自动删除。
可复制的删除与恢复实验应使用专用测试 Provider bucket 或测试 Location。正向路径先登记 component:default/orphan-lab,查询并保存实体 JSON、metadata.uid、backstage.io/managed-by-location、关系和处理状态;随后让 Processor 的父 Location 不再发射它,在 orphanStrategy: keep 下预期实体仍可查询且出现 backstage.io/orphan: 'true'。反向路径只把远端 YAML 改成非法内容,预期处理状态报错、最终实体仍保留上一次正确投影且不出现 orphan 注解。恢复时修复 YAML 或重新加入父 Location,触发 Refresh,预期 orphan 注解和处理错误消失;清理时注销测试 Location,再确认实体 API、关系查询、搜索索引和未处理记录都收敛。不要在共享 Provider 上用半截 full mutation 做实验,因为那验证的是立即删除而不是孤儿恢复。
合并冲突必须失败得足够响亮
目录常见冲突有三类。身份冲突是两个候选声称同一外部键;引用冲突是两个治理 ID 想占用同一 entityRef;字段冲突是不同来源给出不同值。身份和引用冲突应隔离并停止自动覆盖,字段冲突则按权威表决定展示值,同时保留被拒候选。
不要在冲突发生时自动选择“更新者”或“更完整者”。恶意或误配 provider 可以通过补更多字段夺取实体。冲突修复应要求操作员选择:确认同一对象并合并别名;确认不同对象并重命名引用;确认来源错误并修复 provider。自建治理层合并后保留 loser governance ID 到 winner governance ID 的永久重定向,迁移所有关系时使用事务,并防止循环别名;Backstage Catalog 本身不提供这套别名注册表。
排障时按证据链反推:
| 现象 | 第一证据 | 常见原因 | 修复后判据 |
|---|---|---|---|
| 页面出现两个同名服务 | 完整引用与外部键 | namespace 丢失、provider 改名 | 两实体确为不同对象,或别名稳定归并 |
| owner 每次刷新来回跳 | 字段 lineage | 最后写入获胜 | 同一输入连续刷新结果不变 |
| 实体突然大批消失 | Provider mutation 与完整刷新状态 | 半截 full、Token 权限缩小 | 从权威源重发完整集合,实体数回到基线 |
| 关系指向不存在实体 | unresolved edge 列表 | 简写引用解析到错误 namespace | 目标引用可读取,反向边存在 |
| 改名后插件链接失效 | alias 与迁移审计 | 直接改实体引用 | 消费者改用新引用且治理 ID 不变 |
修复后至少连续运行多个完整刷新周期,确认实体数、冲突数和孤儿数回到稳定基线。只看一次页面刷新,无法证明异步处理队列和下一次 provider 运行不会把错误写回来。
生命周期是状态机,不是自由文本标签
experimental、production、deprecated 只有进入条件,没有退出动作,就会退化成彩色徽章。组织应维护有限词表和状态机,例如 proposed -> experimental -> production -> deprecated -> retired。每次转换需要触发者、证据、批准规则和副作用。
experimental -> production 可要求有效 owner、生产值班、运行手册、关键依赖和基础质量证据;production -> deprecated 要给替代方案、迁移负责人和禁止新依赖规则;deprecated -> retired 要证明流量归零、生产实例清零、消费者迁移、凭证撤销、数据保留完成。紧急下线可以跳转,但必须产生事后补录任务。
生命周期不是运行健康。生产服务故障仍是 production,只是在健康状态上 error;已退役实体也可能保留历史 SLO 和依赖墓碑。把故障自动改成 deprecated 会破坏治理语义,把仓库归档等同 retired 则会漏掉运行资源。
退役顺序应遵循“阻止新增、迁移消费、停止流量、删除运行资源、撤销凭证、处理数据、归档代码、留下墓碑”。目录先将实体标为 deprecated 并阻止模板创建新依赖;关系图列出消费者;所有阻塞项清零后才能进入 retired。删除只发生在审计保留期之后,且旧稳定 ID 永不复用。
回滚同样要显式。误退役时恢复实体当前状态、路由和凭证不是一个动作,平台应记录每个外部系统的补偿结果。若生产流量尚未停止,可以把目录状态恢复到 deprecated;若数据已销毁或凭证已撤销,则必须按重新上线流程重建,不能只改标签。
权限、凭证与敏感元数据
目录读取权限看似低风险,实际上能暴露仓库位置、技术栈、依赖拓扑、漏洞状态、值班人员、内部控制台和云资源标识。实体字段应分级:公开组织元数据、内部工程元数据、受限运行元数据、敏感安全证据。搜索索引、导出、插件缓存和通知消息必须继承字段级策略,不能只保护详情页。
Git 集成优先使用安装型应用或短期令牌,只授权选定仓库和只读 metadata/content 权限;组织同步令牌只读用户与组;运行发现使用只读 ServiceAccount,并按集群或账号拆分。写入目录、注册 location、批准覆盖、合并实体、执行退役应是不同权限。Backstage 权限框架需要插件后端真正执行授权,隐藏按钮不构成保护;其 Permission Overview 也提醒默认端点不会自动全部受保护。
凭证不得进入实体注解、处理状态、任务错误或前端响应。provider 日志只记录凭证引用名和上游状态码;调试抓包必须脱敏 Authorization、Cookie 和仓库私有地址。托管目录还要核对数据驻留、备份、导出、删除、供应商支持访问和租户退出能力。退出演练应能导出实体、关系、规则、覆盖、豁免和审计,而不依赖供应商私有页面截图。
容量与成本从刷新扇出开始计算
目录成本不只由实体数决定。近似负载可以拆成:来源扫描请求数、每轮候选数、处理器调用数、关系边数、搜索索引写入、插件补充请求和历史证据保留。若每个实体处理时再同步调用多个外部 API,会形成 实体数 × 处理器数 × 外部调用数 的扇出,并触发配额和长尾延迟。
provider 应优先批量读取、ETag、增量游标和受限并发。processor 不适合对每个实体发起昂贵远程请求;将原始数据批量同步到 provider 或证据缓存后再处理更稳定。数据库容量至少估算实体当前投影、未处理实体、关系、搜索索引、冲突、审计和墓碑;关系边往往比实体增长更快,调用观测边更应设置时间窗口和聚合策略。
托管平台成本常与座席、实体、集成、自动化执行或高级治理能力相关,自托管成本则落在数据库、计算、对象存储、备份、升级和平台团队工时。选型时用“每个活跃实体每月维护成本”“每千次刷新上游调用”“每次目录事故平均定位时间”比较,避免只比许可单价。目录不能通过无限缩短刷新周期购买新鲜度;上游 webhook、批处理和按字段 SLO 更经济。
目录 SLO 证明地图没有过期
目录页面可打开只是门户可用性,不代表数据可信。目录 SLO 至少分成摄取、处理、完整性、可解析性和变更交付五类:
摄取新鲜度:权威来源变化到候选数据成功进入目录的延迟分布。处理延迟:候选进入队列到 stitched 投影可查询的延迟分布。来源成功率:各 provider 完整刷新成功率,部分分页失败单独计数。
身份完整性:活跃实体中具有稳定外部键且无引用冲突的比例。owner 可解析率:生产实体的 ownedBy 指向有效 Group 的比例。关系可解析率:需要目标的边中成功解析并有反向边的比例。
孤儿年龄:孤儿数量及其年龄分布,而非只看当前总数。变更交付:合并元数据修改到目录展示新值的延迟。
阈值应来自业务恢复目标和平台基线。支付生产组件的 owner 与依赖可以要求更紧的时效,历史库不必同级。告警要带 provider、最后成功刷新、失败阶段、影响实体数和是否仍在服务旧投影。上游失败时继续展示最后成功投影是可接受降级,但页面必须标注 stale,不能继续声称“当前”。
长期看板同时展示分母。owner 可解析率 99% 如果活跃实体分母突然减少一半,可能是发现系统坏了而非质量提升。应固定观察活跃来源实体、隔离实体、孤儿、退役墓碑和排除项。每次 provider 升级先影子运行:比较旧、新候选集合与字段差异,达到预算后再切换;回滚时恢复 provider 版本和连接游标,不覆盖已确认的身份别名。
项目接入与长期维护的闭环
项目仓库接入时,把目录描述文件纳入代码评审,并用 schema 校验、引用解析和词表检查做 CI 门禁。CI 只验证候选,不直接写最终目录;合并后由 provider 摄取,目录生成处理状态。模板新建服务时应生成稳定 namespace、owner 引用和系统关系,但 owner 仍需在评审中确认,不能从创建者身份永久推断。
团队推广可分三步。先只读盘点,用自动发现建立候选而不影响发布;再让 owner 认领并修复身份、关系;最后才把关键字段和生命周期门禁接入模板、发布与退役流程。平台团队负责模型、provider、SLO 和冲突工具,服务团队负责描述文件与修复,安全和合规团队负责受限字段规则。无人承担字段权威表维护时,目录会再次退回共享表格。
升级前导出实体、外部键、别名、关系、覆盖和 provider cursor,影子比较新旧处理结果。数据库回滚必须考虑迁移兼容;若新版本已写入旧版本不识别的状态,不能只回滚容器。插件升级也要检查它读取的是关系还是原始字段,是否绕过字段权限,是否扩大外部 API 扇出。
清理试验环境时,先停 provider 与处理队列,再删除试验 location、候选实体、关系、覆盖和测试数据库;撤销 Git App 安装或令牌;确认搜索索引和插件缓存不再返回实体。生产退役则不能使用同一“清空数据库”动作,而应走生命周期状态机。最终验收不是目录里没有红色提示,而是任意抽取一个生产实体,都能从稳定 ID 追到权威来源、有效 owner、可解析关系、当前生命周期和最近成功刷新证据。
