旧 WAR 回去了,旧数据能跟着回去吗

采购申请从 SUBMITTED 进入 APPROVED 时,业务数据已经改变;更换 WAR 不是数据库的撤销操作。一次可回退发布必须明确三个版本:应用包、运行配置和 schema;还要问旧代码能否读取新代码写入的数据。在有异步通知的版本里,尚有在途任务和消息的协议版本。只存一份旧 WAR,不能据此保证业务回退。

本篇源码、迁移、脚本与测试可从固定版本完整归档取得;版本 1b08ada,SHA-256 见源码清单。

基线工程 examples/javaee-enterprise/README.md 提供单 WAR、deploy/server.xml 和只针对空库的 db/migrations/001-initial.sql。版本为 JDK 21、Jakarta EE 11 Platform、Open Liberty 26.0.0.5、PostgreSQL 16.15;数据库口令从环境注入。现有 SQL 是初始化脚本,不是从旧 schema 升级的可重入迁移;001-initial.sql 再运行会因已有表失败。发布到多实例、滚动排空、旧包回切、兼容性窗口和数据恢复均尚未演练。

三个版本并非三行发布注释。webapp/target/procurement-webapp-1.0-SNAPSHOT.war 是构建输出;deploy/server.xml 声明 jakartaee-11.0、jdbc-4.3、监听地址与 jdbc/Procurement 的 JNDI 名;PostgreSQL 的 purchase_request.version 和 purchase_order.request_id UNIQUE 则由数据库独立保存。修改 WAR 不会替换已加载的 DataSource 配置;修改 server.xml 也不能反向重写现存表约束。在对旧申请提供服务前,必须分别说明各项确实部署在目标运行时,而非只在源码目录里存在。

用兼容窗口代替“发布失败就回库”

先新增旧版能忽略的可空列或新表(扩展),再逐步部署能读新旧结构的代码、回填并观察,确定所有旧实例和任务停止依赖旧字段后再收缩。这个顺序只是迁移设计:是否可以这么做取决于列默认值、约束、SQL 和存量记录;在 PostgreSQL 上每次 DDL 的锁、回填时长和回退可行性都需专门演练。旧 WAR 无法读取新状态枚举时,盲目回切会失败。业务状态若已对外确认,不能靠数据库快照覆盖后来合法提交的数据;需明示修复、补偿或只读隔离的选择。

例如新需求要给订单添加 supplier_reference。若旧版 INSERT INTO purchase_order(request_id,tenant_id) 没有提供此列,而迁移第一步就建 NOT NULL 且没有默认值的列,旧应用可能无法继续插入订单。适合旧版继续运行的第一步通常是可空新列或经过验证的默认值;新版先双读,再写新值,并在确认旧写入者和未完成事务全部排空后才施加更强约束。是否允许列值继续为空,要通过采购订单的实际业务不变量评估。这里并未提供 002-...sql 或第二份 WAR,这个演化例子不是“本地迁移已通过”。

另一个兼容方向常被忽略:新版写入旧代码不认识的状态枚举。当前 RequestState.valueOf(rows.getString(4)) 遇到陌生值会抛异常;purchase_request.status 的 CHECK 同时限制合法字符串集合。即使数据库先放宽 CHECK,旧包仍未必可读。要允许回切,必须把“旧代码读/写新数据”和“新代码读/写旧数据”都纳入矩阵,尤其要覆盖已经从 APPROVED 进入 ORDERED 的申请,不能只在空库里证明旧 WAR 启动成功。

构建物的 SHA-256、源码修订、server.xml 摘要、迁移编号与独立数据库版本要保存在同一发布记录。密钥只记录来源和轮换时间,不归档明文。发布前停在数据库迁移失败、驱动不可用、配置缺失等明确门禁;发布后除了 /health,还应查询 DataSource 并执行可验证的采购路径。排空不只看 HTTP 在途数,受管执行器和消息消费者也需各自停接、确认进度与恢复,但本工程尚无这两项实现。

发布记录中的“包哈希”应能由两端计算出来。以下命令不部署任何东西,只核查构建 WAR 和隔离运行时指定文件是否字节相同;在启动服务器前仍要按 README 把 JAVAEE_WAR 指向实际使用的包,不能只在检查终端 export 后宣称服务器已换包。

1
2
3
4
5
6
7
8
# 仍在 examples/javaee-enterprise;JAVAEE_WAR 由本机部署设置提供
: "${JAVAEE_WAR:?Set to the isolated server WAR path before checking}"
sha256sum webapp/target/procurement-webapp-1.0-SNAPSHOT.war "$JAVAEE_WAR" deploy/server.xml
if cmp -s webapp/target/procurement-webapp-1.0-SNAPSHOT.war "$JAVAEE_WAR"; then
echo 'build WAR equals deployed file'
else
echo 'different bytes: stop promotion'; exit 1
fi

cmp 仅比较当前磁盘上的两份文件。若服务器尚未重新加载,或者 server.xml 的 webApplication location 指到别处,仍可能向请求者提供旧代码;必须把启动日志、运行时实际配置和业务响应一起归档。server.xml 中口令引用 ${env.JAVAEE_LAB_PASSWORD},不要直接把已展开口令的服务器环境转储进发布工单。相反,可记录“从哪个安全配置源注入、是否在重启后生效”的可核查事实。

现成入口与失败门禁

从仓库根目录进入工程,先按 README 准备可丢弃的 javaee_lab 与本机服务器、执行初始化 SQL 并部署 WAR;若需运行采购脚本,必须在启动服务器进程前设置 JAVAEE_DEMO_MODE=true。命令可对已经启动且已部署该包的隔离实例核对当前基线,不能自动完成发布:

1
2
3
4
5
6
7
8
9
cd examples/javaee-enterprise
../hibernate-lab/mvnw -B -ntp -f "$PWD/pom.xml" clean verify
sha256sum webapp/target/procurement-webapp-1.0-SNAPSHOT.war
export JAVAEE_PORT=9085 JAVAEE_DEMO_MODE=true JAVAEE_LAB_USER=javaee_lab
printf 'Local lab database password: '; read -rs JAVAEE_LAB_PASSWORD; printf '\n'
export JAVAEE_LAB_PASSWORD
bash scenarios/00-health.sh
bash scenarios/05-datasource.sh
bash scenarios/09-lab-procurement.sh

这个脚本生成一条随机租户的顺序采购记录;/health 和 /db-check 只验证部分能力。要确认被调用的确是上方构建包,必须比对实际部署目录的 WAR SHA-256;只打印构建包哈希不足以证明服务器加载了它。

数据库门禁需把“连到了数据库”与“库符合本版本”区分开。当前空库脚本建申请、明细、订单三表,申请有 version 与状态 CHECK,订单对申请有 UNIQUE 外键。以下查询只读结构和数据库版本,适合预检同一隔离库是否具备最基本对象;它不能自动确认现存数据已迁移,更不能用数据统计猜出迁移编号:

1
2
3
4
PGPASSWORD="$JAVAEE_LAB_PASSWORD" psql -h 127.0.0.1 -U javaee_lab -d javaee_lab \
-v ON_ERROR_STOP=1 -c 'SELECT current_database(),version()'
PGPASSWORD="$JAVAEE_LAB_PASSWORD" psql -h 127.0.0.1 -U javaee_lab -d javaee_lab \
-v ON_ERROR_STOP=1 -c "SELECT conrelid::regclass AS relation,contype,pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid IN ('purchase_request'::regclass,'purchase_order'::regclass) ORDER BY conname"

::regclass 在表缺失时使预检直接报错。若预检发现 purchase_order(request_id) 唯一键缺失,就应停止部署、留存失败输出并切到人工检查的旧库副本;不应直接对仍在处理下单请求的线上库随手添加 UNIQUE,因为存量重复数据和锁等待都要先评估。当前没有 schema 历史表;002/003 迁移已存在,但没有数据库版本链,不能用 001 文件名冒充“已记录 001 迁移完成”。

一个无写入的失败门禁能验证发布脚本是否尊重 psql 非零退出:在本地隔离库执行已知会失败的只读语句,预期打印 preflight rejected,且不能继续复制/部署新 WAR。

1
2
3
4
5
6
7
8
# 在 examples/javaee-enterprise;这是故意失败的演示门禁,不是迁移测试
if PGPASSWORD="$JAVAEE_LAB_PASSWORD" psql -h 127.0.0.1 -U javaee_lab -d javaee_lab \
-v ON_ERROR_STOP=1 -c 'SELECT 1/0'; then
echo 'unexpected success'; exit 1
else
echo 'preflight rejected; stop before deploy' >&2
exit 1
fi

JAVAEE_STAGE_DIR=$(mktemp -d /tmp/javaee-stage.XXXXXX) bash scenarios/38-preflight-and-stage.sh candidate.war "$JAVAEE_STAGE_DIR/staging.war" 将预检和拷贝接在同一个失败即退出的驱动内,要求目标暂存路径事先不存在;scenarios/test-failure-gates.sh 用可控替身强制预检失败,断言后续目标文件绝未生成。脚本只拷贝到暂存路径,不会部署到在线服务器。上述故意失败的内联命令只证明 psql/调用方处理退出码;复制 WAR、滚动部署、坏 DDL 回退以及数据兼容性没有由这段命令验证。完整演练需要新旧包和配置都可独立定位,从空环境部署、迁移前后各跑业务断言、人工暂停实例观察在途请求与任务、失败时切回旧包并重读既有申请;所有终态及未恢复的数据变化按业务键记录。实验合同见 发布与回退合同。

当前可以复跑的恢复只是“预检失败后没有实施新发布”这一种情形:记录 JAVAEE_WAR 当前摘要,运行上面的故意失败命令,确认自动化没有执行包替换,然后再次比较磁盘摘要并重跑 05-datasource.sh 和 09-lab-procurement.sh。如果文件未变而采购脚本失败,应继续查数据库、容器连接和实际加载的包,而不是称作“回退完成”。若已经执行了 DDL 或接入了新消息协议,光复原旧 WAR 不够;必须在隔离旧库副本与两种版本的应用上演练读写、订单结果和在途消息的处理。故障时保留命令退出码、旧/新包哈希、schema 状态以及用户能观察到的请求结果,避免用一条健康响应掩盖订单变化。

另有两个恢复时点不能混淆。发布流量切换前失败,可以直接阻止新实例接收业务;新实例已对用户确认建单后再失败,就涉及已提交的申请和可能异步执行的通知。前者是部署门禁,后者是在线兼容与补偿问题。若必须在上线期间撤掉新实例,先停止新流量、等待已接收的请求完成或明确拒绝,并按申请 ID 汇总未定结果;对超时请求读回是否提交,再决定是否允许同一业务键重试。没有消息实现的当前工程无法验证后台任务排空,不能由同步 SQL 的一次成功写入推断异步通知也被安全回退。

对现有 JDBC 工程,可以从具体 SQL 判断回切风险:JdbcRequestStore.find() 将数据库字符串交给 RequestState.valueOf(),transition() 使用版本条件更新,insertOrder() 借助 ON CONFLICT(request_id) 及随后的按租户读回。假如某次升级改了状态字符串或删除了 version,旧版即使能响应 /health,对旧订单的读取或审批仍可能异常。反过来,仅保留旧表中的所有列也不足够:若新包修改唯一键含义而旧包仍以 request_id 作为冲突目标,旧版写入的行为会改变。兼容性判断须针对正在执行的 SELECT、UPDATE、INSERT 和库约束逐一核对,而不是只比较表名。

实验中可给每一个包准备一组最小数据:草稿、已提交、已批准与已下单各一条,并保留至少一个存量租户和一个上线后创建的租户。升级前先用旧包对这些数据执行其允许的读取与状态迁移;升级后由新包读写旧数据并生成新数据;若设计允许回切,再由旧包读新数据并在隔离环境中安全地推进状态。每一步记录申请 ID、旧/新版本、HTTP 结果和数据库终态。当前缺第二份包及迁移脚本,这张读写矩阵只是放行条件,不能从单 WAR 的业务探针推出通过。

数据库恢复也分两种:DDL 尚未提交且 PostgreSQL 已回滚,可以重新检查约束与表定义,再判断是否允许旧代码继续服务;DDL 已提交并由新代码写入订单后,不能凭“回滚脚本执行零退出”断言数据恢复。需要先用独立连接抽查升级窗口的订单、金额与租户关系,再选择可逆的补迁移、继续前滚或暂停写入。即使选择恢复快照,也要列出快照之后合法提交的采购请求如何保全;否则对外承诺过的订单会随着快照消失。

PostgreSQL 的 ALTER TABLE 可以取得较强的表级锁,具体行为依子命令、现存事务和数据规模而变化;在真实迁移前应于有存量数据的副本评估锁等待和回填耗时。若迁移步骤要求长时间锁表,版本门禁需提供暂停接单、排空在途请求和超时取消的明确政策。把“语法正确”当作“发布时无影响”会漏掉这一段窗口;反过来,不能因为本篇没有锁实验就假定任何 DDL 必然中断所有请求。

回退演练至少留下四个断言的原始记录:故障门禁阻止流量切到未准备好的版本;旧实例能读取升级前的采购申请;切回时所有已确认订单仍可按租户和申请 ID 找回;未确认请求有重试或人工核查结果。第一个断言可先用本文只读错误门禁练习,后面三个需要真实的新旧包、升级窗口和独立数据库读回。对于临时隔离实例,记录服务器如何启动、何时切流、哪些请求仍在途;对于已有内嵌引擎但尚无外部 broker/MDB 的当前工程,将“消息排空”标为未实现而非零条消息通过。

保存这些记录时要把“原始构建成功”“部署物已复制”“服务器真正加载”和“业务响应来自该包”分成独立事件。同名 1.0-SNAPSHOT WAR 不能充当不可变版本号;实际 SHA-256 与运行时路径才可避免旧包被无声覆盖。若两次部署得到不同哈希却使用同一名字,回切时必须能从受控归档恢复指定哈希的旧包,否则所谓可回退只停留在纸面。

发生配置故障时,恢复动作还要核对受管 DataSource 指向 javaee_lab,以及口令和驱动来自哪一份运行时环境。错误配置的实例即使仍返回健康响应,也不应重新接收采购写入。只有资源探针、正常采购和已确认订单读回都符合预期,才有资格讨论重新切流;本例并未实测多实例切流,不能将此顺序写成执行日志。

两道练习

练习一: 新版把 APPROVED 改存新枚举值,旧 WAR 不认识。新版本发布后回切旧包是否构成有效回退?

解: 不构成。旧包的 SQL/状态机必须先在新数据副本上读写测试;若不能兼容,应先设计双读、延后写新值、数据转换或暂时禁止回切的窗口。历史业务记录已提交时,不得靠整库恢复覆盖回切窗口中的合法订单。

练习二: /health 返回 200,但新部署的 /db-check 失败,值班同事提议直接重跑 001-initial.sql。应怎样定位?

解: 先核对实际已部署 WAR 和 server.xml、连接地址/专用角色/驱动路径、数据库版本及迁移状态;空库初始化 SQL 不是已有库的增量修复,盲跑会因表已存在而失败。保留错误输出和状态,阻止后续流量切换,再决定恢复配置或按有数据的迁移方案处理。

边界与官方资料

现有工程可以构建和在单实例做健康、数据源与顺序业务探针;本篇未实施滚动部署、迁移回退、消息排空或从空环境自动交付。不能把预期回退步骤写成成功的发布记录,更不能把坏 SQL 的门禁演示当成旧库升级验证。