新实体能启动,旧订单未必安全

假设申请实体新增 @Version。新库按映射生成表,插入、查询全部通过;生产旧库却没有 version 列。另一个风险更隐蔽:表和列都在,但 purchase_order.request_id 上没有唯一约束,双重建单在并发时才暴露。两种故障分别属于映射与库结构不匹配、业务约束缺失。把“Provider 启动成功”当成“迁移完成”,会同时漏掉数据搬迁、约束和上线顺序。采购审批里的金额、租户与订单唯一性,不能只交给 Java 注解;它们要在现存行、DDL 和事务测试之间取得一致。

本章以 Jakarta Persistence 3.2、Hibernate ORM 7.1.36.Final、PostgreSQL 16、Java 21 的 examples/jpa/ 为入口。实体映射与测试源码已存在;仅有 db/migrations/01-init.sql 一份初始化脚本,没有旧库升级链,也没有“08 专属迁移测试套件”。PostgreSQL 16.15 上的本地事务累计测试已有六项 PASS;建库初始化的原始 stdout 没有归档,旧库升级、坏结构与约束负例则为 NOT_RUN。不能用回归通过代替旧库迁移验收,更不能把预期写成观测日志。

Schema 生成解决什么,不解决什么

规范 §8.2.1.11、§9.4 定义 Schema generation 的属性与执行:jakarta.persistence.schema-generation.database.action 可选 none、create、drop-and-create、drop、validate;缺省不对数据库执行生成操作。scripts.action 可生成脚本,是否以映射元数据或提供的脚本为输入由其他属性决定。这是一种标准能力,但“根据当前映射生成目标结构”不等于“根据老库的数据与业务规则设计无损升级路径”。drop-and-create 尤其不能拿到有订单的库上做迁移。即便选择规范的 validate,也不能仅凭校验成功证明表内旧数据满足新的状态流、租户访问策略、唯一键、精度与索引需求;完整性还需要数据库约束和针对性查询。

工程的 src/main/resources/META-INF/persistence.xml 配的是 hibernate.hbm2ddl.auto=validate,是 Hibernate 的属性,而非上述 jakarta.persistence.schema-generation.database.action=validate。Persistence 3.2 确实定义了标准的 validate 值;两种写法的存在不意味着它们逐项检验完全相同的对象,更不能声称换一个 Provider 就会读取 Hibernate 属性。对现有工程,先执行审阅过的 SQL,再启动应用验证映射,是明确的先后依赖;若先启动,缺表或缺列可能使工厂创建失败,具体失败时点与异常链须按版本记录。validate 不负责生成缺失列,不是自动修复。

现有 01-init.sql 定义三张表。申请表有 version、tenant_id、status、total;明细表检查数量和价格;订单表以唯一外键引用申请;另有按租户和状态查询的索引。ProcurementRequest.java 用 @Version,ProcurementOrder 与申请存在关联。状态只允许 DRAFT、SUBMITTED、APPROVED、REJECTED、ORDERED,这是表层允许值集合,不是状态迁移图。数据库 CHECK (status IN (...)) 会拒绝无效字符串,却不保证 APPROVED 必须来自 SUBMITTED;订单表的唯一键能限制一申请多单,却不保证只有 APPROVED 可建单或订单租户一定等于申请租户。实体的 approve()、markOrdered() 等方法和服务层还需先验证合法迁移及租户;绕过服务写 SQL 的路径应被单独纳入设计审查。

表里的 CREATE TABLE IF NOT EXISTS 和 CREATE INDEX IF NOT EXISTS 适合使初始化脚本在某些重复执行场景不立即报错,但它们不是版本迁移机制。若旧 purchase_request 已存在却缺 version,再次运行脚本不会添列;若旧索引名字相同但定义不同,IF NOT EXISTS 也不能保证定义已更新。旧库不能靠“再跑一次 01”升级。最小升级工序要有已知起点版本、待变更对象、回填策略、发布先后和验证查询;具体增量迁移尚未存在于仓库,不能虚构 02-upgrade.sql 或说它已通过。

还有一个容易漏掉的差异:@Column(nullable = false) 表达持久化映射要求,重启 Provider 不会修复旧库中的空值。从 SQL 层验证旧行时,应分别查询 NULL、负数金额、数量为零、无效状态及重复 request_id。若旧表缺少 CHECK 或 UNIQUE,只查询 JPA 读回的正常样例不会发现缺口。迁移验收应把“结构对象是否存在”“约束是否真的拒绝非法输入”“旧数据是否满足新规则”分开写断言;第三项必须报告发现多少不合格行及如何处理,不能把计数为零设为没有执行过查询的默认值。

旧库升级的切分依据

为申请表补 version NOT NULL 时,空库创建列与存量库加列不是同一操作。旧行最初没有版本值;先决定版本初始值与应用新旧版本共存时的写入协议,再执行加列/回填/约束验证,最后让新代码开始依赖它。要补订单唯一键,先查重并处理已存在的重复订单;否则加约束本身应失败,不能为了“迁移成功”静默删业务数据。若要把金额小数位或状态枚举收紧,先验证老数据是否符合目标精度与新枚举,再选择数据修正或分阶段兼容策略。一个更现实的上线顺序常是扩展结构→回填并验算→应用切换→收紧约束;何处需要并发写入防护,则由生产流量与现存数据决定,不能把这一顺序当作所有 DDL 的免锁承诺。

PostgreSQL 16 的 ALTER TABLE 不同子命令有不同锁要求;通常需要考虑强表锁,持续时间与表规模、并发和具体语句有关。多数 PostgreSQL DDL 可放入事务,以错误时回滚变更;但不要将所有维护操作一概打包,例如 CREATE INDEX CONCURRENTLY 不能在事务块中运行,且失败后可能留下需清理的索引。数据库管理员应在副本或隔离环境评估锁等待、回滚方案与维护窗口。此处的 psql -v ON_ERROR_STOP=1 -f 会使脚本遇 SQL 错误时以非零退出,并不自动让整份 SQL 脚本成为单一事务;需要原子执行时应额外设计 -1/--single-transaction 或显式事务,并核对脚本所含语句是否允许在事务块内运行。CREATE DATABASE 等集群级准备动作也不应混进该迁移事务。

既有 SQL 文件只是初始化版本,不提供 schema history 表或迁移锁。同一批服务实例并发启动时各自跑脚本没有协调保证;上线流水线应在应用启动前由受控的单一迁移步骤执行版本化升级,记录每个版本的成功/失败,失败立即阻止新应用版本接管流量。谁负责迁移、是否允许应用自动建表、何时校验旧数据都须写入部署契约,而不是交给 hbm2ddl.auto 的默认行为。这是运维设计建议,尚非该仓库已实现的部署功能。

旧库演练至少要保存迁移前的 schema 版本、申请与订单行数。还要分别统计缺版本值的申请、违反新 CHECK 的状态或金额,以及相同 request_id 下的重复订单。迁移后用独立连接重查这些数值与约束定义,再跑应用读取和一笔审批→建单的正常流程。如果升级过程报错,要保留错误发生时已经执行的 DDL 列表,判断脚本是否在同一事务;若没有整体回滚,恢复必须从实际数据库状态推导,不能机械地重跑 01。演练期间写入若未暂停,还要考虑统计时点与回填之间新数据的竞争;简单地在开始与结束各数一次行数,并不能证明中途没有漏掉写入。

例如要新增唯一约束,先找出重复申请号并人工决定保留哪条订单;回填结束与加约束之间如果允许两个服务实例继续插入,原先“查重为零”的结论会过期。可以在受控维护窗口停止这类写入,或设计明确的并发迁移协议,但不能把读表检查当作互斥锁。约束真正创建后再安排两个不同数据库连接并发插入同一申请的负例;成功路径须核对其中一条订单最终存在,失败路径须核对没有第二条,而不是仅依赖两个请求各自返回的 HTTP 状态。现有测试只有顺序重试,且这类迁移后的并发约束实验仍为 NOT_RUN。

可运行入口:真正用 PostgreSQL 验证映射

在 examples/jpa/ 目录操作;只使用独立的 jpa_lab 数据库和专用账号,先审阅脚本和 examples/jpa/README.md,再提供 JPA_LAB_PSQL_URL、JPA_LAB_JDBC_URL、JPA_LAB_USER、JPA_LAB_PASSWORD。DatabaseSupport.java 要求 JDBC URL 以 /jpa_lab 结尾且变量齐全,不符合条件直接报错;它没有偷偷回退到内存数据库。下面先对已创建的空库执行初始化,再运行现有 JUnit 测试。测试会为随机租户创建合成申请,不自动清除库中的旧数据。若库里本已有旧结构,先不要执行第一行伪装升级实验。

1
2
bash ./migrate-lab.sh
../hibernate-lab/mvnw -B -ntp -f "$PWD/pom.xml" -Dtest=ProcurementJpaTest test

正常判据不能只看 Maven 退出码:确认脚本退出码为零、用 \d purchase_request 和 \d purchase_order 等只读目录查询核对版本列与约束,再看 JUnit 对实体重读、flush 后回滚、租户/重复建单以及两个上下文版本冲突的断言。归档回归的六项测试零失败,另两项覆盖 JPQL/Criteria/原生查询与 Hibernate 的抓取统计对照。uniqueOrderAndTenantAndRetry() 是顺序重试,optimisticConflictWithControlledInterleaving() 控制读取与提交先后,并非真实并行双连接建单。检查 purchase_order 的 request_id 唯一键及外键,以及金额/数量检查;靠测试“成功走一遍正常业务”不能证明这些约束挡住恶意写入。原始测试源码并没有把本章所列每个 SQL 失败路径变成独立断言。

失败路径分两类。缺迁移:在另建的可丢弃空 jpa_lab 上跳过 SQL,直接运行同一 Maven 命令;期望映射验证或首次数据库访问失败、进程非零、数据库没有测试插入行。错结构/旧库:在专用可丢弃库中准备已有但缺 version 的 purchase_request,其余表和依赖关系保持可解释,再运行 01 和测试;IF NOT EXISTS 不会补列,预期校验失败。后一实验尚需写出可重复的旧库 fixture 和独立测试入口,不能以“有这个设想”冒充已可运行的脚本。生产库禁止删列演示错误。缺迁移路径也必须记录完整异常链;Provider 何时连接数据库由实现决定,不预写假日志。

这两个负例不能共用“应用启动失败”这一个观察值:空库缺表与旧库少列是不同输入。每次准备新库时先记录 current_database()、数据库服务版本及 \d 的实际输出;确认 JDBC URL 与 psql 连到同一个数据库,才比较 Maven 的异常链。旧库 fixture 还需一条符合旧结构的样本行,并确保除缺 version 外的映射对象齐全,避免测试被无关外键或字段缺失打断。若 Hibernate validate 首先报告别的映射问题,应修复 fixture 并重新实验,不得把任意失败都归功于预设的缺 version。这样才能把失败精确归因于迁移缺口,而非环境变量缺失、账户权限错误或驱动连接失败。

数据库约束还能用隔离库中的负向语句补验。例如已有表创建后执行下列命令,预期因 request_status_valid 的 CHECK 报错并以非零退出;BEGIN 后出现错误,连接退出时事务回滚,绝不能在生产库上试错。它检验的是 PostgreSQL DDL 的一个约束,不代表 JPA 实体生命周期通过:

1
2
psql "$JPA_LAB_PSQL_URL" -v ON_ERROR_STOP=1 \
-c "BEGIN; INSERT INTO purchase_request(version, tenant_id, status, total) VALUES (0, 'test-invalid-status', 'INVALID', 0); ROLLBACK;"

若同一命令反而退出零,不能简单把测试标 PASS:应查询约束实际定义与库名,确认是否误连旧库、约束被漏建或脚本语义改变。带有不存在文件的 psql -f 或脚本中非法 SQL 同样应阻止后续启动,须由部署脚本显式检查退出码(例如 psql ... && mvn ...),否则“错误迁移阻止启动”只是愿望。空库、旧库、坏脚本三类输入各用独立可丢弃数据库状态,不要将一个失败实验遗留的半成品库接着当作下一轮正常输入。

归档证据 writing-plans/jpa/verification/20261004T053805Z-pg16-resource-local/RUN.md:Java 21.0.12.1、Hibernate 7.1.36.Final、PostgreSQL 16.15、pgJDBC 42.7.7、Maven Wrapper 3.9.9;clean test 退出码 0,六项测试通过、零失败/错误/跳过,原始测试 stdout/stderr、退出码、基线提交和未提交源码逐文件哈希在同目录。初始化 SQL 当时执行退出 0,但 stdout 未归档,不记为独立迁移验收;上方的 -Dtest 命令未单独执行。首次测试曾因实体 unitPrice 与库列 unit_price 命名不符,在 schema 校验时报错;修正显式映射后重新回归通过,首次失败输出未归入本次 PASS 的证据目录。缺迁移启动:NOT_RUN;旧库升级:NOT_RUN(缺 fixture 与增量 SQL);非法状态 CHECK:NOT_RUN;锁等待/迁移耗时:NOT_RUN。 实际运行其他场景时须保存命令、源码 SHA、版本回执、退出码、原始输出与数据库终态。短研究卡在 实验说明。

两道练习与解答

练习一。 旧库已有 purchase_request,少了 version;团队反复执行现有 01-init.sql,看到 psql 退出零,便称迁移已完成。这个判断有什么漏洞?

CREATE TABLE IF NOT EXISTS 只避免同名表重复创建,不比较列和约束。零退出不意味着旧表符合实体映射,更没有回填版本值。先识别旧库版本和存量行,设计加列、回填、约束与上线顺序的增量迁移;在隔离旧库副本运行并核对结构、数据及 JPA 读取,失败时阻止应用启动。不能直接拿初始化脚本的成功代替旧库升级验收。

练习二。 新库中 ProcurementJpaTest 全部通过,是否能据此宣布任意并发订单都不会重复、租户数据都不会泄漏?再列出至少两个需要补测的断言。

不能。现有重复建单是同一线程的先后重试,测试既未驱动两个独立事务同时插入,也未穷尽租户相关的查询入口。应在两个连接争用同一申请的实验中验证订单唯一键(一个提交,另一个失败或重读已有订单,最终 count=1),并对跨租户按 ID 读取、列表查询及异常/回滚路径分别断言拒绝和数据库终态。唯一键保证同一 request_id 不重复,不替代每个访问入口的授权。

限制与官方资料

validate 主要是映射/结构检查,不是带数据修复、DDL 锁评估、回滚演练的迁移工具。文中的旧库升级流程是待实现设计,不能在未检查备份和兼容窗口时对生产库执行。虽有真实 PostgreSQL 上的本地测试记录,但仍没有独立旧库 fixture、迁移编排及负向约束实验的原始证据;没有性能结论,也没有声称其他 Provider 的校验行为与 Hibernate 相同。