Java EE 企业应用 16:Schema 迁移与应用版本窗口
新 WAR 启动正常,旧申请为什么仍读不了
采购系统部署新版本时,代码可能开始读取旧库没有的列;也可能先删掉某列,正在处理请求的旧 WAR 随即失败。两种错误都无法通过空库上一条建表脚本验证。当前工程确有 examples/javaee-enterprise/db/migrations/001-initial.sql,但没有迁移执行器、版本表或从旧 schema 升级的脚本。说清这点之后才能讨论升级合同:从空库建立初始表是一种实验;保持旧应用在扩展期间可工作、最终删除旧结构,是另一种实验。
本篇源码、迁移、脚本与测试可从固定版本完整归档取得;版本 1b08ada,SHA-256 见源码清单。
初始脚本保护了哪些数据
001-initial.sql 建三张表:purchase_request 保存租户、版本、状态及 numeric(18,2) 总额;purchase_line 用外键关联申请,规定 SKU、价格、数量;purchase_order.request_id 设唯一且引用申请。申请的状态枚举值受 CHECK 限制,总额与单价不为负、数量为正,另有 (tenant_id,status,id) 索引供可能的列表访问。脚本由 psql 显式执行,不会因为 WAR 启动或 CDI 注入成功就自动运行。服务端 db-check 只执行 SELECT current_database();它甚至不查 purchase_request 是否存在,不能当作迁移就绪探针。
数据库的约束还要和应用运行的顺序对应。purchase_line.request_id 在申请行没有插入或已被删除时会违反外键,订单表对同一申请 ID 的第二次插入受到唯一约束;而申请表的 status CHECK 只限定字符串落在五个名称内,并不禁止有人直接把 DRAFT 改为 ORDERED。JdbcRequestStore.transition 用旧版本、旧状态和租户一起更新,属于应用侧更细的状态迁移约束。只读 SQL 能确认表存在,不能确认它满足业务版本和约束;反之,脚本中的 CREATE TABLE 成功,也不能保证别的实例没有在稍后执行不兼容的 DDL。测试应对当前正在使用的库记录 current_database()、三表的列与约束定义,而不是对另一个测试库的截图作证明。
目前的约束也有边界:订单表单独保存 tenant_id,普通 REFERENCES purchase_request(id) 不保证订单租户值与父申请一致;合法状态字符串不保证状态变化遵守 DRAFT → SUBMITTED 等业务边。JdbcRequestStore 的插入、租户条件和版本条件分别承担应用侧责任,但绕开用例的直接 SQL 未必受到同样保护。Schema 设计与应用不变量要共同验证,不能从三张表的 DDL 退出 0 推断已经实现权限控制或跨表业务一致性。
先扩展,再迁移数据,最后收缩
设未来需要记录审批意见,这不是当前仓库已有字段。上线旧 WAR 仍在服务的第一阶段可以新增允许 NULL 的 approval_note 列,并验证旧版插入不需提供它、旧版查询仍能执行。第二阶段再让新 WAR 开始写这个字段,补齐既有行的默认语义与读取逻辑;涉及长期大量数据时回填应限批、记录进度并处理新旧写入交错,而不是一条无限期锁表 SQL。只有旧版实例全部退出、读写与回退条件经验证之后,才能考虑把列变为 NOT NULL、删旧列或加强约束。expand → migrate → contract 是拟议版本策略,并非本例已运行过的第二次迁移。
扩展时需要考虑旧版的 INSERT 是否列出目标列、SELECT 是否依赖固定列顺序。当前 JdbcRequestStore.insert 显式列出 tenant_id,version,status,total,增加一个可空列不会迫使该 INSERT 提供新值;find 显式 SELECT 所需列,读取时按 SELECT 列序号映射。在这条路径上增加末尾可空列,理论上比直接改写已有列名更容易兼容。但旧版若其他 SQL 使用 SELECT *、旧 REST 请求解析新字段、或者新 WAR 在同一事务必须读 approval_note 的非空语义,仍要逐入口验证。现有仓库没有新字段,也没有新旧版本 WAR 同时运行的记录;仅凭这两个现存 DAO SQL 的形状,不能替整个系统保证滚动兼容。
回填与约束加严也分两次验收:旧行在扩展阶段允许 NULL,新版若立刻假设每行都有意见,读老申请时就会出错;若把无意见回填成固定字符串,又可能伪造审批人没有写过的事实。先决定无意见是业务允许、需补录还是仅对新单必填,再设计回填与新写入的时序。加上 NOT NULL 前,核对满足条件的行数与总行数;对于仍未补齐的行保留失败原因和恢复策略。当前采购模型没有审批记录表,不应把迁移新列描绘成“完整审批审计已经上线”。
迁移编号与应用版本应建立明确映射:新 WAR 运行前必须检查其需要的 schema 版本;缺失脚本或版本低于要求时阻止业务入口,不得只让 /api/health 返回常量继续对外接单。当前工程既没有 schema 版本表,也没有启动闸门;不能写成“缺迁移会阻止错误启动”。强制上线回退还需对称的兼容窗口:已经写入新字段的数据,旧 WAR 可能读不了;不可依赖“回滚 WAR 文件”自动回滚数据库。存储迁移一旦产生真实业务数据,先核对兼容范围、备份和回退所需的正向修复,而不是在共享环境运行未经验证的 DROP。
部署闸门至少要区分“仍在迁移”与“迁移版本不符合预期”。如果多个 WAR 实例几乎同时启动,由每个实例各自执行同一份 DDL 可能造成重复建表或相互等待;更稳妥的方案是明确迁移的唯一执行者、在数据库中保存已完成版本及校验值,所有实例只检查当前数据库的版本是否达到该 WAR 的最低要求。版本号不应仅从脚本文件名推导,也要记录对应脚本的内容摘要,以便识别有人修改了已经发布的 001。没有这套协调器时,/api/health 的 HTTP 200 只说明静态 Web 路由可访问,不能作为数据库 Schema 已升级的就绪信号。
即使只加索引,也须确认操作期间的写入和失败恢复方式。比如普通建索引和 CREATE INDEX CONCURRENTLY 对写入阻塞、事务块的要求不同;后者不能放在普通事务块里一并执行,失败时还可能留下需要排查的无效索引。当前的 CREATE INDEX idx_purchase_request_tenant_status 仅出现于空库初始脚本,没有运行期的大表建索引方案。版本记录应包含每步 DDL 的起止时间、有效性检查、索引定义和实际使用的 PostgreSQL 16 实例,避免把脚本启动当成索引就绪。
普通 PostgreSQL DDL 通常可以放入事务中回滚,但现有 psql -f 没有显式设置整份文件单事务;若迁移到第二条语句才失败,第一条可能已提交。psql -X -v ON_ERROR_STOP=1 --single-transaction -f ... 可用于支持事务的初始 DDL 脚本,预期遇到错误时整个文件回滚,仍需用新库故障注入确认实际效果。不能把 CREATE INDEX CONCURRENTLY 塞进同一个事务块,此时须单列阶段、验证失败后的索引有效性,再处理修复。对生产库做这一类 DDL 的锁等待、资源占用和在线写入影响,没有实测便不得声称零停机。
先行 JPA 08:Schema 与集成测试 讨论过新库与旧库的分界,不过它使用独立的 jpa_lab 与 RESOURCE_LOCAL;不能用先行 JPA 的建库结果填当前 javaee_lab 的升级验收。JDBC 的明示 SQL 更直接暴露列名依赖,但“写 SQL 看得见”仍不保证滚动发布安全。尤其当 RETURNING id、SELECT ... 的列下标或 numeric(18,2) 类型改动时,需要为旧/新版本分别运行真实操作,不只是比较表结构文本。
已有空库记录与缺失的升级记录
首次建表、重复脚本失败和旧库升级三种不同验收见Schema 迁移实验卡;卡片要求操作者先确认库是自己的临时实验库。
基础证据目录 writing-plans/javaee-enterprise/verification/20261004T061900Z-pg16-foundation/ 有 migration.stdout.txt 的三次 CREATE TABLE、一次 CREATE INDEX 和 migration-exit-code.txt 的 0;db-version.txt 记录专用 javaee_lab 的 PostgreSQL 16.15。server-final.stdout.txt 与 datasource.body.txt 还证明一次部署及目标数据库的只读连通。RUN.md 明确说明这是空库初始迁移,没有旧库升级证据。当前脚本直接 CREATE TABLE,不含 IF NOT EXISTS;重复运行若已有表,预期失败而不是自动认为版本一致。先确认当前库再执行,不能拿两次不同数据库的退出码比较升级效果。
如果只想复核“当前脚本从空库创建三表一索引”和“重放会拒绝已存在对象”,可手动创建并核对一个从未承载采购业务的专用空库 javaee_lab_16,授予实验角色建表权限,然后在仓库根目录执行下列命令;javaee_lab_16 必须由操作者先创建、确认只供本篇使用,示例不自动创建或删除数据库:
1 | |
第一遍迁移与查询零行是空库正常判据,最后一遍是故意重复执行的失败:预期因为第一张表已存在而非零退出、现存表和约束保持完整。每条命令记录 stdout/stderr、原始退出码、当前数据库和执行时间;真正运行前还要确认该实验角色没有可访问的生产库。整段命令本批 NOT_RUN,当前已经保存的 001 成功属于另一轮、另一库的基础记录。重复脚本失败也不等于“跳过中间迁移时新应用自动拒绝启动”;后者仍要版本闸门与双版 WAR 实验。
本篇正常升级实验应在新的隔离库中依次建立旧版结构与样例申请,保存旧版 WAR 和数据库摘要;运行单独版本的扩展脚本、启动新旧两个受控实例,分别核对旧版仍能创建/读取、新版能写/读新字段,记录变更前后行集合。失败实验从“跳过中间迁移”入手:在干净的旧库上直接启动依赖新列的隔离版 WAR,预期版本闸门拒绝业务访问,保存非零状态与原始日志,再补做迁移并复测。上述新列、脚本、闸门与双实例目前都不存在,正常升级及失败恢复均 NOT_RUN;不能将 001 的空库 PASS 移植到这些路径。
验收清单还应保留三个现场:升级前旧版读到的申请和明细、扩展后旧版仍能写的记录、升级后新版额外字段的读取。错误路径若发生在回填一半时,记录最后处理的主键、已经持久化的行与未处理的行,再决定从哪一批继续,而不是无条件重跑全表 DDL。回退演练必须从已经写过新数据的库开始,尝试让旧 WAR 执行它承诺支持的读写;若旧版本无法表示新数据,先提出恢复或向前修复步骤,不用“把 WAR 文件复制回去”作为结果。只有留下请求、服务器版本、库 schema 版本和数据库最终行,才能区分部署成功与业务继续可用。
唯一索引的上线也可能撞到旧数据:即使新代码不再创建重复订单,旧库已存在的重复行会使新增唯一约束失败。要先在隔离旧库扫描 request_id 的重复分布、保存受影响的申请和订单集合,并决定人工或可审计的自动修复规则;不能让迁移脚本随意删除一张真实订单来换取退出码 0。反过来,当前 001 的 purchase_order.request_id 唯一约束只对这张表生效,不能判断已发出的通知是否重复;新增版本在数据修复期间需要明确旧 WAR 的写入会不会继续制造冲突。这些条件都属于旧库升级合同,没有旧库种子数据时不能填 PASS。
时间窗口还包含读取能力:扩展后的新列在旧 WAR 看来虽可忽略,但如果新版开始把一个旧版不认识的状态写进 purchase_request.status,旧版 RequestState.valueOf 读到未知值可能失败。新增枚举值或改变 numeric(18,2) 的精度,也应按数据格式兼容性逐项审查,而不只看 DDL 能否执行。演练前固定旧新应用的源码与部署包摘要,明确双方会写出的值域;评审迁移时把“表中有什么”和“两版代码分别能读写什么”列在同一张时间表。当前五态模型与初始 schema 尚未引入新状态,这是一项未来变更的风险预案,不是已发生的兼容事故。
应用升级回退的最后一步也要验数据权限:新版即使成功读取所有列,如果它让旧版本忽略了新的租户字段或默认值,旧代码可能得到看似合法、实际上不属于请求者的对象。新版上线测试至少需要两个租户的合成申请,分别由新旧 WAR 在允许的窗口读取与修改;超出权限的请求必须维持拒绝。现在两版 WAR 并行、版本闸门和身份认证都尚未实现,不能把 001 成功迁移与 /api/db-check 的同库连接当作租户兼容性验证。
两道练习与答案
练习一:新版本读 approval_note,旧版本还在写没有该列的 INSERT。能否先加 NOT NULL 无默认值,再滚动升级 WAR?
答案:通常不行,旧版 INSERT 无该字段会失败,旧有数据也可能无法满足约束。先增加兼容列,明确默认/NULL 的业务语义与回填策略,在旧新双版本读写测试之后再考虑收紧。具体锁定与执行时间要在 PostgreSQL 16 隔离库测量;本例没实现该字段。
练习二:/procurement/api/db-check 返回 connected,可以宣布“旧库升到了最新 schema”吗?怎样阻止漏跑版本脚本的部署?
答案:不能,该资源只核对连接及 current_database()。需要可验证的迁移版本记录、启动时明确的最低版本要求和失败时关闭业务入口的闸门,再用旧版样例数据执行真实读写。当前只有 001 空库迁移,版本闸门和旧库升级 NOT_RUN。
边界与资料
本章不执行任何共享库 DDL;所有阶段性脚本都是待设计的演示方案。真实源码入口为 db/migrations/001-initial.sql、deploy/server.xml、webapp/src/main/java/blog/javaee/web/DatabaseCheckResource.java;记录只证明这次空库迁移与只读连通。参考 PostgreSQL 16 ALTER TABLE、表约束、索引 与 Jakarta EE 11 Platform。Jakarta 平台提供部署和数据源契约,不替工程安装或执行数据库版本迁移器。






