深入 Play 24:Evolutions 与模式升级,应用和数据库怎样协同
一段升级脚本先创建表,再向不存在的表插入。自动提交开启时,第一张表留在数据库中,play_evolutions 留下一条 applying_up 记录;自动提交关闭时,这次实验的表创建和版本记录都回滚了。
应用启动失败只是共同的表面结果。下一步应先检查实际 schema 和迁移元数据,确定哪些语句已生效,再决定修复方案。把版本记录直接改成成功,无法补上缺失的数据或撤销已经执行的 DDL。
脚本版本、元数据和数据库事务
Evolutions 按版本组织 up/down 脚本,比较待应用脚本与数据库中的迁移记录,再执行差异。Play 的机制负责版本与执行流程;DDL 是否能够回滚、语句会取得哪些锁,仍由具体数据库及语句决定。
实验固定 Play 3.0.6,源码 SHA 为 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6。入口是 ApplicationEvolutions.scala 的启动应用逻辑,以及 Evolutions.scala 中的脚本计算和执行。
flowchart LR
Files[版本脚本] --> Diff[比较版本与 hash]
Meta[play_evolutions] --> Diff
Diff --> Policy[运行模式与应用开关]
Policy --> Lock[可选启动锁]
Lock --> SQL[执行迁移 SQL]
SQL --> State[更新执行状态]
SQL --> Failure[失败后的 DDL 与元数据]
这一流程有两种测试入口。EvolutionLab 直接调用 Java API,在不同随机 schema 中观察 SQL 与元数据;database_checks.py 启动真实生产 stage 进程,验证生产开关、重新启动和两个启动者。直接 API 成功不证明生产启动默认允许执行迁移。
累计构建新增依赖符号为 evolutions,不是 jdbcEvolutions。实际 stage 使用 PostgreSQL JDBC 42.7.5、HikariCP 5.0.1,数据库为 PostgreSQL 17.6;这些由 jar manifest 和服务器查询确认,没有把依赖声明当作最终解析结果。
先扩展字段,再回填和收紧
revision 1 创建 items 表并插入一条旧数据。revision 2 添加可空 label,把已有 name 回填到 label,再添加 NOT NULL 约束。
1 | |
# --- !Ups 和 # --- !Downs 是 Evolutions 文件分段标记,上面的整段应放在迁移文件中,不应原样交给 psql。当旧数据存在时,先直接添加无默认值的 NOT NULL 列不能完成同样的升级过程。
本实验把三步放进同一 revision,目的是观察模式和数据终态。真实滚动部署可能需要跨版本完成扩展、双写或兼容读取、回填、最后收紧;旧进程仍写入空值时,提前收紧约束会使旧写路径失败。
| 检查点 | 迁移记录 | 业务数据或约束 |
|---|---|---|
| 空 schema 应用 revision 1 | 一条 applied | 1, old |
| 同脚本再次启动 | 仍为一条 applied | 没有再次插入 |
| 增加 revision 2 | 两条 applied | 1, old, old |
| 查询 information_schema | revision 2 已应用 | label 的 is_nullable 为 NO |
重启不重复执行的结论来自相同脚本和相同数据库状态。修改已经应用的旧脚本会影响差异计算,不能把“版本号没变”当成忽略内容变化的保证。
下面的完整 Java 类通过内存脚本演示两个 revision。调用者提供已存在的空 schema 和所拥有的 Database;类校验 schema 标识符再拼入 DDL,并把元数据 schema 显式传给 API。它不关闭调用者持有的池。
1 | |
这里的 false 是 applyEvolutions 的 autocommit 参数。它只控制这次 API 调用,不能代替生产应用的 autoApply 配置,也没有自动取得应用启动流程中的分布式锁。测试 API 和启动流程要分开验证。
失败脚本留下了什么
失败实验分别创建新 schema,执行 CREATE TABLE partial(...) 后访问 missing 表。两次都会抛出 InconsistentDatabase,但数据库终态不同。
| autocommit | partial 表 | play_evolutions 行数 | 记录状态 |
|---|---|---|---|
| true | 存在 | 1 | applying_up |
| false | 不存在 | 0 | 空 |
这些数据来自 PostgreSQL 17.6 和本次两条语句,不能泛化为所有数据库、所有 DDL 都能整体回滚。迁移元数据表本身可以存在而没有记录,因此“metadataRows=0”也不等于整个 schema 不存在任何迁移设施。
故障恢复应同时保存失败日志、脚本内容与 hash、实际表结构、版本表状态。只有确认数据库已经处于目标状态,才讨论如何使迁移状态与之对齐;仅清空版本表可能导致已有 DDL 被再次执行。
生产启动必须显式决定是否应用
固定版本的 reference.conf 默认 autoApply=false、useLocks=false、autocommit=true。这些值控制不同问题,不能因打开其中一个而推定其他能力同时生效。
1 | |
这是实验的关键开关片段,完整配置还需要显式 db.default、JDBC URL、迁移目录与 schema。真实生产 stage 使用默认 autoApply 时退出码为 255,应用表数为 0;显式打开后,空库应用成功,再次启动维持一条 applied,追加 revision 2 后两条记录均为 applied。
在生产环境,执行迁移的身份、窗口和失败恢复流程应提前确定。将 autoApply 开启只提供了启动时执行的权限,不会评估一条 ALTER TABLE 对在线流量的锁等待或复制延迟。
两个启动者与锁表初建竞态
ApplicationEvolutions.withLock 借连接并关闭自动提交,检查锁表,取得锁行的 FOR UPDATE NOWAIT 锁,再运行升级逻辑。当前实现的 attempts 初值为 5,失败后每次等待 1 秒并减一,减到 0 的尝试再失败才抛出,因此最多有首次加五次重试。这些是有限重试,不能承诺所有并发启动都最终成功。
迁移执行本身还需要连接,因此实验把池至少设为 2。若只有一个连接被锁流程占用,第二次借用就可能等待自身持有的资源;“池越小越容易控制并发”在这里需要考虑嵌套借用。
两个 stage 进程各有独立端口、PID 文件和日志,使用相同数据库 schema、useLocks=true。迁移中加入有限 pg_sleep(0.6) 扩大交叠窗口,分别测锁表不存在和预建锁表两种情况。
| 初始条件 | 两个进程进入 ready | 最终版本与业务行数 |
|---|---|---|
| 锁表尚不存在 | true、false | 一条 applied、一条业务行 |
| 锁表及锁行已预建 | true、true | 一条 applied、一条业务行 |
首次创建锁表的失败进程记录了 PostgreSQL pg_type_typname_nsp_index 唯一冲突,涉及 play_evolutions_lock 的并发创建。行锁必须在锁表存在后才能生效,不能反向保护创建锁表这一步。
已有锁表时两个进程本次均启动成功,但没有重复应用业务脚本。两组成功进程由测试驱动发送 SIGTERM 后退出 143;初建失败进程退出 255。这是一次有限并发实验的真实差异,既不能承诺首次并发创建必失败,也不能声称 useLocks 覆盖所有初始化竞态。
运行与原始记录
从仓库根目录使用 JDK 21;容器名和端口替换为第 22 篇建立的专用合成数据库。脚本会生成独立 schema 与临时迁移配置,真实启动多个 stage 进程并保存其退出状态。
1 | |
隔离记录位于 examples/play-lab/evidence/batch22-25/isolated/http-04/。summary.json 包含 evolutions、productionDefault、migrationRevisionTwo 和 lockRaces;production-default-rejected.log、lock-initial-b.log、lock-existing-a.log 与 lock-existing-b.log 保留对应进程输出。
历史隔离整批38项JUnit、37次HTTP观察保留在isolated;共享累计工程数据库启用后46项JUnit及stage通过,37次真实HTTP观察保留在shared-http/。生产迁移另有退出码和PostgreSQL查询:默认autoApply拒绝启动为255、应用表0;新建锁表竞态本轮仍是一端255、一端143,预建锁表后两端143、元数据和业务写入各1。不同轮次谁先成功可变化,不能把所有终态压缩成接口200。
两个改动练习
- 在 revision 1 中加入一条 name 为 null 的旧数据,直接执行现有 revision 2。保存失败后的 label 数据、约束和版本状态,再为回填制定明确的空值规则;不要把删除旧数据当成默认修复。
- 将两个启动者的迁移 SQL 延长到有限的 8 秒,保留独立观察连接和超时。比较初建锁表与预建锁表的结果,记录重试耗尽或成功的实际日志,不预设两者都会启动。
