四级分类:能自动检测的只有前两级
漂移被分成四级。这个分类的价值在于明确说出哪些不检测—— 比含糊地声称「全面监控」诚实,也更有用。
| 级别 | 含义 | 能否自动检测 |
|---|---|---|
| L1 结构漂移 | 表/列还在不在——对象存在性 | ✅ 比对 KB schema_notes.yml 与活库 |
| L2 引用漂移 | 语义模型里引用的表/字段是否还可达 | ✅ 由语义层 provider 计算 |
| L3 语义漂移 | 取值是否还能锚定、关系是否可加 | ❌ 尚未实现(判据上需要活库探测) |
| L4 口径漂移 | 业务的算法改了(如坏账定义变了) | ❌ 不检测,只承接外部声明 |
L3 那一格写的是判据上的可能性,不是现状:要锚定取值得先探测活库,
而这条探测还没接——今天真正实现的检测器只有 L1、L2(SUPPORTED_LEVELS)。
并且请求一个未实现的级别会被明确报错,而不是返回一份空报告:
「L3 干净」与「L3 没跑」在报告上必须是两件事,这正是这一层其余设计的前提。
models.py:36 L1 service.py:49 SUPPORTED_LEVELS service.py:176 UnknownLevel
flowchart LR
LIVE["活库 schema"]:::ext --> D1["L1 结构漂移
表 / 列还在不在"]:::exec
LIVE --> D2["L2 引用漂移
语义模型引用的表 / 字段还可达吗"]:::exec
EXT["外部声明
治理流程 / 人的通知"]:::ext --> D4["L4 口径漂移
业务算法改了,库里没有痕迹"]:::ext
D1 --> ENT["同一条漂移条目
影响面快照 + 同一套处置流程"]:::entry
D2 --> ENT
D4 --> ENT
classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px;
classDef exec fill:#14b8a614,stroke:#14b8a699,stroke-width:1.5px;
classDef ext fill:transparent,stroke:#71717a80,stroke-width:1.5px,stroke-dasharray:4 4;
「本季度的不良率口径改了」这件事,数据库里没有任何痕迹—— 表和列都没变,变的是一句业务定义。任何声称能自动发现它的系统都是在猜。
所以 L4 的设计是承接:接受外部声明(来自数据治理流程或人的通知), 把它记成一条漂移条目,然后走同一套影响面与处置流程。承认边界,然后在这个边界内做到最好。
检测与落库
检测的唯一入口是一个服务类。检测结果会落库——这一点是这次改造的核心: 漂移从「报完即忘」变成了有状态的、可处置的条目。
service.py:91 DriftService service.py:130 detect schema_drift.py:16 detect_drift
存储层有两条值得单独说的设计。第一,幂等:以
(datasource, level, kind, subject) 做唯一键,重检只刷新「最后见到时间」与
「见过几次」,不会产生重复条目。
store.py:63 _SEMANTIC_DRIFT_SQL store.py:105 _migrations
第二,已解决的条目再次被检出 = 回归,会重新打开为 open。
这一条很关键:如果没有它,「已解决」会变成一个谎言——
修好的问题又出现了,而系统还显示一切正常。
被显式豁免(waived)的条目则保持豁免,不再打扰。
后台巡检:serve 自己只会跑 L1
trove serve 带一条后台巡检(retention.sweep_interval_hours,
默认 24 小时)。它挂在记忆子系统的生命周期清理上,顺带跑一次
detect_schema_drift()(开关 memory.schema_drift_check,默认开)
——只跑 L1:拿 KB 的 schema_notes.yml 比活库列集合。
想跑 L2(语义引用)得有人在 API 或 check_drift.py 上显式触发。
两个细节决定这条巡检有没有用。第一,它和 /v1/admin/drift
读写同一个 drift 库(组装时注入同一个 DriftStore)——
两处各建各的库,表现就是「巡检跑了,管理端一条也看不到」。
第二,落库的是全部数据源,返回的只是有漂移的那些:
干净的一次也要留痕,否则历史里只剩有漂移的 run,读起来像这个库一直在漂。
app.py:245 run_lifecycle memory/service.py:465 detect_schema_drift main.py:301 drift_store
检测没跑完 ≠ 没有漂移
这是最容易出事的地方。拿不到活库 schema 时,系统不会报告「干净」,
而是标记为 skipped——本次检测只写一行运行记录,不碰任何条目。
models.py:185 levels_verified models.py:187 verified
报告里带一个「哪些级别真的被验证过」的集合,调用方可以据此区分 「检查过且没问题」与「没检查成」。把「不知道」和「没问题」分开, 是这个模块里最值得学的一个设计。
条目状态与处置
| 状态 | 含义 |
|---|---|
| open | 检出,待处理 |
| acknowledged | 已看到,处理中 |
| resolved | 已解决(再次出现会重开) |
| waived | 已知且接受,不再提醒 |
models.py:43 STATUS_OPEN service.py:240 resolve service.py:261 declare_external
resolve 强制要求一个 reason,代码里的原话是「无理由的 resolve 等于删记录」。
这条约束防的是一种很常见的退化:把「关掉告警」当成「解决问题」。 半年后有人问「这个漂移当时怎么处理的」,理由字段是唯一的答案。
影响面:发现时冻结的快照
一条漂移的危害程度取决于有多少东西依赖它: 哪些指标引用了消失的列、哪些示例用了它、哪些规则提到它。
| 维度 | 内容 |
|---|---|
| metrics | 受影响的指标 |
| examples | 引用了它的 KB 示例 |
| rules | 提到它的规则 |
| lessons | 相关的教训 |
models.py:122 metrics service.py:225 impact
影响面读的是发现时冻结的快照,不实时重算。因为「当时影响面有多大」 是处置决策的依据,事后重算会随着 KB 的修改而漂移,让记录失去意义。
上面这张表对漂移条目而言目前仍是契约,不是现有数据。
四个维度、ImpactSet 形状、读取 API(impact())都已实现,
并有测试正面钉住;填充它的 ImpactResolver(drift/impact.py)
也已存在,并被语义变更评审消费——变更详情里的影响面就是它现算的。
但漂移检测的落库路径仍未接线:落库时
store.record(report) 不传 impact=,store.py
于是回退到空 ImpactSet,affected 列因此恒为空
(四个维度都是空列表)。
所以漂移条目上的「哪些指标引用了消失的列」今天仍问不出来,任何按影响面排序或过滤
漂移的界面读到的都是空集——要等检测侧落库时传入 impact= 才有答案。
这里冻结的是空快照,与变更评审现算的影响面不是同一条数据,不要混看。
API 与门禁脚本
| 端点 | 作用 |
|---|---|
| POST /v1/admin/drift/check | 跑一次检测 |
| GET /v1/admin/drift | 列出条目 |
| GET /v1/admin/drift/{id} | 单条详情 + 影响面快照 |
| GET /v1/admin/drift/runs | 运行历史(含 skipped 的轮次) |
| POST /v1/admin/drift/{id}/resolve | 解决(需 reason) |
| POST /v1/admin/drift/{id}/waive | 豁免 |
| POST /v1/admin/drift/external | 登记外部声明的漂移(L4 入口) |
端点都挂在 /v1 前缀下(与其他路由一致)。除
/external 从请求体取 datasource 外,其余都要带
?datasource=<名字>——条目按数据源隔离,没有「默认源」可用。
drift.py:162 check drift.py:180 503 drift.py:225 drift_detail
检测端点有未完成就返回 503 的约定,而不是「200 + 空列表」。 理由和上面 skipped 一样:一个空列表会被读成「没有问题」。
命令行门禁脚本 scripts/check_drift.py 的退出码分三档:
0 干净且每一项检查都真的跑了、1 发现漂移、
2 出错或任一检查未能完成。最后那句限定词是关键——
它让「没检查成」不会被 CI 当成通过。
check_drift.py:51 _check_datasource