数据库影响判断:如何减少误报
跨程序数据库影响的判定目标不是「找出 A 和 B 是否访问同一张表」,而是:
A 的这次改动,是否改变了 B 能观察到的数据库契约或数据行为?
「同名表」只是候选,不是结论。本文说明判定层在候选之后还要经过哪些准入条件,以及每条规则为什么能减少误报、又会在哪里留下盲区。
1. 四个准入条件
一条数据库候选要成为跨程序影响,必须同时满足:
| 条件 | 问题 | 不满足时 |
|---|---|---|
| 同一数据库对象 | 两个程序真的能看见同一张表吗? | 排除,并计数 |
| 变更可被观察 | 对方的读写方式能感知这次改动吗? | 不命中;必要时记盲区 |
| 契约不兼容 | 改动会让对方已有的值、绑定或映射失效吗? | 兼容变更只计数,不报告 |
| 解析足够确定 | 列名、类型、可空性是否真的解析出来了? | 降低置信度或记盲区,不猜测 |
这四条分别对应下面四节。核心原则是:发现依赖不等于证明缺陷;无法证明时,宁可标成盲区,也不要伪装成命中或干净结果。
2. 同一数据库对象:表名不是身份
orders、user、order_item 这类表名几乎每个库都有。只按 table.column 匹配,会把无关程序拉进审核队列。
系统从两个来源读取数据库身份:
- 配置中的 JDBC URL:
spring.datasource.url、spring.datasource.*.url、spring.datasource.hikari.jdbc-url等,解析出数据库名。 - SQL 中的显式 schema:
order_db.orders的order_db,或 DDL 中的schema.table。
判定规则:
- 两侧都已知、且同一层级(都是数据库名,或都是 schema 名)且不同 → 排除,计入「因数据库不同被排除的候选」。
- 一侧已知、另一侧未知 → 保留匹配,但置信度降低。读不到 URL 不是「不同库」的证据。
- 两侧都是未知 → 保留匹配,置信度降低。
- 数据库名与 schema 名属于不同层级,不直接比较;否则「URL 指向
order_db」与「SQL 写order.orders」会被误判为不同库。
为什么只比较数据库名、不比较 host:同一套 schema 在开发、测试、生产通常通过不同 host 访问;host 不同不代表库不同。
嵌入式数据库(H2 mem、HSQLDB、Derby、SQLite)不参与身份比较:它们是进程内数据库,两个程序都把库命名为 testdb 并不共享任何数据。
3. 变更可被观察:不是所有 DDL 都会影响对方
数据库契约的单位是 table.column,但不同 DDL 对使用方的影响方式不同:
| 本程序改动 | 对方读方 | 对方写方 | 对方实体映射 | 判定 |
|---|---|---|---|---|
显式 DROP COLUMN(迁移脚本) |
命中 | 命中 | 命中 | 列不存在,读写与映射都会失败 |
快照推断字段消失(无 DROP COLUMN) |
不命中 | 不命中 | 不命中 | 仅改 CREATE TABLE 不会删除已部署列;只计数 |
RENAME COLUMN |
命中(旧列) | 命中(旧列) | 命中(旧列) | 旧列名消失;新列名不可能已是依赖 |
RENAME TABLE |
命中(旧表) | 命中(旧表) | 命中(旧表) | 旧表名消失,退回表级契约;新表名不可能已是依赖 |
MODIFY 收窄 / 类型不兼容 |
命中 | 命中 | 命中 | 值可能截断、精度丢失或隐式转换 |
MODIFY 放宽 / 兼容 |
不命中 | 不命中 | 不命中 | 已有值与绑定仍然有效,只计数 |
MODIFY 可空性收窄(NULL → NOT NULL) |
不命中 | 命中 | 不命中 | 读方仍读到相同值;写方可能被拒绝 |
MODIFY 默认值变化 |
不命中 | 不命中 | 不命中 | 只影响省略该列的写入,进入盲区 |
ADD COLUMN |
不命中 | 不命中 | 不命中 | 按列名读写的代码看不见新列 |
ADD COLUMN 且 NOT NULL 无默认值 |
不命中 | 命中(完整 INSERT 省略该列) | 不命中 | 数据库拒绝省略该列的 INSERT;自动填充列除外 |
CREATE TABLE |
不命中 | 不命中 | 不命中 | 新表不可能已经是对方的依赖 |
DROP TABLE |
命中 | 命中 | 命中 | 整表消失,退回表级契约 |
| 删除迁移脚本 | 不命中 | 不命中 | 不命中 | 已执行的 migration 不会因为删文件而回滚 |
| 写入字段集合变化(新增写入列) | 命中 | 不命中 | 不命中 | 本程序开始写某列,读方能观察到值变化 |
| 写入字段集合变化(停止写入列) | 不命中 | 不命中 | 不命中 | 列仍然存在,读方 SQL 不会失败;只计数,不按字段删除传播 |
| 无列名的整表写入 | 命中 | 不命中 | 不命中 | 行级写入变化,但仅限解析不出列名时 |
| 实体字段新增 | 不命中 | 不命中 | 不命中 | 加映射是程序内部行为 |
| 实体字段删除 / Java 类型变化 | 不命中 | 不命中 | 低置信度提示 | 字段映射不是数据库 schema |
表重命名:契约是旧表名
A: ALTER TABLE orders RENAME TO orders_archive; -- 或 RENAME TABLE orders TO orders_archive;
B: SELECT id FROM orders WHERE id = ?
重命名之后旧表名在数据库里不存在了,B 读、写、映射 orders 的代码都会在部署当天失败。因此它按
表级契约命中,契约写旧表名 orders,置信度约 0.90;对方对旧表的所有使用点都受影响。
新表名 orders_archive 刻意不产生任何候选:它是这次改名才出现的,不可能已经是对方的依赖。
把它也当成契约,等于把同一次改名报两遍,还会凭空牵连碰巧叫这个名字的程序。
一条 ALTER 里的多个动作:按动作报告,不按语句
ALTER TABLE orders ADD a INT, DROP b, MODIFY c BIGINT NOT NULL, RENAME d TO e 是四条契约变更,
不是一条。只读第一个动作会漏报后三个;混合动作还曾把 ADD 的类型串到 DROP 的记录上。
- 解析按顶层逗号拆出全部动作(括号与字符串里的逗号不拆),每个动作记
kind / table / column / type / nullable / default / schema / renamedTo;不支持的动作 (ADD INDEX、ADD CONSTRAINT、ENGINE=、CONVERT TO)被忽略,而不是猜成列。 - 变更检测按
kind|table|column对齐两侧动作,每个新增、修改、移除的动作各生成一条DDL_CHANGED。这是必要的:ADD a只是不可观察的加列、DROP b会让读b的代码失败、MODIFY c可能只影响写方,它们不可能共享一个结论。 - 语句级的旧字段(
ddl/ddlColumn/ddlType…)仍保留第一个动作,只为让旧索引与旧报告 继续可读;新逻辑以动作列表为准。单动作 ALTER 的摘要与风险种子与之前一致,extra 只补了oldDefault/newDefault(必填列判定需要「有没有默认值」)。
新增列为什么不再命中读方
A: ALTER TABLE orders ADD COLUMN remark VARCHAR(64);
B: SELECT id, status FROM orders WHERE id = ?
B 的列清单是写死的,新列不会让 B 的 SQL 失败,也不会改变 B 读到的 id/status。真正可能受影响的是:
- 对方写
SELECT *:列清单由运行时 schema 决定; - 对方写
INSERT INTO orders VALUES (...):没有列清单,新增列可能改变插入语义; - 新增列是
NOT NULL且没有默认值:省略该列的完整 INSERT 会被数据库拒绝。
前两种无法用「对方是否读这一列」判断,因此进入字段盲区计数,而不是把每个读方都报成受影响。 第三种是确定的写侧不兼容,按下一节的规则命中写方。
NOT NULL 且无默认值:写方会失败,不再只是盲区
A: ALTER TABLE orders ADD COLUMN tenant VARCHAR(32) NOT NULL; -- 无默认值
B: INSERT INTO orders (id, status) VALUES (?, ?); -- 省略 tenant
MySQL 严格模式、PostgreSQL、Oracle 都会拒绝 B 的这条 INSERT。这是真实的不兼容,不是「无法判断」,
所以它命中写方:契约仍是 orders.tenant,usage=writer,置信度约 0.85。
判定同时要求:
- 变更是
ADD COLUMN且新列NOT NULL且没有默认值;或MODIFY把可空性收窄为NOT NULL且没有默认值; - 对方语句是完整的
INSERT(sqlKind=insert、非动态 SQL),且列清单里没有这一列。
以下情况不算必填列,因为数据库会自己补值:
- 列可空,或有默认值(
DEFAULT 0、DEFAULT CURRENT_TIMESTAMP等); - 列由数据库自动填充:
ALTER定义里带AUTO_INCREMENT、GENERATED(含GENERATED ALWAYS AS IDENTITY/ 计算列)或IDENTITY。识别只看ALTER的列定义文本, 并剔除引号字符串与COMMENT/REFERENCES/CHECK/COLLATE尾部,DEFAULT 'generated'这种字面量不会误判。
有默认值的加列仍然只计数(「对对方不可观察」);SELECT * 与动态 SQL 不命中必填列,
仍计入字段盲区。
兼容变更为什么只计数
VARCHAR(32) → VARCHAR(64) 放宽,已有值全部可表示
INT → BIGINT 放宽,已有值全部可表示
DECIMAL(10,2) → DECIMAL(12,2) 精度增加
DATE → DATETIME 时间信息增加
可空 → NOT NULL 收窄(写方可能失败)
类型兼容规则集中在 ColumnTypeCompatibility。只有收窄、类型族变化或无法判断时才命中;放宽只计入「兼容变更」计数,报告会说明有多少条候选因此没有扩散。
4. 解析足够确定:动态 SQL 不能降级成表级契约
字段级匹配依赖从 SQL 中解析出列名。以下情况解析结果只是下界,不是事实:
- MyBatis
<if>/<choose>/<foreach>/<include>; - 占位符拼接(如 MyBatis 的美元占位符);
SELECT *;INSERT ... SELECT的目标列清单缺失;- 存储过程、视图、触发器、运行时动态表名。
处理方式:
- 本程序侧:解析不完整时,不把「没解析出写入列」当成「写入列集合变了」,也不退回整表契约。
- 对方侧:碰到变更表但没有明确列名的语句,计入「字段盲区」,在报告中列出文件与行号。
- 盲区口径:只有「读写列都没解析出来」或「语句被标记为不完整」才算盲区;一条已经作为 writer 命中的纯写语句不会重复计入盲区。必填列规则命中的 INSERT 也按这条口径排除——它已经有确定结论,不再算「无法判断」;而
SELECT *与动态 SQL 不命中必填列,仍计入盲区。
此外,SQL 符号按文件与行号标识,语句从一个 mapper 移到另一个会同时产生「删除」和「新增」。变更检测会按规范化文本配对这两条记录并取消;文件搬家不应被读成写入字段集合变化。
SELECT * 不是「没有使用这个字段」,而是「无法静态判断使用了哪些字段」。把盲区读成「没影响」是最容易犯的错误,所以它出现在报告里,而不是被静默丢弃。
5. 实体映射与数据库 schema 解耦
JPA 实体字段变化过去被当作 schema 变化,导致每次实体重构都命中对方读写方。现在:
FIELD_ADDED:不产生数据库 delta;FIELD_REMOVED/FIELD_TYPE_CHANGED:只对同样映射该列的对方代码产生低置信度mapping提示,不命中 SQL 读写方;- 这条提示只作为报告说明(
mappingHintMatches),不再从列表节点向对方传播:实体字段删除不改变数据库列,对方的 mapper / SQL 与自己的映射都不会坏。此前它会把对方所有读该列的语句拉成 HIGH 的 DATA 影响,是明确误报;
- 这条提示只作为报告说明(
- 只有显式
@Column(name=...)/@JoinColumn(name=...)才作为映射证据;隐式 camelToSnake 的置信度更低; @Transient、static、集合类型(List/Set/Map/ 数组)不建列映射;userID→user_id、URLPath→url_path,按 Hibernate 的边界规则转换,而不是每个大写字母都插下划线。
真正的 schema 变化仍由 DDL / 迁移脚本捕获,与实体映射分开。
6. 报告如何保持透明
减少误报不能变成「悄悄不报」。报告会保留:
- 因数据库不同被排除的候选数,并给出一句人工复核建议;
- 因类型兼容被排除的候选数,按变更计(同一处改动对比多个程序不会重复累加);
- 对对方不可观察的变更数:可空或带默认值的加列,建表、删除迁移脚本、移除 DDL 动作, 以及「本程序停止写入某列」和「快照推断字段消失但没有 DROP COLUMN 脚本」—— 不扩散,但计数并给出示例;
- 必填列变更数:
NOT NULL且无默认值的加列 / 可空性收窄。这类变更会命中省略该列的 INSERT, 因此不再同时计入「不可观察」——否则报告会一边说「对方看不见」,一边说「对方写入会失败」; 报告给出计数、后果说明与人工确认方式(对方所有插入语句都要补齐该字段); - 低置信度映射提示:本程序删/改实体字段、而对比程序也映射同一列时,只给出提示计数 与示例,不生成跨程序影响;双方通过 Maven 依赖共用同一模型类时才需要按编译契约另外确认;
- 字段盲区:碰到变更表、但无法解析列名的对方语句;
- 未知项:动态 SQL、无法解析的 DDL、存储过程等。
这些计数不参与风险分,但它们决定了「没有发现影响」到底有多可信。
7. 仍然没有解决的问题
| 问题 | 现状 | 下一步 |
|---|---|---|
| 写入列集合不变、值语义变了 | 不产生 delta | 比较 SET/INSERT 赋值表达式;只命中按该列过滤的对方代码 |
| 事务边界、提交时机、锁顺序 | 不分析 | 解析 @Transactional、FOR UPDATE、更新顺序,结合并发测试 |
| 视图 / 触发器 / 存储过程 / 外键级联 | 不建依赖图 | 采集数据库元数据,沿对象依赖传播 |
| 真实数据分布 | 不读取 | 在隔离测试库执行迁移并校验数据 |
| 运行时实际 SQL | 不采集 | MyBatis 插件 / JDBC 代理 / OpenTelemetry 补全 |
| 多版本共存 | 静态契约兼容 | 扩展—迁移—收缩,校验新 A 与旧 B |
| 同一程序多数据源 / 多 schema 同名表 | 快照键只有 table.column,可能互相覆盖 | 按数据源 / 模块隔离键 |
| 数据库自动填充列的识别 | 只看 ALTER 列定义文本里的 AUTO_INCREMENT / GENERATED / IDENTITY;触发器、序列默认值等其他自动生成机制未识别,可能仍按必填列处理 |
采集数据库元数据判断列是否自动生成 |
这些是已知边界,不是「已经支持」。报告中的「未发现影响」只表示在静态契约层没有找到不兼容证据。
8. 一张判定流程图
A 的代码 / SQL / DDL 变更
│
▼
还原数据库 delta:动作级(表.列 / 表 + 操作 + 旧/新类型 + 可空性 + 默认值)
│
├─ 重命名表 ──→ 旧表名按表级契约命中读写与映射;新表名不扩散
│
├─ 删除迁移脚本 / 新增表 / 加列 / 兼容放宽 ──→ 不扩散(计数)
│
├─ 必填列(NOT NULL 无默认值且非自动填充)──→ 命中省略该列的 INSERT 写方
│
▼
查对方使用点:READS_COLUMN / WRITES_COLUMN / MAPS_COLUMN(表级:READS_TABLE / WRITES_TABLE / USES_TABLE)
│
├─ 数据库身份不同 ──→ 排除(计数)
│
├─ 操作类型不接受该使用点 ──→ 不命中
│
▼
命中:进入对方调用图传播,计算风险与置信度
│
▼
未命中但表相同且列名不可解析 ──→ 字段盲区(人工确认)